Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8030b59f9 | ||
|
|
0ab4ac3e4b |
@@ -4,47 +4,19 @@ import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import pt.naps.maestro.connection.DaemonLink
|
||||
import pt.naps.maestro.connection.DaemonPrefs
|
||||
import pt.naps.maestro.connection.LinkState
|
||||
import pt.naps.maestro.data.Session
|
||||
import pt.naps.maestro.ui.sidebar.SidebarContent
|
||||
import pt.naps.maestro.ui.sidebar.sessionName
|
||||
import pt.naps.maestro.ui.terminal.TerminalScreen
|
||||
import pt.naps.maestro.ui.MaestroApp
|
||||
import pt.naps.maestro.ui.theme.MaestroTheme
|
||||
|
||||
/**
|
||||
* Single activity for the whole app (#268): owns the [DaemonLink] for its
|
||||
* lifetime and hosts [MaestroApp], which carries the drawer, navigation and
|
||||
* every pane.
|
||||
*/
|
||||
class MainActivity : ComponentActivity() {
|
||||
private lateinit var daemonLink: DaemonLink
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
@@ -55,52 +27,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
setContent {
|
||||
MaestroTheme {
|
||||
val linkState by daemonLink.state.collectAsState()
|
||||
val baseUrl by prefs.baseUrl.collectAsState()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
var openSession by remember { mutableStateOf<Session?>(null) }
|
||||
|
||||
val session = openSession
|
||||
if (session != null) {
|
||||
TerminalScreen(
|
||||
daemonBaseUrl = baseUrl,
|
||||
sessionId = session.id,
|
||||
sessionTitle = sessionName(session),
|
||||
onBack = { openSession = null },
|
||||
)
|
||||
} else {
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
ModalDrawerSheet(modifier = Modifier.width(320.dp)) {
|
||||
SidebarContent(
|
||||
catalogStore = daemonLink.catalog,
|
||||
onOpenSession = { s ->
|
||||
openSession = s
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Maestro") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
||||
Text("☰")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
PlaceholderScreen(modifier = Modifier.padding(innerPadding), linkState = linkState)
|
||||
}
|
||||
}
|
||||
}
|
||||
MaestroApp(daemonLink = daemonLink, prefs = prefs)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,33 +37,3 @@ class MainActivity : ComponentActivity() {
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PlaceholderScreen(modifier: Modifier = Modifier, linkState: LinkState = LinkState.CONNECTING) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = "Maestro",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(id = R.string.placeholder_message),
|
||||
)
|
||||
Text(
|
||||
text = "Daemon: ${linkState.name.lowercase()}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun PlaceholderScreenPreview() {
|
||||
MaestroTheme {
|
||||
PlaceholderScreen()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
package pt.naps.maestro.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import kotlinx.coroutines.launch
|
||||
import pt.naps.maestro.api.MaestroClient
|
||||
import pt.naps.maestro.connection.DaemonLink
|
||||
import pt.naps.maestro.connection.DaemonPrefs
|
||||
import pt.naps.maestro.connection.LinkState
|
||||
import pt.naps.maestro.data.Session
|
||||
import pt.naps.maestro.data.SessionView
|
||||
import pt.naps.maestro.ui.sidebar.SidebarContent
|
||||
import pt.naps.maestro.ui.sidebar.sessionName
|
||||
import pt.naps.maestro.ui.spawn.SpawnSheet
|
||||
import pt.naps.maestro.ui.spawn.SpawnViewModel
|
||||
import pt.naps.maestro.ui.structured.StructuredScreen
|
||||
import pt.naps.maestro.ui.terminal.TerminalScreen
|
||||
|
||||
/** Route names for the single-activity nav graph (#268). */
|
||||
private object Routes {
|
||||
const val WELCOME = "welcome"
|
||||
private const val SESSION_ID_ARG = "sessionId"
|
||||
const val TERMINAL = "terminal/{$SESSION_ID_ARG}"
|
||||
const val STRUCTURED = "structured/{$SESSION_ID_ARG}"
|
||||
|
||||
fun terminal(id: String) = "terminal/$id"
|
||||
fun structured(id: String) = "structured/$id"
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level app structure (#268): a persistent [ModalNavigationDrawer] around
|
||||
* a single-back-stack [NavHost], tying together the sidebar (#264/#265), the
|
||||
* terminal pane (#266), the structured pane (#267), and the spawn form
|
||||
* (#269) that were each built standalone.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MaestroApp(daemonLink: DaemonLink, prefs: DaemonPrefs) {
|
||||
val navController = rememberNavController()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val sessions by daemonLink.catalog.sessions.collectAsState()
|
||||
val linkState by daemonLink.state.collectAsState()
|
||||
val baseUrl by prefs.baseUrl.collectAsState()
|
||||
var showSpawnSheet by remember { mutableStateOf(false) }
|
||||
|
||||
val infoClient = remember(baseUrl) { MaestroClient(baseUrl) }
|
||||
var hostname by remember(baseUrl) { mutableStateOf("") }
|
||||
LaunchedEffect(linkState, infoClient) {
|
||||
if (linkState == LinkState.ONLINE) {
|
||||
hostname = runCatching { infoClient.getDaemonInfo().hostname }.getOrDefault(hostname)
|
||||
}
|
||||
}
|
||||
|
||||
fun openSession(session: Session) {
|
||||
val route = if (session.view == SessionView.Structured) {
|
||||
Routes.structured(session.id)
|
||||
} else {
|
||||
Routes.terminal(session.id)
|
||||
}
|
||||
navController.navigate(route) {
|
||||
popUpTo(Routes.WELCOME)
|
||||
launchSingleTop = true
|
||||
}
|
||||
scope.launch { drawerState.close() }
|
||||
}
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
ModalDrawerSheet(modifier = Modifier.width(320.dp)) {
|
||||
SidebarContent(
|
||||
catalogStore = daemonLink.catalog,
|
||||
onOpenSession = ::openSession,
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
ConnectionStatusBar(linkState = linkState, hostname = hostname)
|
||||
NavHost(navController = navController, startDestination = Routes.WELCOME) {
|
||||
composable(Routes.WELCOME) {
|
||||
WelcomeScreen(
|
||||
onOpenSessions = { scope.launch { drawerState.open() } },
|
||||
onSpawnSession = { showSpawnSheet = true },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
Routes.TERMINAL,
|
||||
arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
|
||||
) { backStackEntry ->
|
||||
val id = backStackEntry.arguments?.getString("sessionId") ?: return@composable
|
||||
TerminalScreen(
|
||||
daemonBaseUrl = baseUrl,
|
||||
sessionId = id,
|
||||
sessionTitle = sessions[id]?.let(::sessionName) ?: id,
|
||||
onBack = { navController.popBackStack(Routes.WELCOME, inclusive = false) },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
Routes.STRUCTURED,
|
||||
arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
|
||||
) { backStackEntry ->
|
||||
val id = backStackEntry.arguments?.getString("sessionId") ?: return@composable
|
||||
StructuredScreen(
|
||||
daemonBaseUrl = baseUrl,
|
||||
sessionId = id,
|
||||
sessionTitle = sessions[id]?.let(::sessionName) ?: id,
|
||||
onBack = { navController.popBackStack(Routes.WELCOME, inclusive = false) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showSpawnSheet) {
|
||||
val viewModel = remember(infoClient) { SpawnViewModel(infoClient) }
|
||||
SpawnSheet(
|
||||
viewModel = viewModel,
|
||||
onDismiss = { showSpawnSheet = false },
|
||||
onSpawned = { session ->
|
||||
showSpawnSheet = false
|
||||
openSession(session)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Declared last so it sits above the NavHost's own back handling on the
|
||||
// dispatcher stack: a drawer open over a session must close first, not
|
||||
// pop the session back to Welcome.
|
||||
BackHandler(enabled = drawerState.isOpen) {
|
||||
scope.launch { drawerState.close() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Connection indicator (green online / red down) plus the daemon hostname
|
||||
* from `/api/info`, visible above every route. */
|
||||
@Composable
|
||||
private fun ConnectionStatusBar(linkState: LinkState, hostname: String) {
|
||||
val online = linkState == LinkState.ONLINE
|
||||
val dotColor = if (online) Color(0xFF2E7D32) else Color(0xFFC62828)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(dotColor),
|
||||
)
|
||||
Text(
|
||||
text = hostname.ifEmpty { linkState.name.lowercase() },
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package pt.naps.maestro.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import pt.naps.maestro.ui.theme.MaestroTheme
|
||||
|
||||
/**
|
||||
* Shown when no session is open (#268): the app's resting state, reached at
|
||||
* launch and every time a session closes or the operator backs out of one.
|
||||
*/
|
||||
@Composable
|
||||
fun WelcomeScreen(
|
||||
onOpenSessions: () -> Unit,
|
||||
onSpawnSession: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(text = "Maestro", style = MaterialTheme.typography.displaySmall)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "No session open",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Button(onClick = onOpenSessions, modifier = Modifier.fillMaxWidth(0.7f)) {
|
||||
Text("Sessions")
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedButton(onClick = onSpawnSession, modifier = Modifier.fillMaxWidth(0.7f)) {
|
||||
Text("Spawn session")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun WelcomeScreenPreview() {
|
||||
MaestroTheme {
|
||||
WelcomeScreen(onOpenSessions = {}, onSpawnSession = {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package pt.naps.maestro.ui.structured
|
||||
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import pt.naps.maestro.data.ReducedState
|
||||
import pt.naps.maestro.data.TranscriptRow
|
||||
import pt.naps.maestro.data.TranscriptRowKind
|
||||
|
||||
/**
|
||||
* Loose parsing of the opaque ACP JSON a [TranscriptRow.data] /
|
||||
* [ReducedState] field carries (docs/STRUCTURED-VIEW.md: "shaped to
|
||||
* `claude-code-acp`'s ACP v1 schema, not covered by maestro-protocol's own
|
||||
* compatibility guarantee"). Every accessor degrades to a default rather
|
||||
* than throwing on an unexpected shape — ported from `web/src/types.ts`'s
|
||||
* `Acp*` interfaces and `model.ts`'s readers.
|
||||
*/
|
||||
|
||||
internal fun JsonObject.stringField(key: String): String? = (this[key] as? JsonPrimitive)?.contentOrNull
|
||||
|
||||
internal fun JsonObject.longField(key: String): Long? = (this[key] as? JsonPrimitive)?.longOrNull
|
||||
|
||||
sealed class AcpContentBlock {
|
||||
data class Text(val text: String) : AcpContentBlock()
|
||||
data class Image(val mimeType: String?, val data: String?, val uri: String?) : AcpContentBlock()
|
||||
data class Audio(val mimeType: String?, val data: String?) : AcpContentBlock()
|
||||
data class ResourceLink(val name: String?, val uri: String?) : AcpContentBlock()
|
||||
data class Resource(val uri: String?, val text: String?) : AcpContentBlock()
|
||||
object Unknown : AcpContentBlock()
|
||||
}
|
||||
|
||||
sealed class AcpToolCallContent {
|
||||
data class Content(val block: AcpContentBlock) : AcpToolCallContent()
|
||||
data class Diff(val path: String, val oldText: String?, val newText: String) : AcpToolCallContent()
|
||||
data class Terminal(val terminalId: String?) : AcpToolCallContent()
|
||||
}
|
||||
|
||||
data class AcpToolCallLike(
|
||||
val title: String? = null,
|
||||
val name: String? = null,
|
||||
val kind: String? = null,
|
||||
val status: String? = null,
|
||||
val content: List<AcpToolCallContent> = emptyList(),
|
||||
val locations: List<String> = emptyList(),
|
||||
val rawInput: JsonElement? = null,
|
||||
val rawOutput: JsonElement? = null,
|
||||
)
|
||||
|
||||
data class AcpPermissionOption(val optionId: String, val name: String, val kind: String)
|
||||
|
||||
data class AcpApprovalRequest(val toolCall: AcpToolCallLike, val options: List<AcpPermissionOption>)
|
||||
|
||||
/** `maestroTurnMeta` — the daemon-owned durable footer for a settled turn. */
|
||||
data class TurnMetaPayload(
|
||||
val elapsedMs: Long,
|
||||
val outcome: String,
|
||||
val model: String? = null,
|
||||
val contextTokens: Long? = null,
|
||||
val contextDelta: Long? = null,
|
||||
)
|
||||
|
||||
fun parseContentBlock(element: JsonElement?): AcpContentBlock {
|
||||
val obj = element as? JsonObject ?: return AcpContentBlock.Unknown
|
||||
return when (obj.stringField("type")) {
|
||||
"text" -> AcpContentBlock.Text(obj.stringField("text").orEmpty())
|
||||
"image" -> AcpContentBlock.Image(obj.stringField("mimeType"), obj.stringField("data"), obj.stringField("uri"))
|
||||
"audio" -> AcpContentBlock.Audio(obj.stringField("mimeType"), obj.stringField("data"))
|
||||
"resource_link" -> AcpContentBlock.ResourceLink(obj.stringField("name"), obj.stringField("uri"))
|
||||
"resource" -> {
|
||||
val resource = obj["resource"] as? JsonObject
|
||||
AcpContentBlock.Resource(resource?.stringField("uri"), resource?.stringField("text"))
|
||||
}
|
||||
else -> AcpContentBlock.Unknown
|
||||
}
|
||||
}
|
||||
|
||||
/** Content-block text, or a labelled placeholder for anything that isn't
|
||||
* text — never dropped silently. Mirrors `model.ts`'s `contentBlockText`. */
|
||||
fun contentBlockText(block: AcpContentBlock): String = when (block) {
|
||||
is AcpContentBlock.Text -> block.text
|
||||
is AcpContentBlock.Image -> "[image]"
|
||||
is AcpContentBlock.Audio -> "[audio]"
|
||||
is AcpContentBlock.ResourceLink -> "[resource: ${block.name ?: block.uri ?: "linked"}]"
|
||||
is AcpContentBlock.Resource -> "[resource]"
|
||||
AcpContentBlock.Unknown -> ""
|
||||
}
|
||||
|
||||
/** A `User`/`AgentText`/`AgentThought` row's `data` is a `ContentChunk`:
|
||||
* `{content: ContentBlock, messageId?}`. */
|
||||
fun contentChunkText(data: JsonElement): String {
|
||||
val obj = data as? JsonObject ?: return ""
|
||||
return contentBlockText(parseContentBlock(obj["content"]))
|
||||
}
|
||||
|
||||
private fun parseToolCallContent(element: JsonElement): AcpToolCallContent {
|
||||
val obj = element as? JsonObject ?: return AcpToolCallContent.Terminal(null)
|
||||
return when (obj.stringField("type")) {
|
||||
"content" -> AcpToolCallContent.Content(parseContentBlock(obj["content"]))
|
||||
"diff" -> AcpToolCallContent.Diff(
|
||||
path = obj.stringField("path").orEmpty(),
|
||||
oldText = obj.stringField("oldText"),
|
||||
newText = obj.stringField("newText").orEmpty(),
|
||||
)
|
||||
else -> AcpToolCallContent.Terminal(obj.stringField("terminalId"))
|
||||
}
|
||||
}
|
||||
|
||||
/** A `ToolCall`/`ToolDiff` row's `data`, or an `Approval` row's
|
||||
* `data.toolCall` (`AcpToolCallLike` — both share this shape). */
|
||||
fun parseToolCall(data: JsonElement): AcpToolCallLike {
|
||||
val obj = data as? JsonObject ?: return AcpToolCallLike()
|
||||
return AcpToolCallLike(
|
||||
title = obj.stringField("title"),
|
||||
name = obj.stringField("name"),
|
||||
kind = obj.stringField("kind"),
|
||||
status = obj.stringField("status"),
|
||||
content = (obj["content"] as? JsonArray)?.map(::parseToolCallContent) ?: emptyList(),
|
||||
locations = (obj["locations"] as? JsonArray)
|
||||
?.mapNotNull { (it as? JsonObject)?.stringField("path") }
|
||||
?: emptyList(),
|
||||
rawInput = obj["rawInput"],
|
||||
rawOutput = obj["rawOutput"],
|
||||
)
|
||||
}
|
||||
|
||||
/** An `Approval` row's `data` — a `RequestPermissionRequest` serialized
|
||||
* without its nonce (docs/STRUCTURED-VIEW.md's "Approvals"). */
|
||||
fun parseApprovalRequest(data: JsonElement): AcpApprovalRequest {
|
||||
val obj = data as? JsonObject ?: return AcpApprovalRequest(AcpToolCallLike(), emptyList())
|
||||
val toolCall = (obj["toolCall"] as? JsonObject)?.let(::parseToolCall) ?: AcpToolCallLike()
|
||||
val options = (obj["options"] as? JsonArray)?.mapNotNull { option ->
|
||||
val o = option as? JsonObject ?: return@mapNotNull null
|
||||
val optionId = o.stringField("optionId") ?: return@mapNotNull null
|
||||
val name = o.stringField("name") ?: return@mapNotNull null
|
||||
val kind = o.stringField("kind") ?: return@mapNotNull null
|
||||
AcpPermissionOption(optionId, name, kind)
|
||||
} ?: emptyList()
|
||||
return AcpApprovalRequest(toolCall, options)
|
||||
}
|
||||
|
||||
/** The `maestroTurnMeta` footer a `Notice` row carries when it is a
|
||||
* completed, cancelled or failed turn's durable footer — `null` for every
|
||||
* other notice. */
|
||||
fun parseTurnMeta(row: TranscriptRow): TurnMetaPayload? {
|
||||
if (row.kind != TranscriptRowKind.Notice) return null
|
||||
val obj = row.data as? JsonObject ?: return null
|
||||
val meta = obj["maestroTurnMeta"] as? JsonObject ?: return null
|
||||
val elapsedMs = meta.longField("elapsedMs") ?: return null
|
||||
val outcome = meta.stringField("outcome") ?: return null
|
||||
return TurnMetaPayload(
|
||||
elapsedMs = elapsedMs,
|
||||
outcome = outcome,
|
||||
model = meta.stringField("model"),
|
||||
contextTokens = meta.longField("contextTokens"),
|
||||
contextDelta = meta.longField("contextDelta"),
|
||||
)
|
||||
}
|
||||
|
||||
/** Notices the client consumes as state rather than showing in the
|
||||
* transcript (docs/STRUCTURED-VIEW.md, "A client decides"). */
|
||||
fun isStateOnlyNotice(row: TranscriptRow): Boolean {
|
||||
if (row.kind != TranscriptRowKind.Notice) return false
|
||||
val update = (row.data as? JsonObject)?.stringField("sessionUpdate")
|
||||
return update == "available_commands_update" || update == "session_info_update"
|
||||
}
|
||||
|
||||
/** `ReducedState.pending_approval` is `{nonce, request}` while a permission
|
||||
* request is live, absent otherwise. */
|
||||
fun pendingApprovalNonce(state: ReducedState): String? =
|
||||
(state.pending_approval as? JsonObject)?.stringField("nonce")
|
||||
@@ -0,0 +1,92 @@
|
||||
package pt.naps.maestro.ui.structured
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import pt.naps.maestro.data.StructuredApprovalDecision
|
||||
|
||||
private val DECISION_LABELS = listOf(
|
||||
StructuredApprovalDecision.AllowOnce to "Allow once",
|
||||
StructuredApprovalDecision.AllowForSession to "Allow for session",
|
||||
StructuredApprovalDecision.Deny to "Deny",
|
||||
)
|
||||
|
||||
/**
|
||||
* An `Approval` row. The daemon exposes exactly three decisions
|
||||
* (docs/STRUCTURED-VIEW.md) and mints the nonce; this only solicits one of
|
||||
* them. A decision the request offers no matching ACP option for is not
|
||||
* shown — its button would "allow" something that silently resolves
|
||||
* `Cancelled`.
|
||||
*/
|
||||
@Composable
|
||||
fun ApprovalCard(
|
||||
request: AcpApprovalRequest,
|
||||
pending: Boolean,
|
||||
busy: Boolean,
|
||||
onDecide: (StructuredApprovalDecision) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val what = request.toolCall.title ?: StructuredModel.toolKindLabel(request.toolCall.kind)
|
||||
|
||||
if (!pending) {
|
||||
Text(
|
||||
text = "$what — approved",
|
||||
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val offered = StructuredModel.offeredDecisions(request.options)
|
||||
val path = request.toolCall.locations.firstOrNull()
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.25f), RoundedCornerShape(8.dp))
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Text("⚠ Permission requested", style = MaterialTheme.typography.labelMedium)
|
||||
Text(what, style = MaterialTheme.typography.bodyMedium)
|
||||
if (path != null) {
|
||||
Text(path, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
if (offered.isEmpty()) {
|
||||
Text(
|
||||
"no decision offered",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
Row(modifier = Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
for ((decision, label) in DECISION_LABELS) {
|
||||
if (decision !in offered) continue
|
||||
val isDeny = decision == StructuredApprovalDecision.Deny
|
||||
Button(
|
||||
onClick = { onDecide(decision) },
|
||||
enabled = !busy,
|
||||
colors = if (isDeny) {
|
||||
ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
|
||||
} else {
|
||||
ButtonDefaults.buttonColors()
|
||||
},
|
||||
) {
|
||||
Text(label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package pt.naps.maestro.ui.structured
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Multi-line draft input plus send/cancel (#267). Sending is the pane's
|
||||
* job — this only hands back the current draft text.
|
||||
*/
|
||||
@Composable
|
||||
fun Composer(
|
||||
draft: String,
|
||||
onDraftChange: (String) -> Unit,
|
||||
onSend: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
turnActive: Boolean,
|
||||
sendEnabled: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth().padding(8.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = draft,
|
||||
onValueChange = onDraftChange,
|
||||
modifier = Modifier.weight(1f),
|
||||
placeholder = { Text("Reply…") },
|
||||
minLines = 1,
|
||||
maxLines = 6,
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
if (turnActive) {
|
||||
IconButton(onClick = onCancel) {
|
||||
Text(text = "■", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onSend, enabled = sendEnabled && draft.isNotBlank()) {
|
||||
Text(text = "➤")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package pt.naps.maestro.ui.structured
|
||||
|
||||
/** One rendered line of a [ToolDiff][pt.naps.maestro.data.TranscriptRowKind]
|
||||
* content block. A `Hunk` line is a human summary, not source text; an `Eof`
|
||||
* line marks a missing trailing newline. Ported from `web/src/components/
|
||||
* structured/diff.ts`. */
|
||||
data class DiffLine(val kind: Kind, val text: String) {
|
||||
enum class Kind { Add, Del, Ctx, Hunk, Eof }
|
||||
}
|
||||
|
||||
/**
|
||||
* Line-level diff for a `Diff` content block (#140). ACP hands over whole
|
||||
* `oldText`/`newText`, not a unified-diff patch, so the coloured hunks a
|
||||
* diff view needs are computed here rather than parsed out of a patch.
|
||||
*/
|
||||
object DiffModel {
|
||||
/** Interior unchanged runs longer than this collapse to a summary line
|
||||
* flanked by this many lines of real context on each side. */
|
||||
private const val CONTEXT = 3
|
||||
|
||||
/** Bounds the O(n·m) LCS table: above this cell count, the changed
|
||||
* middle is shown as one coarse del-then-add block instead of a
|
||||
* fine-grained diff — still correct, just not minimal. */
|
||||
private const val LCS_CELL_LIMIT = 250_000
|
||||
|
||||
private data class SourceLine(val text: String, val terminated: Boolean)
|
||||
|
||||
private fun splitLines(text: String): List<SourceLine> {
|
||||
if (text.isEmpty()) return emptyList()
|
||||
val hasFinalNewline = text.endsWith("\n")
|
||||
val parts = text.split("\n").let { if (hasFinalNewline) it.dropLast(1) else it }
|
||||
return parts.mapIndexed { index, line -> SourceLine(line, index < parts.size - 1 || hasFinalNewline) }
|
||||
}
|
||||
|
||||
private data class Op(val kind: DiffLine.Kind, val line: SourceLine)
|
||||
|
||||
private fun sameLine(a: SourceLine, b: SourceLine) = a.text == b.text && a.terminated == b.terminated
|
||||
|
||||
/** Longest-common-subsequence diff of two line arrays, already trimmed
|
||||
* of any shared prefix/suffix by the caller. */
|
||||
private fun lcsDiff(a: List<SourceLine>, b: List<SourceLine>): List<Op> {
|
||||
val n = a.size
|
||||
val m = b.size
|
||||
if (n == 0) return b.map { Op(DiffLine.Kind.Add, it) }
|
||||
if (m == 0) return a.map { Op(DiffLine.Kind.Del, it) }
|
||||
if (n.toLong() * m.toLong() > LCS_CELL_LIMIT) {
|
||||
return a.map { Op(DiffLine.Kind.Del, it) } + b.map { Op(DiffLine.Kind.Add, it) }
|
||||
}
|
||||
val columns = m + 1
|
||||
val dp = IntArray((n + 1) * columns)
|
||||
for (i in n - 1 downTo 0) {
|
||||
for (j in m - 1 downTo 0) {
|
||||
dp[i * columns + j] = if (sameLine(a[i], b[j])) {
|
||||
dp[(i + 1) * columns + j + 1] + 1
|
||||
} else {
|
||||
maxOf(dp[(i + 1) * columns + j], dp[i * columns + j + 1])
|
||||
}
|
||||
}
|
||||
}
|
||||
val ops = mutableListOf<Op>()
|
||||
var i = 0
|
||||
var j = 0
|
||||
while (i < n && j < m) {
|
||||
when {
|
||||
sameLine(a[i], b[j]) -> {
|
||||
ops.add(Op(DiffLine.Kind.Ctx, a[i]))
|
||||
i++
|
||||
j++
|
||||
}
|
||||
dp[(i + 1) * columns + j] >= dp[i * columns + j + 1] -> {
|
||||
ops.add(Op(DiffLine.Kind.Del, a[i]))
|
||||
i++
|
||||
}
|
||||
else -> {
|
||||
ops.add(Op(DiffLine.Kind.Add, b[j]))
|
||||
j++
|
||||
}
|
||||
}
|
||||
}
|
||||
while (i < n) ops.add(Op(DiffLine.Kind.Del, a[i++]))
|
||||
while (j < m) ops.add(Op(DiffLine.Kind.Add, b[j++]))
|
||||
return ops
|
||||
}
|
||||
|
||||
private fun diffLines(a: List<SourceLine>, b: List<SourceLine>): List<Op> {
|
||||
var pre = 0
|
||||
while (pre < a.size && pre < b.size && sameLine(a[pre], b[pre])) pre++
|
||||
var suf = 0
|
||||
while (suf < a.size - pre && suf < b.size - pre && sameLine(a[a.size - 1 - suf], b[b.size - 1 - suf])) suf++
|
||||
val prefixCtx = a.subList(0, pre).map { Op(DiffLine.Kind.Ctx, it) }
|
||||
val suffixCtx = a.subList(a.size - suf, a.size).map { Op(DiffLine.Kind.Ctx, it) }
|
||||
val middle = lcsDiff(a.subList(pre, a.size - suf), b.subList(pre, b.size - suf))
|
||||
return prefixCtx + middle + suffixCtx
|
||||
}
|
||||
|
||||
/** Collapse a long run of unchanged lines to a summary, keeping up to
|
||||
* [CONTEXT] real lines adjacent to each neighbouring change. */
|
||||
private fun hunk(ops: List<Op>): List<DiffLine> {
|
||||
val out = mutableListOf<DiffLine>()
|
||||
fun push(op: Op) {
|
||||
out.add(DiffLine(op.kind, op.line.text))
|
||||
if (!op.line.terminated) out.add(DiffLine(DiffLine.Kind.Eof, "\\ No newline at end of file"))
|
||||
}
|
||||
var i = 0
|
||||
while (i < ops.size) {
|
||||
if (ops[i].kind != DiffLine.Kind.Ctx) {
|
||||
push(ops[i])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
var j = i
|
||||
while (j < ops.size && ops[j].kind == DiffLine.Kind.Ctx) j++
|
||||
val run = ops.subList(i, j)
|
||||
val wholeDiff = i == 0 && j == ops.size
|
||||
val keepHead = if (wholeDiff || i > 0) CONTEXT else 0
|
||||
val keepTail = if (wholeDiff || j < ops.size) CONTEXT else 0
|
||||
if (run.size <= keepHead + keepTail) {
|
||||
run.forEach(::push)
|
||||
} else {
|
||||
if (keepHead > 0) run.subList(0, keepHead).forEach(::push)
|
||||
val collapsed = run.subList(keepHead, run.size - keepTail)
|
||||
out.add(DiffLine(DiffLine.Kind.Hunk, "⋯ ${collapsed.size} unchanged lines ⋯"))
|
||||
if (collapsed.any { !it.line.terminated }) {
|
||||
out.add(DiffLine(DiffLine.Kind.Eof, "\\ No newline at end of file"))
|
||||
}
|
||||
if (keepTail > 0) run.subList(run.size - keepTail, run.size).forEach(::push)
|
||||
}
|
||||
i = j
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Coloured, hunked diff lines for a `Diff` content block. `oldText`
|
||||
* absent (a new file) renders every line of `newText` as added. */
|
||||
fun computeDiffLines(oldText: String?, newText: String): List<DiffLine> {
|
||||
val newLines = splitLines(newText)
|
||||
if (oldText == null) return hunk(newLines.map { Op(DiffLine.Kind.Add, it) })
|
||||
return hunk(diffLines(splitLines(oldText), newLines))
|
||||
}
|
||||
|
||||
data class Stats(val added: Int, val removed: Int)
|
||||
|
||||
/** `+added -removed` counts straight off the diff — real derived data,
|
||||
* never a fabricated count. */
|
||||
fun diffStats(lines: List<DiffLine>): Stats {
|
||||
var added = 0
|
||||
var removed = 0
|
||||
for (line in lines) when (line.kind) {
|
||||
DiffLine.Kind.Add -> added++
|
||||
DiffLine.Kind.Del -> removed++
|
||||
else -> {}
|
||||
}
|
||||
return Stats(added, removed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package pt.naps.maestro.ui.structured
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
private val DiffAddColor = Color(0xFF2E7D32)
|
||||
private val DiffAddBackground = Color(0x1A2E7D32)
|
||||
private val DiffDelBackground = Color(0x1AD32F2F)
|
||||
|
||||
/** A `ToolDiff` row's diff content: `+` lines green, `-` lines red
|
||||
* (docs/STRUCTURED-VIEW.md's "diff view" is a client decision). Lines are
|
||||
* pre-computed by [DiffModel]. */
|
||||
@Composable
|
||||
fun DiffView(path: String, oldText: String?, newText: String, modifier: Modifier = Modifier) {
|
||||
val lines = remember(oldText, newText) { DiffModel.computeDiffLines(oldText, newText) }
|
||||
Column(modifier = modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Text(
|
||||
text = path,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
for (line in lines) {
|
||||
val (prefix, color, background) = when (line.kind) {
|
||||
DiffLine.Kind.Add -> Triple("+ ", DiffAddColor, DiffAddBackground)
|
||||
DiffLine.Kind.Del -> Triple("- ", MaterialTheme.colorScheme.error, DiffDelBackground)
|
||||
DiffLine.Kind.Ctx -> Triple(" ", MaterialTheme.colorScheme.onSurfaceVariant, Color.Transparent)
|
||||
DiffLine.Kind.Hunk, DiffLine.Kind.Eof -> Triple("", MaterialTheme.colorScheme.onSurfaceVariant, Color.Transparent)
|
||||
}
|
||||
Text(
|
||||
text = prefix + line.text,
|
||||
color = color,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(background)
|
||||
.padding(horizontal = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package pt.naps.maestro.ui.structured
|
||||
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withStyle
|
||||
|
||||
/**
|
||||
* Minimal markdown-to-[AnnotatedString] renderer for agent replies (#267
|
||||
* v1): fenced code blocks, `#` headers, `-`/`*` bullets, and inline
|
||||
* `**bold**`/`*italic*`/`` `code` ``. Not a CommonMark implementation —
|
||||
* good enough for model prose without pulling in a markdown library.
|
||||
*/
|
||||
fun renderSimpleMarkdown(text: String): AnnotatedString = buildAnnotatedString {
|
||||
var inCodeBlock = false
|
||||
val lines = text.split("\n")
|
||||
lines.forEachIndexed { index, rawLine ->
|
||||
when {
|
||||
rawLine.trim().startsWith("```") -> inCodeBlock = !inCodeBlock
|
||||
inCodeBlock -> withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { append(rawLine) }
|
||||
else -> {
|
||||
val header = HEADER_PATTERN.matchEntire(rawLine)
|
||||
val bullet = BULLET_PATTERN.matchEntire(rawLine)
|
||||
when {
|
||||
header != null -> withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
appendInlineMarkdown(header.groupValues[2])
|
||||
}
|
||||
bullet != null -> {
|
||||
append("• ")
|
||||
appendInlineMarkdown(bullet.groupValues[1])
|
||||
}
|
||||
else -> appendInlineMarkdown(rawLine)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (index != lines.lastIndex) append("\n")
|
||||
}
|
||||
}
|
||||
|
||||
private val HEADER_PATTERN = Regex("^(#{1,6})\\s+(.*)$")
|
||||
private val BULLET_PATTERN = Regex("^[-*]\\s+(.*)$")
|
||||
|
||||
/** Scans for the next inline marker (`` ` ``, `**`, `*`) and applies its
|
||||
* span style, falling back to plain text for anything before it. */
|
||||
private fun AnnotatedString.Builder.appendInlineMarkdown(text: String) {
|
||||
var i = 0
|
||||
while (i < text.length) {
|
||||
when {
|
||||
text.startsWith("**", i) -> {
|
||||
val end = text.indexOf("**", i + 2)
|
||||
if (end == -1) {
|
||||
append(text.substring(i))
|
||||
i = text.length
|
||||
} else {
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(text.substring(i + 2, end)) }
|
||||
i = end + 2
|
||||
}
|
||||
}
|
||||
text.startsWith("`", i) -> {
|
||||
val end = text.indexOf("`", i + 1)
|
||||
if (end == -1) {
|
||||
append(text.substring(i))
|
||||
i = text.length
|
||||
} else {
|
||||
withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { append(text.substring(i + 1, end)) }
|
||||
i = end + 1
|
||||
}
|
||||
}
|
||||
text.startsWith("*", i) -> {
|
||||
val end = text.indexOf("*", i + 1)
|
||||
if (end == -1) {
|
||||
append(text.substring(i))
|
||||
i = text.length
|
||||
} else {
|
||||
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { append(text.substring(i + 1, end)) }
|
||||
i = end + 1
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
val next = listOf(text.indexOf("**", i), text.indexOf("`", i), text.indexOf("*", i))
|
||||
.filter { it >= 0 }
|
||||
.minOrNull() ?: text.length
|
||||
append(text.substring(i, next))
|
||||
i = next
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package pt.naps.maestro.ui.structured
|
||||
|
||||
import pt.naps.maestro.data.ReducedState
|
||||
import pt.naps.maestro.data.StructuredApprovalDecision
|
||||
import pt.naps.maestro.data.TranscriptRow
|
||||
import pt.naps.maestro.data.TranscriptRowKind
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* Pure transcript logic for the structured pane (#267), kept out of the
|
||||
* composables so it is testable without Compose. Ported from
|
||||
* `web/src/components/structured/model.ts`; docs/STRUCTURED-VIEW.md is the
|
||||
* contract for what a client, versus the daemon, decides.
|
||||
*/
|
||||
object StructuredModel {
|
||||
private val KIND_LABELS = mapOf(
|
||||
"read" to "Read",
|
||||
"edit" to "Edit",
|
||||
"delete" to "Delete",
|
||||
"move" to "Move",
|
||||
"search" to "Search",
|
||||
"execute" to "Execute",
|
||||
"think" to "Think",
|
||||
"fetch" to "Fetch",
|
||||
"switch_mode" to "Switch mode",
|
||||
)
|
||||
|
||||
/** Human label behind a `ToolKind`. An unrecognised/absent kind is
|
||||
* "Tool" — never a blank label. */
|
||||
fun toolKindLabel(kind: String?): String = kind?.let { KIND_LABELS[it] } ?: "Tool"
|
||||
|
||||
data class ToolTitle(val label: String, val arg: String)
|
||||
|
||||
private val TITLE_PATTERN = Regex("^(\\S+)\\(([\\s\\S]*)\\)$")
|
||||
|
||||
/** Split a title into `{label, arg}` when it follows the adapter's own
|
||||
* `Name(argument)` convention; otherwise the whole title is the arg and
|
||||
* `kind` supplies a generic label. */
|
||||
fun splitToolTitle(title: String?, name: String?, kind: String?): ToolTitle {
|
||||
val label = name ?: toolKindLabel(kind)
|
||||
if (title.isNullOrEmpty()) return ToolTitle(label, "")
|
||||
val match = TITLE_PATTERN.matchEntire(title)
|
||||
return if (match != null) {
|
||||
ToolTitle(name ?: match.groupValues[1], match.groupValues[2])
|
||||
} else {
|
||||
ToolTitle(label, title)
|
||||
}
|
||||
}
|
||||
|
||||
data class StatusMeta(val text: String, val kind: String)
|
||||
|
||||
fun statusMeta(status: String?, hasDiff: Boolean): StatusMeta? = when {
|
||||
status == "in_progress" -> StatusMeta("running", "running")
|
||||
status == "failed" -> StatusMeta("failed", "failed")
|
||||
status == "pending" && !hasDiff -> StatusMeta("pending", "pending")
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun lastRowIdOfKind(rows: List<TranscriptRow>, kind: TranscriptRowKind): String? =
|
||||
rows.lastOrNull { it.kind == kind }?.id
|
||||
|
||||
/** The row id a live `pending_approval` names: the newest `Approval`
|
||||
* row. Only one approval is ever live (docs/STRUCTURED-VIEW.md), so the
|
||||
* last one is the pending one. */
|
||||
fun pendingApprovalRowId(rows: List<TranscriptRow>, state: ReducedState): String? =
|
||||
if (state.pending_approval != null) lastRowIdOfKind(rows, TranscriptRowKind.Approval) else null
|
||||
|
||||
fun turnIsActive(state: ReducedState): Boolean = state.turn.responding || state.turn.thinking
|
||||
|
||||
/** Which decisions a permission request actually backs, by the
|
||||
* `PermissionOptionKind` values it offers. A decision with no matching
|
||||
* option resolves `Cancelled` server-side regardless — offering its
|
||||
* button would let the operator "allow" something that silently
|
||||
* no-ops. */
|
||||
fun offeredDecisions(options: List<AcpPermissionOption>): List<StructuredApprovalDecision> {
|
||||
val kinds = options.map { it.kind }.toSet()
|
||||
val decisions = mutableListOf<StructuredApprovalDecision>()
|
||||
if ("allow_once" in kinds) decisions += StructuredApprovalDecision.AllowOnce
|
||||
if ("allow_always" in kinds) decisions += StructuredApprovalDecision.AllowForSession
|
||||
if ("reject_once" in kinds || "reject_always" in kinds) decisions += StructuredApprovalDecision.Deny
|
||||
return decisions
|
||||
}
|
||||
|
||||
data class ActivityGroup(
|
||||
val toolCount: Int,
|
||||
val rows: List<TranscriptRow>,
|
||||
val lastToolLabel: String,
|
||||
val lastToolArg: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Group contiguous tool rows (and resolved approvals between them) into
|
||||
* collapsible blocks. An `AgentText`/`AgentThought` row between tools
|
||||
* splits the run. A group must contain at least two tools to qualify; a
|
||||
* single tool stays standalone. Keyed by `turn:<userId>:<seq>`.
|
||||
*/
|
||||
fun activityGroupsByTurn(rows: List<TranscriptRow>, pendingApprovalId: String?): Map<String, ActivityGroup> {
|
||||
val groups = LinkedHashMap<String, ActivityGroup>()
|
||||
var turnId = "before-first-message"
|
||||
var runSeq = 0
|
||||
var runTools = mutableListOf<TranscriptRow>()
|
||||
var runPending = mutableListOf<TranscriptRow>()
|
||||
|
||||
fun flushRun() {
|
||||
if (runTools.size >= 2) {
|
||||
val gid = "turn:$turnId:$runSeq"
|
||||
val tool = parseToolCall(runTools.last().data)
|
||||
val title = splitToolTitle(tool.title, tool.name, tool.kind)
|
||||
groups[gid] = ActivityGroup(runTools.size, runTools.toList(), title.label, title.arg)
|
||||
runSeq++
|
||||
}
|
||||
runTools = mutableListOf()
|
||||
runPending = mutableListOf()
|
||||
}
|
||||
|
||||
for (row in rows) {
|
||||
when {
|
||||
row.kind == TranscriptRowKind.User -> {
|
||||
flushRun()
|
||||
turnId = row.id
|
||||
runSeq = 0
|
||||
}
|
||||
row.kind == TranscriptRowKind.AgentText || row.kind == TranscriptRowKind.AgentThought -> flushRun()
|
||||
row.kind == TranscriptRowKind.Approval && row.id != pendingApprovalId -> runPending.add(row)
|
||||
row.kind == TranscriptRowKind.ToolCall || row.kind == TranscriptRowKind.ToolDiff -> {
|
||||
runTools.addAll(runPending)
|
||||
runPending = mutableListOf()
|
||||
runTools.add(row)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
flushRun()
|
||||
return groups
|
||||
}
|
||||
|
||||
/** One entry of the flattened render list: either a standalone row or a
|
||||
* whole activity group. */
|
||||
sealed class TranscriptItem {
|
||||
data class RowItem(val row: TranscriptRow) : TranscriptItem()
|
||||
data class GroupItem(val id: String, val group: ActivityGroup) : TranscriptItem()
|
||||
}
|
||||
|
||||
/**
|
||||
* The transcript in render order: rows, with each contiguous tool
|
||||
* stretch folded into a single group item where one exists. `Plan` rows
|
||||
* and state-only notices are dropped (both clients omit `Plan`). A turn
|
||||
* footer only survives as the final item, so old footers stop reading
|
||||
* like transcript content.
|
||||
*/
|
||||
fun transcriptItems(rows: List<TranscriptRow>, pendingApprovalId: String?): List<TranscriptItem> {
|
||||
val groups = activityGroupsByTurn(rows, pendingApprovalId)
|
||||
val rowToGroup = HashMap<String, String>()
|
||||
for ((groupId, group) in groups) for (r in group.rows) rowToGroup[r.id] = groupId
|
||||
|
||||
val items = mutableListOf<TranscriptItem>()
|
||||
val emitted = mutableSetOf<String>()
|
||||
for (row in rows) {
|
||||
val groupId = rowToGroup[row.id]
|
||||
if (groupId != null) {
|
||||
if (emitted.add(groupId)) items.add(TranscriptItem.GroupItem(groupId, groups.getValue(groupId)))
|
||||
continue
|
||||
}
|
||||
if (row.kind == TranscriptRowKind.Plan) continue
|
||||
if (row.kind == TranscriptRowKind.Notice && isStateOnlyNotice(row)) continue
|
||||
if (row.kind == TranscriptRowKind.Approval && row.id != pendingApprovalId) continue
|
||||
items.add(TranscriptItem.RowItem(row))
|
||||
}
|
||||
|
||||
return items.filterIndexed { index, item ->
|
||||
!(item is TranscriptItem.RowItem && parseTurnMeta(item.row) != null) || index == items.size - 1
|
||||
}
|
||||
}
|
||||
|
||||
fun formatElapsed(milliseconds: Long): String {
|
||||
val seconds = maxOf(0L, milliseconds / 1_000)
|
||||
if (seconds < 60) return "${seconds}s"
|
||||
val minutes = seconds / 60
|
||||
val remainder = seconds % 60
|
||||
return if (remainder == 0L) "${minutes}m" else "${minutes}m ${remainder}s"
|
||||
}
|
||||
|
||||
private fun compactNumber(value: Long): String {
|
||||
val magnitude = abs(value)
|
||||
return when {
|
||||
magnitude < 1_000 -> value.toString()
|
||||
magnitude < 1_000_000 -> {
|
||||
if (value % 1_000 == 0L) "${value / 1_000}k" else "%.1fk".format(value / 1_000.0)
|
||||
}
|
||||
else -> {
|
||||
if (value % 1_000_000 == 0L) "${value / 1_000_000}m" else "%.1fm".format(value / 1_000_000.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun formatContextTokens(value: Long): String = "${compactNumber(value)} context"
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package pt.naps.maestro.ui.structured
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import pt.naps.maestro.api.MaestroClient
|
||||
import pt.naps.maestro.api.MaestroWebSocket
|
||||
import pt.naps.maestro.data.StructuredApprovalDecision
|
||||
import pt.naps.maestro.data.Uuid
|
||||
|
||||
/**
|
||||
* Full-screen structured pane for one session (issue #267): an ACP
|
||||
* transcript over `WS /api/structured/:id`, the composer that drives
|
||||
* `.../prompt` and `.../cancel`, and the approval controls that answer
|
||||
* `.../approvals/:nonce`. docs/STRUCTURED-VIEW.md is the contract.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun StructuredScreen(
|
||||
daemonBaseUrl: String,
|
||||
sessionId: Uuid,
|
||||
sessionTitle: String,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val client = remember(daemonBaseUrl) { MaestroClient(daemonBaseUrl) }
|
||||
val webSocket = remember(daemonBaseUrl) { MaestroWebSocket(daemonBaseUrl) }
|
||||
val viewModel = remember(sessionId, daemonBaseUrl) { StructuredViewModel(client, webSocket, sessionId) }
|
||||
DisposableEffect(viewModel) { onDispose { viewModel.close() } }
|
||||
|
||||
val uiState by viewModel.state.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(sessionTitle) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Text(text = "←", style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
StructuredPane(
|
||||
state = uiState,
|
||||
onDraftChange = viewModel::setDraft,
|
||||
onSend = viewModel::sendPrompt,
|
||||
onCancel = viewModel::cancelTurn,
|
||||
onDecide = viewModel::resolveApproval,
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The pane's body, independent of the screen chrome above — easier to
|
||||
* preview/test without a daemon or a `Scaffold`. */
|
||||
@Composable
|
||||
fun StructuredPane(
|
||||
state: StructuredUiState,
|
||||
onDraftChange: (String) -> Unit,
|
||||
onSend: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
onDecide: (rowId: String, decision: StructuredApprovalDecision) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
var expandedRows by remember { mutableStateOf(setOf<String>()) }
|
||||
var expandedGroups by remember { mutableStateOf(setOf<String>()) }
|
||||
var followBottom by remember { mutableStateOf(true) }
|
||||
|
||||
val items = state.items
|
||||
val pendingRowId = state.pendingApprovalRowId
|
||||
|
||||
// Track whether the operator is at (or near) the bottom, so new content
|
||||
// only pulls the view down while they were already following it.
|
||||
LaunchedEffect(listState) {
|
||||
snapshotFlow { listState.layoutInfo }.collect { info ->
|
||||
val last = info.visibleItemsInfo.lastOrNull()
|
||||
followBottom = last == null || last.index >= info.totalItemsCount - 1
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(items, state.activityLabel()) {
|
||||
if (followBottom && items.isNotEmpty()) {
|
||||
listState.scrollToItem(items.size - 1)
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp),
|
||||
) {
|
||||
items(
|
||||
items,
|
||||
key = { item ->
|
||||
when (item) {
|
||||
is StructuredModel.TranscriptItem.RowItem -> item.row.id
|
||||
is StructuredModel.TranscriptItem.GroupItem -> item.id
|
||||
}
|
||||
},
|
||||
) { item ->
|
||||
when (item) {
|
||||
is StructuredModel.TranscriptItem.RowItem -> TranscriptRowView(
|
||||
row = item.row,
|
||||
pendingApproval = item.row.id == pendingRowId,
|
||||
approvalBusy = state.approvalBusy,
|
||||
expanded = expandedRows.contains(item.row.id),
|
||||
onExpandedChange = { expanded -> expandedRows = toggled(expandedRows, item.row.id, expanded) },
|
||||
onDecide = { decision -> onDecide(item.row.id, decision) },
|
||||
)
|
||||
is StructuredModel.TranscriptItem.GroupItem -> ToolGroup(
|
||||
toolCount = item.group.toolCount,
|
||||
lastToolLabel = item.group.lastToolLabel,
|
||||
lastToolArg = item.group.lastToolArg,
|
||||
expanded = expandedGroups.contains(item.id),
|
||||
onExpandedChange = { expanded -> expandedGroups = toggled(expandedGroups, item.id, expanded) },
|
||||
) {
|
||||
for (row in item.group.rows) {
|
||||
TranscriptRowView(
|
||||
row = row,
|
||||
pendingApproval = row.id == pendingRowId,
|
||||
approvalBusy = state.approvalBusy,
|
||||
expanded = expandedRows.contains(row.id),
|
||||
onExpandedChange = { expanded -> expandedRows = toggled(expandedRows, row.id, expanded) },
|
||||
onDecide = { decision -> onDecide(row.id, decision) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
TurnStatusBar(label = state.activityLabel())
|
||||
if (state.error != null) {
|
||||
Text(
|
||||
text = state.error,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
Composer(
|
||||
draft = state.draft,
|
||||
onDraftChange = onDraftChange,
|
||||
onSend = onSend,
|
||||
onCancel = onCancel,
|
||||
turnActive = state.turnActive,
|
||||
sendEnabled = !state.posting,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun toggled(set: Set<String>, id: String, expanded: Boolean): Set<String> =
|
||||
if (expanded) set + id else set - id
|
||||
@@ -0,0 +1,215 @@
|
||||
package pt.naps.maestro.ui.structured
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import pt.naps.maestro.api.MaestroClient
|
||||
import pt.naps.maestro.api.MaestroWebSocket
|
||||
import pt.naps.maestro.api.StructuredConnection
|
||||
import pt.naps.maestro.data.ReducedState
|
||||
import pt.naps.maestro.data.StructuredApprovalDecision
|
||||
import pt.naps.maestro.data.StructuredEvent
|
||||
import pt.naps.maestro.data.TranscriptDelta
|
||||
import pt.naps.maestro.data.TranscriptRow
|
||||
import pt.naps.maestro.data.Uuid
|
||||
|
||||
enum class StructuredConnectionStatus { Connecting, Open, Closed, Error }
|
||||
|
||||
data class StructuredUiState(
|
||||
val rows: List<TranscriptRow> = emptyList(),
|
||||
val reducedState: ReducedState = ReducedState(),
|
||||
val connection: StructuredConnectionStatus = StructuredConnectionStatus.Connecting,
|
||||
val connectionDetail: String? = null,
|
||||
val draft: String = "",
|
||||
val posting: Boolean = false,
|
||||
val approvalBusy: Boolean = false,
|
||||
val error: String? = null,
|
||||
val nowMs: Long = System.currentTimeMillis(),
|
||||
) {
|
||||
val turnActive: Boolean get() = StructuredModel.turnIsActive(reducedState)
|
||||
val pendingApprovalRowId: String? get() = StructuredModel.pendingApprovalRowId(rows, reducedState)
|
||||
val items: List<StructuredModel.TranscriptItem> get() = StructuredModel.transcriptItems(rows, pendingApprovalRowId)
|
||||
|
||||
/** The turn-status strip's label, or `null` while nothing is happening
|
||||
* (docs/STRUCTURED-VIEW.md's turn indicators, rendered as a client
|
||||
* decides). */
|
||||
fun activityLabel(): String? {
|
||||
val turn = reducedState.turn
|
||||
if (!turnActive && !posting) return null
|
||||
val tools = turn.active_tool_calls.size
|
||||
val label = when {
|
||||
posting && !turnActive -> "Sending"
|
||||
turn.thinking -> "Thinking"
|
||||
tools > 0 -> "Running $tools ${if (tools == 1) "tool" else "tools"}"
|
||||
else -> "Responding"
|
||||
}
|
||||
val startedAtMs = turn.started_at?.let { runCatching { java.time.Instant.parse(it).toEpochMilli() }.getOrNull() }
|
||||
val elapsed = startedAtMs?.let { maxOf(0L, nowMs - it) } ?: 0L
|
||||
return "$label · ${StructuredModel.formatElapsed(elapsed)}"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the `WS /api/structured/:id` connection and the REST calls the
|
||||
* pane's composer/approval controls make. A plain class over the socket's
|
||||
* `Flow` rather than an `androidx.lifecycle.ViewModel` — same shape as
|
||||
* [pt.naps.maestro.ui.spawn.SpawnViewModel]; the caller's own `remember`
|
||||
* scope owns its lifetime.
|
||||
*/
|
||||
class StructuredViewModel(
|
||||
private val client: MaestroClient,
|
||||
private val webSocket: MaestroWebSocket,
|
||||
private val sessionId: Uuid,
|
||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||
) {
|
||||
private val _state = MutableStateFlow(StructuredUiState())
|
||||
val state: StateFlow<StructuredUiState> = _state.asStateFlow()
|
||||
|
||||
private var connection: StructuredConnection? = null
|
||||
private var socketJob: Job? = null
|
||||
private var clockJob: Job? = null
|
||||
|
||||
/** The nonce this view model's own in-flight `resolveApproval` call
|
||||
* targets, so a later projection update can tell "resolved" apart from
|
||||
* "superseded by a new request" without waiting on the POST itself. */
|
||||
private var approvalInFlightNonce: String? = null
|
||||
|
||||
init {
|
||||
connect()
|
||||
clockJob = scope.launch {
|
||||
while (true) {
|
||||
delay(1_000)
|
||||
_state.update { it.copy(nowMs = System.currentTimeMillis()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** (Re)opens the socket. Every connect resends the whole transcript
|
||||
* (docs/STRUCTURED-VIEW.md), so state is replaced wholesale rather than
|
||||
* merged into what came before. */
|
||||
fun connect() {
|
||||
socketJob?.cancel()
|
||||
connection?.close()
|
||||
_state.update { it.copy(connection = StructuredConnectionStatus.Connecting, connectionDetail = null) }
|
||||
val conn = webSocket.connectStructured(sessionId)
|
||||
connection = conn
|
||||
socketJob = scope.launch {
|
||||
try {
|
||||
conn.events.collect { event ->
|
||||
_state.update { it.copy(connection = StructuredConnectionStatus.Open) }
|
||||
applyEvent(event)
|
||||
}
|
||||
_state.update { it.copy(connection = StructuredConnectionStatus.Closed) }
|
||||
} catch (e: Throwable) {
|
||||
_state.update { it.copy(connection = StructuredConnectionStatus.Error, connectionDetail = e.message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyEvent(event: StructuredEvent) {
|
||||
_state.update { current ->
|
||||
when (event) {
|
||||
is StructuredEvent.TranscriptSnapshotEvent -> current.copy(rows = event.snapshot.rows)
|
||||
is StructuredEvent.ReducedStateEvent -> withReducedState(current, event.state)
|
||||
is StructuredEvent.ProjectionUpdate -> {
|
||||
val rows = event.transcript?.let { applyDelta(current.rows, it) } ?: current.rows
|
||||
val next = withReducedState(current, event.state)
|
||||
val posting = if (StructuredModel.turnIsActive(event.state)) false else next.posting
|
||||
next.copy(rows = rows, posting = posting)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Recomputes [StructuredUiState.approvalBusy] against whatever nonce
|
||||
* this view model's own approval call is waiting on — cleared the
|
||||
* moment the daemon's own state no longer names that nonce as pending,
|
||||
* whether because it was answered or superseded. */
|
||||
private fun withReducedState(current: StructuredUiState, state: ReducedState): StructuredUiState {
|
||||
val inFlight = approvalInFlightNonce != null && pendingApprovalNonce(state) == approvalInFlightNonce
|
||||
if (!inFlight) approvalInFlightNonce = null
|
||||
return current.copy(reducedState = state, approvalBusy = inFlight)
|
||||
}
|
||||
|
||||
private fun applyDelta(rows: List<TranscriptRow>, delta: TranscriptDelta): List<TranscriptRow> = when (delta) {
|
||||
is TranscriptDelta.Append -> upsert(rows, delta.row)
|
||||
is TranscriptDelta.Patch -> upsert(rows, delta.row)
|
||||
is TranscriptDelta.Remove -> rows.filterNot { it.id == delta.id }
|
||||
}
|
||||
|
||||
private fun upsert(rows: List<TranscriptRow>, row: TranscriptRow): List<TranscriptRow> {
|
||||
val index = rows.indexOfFirst { it.id == row.id }
|
||||
return if (index == -1) rows + row else rows.toMutableList().also { it[index] = row }
|
||||
}
|
||||
|
||||
fun setDraft(text: String) {
|
||||
_state.update { it.copy(draft = text) }
|
||||
}
|
||||
|
||||
/** `POST .../prompt`: accepted at any time — idle or mid-turn, the
|
||||
* daemon queues it server-side (docs/STRUCTURED-VIEW.md), so this never
|
||||
* queues locally. */
|
||||
fun sendPrompt() {
|
||||
val text = _state.value.draft.trim()
|
||||
if (text.isEmpty() || _state.value.posting) return
|
||||
_state.update { it.copy(draft = "", posting = true, error = null) }
|
||||
scope.launch {
|
||||
try {
|
||||
client.sendStructuredPrompt(sessionId, text)
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(posting = false, draft = text, error = e.message ?: "Failed to send prompt") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelTurn() {
|
||||
_state.update { it.copy(error = null) }
|
||||
scope.launch {
|
||||
try {
|
||||
client.cancelStructuredTurn(sessionId)
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(error = e.message ?: "Failed to cancel turn") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Answers a pending permission request. A no-op — not an error — once
|
||||
* this is no longer the pending approval (the nonce moved on, or
|
||||
* resolved elsewhere), matching the daemon's own always-`204`
|
||||
* semantics. */
|
||||
fun resolveApproval(rowId: String, decision: StructuredApprovalDecision) {
|
||||
val current = _state.value
|
||||
val nonce = pendingApprovalNonce(current.reducedState)
|
||||
if (nonce == null || rowId != current.pendingApprovalRowId) return
|
||||
approvalInFlightNonce = nonce
|
||||
_state.update { it.copy(approvalBusy = true) }
|
||||
scope.launch {
|
||||
try {
|
||||
client.resolveStructuredApproval(sessionId, nonce, decision)
|
||||
} catch (e: Exception) {
|
||||
if (approvalInFlightNonce == nonce) {
|
||||
approvalInFlightNonce = null
|
||||
_state.update { it.copy(approvalBusy = false, error = e.message ?: "Failed to resolve approval") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissError() {
|
||||
_state.update { it.copy(error = null) }
|
||||
}
|
||||
|
||||
fun close() {
|
||||
clockJob?.cancel()
|
||||
socketJob?.cancel()
|
||||
connection?.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package pt.naps.maestro.ui.structured
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* A turn's collapsed activity block: two or more tool rows folded by
|
||||
* [StructuredModel.activityGroupsByTurn]. The header counts tool rows only
|
||||
* and shows the last/current tool label so the operator sees activity at a
|
||||
* glance without expanding (docs/STRUCTURED-VIEW.md). Opening it reveals
|
||||
* tools in chronological order.
|
||||
*/
|
||||
@Composable
|
||||
fun ToolGroup(
|
||||
toolCount: Int,
|
||||
lastToolLabel: String,
|
||||
lastToolArg: String,
|
||||
expanded: Boolean,
|
||||
onExpandedChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val summary = if (lastToolArg.isNotEmpty()) {
|
||||
"$toolCount tool calls · $lastToolLabel '$lastToolArg'"
|
||||
} else {
|
||||
"$toolCount tool calls"
|
||||
}
|
||||
Column(modifier = modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onExpandedChange(!expanded) }
|
||||
.padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(if (expanded) "▾" else "›", modifier = Modifier.width(20.dp))
|
||||
Text(
|
||||
text = summary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (expanded) {
|
||||
Column(modifier = Modifier.padding(start = 20.dp), content = content)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package pt.naps.maestro.ui.structured
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import androidx.compose.ui.unit.dp
|
||||
import pt.naps.maestro.data.StructuredApprovalDecision
|
||||
import pt.naps.maestro.data.TranscriptRow
|
||||
import pt.naps.maestro.data.TranscriptRowKind
|
||||
|
||||
private val prettyJson = Json { prettyPrint = true }
|
||||
|
||||
/** One transcript row, dispatched by kind (docs/STRUCTURED-VIEW.md fixes
|
||||
* the eight kinds; rendering is entirely a client decision). `Plan` rows
|
||||
* and state-only notices are filtered out upstream by
|
||||
* [StructuredModel.transcriptItems] and never reach here. */
|
||||
@Composable
|
||||
fun TranscriptRowView(
|
||||
row: TranscriptRow,
|
||||
pendingApproval: Boolean,
|
||||
approvalBusy: Boolean,
|
||||
expanded: Boolean,
|
||||
onExpandedChange: (Boolean) -> Unit,
|
||||
onDecide: (StructuredApprovalDecision) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when (row.kind) {
|
||||
TranscriptRowKind.User -> UserRow(row, modifier)
|
||||
TranscriptRowKind.AgentText -> AgentTextRow(row, modifier)
|
||||
TranscriptRowKind.AgentThought -> AgentThoughtRow(row, expanded, onExpandedChange, modifier)
|
||||
TranscriptRowKind.ToolCall, TranscriptRowKind.ToolDiff -> ToolCallRow(row, expanded, onExpandedChange, modifier)
|
||||
TranscriptRowKind.Approval -> {
|
||||
val request = remember(row.data) { parseApprovalRequest(row.data) }
|
||||
ApprovalCard(request = request, pending = pendingApproval, busy = approvalBusy, onDecide = onDecide, modifier = modifier)
|
||||
}
|
||||
TranscriptRowKind.Plan -> {}
|
||||
TranscriptRowKind.Notice -> {
|
||||
val meta = remember(row.data) { parseTurnMeta(row) }
|
||||
if (meta != null) TurnMetaRow(meta, modifier) else NoticeRow(row, modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserRow(row: TranscriptRow, modifier: Modifier = Modifier) {
|
||||
val text = remember(row.data) { contentChunkText(row.data) }
|
||||
if (text.isBlank()) return
|
||||
Box(modifier = modifier.fillMaxWidth().padding(vertical = 4.dp), contentAlignment = Alignment.CenterEnd) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.wrapContentWidth()
|
||||
.background(MaterialTheme.colorScheme.primaryContainer, RoundedCornerShape(12.dp))
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(text, color = MaterialTheme.colorScheme.onPrimaryContainer, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgentTextRow(row: TranscriptRow, modifier: Modifier = Modifier) {
|
||||
val text = remember(row.data) { contentChunkText(row.data) }
|
||||
if (text.isBlank()) return
|
||||
Text(
|
||||
text = renderSimpleMarkdown(text),
|
||||
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgentThoughtRow(
|
||||
row: TranscriptRow,
|
||||
expanded: Boolean,
|
||||
onExpandedChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val text = remember(row.data) { contentChunkText(row.data).trim() }
|
||||
if (text.isBlank()) return
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onExpandedChange(!expanded) }
|
||||
.padding(vertical = 4.dp),
|
||||
) {
|
||||
Text("Thinking", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(
|
||||
text = text,
|
||||
maxLines = if (expanded) Int.MAX_VALUE else 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToolCallRow(
|
||||
row: TranscriptRow,
|
||||
expanded: Boolean,
|
||||
onExpandedChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val tool = remember(row.data) { parseToolCall(row.data) }
|
||||
val hasDiff = tool.content.any { it is AcpToolCallContent.Diff }
|
||||
val title = remember(tool) { StructuredModel.splitToolTitle(tool.title, tool.name, tool.kind) }
|
||||
val statusMeta = remember(tool.status, hasDiff) { StructuredModel.statusMeta(tool.status, hasDiff) }
|
||||
val diffSummary = if (hasDiff) remember(tool.content) { diffStatsSummary(tool.content) } else null
|
||||
val disclosable = tool.content.isNotEmpty() || tool.rawInput != null || tool.rawOutput != null
|
||||
val metaText = listOfNotNull(diffSummary, statusMeta?.text).joinToString(" · ")
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.let { if (disclosable) it.clickable { onExpandedChange(!expanded) } else it }
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(title.label, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Medium)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
title.arg,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
if (metaText.isNotEmpty()) {
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
metaText,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = when (statusMeta?.kind) {
|
||||
"failed" -> MaterialTheme.colorScheme.error
|
||||
"running" -> MaterialTheme.colorScheme.primary
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
if (disclosable && expanded) {
|
||||
Column(modifier = Modifier.padding(start = 8.dp, top = 4.dp)) {
|
||||
for (block in tool.content) {
|
||||
when (block) {
|
||||
is AcpToolCallContent.Content -> {
|
||||
val text = contentBlockText(block.block)
|
||||
if (text.isNotBlank()) {
|
||||
Text(text, style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace))
|
||||
}
|
||||
}
|
||||
is AcpToolCallContent.Diff -> DiffView(path = block.path, oldText = block.oldText, newText = block.newText)
|
||||
is AcpToolCallContent.Terminal -> Text(
|
||||
block.terminalId?.let { "Terminal · $it" } ?: "Embedded terminal",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
tool.rawInput?.let { ToolDetailBlock("Input", prettyPrintJson(it)) }
|
||||
tool.rawOutput?.let { ToolDetailBlock("Output", prettyPrintJson(it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToolDetailBlock(label: String, text: String) {
|
||||
Column(modifier = Modifier.padding(top = 4.dp)) {
|
||||
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(text, style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace))
|
||||
}
|
||||
}
|
||||
|
||||
private fun diffStatsSummary(content: List<AcpToolCallContent>): String {
|
||||
var added = 0
|
||||
var removed = 0
|
||||
for (block in content) {
|
||||
if (block !is AcpToolCallContent.Diff) continue
|
||||
val stats = DiffModel.diffStats(DiffModel.computeDiffLines(block.oldText, block.newText))
|
||||
added += stats.added
|
||||
removed += stats.removed
|
||||
}
|
||||
return "+$added −$removed"
|
||||
}
|
||||
|
||||
private fun prettyPrintJson(element: JsonElement): String =
|
||||
runCatching { prettyJson.encodeToString(JsonElement.serializer(), element) }.getOrElse { element.toString() }
|
||||
|
||||
@Composable
|
||||
private fun NoticeRow(row: TranscriptRow, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = "[${row.kind.name}]",
|
||||
modifier = modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TurnMetaRow(meta: TurnMetaPayload, modifier: Modifier = Modifier) {
|
||||
val parts = mutableListOf(StructuredModel.formatElapsed(meta.elapsedMs))
|
||||
when {
|
||||
meta.contextDelta != null -> {
|
||||
val sign = if (meta.contextDelta > 0) "+" else ""
|
||||
parts += "$sign${meta.contextDelta} context tokens"
|
||||
}
|
||||
meta.contextTokens != null -> parts += StructuredModel.formatContextTokens(meta.contextTokens)
|
||||
}
|
||||
meta.model?.let { parts += it }
|
||||
if (meta.outcome != "completed") parts += meta.outcome
|
||||
Text(
|
||||
text = parts.joinToString(" · "),
|
||||
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package pt.naps.maestro.ui.structured
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/** Strip above the composer: responding/thinking/active tool calls plus
|
||||
* elapsed time (docs/STRUCTURED-VIEW.md's turn indicators, drawn as a
|
||||
* client decides). Hidden while nothing is happening. */
|
||||
@Composable
|
||||
fun TurnStatusBar(label: String?, modifier: Modifier = Modifier) {
|
||||
if (label == null) return
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(14.dp), strokeWidth = 2.dp)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package pt.naps.maestro.ui.structured
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import pt.naps.maestro.data.ReducedState
|
||||
import pt.naps.maestro.data.TranscriptRow
|
||||
import pt.naps.maestro.data.TranscriptRowKind
|
||||
|
||||
private fun json(text: String) = Json.parseToJsonElement(text)
|
||||
|
||||
class AcpPayloadTest {
|
||||
@Test
|
||||
fun `contentChunkText reads a text content block`() {
|
||||
assertEquals("hello", contentChunkText(json("""{"content":{"type":"text","text":"hello"}}""")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `contentBlockText placeholders non-text blocks instead of dropping them`() {
|
||||
assertEquals("[image]", contentChunkText(json("""{"content":{"type":"image"}}""")))
|
||||
assertEquals("[audio]", contentChunkText(json("""{"content":{"type":"audio"}}""")))
|
||||
assertEquals(
|
||||
"[resource: notes.txt]",
|
||||
contentChunkText(json("""{"content":{"type":"resource_link","name":"notes.txt"}}""")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseToolCall reads the fields ToolRow needs`() {
|
||||
val tool = parseToolCall(
|
||||
json(
|
||||
"""{"title":"Bash(ls)","kind":"execute","status":"in_progress",
|
||||
"content":[{"type":"diff","path":"a.txt","oldText":"x","newText":"y"}],
|
||||
"locations":[{"path":"a.txt"}]}""",
|
||||
),
|
||||
)
|
||||
assertEquals("Bash(ls)", tool.title)
|
||||
assertEquals("execute", tool.kind)
|
||||
assertEquals("in_progress", tool.status)
|
||||
assertEquals(listOf("a.txt"), tool.locations)
|
||||
assertTrue(tool.content.single() is AcpToolCallContent.Diff)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseToolCall degrades gracefully on a malformed shape`() {
|
||||
val tool = parseToolCall(json("""["not", "an", "object"]"""))
|
||||
assertNull(tool.title)
|
||||
assertEquals(emptyList<AcpToolCallContent>(), tool.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseApprovalRequest reads the tool call and permission options`() {
|
||||
val request = parseApprovalRequest(
|
||||
json(
|
||||
"""{"toolCall":{"title":"Write file"},
|
||||
"options":[{"optionId":"1","name":"Allow","kind":"allow_once"}]}""",
|
||||
),
|
||||
)
|
||||
assertEquals("Write file", request.toolCall.title)
|
||||
assertEquals(1, request.options.size)
|
||||
assertEquals("allow_once", request.options.single().kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseTurnMeta reads the daemon footer and ignores rows without one`() {
|
||||
val footer = TranscriptRow(
|
||||
id = "notice:1",
|
||||
kind = TranscriptRowKind.Notice,
|
||||
data = json("""{"maestroTurnMeta":{"elapsedMs":4200,"outcome":"cancelled","model":"claude"}}"""),
|
||||
)
|
||||
val meta = parseTurnMeta(footer)
|
||||
assertEquals(4200L, meta?.elapsedMs)
|
||||
assertEquals("cancelled", meta?.outcome)
|
||||
assertEquals("claude", meta?.model)
|
||||
|
||||
val plain = TranscriptRow(id = "notice:2", kind = TranscriptRowKind.Notice, data = json("""{"sessionUpdate":"x"}"""))
|
||||
assertNull(parseTurnMeta(plain))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isStateOnlyNotice recognises the two consumed-as-state variants`() {
|
||||
fun notice(update: String) =
|
||||
TranscriptRow(id = "n", kind = TranscriptRowKind.Notice, data = json("""{"sessionUpdate":"$update"}"""))
|
||||
assertTrue(isStateOnlyNotice(notice("available_commands_update")))
|
||||
assertTrue(isStateOnlyNotice(notice("session_info_update")))
|
||||
assertTrue(!isStateOnlyNotice(notice("agent_message_chunk")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pendingApprovalNonce reads the nonce only while one is present`() {
|
||||
assertEquals("abc", pendingApprovalNonce(ReducedState(pending_approval = json("""{"nonce":"abc"}"""))))
|
||||
assertNull(pendingApprovalNonce(ReducedState()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package pt.naps.maestro.ui.structured
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class DiffModelTest {
|
||||
@Test
|
||||
fun `new file adds every line`() {
|
||||
val lines = DiffModel.computeDiffLines(null, "a\nb\n")
|
||||
assertEquals(listOf(DiffLine.Kind.Add, DiffLine.Kind.Add), lines.map { it.kind })
|
||||
assertEquals(listOf("a", "b"), lines.map { it.text })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `identical text is all context, no hunk collapsing for a short file`() {
|
||||
val lines = DiffModel.computeDiffLines("a\nb\nc\n", "a\nb\nc\n")
|
||||
assertEquals(listOf(DiffLine.Kind.Ctx, DiffLine.Kind.Ctx, DiffLine.Kind.Ctx), lines.map { it.kind })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a changed middle line reports one del and one add`() {
|
||||
val lines = DiffModel.computeDiffLines("a\nb\nc\n", "a\nx\nc\n")
|
||||
val stats = DiffModel.diffStats(lines)
|
||||
assertEquals(1, stats.added)
|
||||
assertEquals(1, stats.removed)
|
||||
assertEquals(listOf(DiffLine.Kind.Ctx, DiffLine.Kind.Del, DiffLine.Kind.Add, DiffLine.Kind.Ctx), lines.map { it.kind })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a long unchanged run collapses to a hunk between real context`() {
|
||||
val old = (1..20).joinToString("\n") { "line$it" } + "\n"
|
||||
val new = old.replaceFirst("line1\n", "line1\nINSERTED\n")
|
||||
val lines = DiffModel.computeDiffLines(old, new)
|
||||
assertEquals(1, lines.count { it.kind == DiffLine.Kind.Hunk })
|
||||
assertEquals(1, DiffModel.diffStats(lines).added)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a missing trailing newline is marked`() {
|
||||
val lines = DiffModel.computeDiffLines(null, "a")
|
||||
assertEquals(listOf(DiffLine.Kind.Add, DiffLine.Kind.Eof), lines.map { it.kind })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty old and new text produce no lines`() {
|
||||
assertEquals(emptyList<DiffLine>(), DiffModel.computeDiffLines("", ""))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package pt.naps.maestro.ui.structured
|
||||
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class MarkdownTextTest {
|
||||
@Test
|
||||
fun `plain text passes through unchanged`() {
|
||||
val result = renderSimpleMarkdown("just some text")
|
||||
assertEquals("just some text", result.text)
|
||||
assertTrue(result.spanStyles.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bold and italic markers are stripped from the text and styled`() {
|
||||
val result = renderSimpleMarkdown("**bold** and *italic*")
|
||||
assertEquals("bold and italic", result.text)
|
||||
assertTrue(result.spanStyles.any { it.item == SpanStyle(fontWeight = FontWeight.Bold) })
|
||||
assertTrue(result.spanStyles.any { it.item == SpanStyle(fontStyle = FontStyle.Italic) })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inline code keeps its text without the backticks`() {
|
||||
val result = renderSimpleMarkdown("run `ls -la` now")
|
||||
assertEquals("run ls -la now", result.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a header line is bold and a bullet gets a leading dot`() {
|
||||
val header = renderSimpleMarkdown("# Title")
|
||||
assertEquals("Title", header.text)
|
||||
|
||||
val bullet = renderSimpleMarkdown("- one\n- two")
|
||||
assertEquals("• one\n• two", bullet.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a fenced code block keeps its content without the fence markers`() {
|
||||
val result = renderSimpleMarkdown("```\nlet x = 1;\n```")
|
||||
assertEquals("\nlet x = 1;\n", result.text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package pt.naps.maestro.ui.structured
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import pt.naps.maestro.data.ReducedState
|
||||
import pt.naps.maestro.data.StructuredApprovalDecision
|
||||
import pt.naps.maestro.data.TranscriptRow
|
||||
import pt.naps.maestro.data.TranscriptRowKind
|
||||
import pt.naps.maestro.data.TurnState
|
||||
|
||||
private fun row(id: String, kind: TranscriptRowKind, json: String = "{}") =
|
||||
TranscriptRow(id = id, kind = kind, data = Json.parseToJsonElement(json))
|
||||
|
||||
class StructuredModelTest {
|
||||
// -- splitToolTitle / toolKindLabel --------------------------------
|
||||
|
||||
@Test
|
||||
fun `splitToolTitle reads the Name(argument) convention`() {
|
||||
val title = StructuredModel.splitToolTitle("Bash(git status)", null, "execute")
|
||||
assertEquals("Bash", title.label)
|
||||
assertEquals("git status", title.arg)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `splitToolTitle prefers an explicit name over the parsed one`() {
|
||||
val title = StructuredModel.splitToolTitle("Bash(git status)", "Shell", "execute")
|
||||
assertEquals("Shell", title.label)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `splitToolTitle without the convention keeps the whole title as the arg`() {
|
||||
val title = StructuredModel.splitToolTitle("Reading src/main.rs", null, "read")
|
||||
assertEquals("Read", title.label)
|
||||
assertEquals("Reading src/main.rs", title.arg)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `splitToolTitle with no title at all has an empty arg`() {
|
||||
val title = StructuredModel.splitToolTitle(null, null, null)
|
||||
assertEquals("Tool", title.label)
|
||||
assertEquals("", title.arg)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toolKindLabel falls back to Tool for an unrecognised kind`() {
|
||||
assertEquals("Tool", StructuredModel.toolKindLabel("something_new"))
|
||||
assertEquals("Execute", StructuredModel.toolKindLabel("execute"))
|
||||
}
|
||||
|
||||
// -- statusMeta -----------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `statusMeta reports running and failed regardless of diff`() {
|
||||
assertEquals("running", StructuredModel.statusMeta("in_progress", hasDiff = true)?.text)
|
||||
assertEquals("failed", StructuredModel.statusMeta("failed", hasDiff = false)?.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `statusMeta hides pending once a diff is present`() {
|
||||
assertEquals("pending", StructuredModel.statusMeta("pending", hasDiff = false)?.text)
|
||||
assertNull(StructuredModel.statusMeta("pending", hasDiff = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `statusMeta is null once a tool has completed`() {
|
||||
assertNull(StructuredModel.statusMeta("completed", hasDiff = false))
|
||||
}
|
||||
|
||||
// -- turn / approval state -------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `turnIsActive is true while responding or thinking`() {
|
||||
assertTrue(StructuredModel.turnIsActive(ReducedState(turn = TurnState(responding = true))))
|
||||
assertTrue(StructuredModel.turnIsActive(ReducedState(turn = TurnState(thinking = true))))
|
||||
assertTrue(!StructuredModel.turnIsActive(ReducedState(turn = TurnState())))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pendingApprovalRowId is the newest approval row only while one is pending`() {
|
||||
val rows = listOf(
|
||||
row("approval:1", TranscriptRowKind.Approval),
|
||||
row("tool:1", TranscriptRowKind.ToolCall),
|
||||
row("approval:2", TranscriptRowKind.Approval),
|
||||
)
|
||||
val pending = ReducedState(pending_approval = Json.parseToJsonElement("""{"nonce":"n"}"""))
|
||||
assertEquals("approval:2", StructuredModel.pendingApprovalRowId(rows, pending))
|
||||
assertNull(StructuredModel.pendingApprovalRowId(rows, ReducedState()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `offeredDecisions maps ACP option kinds to the three daemon decisions`() {
|
||||
val decisions = StructuredModel.offeredDecisions(
|
||||
listOf(
|
||||
AcpPermissionOption("1", "Allow", "allow_once"),
|
||||
AcpPermissionOption("2", "Reject", "reject_always"),
|
||||
),
|
||||
)
|
||||
assertEquals(listOf(StructuredApprovalDecision.AllowOnce, StructuredApprovalDecision.Deny), decisions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `offeredDecisions is empty when nothing recognisable is offered`() {
|
||||
assertEquals(emptyList<StructuredApprovalDecision>(), StructuredModel.offeredDecisions(emptyList()))
|
||||
}
|
||||
|
||||
// -- grouping / transcriptItems ---------------------------------------
|
||||
|
||||
private fun toolRow(id: String, title: String) = row(id, TranscriptRowKind.ToolCall, """{"title":"$title"}""")
|
||||
|
||||
@Test
|
||||
fun `two or more consecutive tool rows fold into a group`() {
|
||||
val rows = listOf(
|
||||
row("user:1", TranscriptRowKind.User),
|
||||
toolRow("tool:1", "Bash(ls)"),
|
||||
toolRow("tool:2", "Bash(pwd)"),
|
||||
)
|
||||
val items = StructuredModel.transcriptItems(rows, pendingApprovalId = null)
|
||||
assertEquals(2, items.size) // user row + one group
|
||||
val group = items[1] as StructuredModel.TranscriptItem.GroupItem
|
||||
assertEquals(2, group.group.toolCount)
|
||||
assertEquals("Bash", group.group.lastToolLabel)
|
||||
assertEquals("pwd", group.group.lastToolArg)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a single tool row stays standalone`() {
|
||||
val rows = listOf(toolRow("tool:1", "Bash(ls)"))
|
||||
val items = StructuredModel.transcriptItems(rows, pendingApprovalId = null)
|
||||
assertEquals(1, items.size)
|
||||
assertTrue(items[0] is StructuredModel.TranscriptItem.RowItem)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `agent text between tool calls splits the run into separate groups`() {
|
||||
val rows = listOf(
|
||||
toolRow("tool:1", "Bash(a)"),
|
||||
toolRow("tool:2", "Bash(b)"),
|
||||
row("agent:1", TranscriptRowKind.AgentText, """{"content":{"type":"text","text":"hi"}}"""),
|
||||
toolRow("tool:3", "Bash(c)"),
|
||||
toolRow("tool:4", "Bash(d)"),
|
||||
)
|
||||
val items = StructuredModel.transcriptItems(rows, pendingApprovalId = null)
|
||||
val groups = items.filterIsInstance<StructuredModel.TranscriptItem.GroupItem>()
|
||||
assertEquals(2, groups.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plan rows and state-only notices never render`() {
|
||||
val rows = listOf(
|
||||
row("plan", TranscriptRowKind.Plan, """{"entries":[]}"""),
|
||||
row("notice:1", TranscriptRowKind.Notice, """{"sessionUpdate":"session_info_update"}"""),
|
||||
)
|
||||
assertEquals(emptyList<StructuredModel.TranscriptItem>(), StructuredModel.transcriptItems(rows, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a resolved approval outside a group is dropped, a pending one always renders`() {
|
||||
val rows = listOf(row("approval:1", TranscriptRowKind.Approval))
|
||||
assertEquals(emptyList<StructuredModel.TranscriptItem>(), StructuredModel.transcriptItems(rows, pendingApprovalId = null))
|
||||
val pendingItems = StructuredModel.transcriptItems(rows, pendingApprovalId = "approval:1")
|
||||
assertEquals(1, pendingItems.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only the final row may be a turn footer, earlier ones are dropped`() {
|
||||
fun footer(id: String) = row(
|
||||
id,
|
||||
TranscriptRowKind.Notice,
|
||||
"""{"maestroTurnMeta":{"elapsedMs":1000,"outcome":"completed"}}""",
|
||||
)
|
||||
val rows = listOf(footer("turn_meta:1"), toolRow("tool:1", "Bash(ls)"), footer("turn_meta:2"))
|
||||
val items = StructuredModel.transcriptItems(rows, pendingApprovalId = null)
|
||||
assertEquals(2, items.size)
|
||||
assertEquals("turn_meta:2", (items.last() as StructuredModel.TranscriptItem.RowItem).row.id)
|
||||
}
|
||||
|
||||
// -- formatting ---------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `formatElapsed rounds down to whole seconds and switches to minutes at 60s`() {
|
||||
assertEquals("0s", StructuredModel.formatElapsed(0))
|
||||
assertEquals("59s", StructuredModel.formatElapsed(59_000))
|
||||
assertEquals("1m", StructuredModel.formatElapsed(60_000))
|
||||
assertEquals("1m 1s", StructuredModel.formatElapsed(61_000))
|
||||
assertEquals("2m", StructuredModel.formatElapsed(120_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `formatContextTokens compacts large values`() {
|
||||
assertEquals("999 context", StructuredModel.formatContextTokens(999))
|
||||
assertEquals("1k context", StructuredModel.formatContextTokens(1_000))
|
||||
assertEquals("1.5k context", StructuredModel.formatContextTokens(1_500))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package pt.naps.maestro.ui.structured
|
||||
|
||||
import app.cash.turbine.test
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import pt.naps.maestro.api.MaestroClient
|
||||
import pt.naps.maestro.api.MaestroWebSocket
|
||||
import pt.naps.maestro.data.StructuredApprovalDecision
|
||||
|
||||
/** Exercises [StructuredViewModel] end to end against a real
|
||||
* `MaestroWebSocket`/`MaestroClient` pair, using `MockWebServer`'s socket
|
||||
* upgrade to stand in for the daemon (docs/STRUCTURED-VIEW.md). */
|
||||
class StructuredViewModelTest {
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var client: MaestroClient
|
||||
private lateinit var webSocket: MaestroWebSocket
|
||||
private lateinit var scope: CoroutineScope
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
val baseUrl = server.url("/").toString()
|
||||
client = MaestroClient(baseUrl)
|
||||
webSocket = MaestroWebSocket(baseUrl)
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
scope.cancel()
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
/** Enqueues one server-side socket upgrade; the returned handle
|
||||
* resolves once the client's connect has completed the handshake. */
|
||||
private fun enqueueSocket(): CompletableDeferred<WebSocket> {
|
||||
val opened = CompletableDeferred<WebSocket>()
|
||||
server.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
opened.complete(webSocket)
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
return opened
|
||||
}
|
||||
|
||||
private suspend fun StructuredViewModel.awaitState(predicate: (StructuredUiState) -> Boolean): StructuredUiState {
|
||||
var result: StructuredUiState? = null
|
||||
state.test(timeout = 5.seconds) {
|
||||
var item = awaitItem()
|
||||
while (!predicate(item)) item = awaitItem()
|
||||
result = item
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
return result ?: error("state predicate never satisfied")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applies a transcript snapshot from the socket`() = runBlocking {
|
||||
val opened = enqueueSocket()
|
||||
val vm = StructuredViewModel(client, webSocket, "s1", scope)
|
||||
val serverSocket = opened.await()
|
||||
|
||||
serverSocket.send(
|
||||
"""{"type":"transcript_snapshot","snapshot":{"cursor":1,
|
||||
"rows":[{"id":"user:1","kind":"user","data":{"content":{"type":"text","text":"hi"}}}]}}""",
|
||||
)
|
||||
|
||||
val after = vm.awaitState { it.rows.isNotEmpty() }
|
||||
assertEquals(1, after.rows.size)
|
||||
assertEquals("user:1", after.rows.first().id)
|
||||
assertEquals(StructuredConnectionStatus.Open, after.connection)
|
||||
vm.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `appends and patches rows via projection updates, driven by reduced state`() = runBlocking {
|
||||
val opened = enqueueSocket()
|
||||
val vm = StructuredViewModel(client, webSocket, "s1", scope)
|
||||
val serverSocket = opened.await()
|
||||
|
||||
serverSocket.send("""{"type":"transcript_snapshot","snapshot":{"cursor":1,"rows":[]}}""")
|
||||
vm.awaitState { it.connection == StructuredConnectionStatus.Open }
|
||||
|
||||
serverSocket.send(
|
||||
"""{"type":"projection_update","transcript":{"type":"append","row":
|
||||
{"id":"agent:1","kind":"agent_text","data":{"content":{"type":"text","text":"hi"}}}},
|
||||
"state":{"turn":{"responding":true,"thinking":false,"active_tool_calls":[]}}}""",
|
||||
)
|
||||
val afterAppend = vm.awaitState { it.rows.size == 1 }
|
||||
assertTrue(afterAppend.turnActive)
|
||||
|
||||
serverSocket.send(
|
||||
"""{"type":"projection_update","transcript":{"type":"patch","row":
|
||||
{"id":"agent:1","kind":"agent_text","data":{"content":{"type":"text","text":"hi there"}}}},
|
||||
"state":{"turn":{"responding":false,"thinking":false,"active_tool_calls":[]}}}""",
|
||||
)
|
||||
val afterPatch = vm.awaitState { !it.turnActive }
|
||||
assertEquals(1, afterPatch.rows.size)
|
||||
assertEquals("hi there", contentChunkText(afterPatch.rows.single().data))
|
||||
vm.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a remove delta drops the row`() = runBlocking {
|
||||
val opened = enqueueSocket()
|
||||
val vm = StructuredViewModel(client, webSocket, "s1", scope)
|
||||
val serverSocket = opened.await()
|
||||
|
||||
serverSocket.send(
|
||||
"""{"type":"transcript_snapshot","snapshot":{"cursor":1,
|
||||
"rows":[{"id":"notice:1","kind":"notice","data":{}}]}}""",
|
||||
)
|
||||
vm.awaitState { it.rows.size == 1 }
|
||||
|
||||
serverSocket.send(
|
||||
"""{"type":"projection_update","transcript":{"type":"remove","id":"notice:1"},
|
||||
"state":{"turn":{"responding":false,"thinking":false,"active_tool_calls":[]}}}""",
|
||||
)
|
||||
val after = vm.awaitState { it.rows.isEmpty() }
|
||||
assertEquals(emptyList<Any>(), after.rows)
|
||||
vm.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sendPrompt posts to the daemon and clears the draft`() = runBlocking {
|
||||
val opened = enqueueSocket()
|
||||
val vm = StructuredViewModel(client, webSocket, "s1", scope)
|
||||
opened.await()
|
||||
|
||||
server.enqueue(MockResponse().setResponseCode(204))
|
||||
vm.setDraft("hello")
|
||||
vm.sendPrompt()
|
||||
|
||||
val after = vm.awaitState { !it.posting }
|
||||
assertEquals("", after.draft)
|
||||
|
||||
var request = server.takeRequest()
|
||||
while (request.method != "POST") request = server.takeRequest()
|
||||
assertEquals("/api/sessions/s1/structured/prompt", request.path)
|
||||
vm.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed prompt restores the draft and surfaces an error`() = runBlocking {
|
||||
val opened = enqueueSocket()
|
||||
val vm = StructuredViewModel(client, webSocket, "s1", scope)
|
||||
opened.await()
|
||||
|
||||
server.enqueue(MockResponse().setResponseCode(500))
|
||||
vm.setDraft("hello")
|
||||
vm.sendPrompt()
|
||||
|
||||
val after = vm.awaitState { it.error != null }
|
||||
assertEquals("hello", after.draft)
|
||||
vm.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancelTurn posts to the cancel endpoint`() = runBlocking {
|
||||
val opened = enqueueSocket()
|
||||
val vm = StructuredViewModel(client, webSocket, "s1", scope)
|
||||
opened.await()
|
||||
|
||||
server.enqueue(MockResponse().setResponseCode(204))
|
||||
vm.cancelTurn()
|
||||
|
||||
var request = server.takeRequest()
|
||||
while (request.method != "POST") request = server.takeRequest()
|
||||
assertEquals("/api/sessions/s1/structured/cancel", request.path)
|
||||
vm.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolveApproval is a no-op for a stale row id and posts for the pending one`() = runBlocking {
|
||||
val opened = enqueueSocket()
|
||||
val vm = StructuredViewModel(client, webSocket, "s1", scope)
|
||||
val serverSocket = opened.await()
|
||||
|
||||
serverSocket.send(
|
||||
"""{"type":"transcript_snapshot","snapshot":{"cursor":1,
|
||||
"rows":[{"id":"approval:1","kind":"approval","data":{}}]}}""",
|
||||
)
|
||||
serverSocket.send(
|
||||
"""{"type":"reduced_state","state":{"turn":{"responding":false,"thinking":false,"active_tool_calls":[]},
|
||||
"pending_approval":{"nonce":"n1"}}}""",
|
||||
)
|
||||
vm.awaitState { it.pendingApprovalRowId == "approval:1" }
|
||||
|
||||
// A row id that is not the pending one never reaches the network —
|
||||
// the daemon's own nonce rules make this a no-op, not an error.
|
||||
vm.resolveApproval("approval:0", StructuredApprovalDecision.AllowOnce)
|
||||
assertEquals(1, server.requestCount) // only the socket upgrade so far
|
||||
|
||||
server.enqueue(MockResponse().setResponseCode(204))
|
||||
vm.resolveApproval("approval:1", StructuredApprovalDecision.AllowOnce)
|
||||
|
||||
var request = server.takeRequest()
|
||||
while (request.method != "POST") request = server.takeRequest()
|
||||
assertEquals("/api/sessions/s1/structured/approvals/n1", request.path)
|
||||
vm.close()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user