diff --git a/android/app/src/main/java/pt/naps/maestro/MainActivity.kt b/android/app/src/main/java/pt/naps/maestro/MainActivity.kt index d13fe27..c868e39 100644 --- a/android/app/src/main/java/pt/naps/maestro/MainActivity.kt +++ b/android/app/src/main/java/pt/naps/maestro/MainActivity.kt @@ -36,8 +36,10 @@ 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.structured.StructuredScreen import pt.naps.maestro.ui.terminal.TerminalScreen import pt.naps.maestro.ui.theme.MaestroTheme @@ -62,7 +64,14 @@ class MainActivity : ComponentActivity() { var openSession by remember { mutableStateOf(null) } val session = openSession - if (session != null) { + if (session != null && session.view == SessionView.Structured) { + StructuredScreen( + daemonBaseUrl = baseUrl, + sessionId = session.id, + sessionTitle = sessionName(session), + onBack = { openSession = null }, + ) + } else if (session != null) { TerminalScreen( daemonBaseUrl = baseUrl, sessionId = session.id, diff --git a/android/app/src/main/java/pt/naps/maestro/ui/structured/AcpPayload.kt b/android/app/src/main/java/pt/naps/maestro/ui/structured/AcpPayload.kt new file mode 100644 index 0000000..3cb1112 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/ui/structured/AcpPayload.kt @@ -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 = emptyList(), + val locations: List = 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) + +/** `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") diff --git a/android/app/src/main/java/pt/naps/maestro/ui/structured/ApprovalCard.kt b/android/app/src/main/java/pt/naps/maestro/ui/structured/ApprovalCard.kt new file mode 100644 index 0000000..d06c6e0 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/ui/structured/ApprovalCard.kt @@ -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) + } + } + } + } + } +} diff --git a/android/app/src/main/java/pt/naps/maestro/ui/structured/Composer.kt b/android/app/src/main/java/pt/naps/maestro/ui/structured/Composer.kt new file mode 100644 index 0000000..73eecca --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/ui/structured/Composer.kt @@ -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 = "➤") + } + } +} diff --git a/android/app/src/main/java/pt/naps/maestro/ui/structured/DiffModel.kt b/android/app/src/main/java/pt/naps/maestro/ui/structured/DiffModel.kt new file mode 100644 index 0000000..69b6414 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/ui/structured/DiffModel.kt @@ -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 { + 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, b: List): List { + 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() + 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, b: List): List { + 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): List { + val out = mutableListOf() + 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 { + 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): 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) + } +} diff --git a/android/app/src/main/java/pt/naps/maestro/ui/structured/DiffView.kt b/android/app/src/main/java/pt/naps/maestro/ui/structured/DiffView.kt new file mode 100644 index 0000000..30298e3 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/ui/structured/DiffView.kt @@ -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), + ) + } + } +} diff --git a/android/app/src/main/java/pt/naps/maestro/ui/structured/MarkdownText.kt b/android/app/src/main/java/pt/naps/maestro/ui/structured/MarkdownText.kt new file mode 100644 index 0000000..310c9e5 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/ui/structured/MarkdownText.kt @@ -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 + } + } + } +} diff --git a/android/app/src/main/java/pt/naps/maestro/ui/structured/StructuredModel.kt b/android/app/src/main/java/pt/naps/maestro/ui/structured/StructuredModel.kt new file mode 100644 index 0000000..1c90dd1 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/ui/structured/StructuredModel.kt @@ -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, 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, 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): List { + val kinds = options.map { it.kind }.toSet() + val decisions = mutableListOf() + 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, + 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::`. + */ + fun activityGroupsByTurn(rows: List, pendingApprovalId: String?): Map { + val groups = LinkedHashMap() + var turnId = "before-first-message" + var runSeq = 0 + var runTools = mutableListOf() + var runPending = mutableListOf() + + 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, pendingApprovalId: String?): List { + val groups = activityGroupsByTurn(rows, pendingApprovalId) + val rowToGroup = HashMap() + for ((groupId, group) in groups) for (r in group.rows) rowToGroup[r.id] = groupId + + val items = mutableListOf() + val emitted = mutableSetOf() + 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" +} diff --git a/android/app/src/main/java/pt/naps/maestro/ui/structured/StructuredPane.kt b/android/app/src/main/java/pt/naps/maestro/ui/structured/StructuredPane.kt new file mode 100644 index 0000000..e3bb1e4 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/ui/structured/StructuredPane.kt @@ -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()) } + var expandedGroups by remember { mutableStateOf(setOf()) } + 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, id: String, expanded: Boolean): Set = + if (expanded) set + id else set - id diff --git a/android/app/src/main/java/pt/naps/maestro/ui/structured/StructuredViewModel.kt b/android/app/src/main/java/pt/naps/maestro/ui/structured/StructuredViewModel.kt new file mode 100644 index 0000000..45a1f76 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/ui/structured/StructuredViewModel.kt @@ -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 = 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 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 = _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, delta: TranscriptDelta): List = 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, row: TranscriptRow): List { + 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() + } +} diff --git a/android/app/src/main/java/pt/naps/maestro/ui/structured/ToolGroup.kt b/android/app/src/main/java/pt/naps/maestro/ui/structured/ToolGroup.kt new file mode 100644 index 0000000..643a184 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/ui/structured/ToolGroup.kt @@ -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) + } + } +} diff --git a/android/app/src/main/java/pt/naps/maestro/ui/structured/TranscriptRowView.kt b/android/app/src/main/java/pt/naps/maestro/ui/structured/TranscriptRowView.kt new file mode 100644 index 0000000..c1cca92 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/ui/structured/TranscriptRowView.kt @@ -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): 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, + ) +} diff --git a/android/app/src/main/java/pt/naps/maestro/ui/structured/TurnStatusBar.kt b/android/app/src/main/java/pt/naps/maestro/ui/structured/TurnStatusBar.kt new file mode 100644 index 0000000..9e2bddf --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/ui/structured/TurnStatusBar.kt @@ -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) + } +} diff --git a/android/app/src/test/java/pt/naps/maestro/ui/structured/AcpPayloadTest.kt b/android/app/src/test/java/pt/naps/maestro/ui/structured/AcpPayloadTest.kt new file mode 100644 index 0000000..c606a56 --- /dev/null +++ b/android/app/src/test/java/pt/naps/maestro/ui/structured/AcpPayloadTest.kt @@ -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(), 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())) + } +} diff --git a/android/app/src/test/java/pt/naps/maestro/ui/structured/DiffModelTest.kt b/android/app/src/test/java/pt/naps/maestro/ui/structured/DiffModelTest.kt new file mode 100644 index 0000000..98baa70 --- /dev/null +++ b/android/app/src/test/java/pt/naps/maestro/ui/structured/DiffModelTest.kt @@ -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(), DiffModel.computeDiffLines("", "")) + } +} diff --git a/android/app/src/test/java/pt/naps/maestro/ui/structured/MarkdownTextTest.kt b/android/app/src/test/java/pt/naps/maestro/ui/structured/MarkdownTextTest.kt new file mode 100644 index 0000000..0c0ad2c --- /dev/null +++ b/android/app/src/test/java/pt/naps/maestro/ui/structured/MarkdownTextTest.kt @@ -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) + } +} diff --git a/android/app/src/test/java/pt/naps/maestro/ui/structured/StructuredModelTest.kt b/android/app/src/test/java/pt/naps/maestro/ui/structured/StructuredModelTest.kt new file mode 100644 index 0000000..e9bddc6 --- /dev/null +++ b/android/app/src/test/java/pt/naps/maestro/ui/structured/StructuredModelTest.kt @@ -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(), 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() + 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.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.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)) + } +} diff --git a/android/app/src/test/java/pt/naps/maestro/ui/structured/StructuredViewModelTest.kt b/android/app/src/test/java/pt/naps/maestro/ui/structured/StructuredViewModelTest.kt new file mode 100644 index 0000000..fb58510 --- /dev/null +++ b/android/app/src/test/java/pt/naps/maestro/ui/structured/StructuredViewModelTest.kt @@ -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 { + val opened = CompletableDeferred() + 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(), 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() + } +}