Compare commits

...
Author SHA1 Message Date
Miguel Palhas b27dda6c6b Merge remote-tracking branch 'origin/main' into chorale
ci / web (push) Successful in 37s
ci / flake (push) Successful in 3m58s
ci / rust (push) Successful in 7m14s
2026-09-17 11:18:49 +01:00
Miguel PalhasandClaude Opus 4.6 5bdedc8d23 feat(maestro): add elicitation web UI and TUI stub
ElicitationView renders form-mode questions from the agent:
single-property string enums as a button group (the common
multiple-choice case), general schemas as a form with typed
inputs.

Wired into TranscriptRow, StructuredPane, and the model
helpers. TUI renders elicitation rows as plain text for now.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-17 08:32:11 +01:00
Miguel PalhasandClaude Opus 4.6 1e628789fb feat(maestro): add ACP elicitation support (backend)
Advertise `elicitation.form` in the ACP handshake and handle
`elicitation/create` requests end-to-end through the daemon.

Adds `Elicitation` transcript row kind, `pending_elicitation` to
ReducedState, the elicitation pump, persistence, and a new
`POST /api/sessions/:id/structured/elicitations/:nonce` endpoint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-17 08:22:37 +01:00
Miguel Palhas 3f2473d910 feat(maestro): hide tab bar on mobile, add sidebar button to pane header
ci / web (push) Successful in 38s
ci / flake (push) Successful in 4m2s
ci / rust (push) Successful in 7m23s
On mobile only one session is focused at a time, so the tab bar is
redundant — the sidebar drawer is the canonical way to switch. A ☰
button in the pane header gives tap access without requiring a swipe.

Closes #273
2026-09-17 08:06:44 +01:00
Miguel PalhasandClaude Opus 4.6 b56a35f3ee fix(maestro): simplify tool-group test to match actual grouping
ci / web (push) Successful in 34s
ci / flake (push) Successful in 3m44s
ci / rust (push) Successful in 7m3s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-15 11:23:28 +01:00
Miguel Palhas b1f6014a6a Merge remote-tracking branch 'origin/main' into tritone-2
ci / web (push) Failing after 6s
ci / flake (push) Successful in 4m3s
ci / rust (push) Successful in 7m55s
2026-09-14 11:07:43 +01:00
Miguel PalhasandClaude Opus 4.6 01db6459cb fix(maestro): block event pump until turn is settled
The adapter returns the session/prompt response before the agent
finishes working.  Late-arriving notifications raced past the turn
marker and leaked into the next turn, making each response appear
one prompt behind.

The event pump now sends a settled oneshot through the done payload
and awaits it before processing further messages.
finish_queued_acp_turn fires it after persisting the turn marker and
dispatching any queued prompt.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-13 12:22:59 +01:00
Miguel PalhasandClaude Opus 4.6 d332c43a9f fix(maestro): handle 202 as no-body response in jsonOrError
ci / web (push) Failing after 26s
ci / flake (push) Successful in 4m8s
ci / rust (push) Successful in 7m57s
The daemon returns 202 Accepted when a structured prompt is queued.
jsonOrError only skipped res.json() for 204, so the empty 202 body
caused a JSON parse error.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-13 12:13:35 +01:00
Miguel Palhas 1cc05af323 feat(maestro): add just apk recipe for the Android debug build
ci / web (push) Failing after 32s
ci / flake (push) Successful in 4m30s
ci / rust (push) Successful in 8m7s
Runs ./gradlew assembleDebug and copies app-debug.apk next to the
android/ project root.
2026-09-13 02:43:07 +01:00
Miguel Palhas 6eb048575c feat(maestro): match adaptive icon to web PWA mark
Replaces the default Android Studio placeholder icon with the same
concentric-circle mark as web/public/icon.svg, scaled into the
existing 108x108 adaptive-icon vectors.
2026-09-13 02:43:04 +01:00
Miguel Palhas 5cdc6135f5 feat(maestro): match activity rail blink/flash to web timings
Replaces the tween/Reverse pulse with keyframe animations mirroring
web/src/components/sidebar/sidebar.css exactly: a hard 1200ms on/off
blink for AwaitingInput (steps(1), no crossfade) and a 1400ms
ease-out double-flash for Error.
2026-09-13 02:43:00 +01:00
Miguel Palhas f6be34ae48 feat(maestro): bundle JetBrains Mono and match web's type scale
Bundles JetBrainsMono Regular/Medium/Bold (SIL OFL, see
android/licenses/) so terminal and code content render the same face
as the web SPA's --font-mono instead of falling back to the device's
system mono. Typography now steps through the same sizes as the web
--fs-* tokens, using the platform sans for UI chrome.
2026-09-13 02:42:55 +01:00
Miguel Palhas 4f06d1fc8f feat(maestro): translate web OKLCH tokens to Material 3 color scheme
Maps bg/surface/ink/muted/faint/accent/live/danger/warn from
web/src/styles.css to background/surface/onSurface/onSurfaceVariant/
outline/primary/tertiary/error/secondary, for both dark (default) and
light Material 3 schemes. Drops the redundant statusBarColor override
in themes.xml now that MainActivity's enableEdgeToEdge() owns it.
2026-09-13 02:42:51 +01:00
Miguel Palhas d1b8b0abc1 feat(maestro): add Android command bar (#270)
Bottom-sheet ctrl-k-style search over sessions, a `>` command
registry and `~` directories (docs/COMMAND-BAR.md), with a
long-press action menu for rename/kill/spawn-similar. v1 uses a
simple weighted substring/fuzzy matcher rather than the scored
matcher web/#146 and the TUI/#147 share. Wires an FAB and a
best-effort hardware Ctrl-K into MaestroApp.
2026-09-13 02:20:46 +01:00
Miguel PalhasandClaude Opus 4.6 d8030b59f9 feat(maestro): add app shell with navigation (#268)
MaestroApp root composable with ModalNavigationDrawer,
NavHost (Welcome/Terminal/Structured routes), WelcomeScreen,
connection status, back press handling, spawn form integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-13 01:58:44 +01:00
Miguel PalhasandClaude Opus 4.6 0ab4ac3e4b feat(maestro): add structured session pane (#267)
Transcript view for ACP structured sessions (Claude Code, Codex).
Renders all 8 row kinds, collapsible tool groups, approval cards,
composer with send/cancel, turn state strip.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-13 01:47:16 +01:00
Miguel Palhas c90bdfbe42 feat(maestro): open terminal pane from a tapped sidebar session
Tapping a session in the sidebar now opens TerminalScreen full-screen
instead of just closing the drawer. windowSoftInputMode=adjustResize
lets WindowInsets.isImeVisible drive the key bar's visibility.
2026-09-13 01:07:32 +01:00
Miguel Palhas 27b6e647c1 feat(maestro): add Android terminal pane composable
TerminalWebView wraps a WebView around the bundled xterm.js page, with
a JS bridge (maestroKey/maestroToggleMod/maestroFocus) the native key
bar drives; onModifierConsumed reports back when the page's own JS
folds an armed Ctrl/Alt into a soft-keyboard keystroke, so the native
toggle stays in sync. TerminalScreen composes the WebView with the key
bar, showing the bar only while WindowInsets.isImeVisible.
2026-09-13 01:07:23 +01:00
Miguel Palhas 0631e69ef0 feat(maestro): add Android terminal extra key bar composable
Termux-style row for the soft keyboard (issue #266): Tab, Esc, one-shot
Ctrl/Alt modifiers, and the four cursor arrows, scrollable horizontally
for narrow screens.
2026-09-13 01:07:15 +01:00
Miguel Palhas f98c637cd9 feat(maestro): add Android terminal attach URL builder
TerminalPageUrl builds the file:///android_asset/terminal/index.html
URL for one session, with the WS /api/attach/:id URL Routes already
knows how to build passed through as an encoded query param.
2026-09-13 01:07:07 +01:00
Miguel Palhas 554182d956 feat(maestro): bundle xterm.js terminal page for Android WebView pane
Vendors @xterm/xterm 5.5.0 and @xterm/addon-fit 0.10.0 as static assets
plus a small HTML/JS page that owns the WS /api/attach/:id connection
directly, the same shape as the web SPA's TerminalPane.tsx. Bundled
locally so the pane only needs LAN reachability to the daemon, not
internet access to a CDN.
2026-09-13 01:06:59 +01:00
70 changed files with 5279 additions and 184 deletions
+5
View File
@@ -23,6 +23,11 @@ web-typecheck:
pnpm -C web install --frozen-lockfile
pnpm -C web typecheck
# Build the Android debug APK and copy it next to the android/ project root.
apk:
cd android && ./gradlew assembleDebug
cp android/app/build/outputs/apk/debug/app-debug.apk android/app-debug.apk
# Full workspace build + tests.
test:
cargo fmt --all -- --check
+1
View File
@@ -1,5 +1,6 @@
*.iml
.gradle/
.kotlin/
/local.properties
/.idea/
.DS_Store
+1
View File
@@ -15,6 +15,7 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.Maestro">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -0,0 +1,165 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<link rel="stylesheet" href="vendor/xterm.css" />
<style>
html, body, #terminal { height: 100%; margin: 0; padding: 0; background: #121419; overflow: hidden; }
.xterm { padding: 4px; }
</style>
</head>
<body>
<div id="terminal"></div>
<script src="vendor/xterm.js"></script>
<script src="vendor/addon-fit.js"></script>
<script>
// Owns the `WS /api/attach/:id` connection directly, the same shape as
// web/src/components/terminal/TerminalPane.tsx — PTY bytes in binary
// frames both ways, `ClientControl` JSON in text frames. The native
// Android side (TerminalWebView.kt) never touches the socket; it only
// calls the maestro* bridge functions below for the extra key bar.
var params = new URLSearchParams(location.search);
var wsUrl = params.get("ws");
var term = new Terminal({
cursorBlink: true,
fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
fontSize: 13,
convertEol: false,
scrollback: 2000,
theme: {
background: "#121419",
foreground: "#e6e8ec",
cursor: "#5fe3a1",
cursorAccent: "#121419",
selectionBackground: "#2b6b4f",
},
});
var fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
var container = document.getElementById("terminal");
term.open(container);
fitAddon.fit();
var ws = null;
var awaitingReplay = true;
var armed = { ctrl: false, alt: false };
function sendControl(msg) {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
}
function sendInput(data) {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(new TextEncoder().encode(data));
}
function connect() {
awaitingReplay = true;
ws = new WebSocket(wsUrl);
ws.binaryType = "arraybuffer";
ws.onopen = function () {
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
};
ws.onmessage = function (ev) {
if (typeof ev.data === "string") return; // control frame, nothing to render
var replay = awaitingReplay;
awaitingReplay = false;
term.write(new Uint8Array(ev.data), function () {
if (replay) term.scrollToBottom();
});
};
ws.onclose = function (ev) {
if (ev.code === 1000) return;
term.write("\r\n\x1b[31m[disconnected]\x1b[0m\r\n");
};
}
// Ctrl maps to the C0 control range (A-Z, [, \, ], ^, _); Alt prefixes ESC
// — the same encoding buildKeyBar/applyModsToChar use on the web client.
function applyModsToChar(ch, ctrl, alt) {
var out = ch;
if (ctrl) {
var code = ch.toUpperCase().charCodeAt(0);
if (code >= 64 && code <= 95) out = String.fromCharCode(code - 64);
}
if (alt) out = "\x1b" + out;
return out;
}
// CSI cursor sequence with the modifier parameter xterm expects:
// `1;<mod>` where 3=alt, 5=ctrl, 7=ctrl+alt.
function cursorSeq(final, ctrl, alt) {
if (!ctrl && !alt) return "\x1b[" + final;
var mod = 1 + (alt ? 2 : 0) + (ctrl ? 4 : 0);
return "\x1b[1;" + mod + final;
}
term.onData(function (data) {
if ((armed.ctrl || armed.alt) && data.length === 1) {
var hadCtrl = armed.ctrl;
var hadAlt = armed.alt;
sendInput(applyModsToChar(data, hadCtrl, hadAlt));
armed.ctrl = false;
armed.alt = false;
if (window.AndroidBridge) {
if (hadCtrl) AndroidBridge.onModConsumed("ctrl");
if (hadAlt) AndroidBridge.onModConsumed("alt");
}
} else {
sendInput(data);
}
});
term.onResize(function (size) {
sendControl({ type: "Resize", cols: size.cols, rows: size.rows });
});
container.addEventListener("click", function () {
term.focus();
});
var ro = new ResizeObserver(function () {
try {
fitAddon.fit();
} catch (e) {
// container not measurable yet (e.g. mid-teardown) — next observation retries
}
});
ro.observe(container);
// Bridge called from TerminalWebViewController via evaluateJavascript —
// the native extra key bar's Tab/Esc/arrow buttons and Ctrl/Alt toggles.
window.maestroKey = function (key) {
var ctrl = armed.ctrl;
var alt = armed.alt;
var seq;
switch (key) {
case "Tab": seq = "\t"; break;
case "Esc": seq = "\x1b"; break;
case "Up": seq = cursorSeq("A", ctrl, alt); break;
case "Down": seq = cursorSeq("B", ctrl, alt); break;
case "Right": seq = cursorSeq("C", ctrl, alt); break;
case "Left": seq = cursorSeq("D", ctrl, alt); break;
default: return;
}
sendInput(seq);
armed.ctrl = false;
armed.alt = false;
};
window.maestroToggleMod = function (name) {
if (name === "ctrl" || name === "alt") armed[name] = !armed[name];
};
window.maestroFocus = function () {
term.focus();
};
connect();
</script>
</body>
</html>
@@ -0,0 +1,2 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
//# sourceMappingURL=addon-fit.js.map
+218
View File
@@ -0,0 +1,218 @@
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
* https://github.com/chjj/term.js
* @license MIT
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* Originally forked from (with the author's permission):
* Fabrice Bellard's javascript vt100 for jslinux:
* http://bellard.org/jslinux/
* Copyright (c) 2011 Fabrice Bellard
* The original design remains. The terminal itself
* has been extended to include xterm CSI codes, among
* other features.
*/
/**
* Default styles for xterm.js
*/
.xterm {
cursor: text;
position: relative;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
}
.xterm.focus,
.xterm:focus {
outline: none;
}
.xterm .xterm-helpers {
position: absolute;
top: 0;
/**
* The z-index of the helpers must be higher than the canvases in order for
* IMEs to appear on top.
*/
z-index: 5;
}
.xterm .xterm-helper-textarea {
padding: 0;
border: 0;
margin: 0;
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
position: absolute;
opacity: 0;
left: -9999em;
top: 0;
width: 0;
height: 0;
z-index: -5;
/** Prevent wrapping so the IME appears against the textarea at the correct position */
white-space: nowrap;
overflow: hidden;
resize: none;
}
.xterm .composition-view {
/* TODO: Composition position got messed up somewhere */
background: #000;
color: #FFF;
display: none;
position: absolute;
white-space: nowrap;
z-index: 1;
}
.xterm .composition-view.active {
display: block;
}
.xterm .xterm-viewport {
/* On OS X this is required in order for the scroll bar to appear fully opaque */
background-color: #000;
overflow-y: scroll;
cursor: default;
position: absolute;
right: 0;
left: 0;
top: 0;
bottom: 0;
}
.xterm .xterm-screen {
position: relative;
}
.xterm .xterm-screen canvas {
position: absolute;
left: 0;
top: 0;
}
.xterm .xterm-scroll-area {
visibility: hidden;
}
.xterm-char-measure-element {
display: inline-block;
visibility: hidden;
position: absolute;
top: 0;
left: -9999em;
line-height: normal;
}
.xterm.enable-mouse-events {
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
cursor: default;
}
.xterm.xterm-cursor-pointer,
.xterm .xterm-cursor-pointer {
cursor: pointer;
}
.xterm.column-select.focus {
/* Column selection mode */
cursor: crosshair;
}
.xterm .xterm-accessibility:not(.debug),
.xterm .xterm-message {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
z-index: 10;
color: transparent;
pointer-events: none;
}
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
color: transparent;
}
.xterm .xterm-accessibility-tree {
user-select: text;
white-space: pre;
}
.xterm .live-region {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.xterm-dim {
/* Dim should not apply to background, so the opacity of the foreground color is applied
* explicitly in the generated class and reset to 1 here */
opacity: 1 !important;
}
.xterm-underline-1 { text-decoration: underline; }
.xterm-underline-2 { text-decoration: double underline; }
.xterm-underline-3 { text-decoration: wavy underline; }
.xterm-underline-4 { text-decoration: dotted underline; }
.xterm-underline-5 { text-decoration: dashed underline; }
.xterm-overline {
text-decoration: overline;
}
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
.xterm-strikethrough {
text-decoration: line-through;
}
.xterm-screen .xterm-decoration-container .xterm-decoration {
z-index: 6;
position: absolute;
}
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
z-index: 7;
}
.xterm-decoration-overview-ruler {
z-index: 8;
position: absolute;
top: 0;
right: 0;
pointer-events: none;
}
.xterm-decoration-top {
z-index: 2;
position: relative;
}
File diff suppressed because one or more lines are too long
@@ -4,41 +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.rememberCoroutineScope
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.ui.sidebar.SidebarContent
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()
@@ -49,37 +27,7 @@ class MainActivity : ComponentActivity() {
setContent {
MaestroTheme {
val linkState by daemonLink.state.collectAsState()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
ModalDrawerSheet(modifier = Modifier.width(320.dp)) {
SidebarContent(
catalogStore = daemonLink.catalog,
onOpenSession = { 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)
}
}
}
@@ -89,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,298 @@
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.fillMaxSize
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.FloatingActionButton
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusTarget
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isCtrlPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
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.commandbar.CommandBarSheet
import pt.naps.maestro.ui.commandbar.CommandBarViewModel
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"
}
/** What to pre-fill the spawn form with when it was opened from the command
* bar (#270) rather than from a blank "Spawn session" tap. */
private sealed interface SpawnSeed {
data class Similar(val session: Session) : SpawnSeed
data class Directory(val path: String) : SpawnSeed
}
/**
* 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) }
var spawnSeed by remember { mutableStateOf<SpawnSeed?>(null) }
var showCommandBar 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() }
}
fun spawnNew() {
spawnSeed = null
showSpawnSheet = true
}
fun spawnInDirectory(path: String) {
spawnSeed = SpawnSeed.Directory(path)
showSpawnSheet = true
}
fun spawnSimilar(session: Session) {
spawnSeed = SpawnSeed.Similar(session)
showSpawnSheet = true
}
// Ctrl-K opens the bar from anywhere (docs/COMMAND-BAR.md); a focusable
// root keeps the shortcut live except while something else (the bar's own
// search field, a text input) holds focus instead.
val rootFocusRequester = remember { FocusRequester() }
LaunchedEffect(showCommandBar) {
if (!showCommandBar) rootFocusRequester.requestFocus()
}
Box(
modifier = Modifier
.fillMaxSize()
.focusRequester(rootFocusRequester)
.focusTarget()
.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.isCtrlPressed && event.key == Key.K) {
showCommandBar = true
true
} else {
false
}
},
) {
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 = { spawnNew() },
)
}
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) },
)
}
}
}
}
FloatingActionButton(
onClick = { showCommandBar = true },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp)
.semantics { contentDescription = "Search sessions and commands" },
) {
Text("⌕")
}
}
if (showSpawnSheet) {
val seed = spawnSeed
val viewModel = remember(infoClient, seed) {
SpawnViewModel(infoClient).also {
when (seed) {
is SpawnSeed.Similar -> {
it.setCwd(seed.session.cwd)
it.setCustomCommand(seed.session.command)
}
is SpawnSeed.Directory -> it.setCwd(seed.path)
null -> {}
}
}
}
SpawnSheet(
viewModel = viewModel,
onDismiss = {
showSpawnSheet = false
spawnSeed = null
},
onSpawned = { session ->
showSpawnSheet = false
spawnSeed = null
openSession(session)
},
)
}
if (showCommandBar) {
val commandBarViewModel = remember(daemonLink, infoClient) {
CommandBarViewModel(daemonLink.catalog, infoClient)
}
CommandBarSheet(
viewModel = commandBarViewModel,
onDismiss = { showCommandBar = false },
onOpenSession = ::openSession,
onSpawnNew = ::spawnNew,
onSpawnDirectory = ::spawnInDirectory,
onSpawnSimilar = ::spawnSimilar,
)
}
// 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,190 @@
package pt.naps.maestro.ui.commandbar
import java.time.Instant
import pt.naps.maestro.data.Session
import pt.naps.maestro.data.SessionStatus
import pt.naps.maestro.ui.sidebar.programName
import pt.naps.maestro.ui.sidebar.sessionName
/** The command bar's pure model (docs/COMMAND-BAR.md): candidates, the sigil
* grammar, and the ranking. Android's v1 matcher is deliberately a simple
* weighted substring/fuzzy scorer, not the scored dynamic-program matcher
* `web`/#146 and the TUI/#147 share — see the issue for why a lighter
* matcher was chosen for the first cut. */
sealed interface BarCandidate {
data class SessionItem(val session: Session) : BarCandidate
data class CommandItem(val command: BarCommand) : BarCandidate
data class DirectoryItem(val path: String, val display: String) : BarCandidate
}
data class BarCommand(val id: String, val label: String)
/** The bar's fixed `>` registry for v1: the app has no keymap/command palette
* of its own yet, so spawning is the one action worth reaching from anywhere. */
val BAR_COMMANDS: List<BarCommand> = listOf(BarCommand(id = "spawn", label = "Spawn session"))
sealed interface BarSigil {
object Commands : BarSigil
object Directories : BarSigil
}
data class ParsedBarQuery(val sigil: BarSigil?, val query: String)
/** A sigil is only a sigil in first position (docs/COMMAND-BAR.md) — typed
* anywhere else, or backspaced away, it is an ordinary query character. */
fun parseBarQuery(raw: String): ParsedBarQuery = when (raw.firstOrNull()) {
'>' -> ParsedBarQuery(BarSigil.Commands, raw.substring(1))
'~' -> ParsedBarQuery(BarSigil.Directories, raw.substring(1))
else -> ParsedBarQuery(null, raw)
}
private fun instantMs(iso: String?): Long? {
if (iso.isNullOrEmpty()) return null
return runCatching { Instant.parse(iso).toEpochMilli() }.getOrNull()
}
/** Empty-query session order (docs/COMMAND-BAR.md, "Empty query"): most
* recently focused first; a session never focused sorts after every session
* that has been, most recently created first among those; a dead session
* sorts last regardless of recency. */
fun orderSessionsForBar(sessions: List<Session>): List<Session> = sessions.sortedWith(
Comparator { a, b ->
val deadA = if (a.status == SessionStatus.Dead) 1 else 0
val deadB = if (b.status == SessionStatus.Dead) 1 else 0
if (deadA != deadB) return@Comparator deadA - deadB
val focusA = instantMs(a.last_focused_at)
val focusB = instantMs(b.last_focused_at)
if ((focusA == null) != (focusB == null)) return@Comparator if (focusA == null) 1 else -1
if (focusA != null && focusB != null && focusA != focusB) return@Comparator focusB.compareTo(focusA)
(instantMs(b.created_at) ?: 0L).compareTo(instantMs(a.created_at) ?: 0L)
},
)
/** `~/dev/api`, not `/home/me/dev/api` — the abbreviation the sidebar and the
* spawn form both use. */
fun abbreviateCwd(path: String, homeDir: String): String = when {
homeDir.isEmpty() -> path
path == homeDir -> "~"
path.startsWith("$homeDir/") -> "~" + path.removePrefix(homeDir)
else -> path
}
/** The inverse of [abbreviateCwd], for a path the operator typed. */
fun expandHome(path: String, homeDir: String): String = when {
homeDir.isEmpty() -> path
path == "~" -> homeDir
path.startsWith("~/") -> homeDir + path.removePrefix("~")
else -> path
}
/** `~`'s candidates, deduped by the directory they spawn in and keeping the
* first mention of each. */
fun directoryCandidates(paths: List<String>, homeDir: String): List<BarCandidate.DirectoryItem> {
val seen = LinkedHashSet<String>()
val out = mutableListOf<BarCandidate.DirectoryItem>()
for (path in paths) {
if (path.isEmpty() || !seen.add(path)) continue
out += BarCandidate.DirectoryItem(path = path, display = abbreviateCwd(path, homeDir))
}
return out
}
private data class BarField(val name: String, val text: String, val weight: Int)
/** Field weights, ported from docs/COMMAND-BAR.md's candidate table — kept so
* an exact `tool` match can still outrank a weaker `name` match, even though
* the per-character scoring underneath is simplified. */
private fun fieldsFor(candidate: BarCandidate, homeDir: String): List<BarField> = when (candidate) {
is BarCandidate.SessionItem -> {
val session = candidate.session
val directory = session.worktree?.base_repo ?: session.cwd
listOfNotNull(
BarField("name", sessionName(session), 0),
BarField("directory", abbreviateCwd(directory, homeDir), -24),
session.command.takeIf { it.isNotEmpty() }?.let { BarField("command", it, -32) },
BarField("tool", programName(session.command), -40),
)
}
is BarCandidate.CommandItem -> listOf(BarField("label", candidate.command.label, 0))
is BarCandidate.DirectoryItem -> listOf(BarField("path", candidate.display, 0))
}
private const val MATCH_EXACT = 100
private const val MATCH_PREFIX = 70
private const val MATCH_CONTAINS = 50
private const val MATCH_FUZZY = 20
/** Case-insensitive subsequence: every character of [query] appears in [text]
* in order, not necessarily adjacent. */
private fun isSubsequence(query: String, text: String): Boolean {
var i = 0
for (c in text) {
if (i >= query.length) break
if (c == query[i]) i++
}
return i == query.length
}
private fun fieldMatchScore(query: String, text: String): Int? {
val q = query.lowercase()
val t = text.lowercase()
return when {
t == q -> MATCH_EXACT
t.startsWith(q) -> MATCH_PREFIX
t.contains(q) -> MATCH_CONTAINS
isSubsequence(q, t) -> MATCH_FUZZY
else -> null
}
}
private fun kindOrder(candidate: BarCandidate): Int = when (candidate) {
is BarCandidate.SessionItem -> 0
is BarCandidate.CommandItem -> 1
is BarCandidate.DirectoryItem -> 2
}
data class BarResult(val candidate: BarCandidate, val score: Int, val matchedField: String?)
/** Ranks [candidates] against [query]. An empty query matches everything at
* score 0 in input order (docs/COMMAND-BAR.md, "Empty query"); the bar's own
* rules decide what is in the candidate list at that point. */
fun rankCandidates(query: String, candidates: List<BarCandidate>, homeDir: String): List<BarResult> {
val trimmed = query.trim()
if (trimmed.isEmpty()) return candidates.map { BarResult(it, 0, null) }
val results = candidates.mapNotNull { candidate ->
var best: BarResult? = null
for (field in fieldsFor(candidate, homeDir)) {
val matchScore = fieldMatchScore(trimmed, field.text) ?: continue
val total = matchScore + field.weight
if (best == null || total > best.score) best = BarResult(candidate, total, field.name)
}
best
}
// Stable sort: ties keep the caller's order (sessions most-recently-focused
// first, commands in registry order), matching the shared matcher's last
// tie-break without reproducing its length/codepoint rules.
return results.sortedWith(compareByDescending<BarResult> { it.score }.thenBy { kindOrder(it.candidate) })
}
data class BarGroup(val title: String, val results: List<BarResult>)
private fun titleFor(candidate: BarCandidate): String = when (candidate) {
is BarCandidate.SessionItem -> "Sessions"
is BarCandidate.CommandItem -> "Commands"
is BarCandidate.DirectoryItem -> "Directories"
}
/** Buckets a ranked list by kind for rendering. Grouping is a rendering of the
* ranking, never a reordering of it: groups appear in the order their
* best-scoring member does, because [results] is already rank-ordered and a
* `LinkedHashMap` keeps first-seen order. */
fun groupResults(results: List<BarResult>): List<BarGroup> {
val groups = LinkedHashMap<String, MutableList<BarResult>>()
for (result in results) {
groups.getOrPut(titleFor(result.candidate)) { mutableListOf() }.add(result)
}
return groups.map { (title, group) -> BarGroup(title, group) }
}
@@ -0,0 +1,307 @@
package pt.naps.maestro.ui.commandbar
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
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.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
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.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import pt.naps.maestro.data.Session
import pt.naps.maestro.ui.sidebar.ActivityRail
import pt.naps.maestro.ui.sidebar.MetaLine
import pt.naps.maestro.ui.sidebar.customSessionName
import pt.naps.maestro.ui.sidebar.formatInTheWay
import pt.naps.maestro.ui.sidebar.rememberTickingNowMs
import pt.naps.maestro.ui.sidebar.sessionBlockers
import pt.naps.maestro.ui.sidebar.sessionName
/** #270's `ctrl-k` command bar (docs/COMMAND-BAR.md): a search field over a
* bottom sheet, sessions and the `>` registry mixed by default, `>` and `~`
* narrowing to one kind. Tap opens a result; long-press on a session opens
* its action menu (rename, kill, spawn similar) — the bar picks the action
* and hands off to the flow that already implements it, it never
* reimplements one. */
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun CommandBarSheet(
viewModel: CommandBarViewModel,
onDismiss: () -> Unit,
onOpenSession: (Session) -> Unit,
onSpawnNew: () -> Unit,
onSpawnDirectory: (String) -> Unit,
onSpawnSimilar: (Session) -> Unit,
modifier: Modifier = Modifier,
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
val state by viewModel.state.collectAsState()
val nowMs = rememberTickingNowMs()
val focusRequester = remember { FocusRequester() }
var actionSession by remember { mutableStateOf<Session?>(null) }
var renaming by remember { mutableStateOf<Session?>(null) }
var confirmingKill by remember { mutableStateOf<Session?>(null) }
fun activate(candidate: BarCandidate) {
when (candidate) {
is BarCandidate.SessionItem -> onOpenSession(candidate.session)
is BarCandidate.CommandItem -> onSpawnNew()
is BarCandidate.DirectoryItem -> onSpawnDirectory(candidate.path)
}
onDismiss()
}
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) {
Column(Modifier.fillMaxWidth().heightIn(max = 560.dp)) {
OutlinedTextField(
value = state.query,
onValueChange = viewModel::onQueryChange,
placeholder = { Text(placeholderFor(state.sigil)) },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
.focusRequester(focusRequester),
)
LaunchedEffect(Unit) { focusRequester.requestFocus() }
if (state.groups.isEmpty()) {
Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) {
Text(
"No matches",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
}
} else {
val showHeaders = state.sigil == null
LazyColumn(Modifier.fillMaxWidth()) {
for (group in state.groups) {
if (showHeaders) {
item(key = "header:${group.title}") {
Text(
text = group.title,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
)
}
}
items(group.results, key = { rowKey(it.candidate) }) { result ->
BarRow(
candidate = result.candidate,
nowMs = nowMs,
onClick = { activate(result.candidate) },
onLongClick = {
val session = (result.candidate as? BarCandidate.SessionItem)?.session
if (session != null) actionSession = session
},
)
}
}
}
}
}
}
actionSession?.let { session ->
SessionActionMenu(
session = session,
onDismiss = { actionSession = null },
onRename = {
actionSession = null
renaming = session
},
onKill = {
actionSession = null
confirmingKill = session
},
onSpawnSimilar = {
actionSession = null
onDismiss()
onSpawnSimilar(session)
},
)
}
renaming?.let { session ->
RenameDialog(
initial = customSessionName(session) ?: "",
onCommit = {
renaming = null
viewModel.rename(session, it)
},
onCancel = { renaming = null },
)
}
confirmingKill?.let { session ->
KillConfirmDialog(
session = session,
onConfirm = {
confirmingKill = null
viewModel.kill(session)
},
onCancel = { confirmingKill = null },
)
}
}
private fun placeholderFor(sigil: BarSigil?): String = when (sigil) {
BarSigil.Commands -> "Run a command…"
BarSigil.Directories -> "Spawn in a directory…"
null -> "Go to a session…"
}
private fun rowKey(candidate: BarCandidate): String = when (candidate) {
is BarCandidate.SessionItem -> "session:${candidate.session.id}"
is BarCandidate.CommandItem -> "command:${candidate.command.id}"
is BarCandidate.DirectoryItem -> "directory:${candidate.path}"
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun BarRow(
candidate: BarCandidate,
nowMs: Long,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
when (candidate) {
is BarCandidate.SessionItem -> {
ActivityRail(activity = candidate.session.activity, modifier = Modifier.height(36.dp))
Spacer(Modifier.width(8.dp))
Column(Modifier.fillMaxWidth()) {
Text(
text = sessionName(candidate.session),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium,
)
MetaLine(session = candidate.session, nowMs = nowMs)
}
}
is BarCandidate.CommandItem -> {
Text(
text = candidate.command.label,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.fillMaxWidth(),
)
}
is BarCandidate.DirectoryItem -> {
Text(
text = candidate.display,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
/** Long-press's action menu (docs/COMMAND-BAR.md, "Drill-in"), collapsed from
* the desktop clients' fuzzy-searchable action page to a plain dialog — a
* touch long-press already named the one session being acted on, so there is
* nothing left to search. */
@Composable
private fun SessionActionMenu(
session: Session,
onDismiss: () -> Unit,
onRename: () -> Unit,
onKill: () -> Unit,
onSpawnSimilar: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(sessionName(session)) },
text = {
Column {
TextButton(onClick = onRename, modifier = Modifier.fillMaxWidth()) { Text("Rename") }
TextButton(onClick = onSpawnSimilar, modifier = Modifier.fillMaxWidth()) { Text("Spawn similar") }
TextButton(onClick = onKill, modifier = Modifier.fillMaxWidth()) { Text("Kill") }
}
},
confirmButton = {},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
@Composable
private fun RenameDialog(initial: String, onCommit: (String) -> Unit, onCancel: () -> Unit) {
var text by remember { mutableStateOf(initial) }
AlertDialog(
onDismissRequest = onCancel,
title = { Text("Rename session") },
text = {
OutlinedTextField(
value = text,
onValueChange = { text = it },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
)
},
confirmButton = { TextButton(onClick = { onCommit(text.trim()) }) { Text("Rename") } },
dismissButton = { TextButton(onClick = onCancel) { Text("Cancel") } },
)
}
@Composable
private fun KillConfirmDialog(session: Session, onConfirm: () -> Unit, onCancel: () -> Unit) {
val blockers = sessionBlockers(session)
AlertDialog(
onDismissRequest = onCancel,
title = { Text("Kill “${sessionName(session)}”?") },
text = {
Text(
if (blockers != null) {
"This worktree has unsaved work: ${formatInTheWay(blockers)}. Killing may be refused unless forced."
} else {
"This ends the session's process. This can't be undone."
},
)
},
confirmButton = { TextButton(onClick = onConfirm) { Text("Kill") } },
dismissButton = { TextButton(onClick = onCancel) { Text("Cancel") } },
)
}
@@ -0,0 +1,135 @@
package pt.naps.maestro.ui.commandbar
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.update
import kotlinx.coroutines.launch
import pt.naps.maestro.api.MaestroClient
import pt.naps.maestro.connection.CatalogStore
import pt.naps.maestro.data.Session
/** Quiet period before a typed `~` path is probed over `GET /api/dirs`,
* mirroring the spawn form's own autocomplete debounce. */
private const val PROBE_DEBOUNCE_MS = 250L
data class CommandBarUiState(
val query: String = "",
val sigil: BarSigil? = null,
val groups: List<BarGroup> = emptyList(),
)
/**
* Search logic behind [pt.naps.maestro.ui.commandbar.CommandBarSheet]
* (#270): folds the live session catalog, the fixed `>` command registry and
* the `~` directory list into one ranked, grouped result set as the query
* changes. A plain class, not an `androidx.lifecycle.ViewModel` — same shape
* as [pt.naps.maestro.ui.spawn.SpawnViewModel], which this otherwise mirrors.
*/
class CommandBarViewModel(
private val catalogStore: CatalogStore,
private val client: MaestroClient,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
) {
private val _state = MutableStateFlow(CommandBarUiState())
val state: StateFlow<CommandBarUiState> = _state.asStateFlow()
private var homeDir: String = ""
private var recentDirectories: List<BarCandidate.DirectoryItem> = emptyList()
private var probedDirectories: List<BarCandidate.DirectoryItem> = emptyList()
private var recentDirectoriesLoaded = false
private var probeJob: Job? = null
init {
scope.launch {
homeDir = runCatching { client.getDaemonInfo().home_dir }.getOrDefault("")
recompute()
}
scope.launch {
catalogStore.sessions.collect { recompute() }
}
}
fun onQueryChange(raw: String) {
_state.update { it.copy(query = raw, sigil = parseBarQuery(raw).sigil) }
val parsed = parseBarQuery(raw)
if (parsed.sigil is BarSigil.Directories) {
ensureRecentDirectoriesLoaded()
probeTypedDirectory(parsed.query)
}
recompute()
}
/** `PATCH /api/sessions/:id`. Fire-and-forget: the catalog reflects the
* rename over the events socket like any other client's edit. */
fun rename(session: Session, name: String) {
scope.launch { runCatching { client.renameSession(session.id, name) } }
}
/** `DELETE /api/sessions/:id`, unforced — the same default the sidebar's
* own kill confirmation uses. */
fun kill(session: Session) {
scope.launch { runCatching { client.killSession(session.id) } }
}
private fun ensureRecentDirectoriesLoaded() {
if (recentDirectoriesLoaded) return
recentDirectoriesLoaded = true
scope.launch {
val recents = runCatching { client.recentDirs().dirs.map { it.cwd } }.getOrDefault(emptyList())
val projects = runCatching { client.listProjects().map { it.path } }.getOrDefault(emptyList())
recentDirectories = directoryCandidates(recents + projects, homeDir)
recompute()
}
}
/** Fills the `~` list in as a path is typed, the way the spawn form's own
* autocomplete does: the directory named so far is listed and its
* children join the candidates. */
private fun probeTypedDirectory(query: String) {
val slash = query.lastIndexOf('/')
if (slash < 0) {
probedDirectories = emptyList()
probeJob?.cancel()
return
}
val dir = expandHome(query.substring(0, slash + 1), homeDir)
probeJob?.cancel()
probeJob = scope.launch {
delay(PROBE_DEBOUNCE_MS)
val entries = runCatching { client.listDir(dir).entries }.getOrDefault(emptyList())
probedDirectories = directoryCandidates(entries.map { "$dir$it" }, homeDir)
recompute()
}
}
private fun directoryListCandidates(): List<BarCandidate> {
val seen = LinkedHashSet<String>()
val out = mutableListOf<BarCandidate.DirectoryItem>()
for (candidate in recentDirectories + probedDirectories) {
if (seen.add(candidate.path)) out += candidate
}
return out
}
private fun recompute() {
val raw = _state.value.query
val parsed = parseBarQuery(raw)
val candidates: List<BarCandidate> = when (parsed.sigil) {
is BarSigil.Commands -> BAR_COMMANDS.map { BarCandidate.CommandItem(it) }
is BarSigil.Directories -> directoryListCandidates()
null -> {
val sessions = orderSessionsForBar(catalogStore.sessions.value.values.toList())
.map { BarCandidate.SessionItem(it) }
if (parsed.query.isEmpty()) sessions else sessions + BAR_COMMANDS.map { BarCandidate.CommandItem(it) }
}
}
val ranked = rankCandidates(parsed.query, candidates, homeDir)
_state.update { it.copy(groups = groupResults(ranked)) }
}
}
@@ -2,12 +2,12 @@ package pt.naps.maestro.ui.sidebar
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.InfiniteTransition
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
@@ -52,18 +52,51 @@ fun activityStateWord(activity: ActivityState): String =
ActivityState.Error -> "Error"
}
/** A held bar that pulses its alpha between 1 and [minAlpha] every
* [periodMs] — the blink/flash motion `AwaitingInput`/`Error` earn. */
/** A hard on/off blink, one cycle per 1200ms — mirrors the web rail's
* `maestro-rail-blink` keyframes (docs/SIDEBAR.md, web/src/components/sidebar/sidebar.css):
* held at full alpha for the first half of the cycle, then snapped to 0 for
* the second half, with no crossfade. */
@Composable
private fun InfiniteTransition.pulsingAlpha(minAlpha: Float, periodMs: Int): Float {
private fun InfiniteTransition.blinkAlpha(): Float {
val alpha by animateFloat(
initialValue = 1f,
targetValue = minAlpha,
targetValue = 0f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = periodMs, easing = LinearEasing),
repeatMode = RepeatMode.Reverse,
animation = keyframes {
durationMillis = 1200
1f at 0
1f at 599
0f at 600
0f at 1199
},
repeatMode = RepeatMode.Restart,
),
label = "activity-rail-alpha",
label = "activity-rail-blink",
)
return alpha
}
/** A fast double-flash every 1400ms — mirrors the web rail's
* `maestro-rail-flash` keyframes: two dips to quarter alpha early in the
* cycle, then held at full alpha for the remainder. */
@Composable
private fun InfiniteTransition.flashAlpha(): Float {
val alpha by animateFloat(
initialValue = 1f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 1400
1f at 0 using LinearOutSlowInEasing
0.25f at 210 using LinearOutSlowInEasing
1f at 420 using LinearOutSlowInEasing
0.25f at 630 using LinearOutSlowInEasing
1f at 840 using LinearOutSlowInEasing
1f at 1400
},
repeatMode = RepeatMode.Restart,
),
label = "activity-rail-flash",
)
return alpha
}
@@ -77,8 +110,8 @@ fun ActivityRail(activity: ActivityState, modifier: Modifier = Modifier) {
val color by animateColorAsState(targetValue = activityColor(activity), label = "activity-rail-color")
val infinite = rememberInfiniteTransition(label = "activity-rail-motion")
val alpha = when (activity) {
ActivityState.AwaitingInput -> infinite.pulsingAlpha(minAlpha = 0.35f, periodMs = 600)
ActivityState.Error -> infinite.pulsingAlpha(minAlpha = 0.25f, periodMs = 220)
ActivityState.AwaitingInput -> infinite.blinkAlpha()
ActivityState.Error -> infinite.flashAlpha()
else -> 1f
}
Box(
@@ -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,76 @@
package pt.naps.maestro.ui.terminal
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/** One button on the [TerminalKeyBar]. A [Modifier] toggles Ctrl/Alt state
* in the terminal page's own JS instead of sending a byte sequence. */
sealed class TerminalKeyAction(val label: String) {
data class Modifier(val name: String, val display: String) : TerminalKeyAction(display)
data class Send(val display: String, val key: String) : TerminalKeyAction(display)
}
/**
* Termux-style extra key row for the on-screen keyboard (issue #266): Tab,
* Esc, Ctrl/Alt, and the four cursor arrows. Ctrl/Alt are one-shot modifiers
* — armed here, then folded into the very next key (tapped on this bar or
* typed on the soft keyboard) by `assets/terminal/index.html`'s own JS.
*/
val TERMINAL_KEY_ACTIONS: List<TerminalKeyAction> = listOf(
TerminalKeyAction.Send("Esc", "Esc"),
TerminalKeyAction.Send("Tab", "Tab"),
TerminalKeyAction.Modifier("ctrl", "Ctrl"),
TerminalKeyAction.Modifier("alt", "Alt"),
TerminalKeyAction.Send("↑", "Up"),
TerminalKeyAction.Send("↓", "Down"),
TerminalKeyAction.Send("←", "Left"),
TerminalKeyAction.Send("→", "Right"),
)
@Composable
fun TerminalKeyBar(
armed: Set<String>,
onSend: (String) -> Unit,
onToggleModifier: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.background(MaterialTheme.colorScheme.surfaceVariant)
.horizontalScroll(rememberScrollState())
.padding(horizontal = 4.dp, vertical = 2.dp),
) {
for (action in TERMINAL_KEY_ACTIONS) {
when (action) {
is TerminalKeyAction.Modifier -> {
val isArmed = action.name in armed
TextButton(onClick = { onToggleModifier(action.name) }) {
Text(
text = action.display,
color = if (isArmed) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
is TerminalKeyAction.Send -> {
TextButton(onClick = { onSend(action.key) }) {
Text(text = action.display)
}
}
}
}
}
}
@@ -0,0 +1,21 @@
package pt.naps.maestro.ui.terminal
import java.net.URLEncoder
import pt.naps.maestro.api.Routes
import pt.naps.maestro.data.Uuid
/**
* The `file:///android_asset/terminal/index.html` URL a [TerminalWebView]
* loads for one session. The embedded xterm.js page reads its `ws` query
* param and opens that as its own `WS /api/attach/:id` connection directly
* — this object only computes the URL, it never touches the socket.
*/
object TerminalPageUrl {
private const val ASSET_URL = "file:///android_asset/terminal/index.html"
fun build(daemonBaseUrl: String, sessionId: Uuid): String {
val wsUrl = Routes.attachWsUrl(daemonBaseUrl, sessionId)
val encoded = URLEncoder.encode(wsUrl, "UTF-8")
return "$ASSET_URL?ws=$encoded"
}
}
@@ -0,0 +1,83 @@
package pt.naps.maestro.ui.terminal
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.isImeVisible
import androidx.compose.foundation.layout.padding
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.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import pt.naps.maestro.data.Uuid
/**
* Full-screen terminal pane for one session (issue #266): a [TerminalWebView]
* showing xterm.js bound to `WS /api/attach/:id`, plus the native
* [TerminalKeyBar] overlaid above the soft keyboard while it's open.
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun TerminalScreen(
daemonBaseUrl: String,
sessionId: Uuid,
sessionTitle: String,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
val pageUrl = remember(daemonBaseUrl, sessionId) { TerminalPageUrl.build(daemonBaseUrl, sessionId) }
var controller by remember { mutableStateOf<TerminalWebViewController?>(null) }
var armed by remember { mutableStateOf(emptySet<String>()) }
val keyboardOpen = WindowInsets.isImeVisible
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text(sessionTitle) },
navigationIcon = {
IconButton(onClick = onBack) {
Text(text = "←", style = MaterialTheme.typography.titleLarge)
}
},
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
TerminalWebView(
pageUrl = pageUrl,
modifier = Modifier
.weight(1f)
.fillMaxSize(),
onReady = { controller = it },
onModifierConsumed = { name -> armed = armed - name },
)
if (keyboardOpen) {
TerminalKeyBar(
armed = armed,
onSend = { key ->
controller?.sendKey(key)
armed = emptySet()
},
onToggleModifier = { name ->
armed = if (name in armed) armed - name else armed + name
controller?.toggleModifier(name)
},
)
}
}
}
}
@@ -0,0 +1,89 @@
package pt.naps.maestro.ui.terminal
import android.annotation.SuppressLint
import android.os.Handler
import android.os.Looper
import android.view.ViewGroup
import android.webkit.JavascriptInterface
import android.webkit.WebSettings
import android.webkit.WebView
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import org.json.JSONObject
/** Matches `#121419` in `assets/terminal/index.html` and the web SPA's own
* xterm theme, so the WebView doesn't flash white while the page loads. */
private const val TERMINAL_BACKGROUND_ARGB = 0xFF121419.toInt()
/**
* Drives the terminal page loaded into a [TerminalWebView] from native code
* — the extra key bar's Tab/Esc/arrow buttons and Ctrl/Alt toggles. The page
* itself owns the `WS /api/attach/:id` socket and the xterm.js instance;
* this controller only calls the `maestro*` bridge functions it exposes.
*/
class TerminalWebViewController internal constructor(private val webView: WebView) {
fun sendKey(key: String) {
val arg = JSONObject.quote(key)
webView.evaluateJavascript("window.maestroKey && window.maestroKey($arg)", null)
}
fun toggleModifier(name: String) {
val arg = JSONObject.quote(name)
webView.evaluateJavascript("window.maestroToggleMod && window.maestroToggleMod($arg)", null)
}
fun focus() {
webView.requestFocus()
webView.evaluateJavascript("window.maestroFocus && window.maestroFocus()", null)
}
}
/**
* Terminal pane composable for issue #266: a [WebView] loading the bundled
* xterm.js page (`assets/terminal/index.html`), which connects straight to
* the daemon's `WS /api/attach/:id` for this session. [onModifierConsumed]
* fires when the page's own JS folds an armed Ctrl/Alt into a key typed on
* the soft keyboard, so the native key bar can clear its toggle to match.
*/
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun TerminalWebView(
pageUrl: String,
modifier: Modifier = Modifier,
onReady: (TerminalWebViewController) -> Unit = {},
onModifierConsumed: (String) -> Unit = {},
) {
val mainHandler = remember { Handler(Looper.getMainLooper()) }
AndroidView(
modifier = modifier,
factory = { context ->
WebView(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
setBackgroundColor(TERMINAL_BACKGROUND_ARGB)
addJavascriptInterface(
object {
@JavascriptInterface
fun onModConsumed(name: String) {
mainHandler.post { onModifierConsumed(name) }
}
},
"AndroidBridge",
)
onReady(TerminalWebViewController(this))
loadUrl(pageUrl)
}
},
update = { webView ->
if (webView.url != pageUrl) webView.loadUrl(pageUrl)
},
onRelease = { it.destroy() },
)
}
@@ -2,10 +2,45 @@ package pt.naps.maestro.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
/** Palette translated from the web SPA's OKLCH design tokens
* (web/src/styles.css) into sRGB. Dark is the default theme; Light is opt-in.
* Semantic mapping (see docs referenced from issue #271):
* bg -> background, surface* -> surface layers, ink -> onBackground/onSurface,
* muted -> onSurfaceVariant, faint -> outline, accent -> primary,
* live -> tertiary, danger -> error, warn -> secondary. */
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
// ---- Dark theme (default) ----
val DarkBg = Color(0xFF0E1012)
val DarkSurface = Color(0xFF16181B)
val DarkSurface2 = Color(0xFF1E2125)
val DarkSurface3 = Color(0xFF272B2F)
val DarkBorder = Color(0xFF2E3136)
val DarkBorderStrong = Color(0xFF42474D)
val DarkInk = Color(0xFFEBEDEF)
val DarkMuted = Color(0xFFA1A5AA)
val DarkFaint = Color(0xFF777B7F)
val DarkAccent = Color(0xFFE2E5E8)
val DarkAccentStrong = Color(0xFFF4F5F7)
val DarkAccentInk = Color(0xFF13161C)
val DarkLive = Color(0xFF65EA92)
val DarkDanger = Color(0xFFFF6F69)
val DarkWarn = Color(0xFFEEBC4A)
val DarkTermBg = Color(0xFF0B0C0F)
// ---- Light theme ----
val LightBg = Color(0xFFF9FAFB)
val LightSurface = Color(0xFFFFFFFF)
val LightSurface2 = Color(0xFFF2F3F5)
val LightSurface3 = Color(0xFFE6E8EA)
val LightBorder = Color(0xFFD9DBDF)
val LightBorderStrong = Color(0xFFC1C4C9)
val LightInk = Color(0xFF20242A)
val LightMuted = Color(0xFF565B62)
val LightFaint = Color(0xFF81878D)
val LightAccent = Color(0xFF23272B)
val LightAccentStrong = Color(0xFF13161B)
val LightAccentInk = Color(0xFFF9FAFB)
val LightLive = Color(0xFF069A4A)
val LightDanger = Color(0xFFD01D21)
val LightWarn = Color(0xFFC78200)
val LightTermBg = Color(0xFF0B0F14)
@@ -10,18 +10,71 @@ import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
/** Dark scheme (default), translated from the web SPA's dark token set
* (web/src/styles.css) — see Color.kt for the token -> role mapping. */
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80,
background = DarkBg,
onBackground = DarkInk,
surface = DarkSurface,
onSurface = DarkInk,
surfaceVariant = DarkSurface2,
onSurfaceVariant = DarkMuted,
outline = DarkFaint,
outlineVariant = DarkBorder,
surfaceTint = DarkAccent,
primary = DarkAccent,
onPrimary = DarkAccentInk,
primaryContainer = DarkSurface3,
onPrimaryContainer = DarkInk,
secondary = DarkWarn,
onSecondary = DarkAccentInk,
secondaryContainer = DarkSurface3,
onSecondaryContainer = DarkInk,
tertiary = DarkLive,
onTertiary = DarkAccentInk,
tertiaryContainer = DarkSurface3,
onTertiaryContainer = DarkInk,
error = DarkDanger,
onError = DarkAccentInk,
errorContainer = DarkSurface3,
onErrorContainer = DarkDanger,
)
/** Light scheme, opt-in. */
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40,
background = LightBg,
onBackground = LightInk,
surface = LightSurface,
onSurface = LightInk,
surfaceVariant = LightSurface2,
onSurfaceVariant = LightMuted,
outline = LightFaint,
outlineVariant = LightBorder,
surfaceTint = LightAccent,
primary = LightAccent,
onPrimary = LightAccentInk,
primaryContainer = LightSurface3,
onPrimaryContainer = LightInk,
secondary = LightWarn,
onSecondary = LightAccentInk,
secondaryContainer = LightSurface3,
onSecondaryContainer = LightInk,
tertiary = LightLive,
onTertiary = LightAccentInk,
tertiaryContainer = LightSurface3,
onTertiaryContainer = LightInk,
error = LightDanger,
onError = LightAccentInk,
errorContainer = LightSurface3,
onErrorContainer = LightDanger,
)
/** Dark is the product default (docs/DESIGN.md); light is opt-in, and dynamic
* color (Material You, API 31+) is opt-in on top of that. Edge-to-edge itself
* is enabled once in MainActivity#onCreate — [enableEdgeToEdge] draws content
* behind transparent system bars for whichever scheme resolves here, and the
* default `SystemBarStyle.auto` picks light/dark bar icons from this theme's
* [darkTheme] flag, not the system setting. */
@Composable
fun MaestroTheme(
darkTheme: Boolean = true,
@@ -1,17 +1,99 @@
package pt.naps.maestro.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.sp
import pt.naps.maestro.R
/** Bundled (SIL OFL, android/licenses/JetBrainsMono-OFL.txt) rather than the
* system mono fallback, so the terminal pane and code blocks render the same
* face as the web SPA (--font-mono) on every device. */
val JetBrainsMono = FontFamily(
Font(R.font.jetbrains_mono_regular, FontWeight.Normal),
Font(R.font.jetbrains_mono_medium, FontWeight.Medium),
Font(R.font.jetbrains_mono_bold, FontWeight.Bold),
)
/** UI chrome uses the platform sans (--font-sans is a system-ui stack on
* web); only terminal/code content switches to [JetBrainsMono]. */
private val Sans = FontFamily.Default
/** Scale roughly mirrors the web token steps (web/src/styles.css --fs-11
* through --fs-20); role names are Material3's, not the CSS ones. */
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
titleLarge = TextStyle(
fontFamily = Sans,
fontWeight = FontWeight.SemiBold,
fontSize = 20.sp,
lineHeight = 26.sp,
letterSpacing = 0.sp,
),
titleMedium = TextStyle(
fontFamily = Sans,
fontWeight = FontWeight.Medium,
fontSize = 17.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp,
letterSpacing = 0.sp,
),
titleSmall = TextStyle(
fontFamily = Sans,
fontWeight = FontWeight.Medium,
fontSize = 15.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
),
bodyLarge = TextStyle(
fontFamily = Sans,
fontWeight = FontWeight.Normal,
fontSize = 15.sp,
lineHeight = 22.sp,
letterSpacing = 0.1.sp,
),
bodyMedium = TextStyle(
fontFamily = Sans,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
),
bodySmall = TextStyle(
fontFamily = Sans,
fontWeight = FontWeight.Normal,
fontSize = 13.sp,
lineHeight = 18.sp,
letterSpacing = 0.1.sp,
),
labelLarge = TextStyle(
fontFamily = Sans,
fontWeight = FontWeight.Medium,
fontSize = 13.sp,
lineHeight = 18.sp,
letterSpacing = 0.1.sp,
),
labelMedium = TextStyle(
fontFamily = Sans,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.2.sp,
),
labelSmall = TextStyle(
fontFamily = Sans,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 14.sp,
letterSpacing = 0.2.sp,
),
)
/** For terminal output and code blocks — callers set this explicitly instead
* of a [Typography] role, since mono content isn't part of the M3 type scale. */
val MonoTextStyle = TextStyle(
fontFamily = JetBrainsMono,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
)
@@ -1,10 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Matches web/public/icon.svg's background so the adaptive icon reads as
the same mark as the PWA icon (issue #272). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#1B1B1B"
android:fillColor="#1A1D24"
android:pathData="M0,0h108v108h-108z" />
</vector>
@@ -1,10 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Foreground layer of the adaptive icon: the same concentric-circle mark
as web/public/icon.svg, scaled from its 512 viewBox into this 108 one
(issue #272). Stays inside the adaptive-icon safe zone (r=35.86 of 54
half-width, ~66%). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#D0BCFF"
android:pathData="M54,24L74,84L64,84L54,52L44,84L34,84Z" />
android:fillColor="#5FE3A1"
android:pathData="M39.23,54 A14.77,14.77 0 1,0 68.77,54 A14.77,14.77 0 1,0 39.23,54 Z" />
<path
android:fillColor="#00000000"
android:strokeColor="#5FE3A1"
android:strokeAlpha="0.5"
android:strokeWidth="2.53"
android:pathData="M28.69,54 A25.31,25.31 0 1,0 79.31,54 A25.31,25.31 0 1,0 28.69,54 Z" />
<path
android:fillColor="#00000000"
android:strokeColor="#5FE3A1"
android:strokeAlpha="0.25"
android:strokeWidth="2.11"
android:pathData="M18.14,54 A35.86,35.86 0 1,0 89.86,54 A35.86,35.86 0 1,0 18.14,54 Z" />
</vector>
+6 -3
View File
@@ -1,6 +1,9 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<resources>
<style name="Theme.Maestro" parent="android:Theme.Material.NoActionBar">
<item name="android:statusBarColor" tools:targetApi="21">@android:color/black</item>
<item name="android:windowBackground">@android:color/black</item>
<!-- No statusBarColor override: MainActivity#onCreate calls
enableEdgeToEdge(), which makes both system bars transparent at
runtime (issue #271). Setting one here would only show briefly
before that call lands. -->
<item name="android:windowBackground">#FF0E1012</item>
</style>
</resources>
@@ -0,0 +1,134 @@
package pt.naps.maestro.ui.commandbar
import kotlinx.serialization.json.JsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import pt.naps.maestro.data.Session
import pt.naps.maestro.data.SessionStatus
private fun session(
id: String,
name: String = id,
command: String = "bash",
cwd: String = "/home/user/project",
status: SessionStatus = SessionStatus.Running,
createdAt: String = "2026-01-01T00:00:00Z",
lastFocusedAt: String? = null,
) = Session(
id = id,
cwd = cwd,
command = command,
metadata = mapOf("name" to JsonPrimitive(name)),
status = status,
created_at = createdAt,
last_focused_at = lastFocusedAt,
)
class CommandBarModelTest {
@Test
fun `a sigil is only read in first position`() {
assertEquals(BarSigil.Commands, parseBarQuery(">quit").sigil)
assertEquals("quit", parseBarQuery(">quit").query)
assertEquals(BarSigil.Directories, parseBarQuery("~/dev").sigil)
assertEquals("/dev", parseBarQuery("~/dev").query)
assertNull(parseBarQuery("foo>bar").sigil)
assertEquals("foo>bar", parseBarQuery("foo>bar").query)
}
@Test
fun `backspacing past a sigil returns the mixed list`() {
assertNull(parseBarQuery("").sigil)
assertEquals("", parseBarQuery("").query)
}
@Test
fun `most recently focused session sorts first`() {
val a = session("a", lastFocusedAt = "2026-01-01T00:00:00Z")
val b = session("b", lastFocusedAt = "2026-01-02T00:00:00Z")
assertEquals(listOf(b, a), orderSessionsForBar(listOf(a, b)))
}
@Test
fun `a never-focused session sorts after every focused one`() {
val focused = session("a", lastFocusedAt = "2026-01-01T00:00:00Z")
val neverFocused = session("b", createdAt = "2026-01-05T00:00:00Z")
assertEquals(listOf(focused, neverFocused), orderSessionsForBar(listOf(neverFocused, focused)))
}
@Test
fun `among never-focused sessions the most recently created sorts first`() {
val older = session("a", createdAt = "2026-01-01T00:00:00Z")
val newer = session("b", createdAt = "2026-01-02T00:00:00Z")
assertEquals(listOf(newer, older), orderSessionsForBar(listOf(older, newer)))
}
@Test
fun `dead sessions sort last regardless of recency`() {
val dead = session("a", status = SessionStatus.Dead, lastFocusedAt = "2026-01-09T00:00:00Z")
val alive = session("b", lastFocusedAt = "2026-01-01T00:00:00Z")
assertEquals(listOf(alive, dead), orderSessionsForBar(listOf(dead, alive)))
}
@Test
fun `abbreviateCwd replaces the home prefix`() {
assertEquals("~/dev/api", abbreviateCwd("/home/user/dev/api", "/home/user"))
assertEquals("~", abbreviateCwd("/home/user", "/home/user"))
assertEquals("/var/lib", abbreviateCwd("/var/lib", "/home/user"))
}
@Test
fun `expandHome inverts abbreviateCwd`() {
assertEquals("/home/user/dev/api", expandHome("~/dev/api", "/home/user"))
assertEquals("/home/user", expandHome("~", "/home/user"))
}
@Test
fun `an empty query matches every candidate at score 0 in input order`() {
val a = BarCandidate.SessionItem(session("a"))
val b = BarCandidate.SessionItem(session("b"))
val results = rankCandidates("", listOf(a, b), homeDir = "")
assertEquals(listOf(a, b), results.map { it.candidate })
assertTrue(results.all { it.score == 0 })
}
@Test
fun `a query matching nothing returns an empty list`() {
val candidates = listOf(BarCandidate.SessionItem(session("a", command = "bash")))
assertTrue(rankCandidates("zzz", candidates, homeDir = "").isEmpty())
}
@Test
fun `an exact name match outranks a session found through its directory`() {
val api = BarCandidate.SessionItem(session("api", cwd = "/home/user/web"))
val worker = BarCandidate.SessionItem(session("worker", cwd = "/home/user/api"))
val results = rankCandidates("api", listOf(api, worker), homeDir = "")
assertEquals(listOf(api, worker), results.map { it.candidate })
assertEquals("name", results[0].matchedField)
assertEquals("directory", results[1].matchedField)
}
@Test
fun `a session beats a command of equal score, kind order tie-break`() {
val sessionItem = BarCandidate.SessionItem(session("kill"))
val command = BarCandidate.CommandItem(BarCommand(id = "kill", label = "kill"))
val results = rankCandidates("kill", listOf(command, sessionItem), homeDir = "")
assertEquals(listOf(sessionItem, command), results.map { it.candidate })
}
@Test
fun `groups are ordered by their best-scoring member and drop empty kinds`() {
val sessionItem = BarCandidate.SessionItem(session("web-search"))
val command = BarCandidate.CommandItem(BarCommand(id = "spawn", label = "Spawn session"))
val ranked = rankCandidates("web", listOf(command, sessionItem), homeDir = "")
val groups = groupResults(ranked)
assertEquals(listOf("Sessions"), groups.map { it.title })
}
@Test
fun `a fuzzy subsequence still matches out of order characters`() {
val candidate = BarCandidate.SessionItem(session("maestro-tui"))
assertTrue(rankCandidates("mtu", listOf(candidate), homeDir = "").isNotEmpty())
}
}
@@ -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()
}
}
@@ -0,0 +1,24 @@
package pt.naps.maestro.ui.terminal
import org.junit.Assert.assertTrue
import org.junit.Test
class TerminalKeyBarActionsTest {
@Test
fun `contains tab esc and all four arrows`() {
val labels = TERMINAL_KEY_ACTIONS.map { it.label }
assertTrue(labels.containsAll(listOf("Tab", "Esc", "↑", "↓", "←", "→")))
}
@Test
fun `ctrl and alt are modifiers rather than sends`() {
val modifierNames = TERMINAL_KEY_ACTIONS.filterIsInstance<TerminalKeyAction.Modifier>().map { it.name }
assertTrue(modifierNames.containsAll(listOf("ctrl", "alt")))
}
@Test
fun `arrow keys carry the maestroKey names the terminal page expects`() {
val sendKeys = TERMINAL_KEY_ACTIONS.filterIsInstance<TerminalKeyAction.Send>().map { it.key }
assertTrue(sendKeys.containsAll(listOf("Up", "Down", "Left", "Right", "Tab", "Esc")))
}
}
@@ -0,0 +1,29 @@
package pt.naps.maestro.ui.terminal
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class TerminalPageUrlTest {
@Test
fun `builds asset url with the encoded attach ws url`() {
val url = TerminalPageUrl.build("http://192.168.1.5:7070", "abc-123")
assertEquals(
"file:///android_asset/terminal/index.html" +
"?ws=ws%3A%2F%2F192.168.1.5%3A7070%2Fapi%2Fattach%2Fabc-123",
url,
)
}
@Test
fun `swaps an https base to wss`() {
val url = TerminalPageUrl.build("https://daemon.example.com", "s1")
assertTrue(url.contains("wss%3A%2F%2Fdaemon.example.com"))
}
@Test
fun `strips a trailing slash from the daemon base url`() {
val url = TerminalPageUrl.build("http://host:7070/", "s1")
assertTrue(url.contains("ws%3A%2F%2Fhost%3A7070%2Fapi%2Fattach%2Fs1"))
}
}
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+82 -10
View File
@@ -42,12 +42,13 @@ use std::sync::{Arc, Mutex};
use std::time::Duration;
use agent_client_protocol::schema::v1::{
CancelNotification, ClientCapabilities, ConfigOptionUpdate, ContentBlock, CurrentModeUpdate,
FileSystemCapabilities, Implementation, InitializeRequest, PromptRequest,
RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse,
SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory, SessionConfigOptionValue,
SessionConfigSelectOption, SessionId, SessionModeState, SessionNotification, SessionUpdate,
SetSessionConfigOptionRequest, SetSessionModeRequest,
CancelNotification, ClientCapabilities, ConfigOptionUpdate, ContentBlock,
CreateElicitationRequest, CreateElicitationResponse, CurrentModeUpdate, ElicitationAction,
ElicitationCapabilities, ElicitationFormCapabilities, FileSystemCapabilities, Implementation,
InitializeRequest, PromptRequest, RequestPermissionOutcome, RequestPermissionRequest,
RequestPermissionResponse, SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory,
SessionConfigOptionValue, SessionConfigSelectOption, SessionId, SessionModeState,
SessionNotification, SessionUpdate, SetSessionConfigOptionRequest, SetSessionModeRequest,
};
use agent_client_protocol::schema::ProtocolVersion;
use agent_client_protocol::{ByteStreams, Client, JsonRpcRequest, JsonRpcResponse};
@@ -69,13 +70,28 @@ pub enum AcpUpdate {
/// turn precede this marker on the wire, and therefore in this channel.
/// The event pump must fire `done` after persisting every preceding
/// notification.
///
/// The `done` oneshot carries both the prompt result and a `settled_tx`
/// that the receiver fires after the turn marker is persisted. The
/// event pump awaits the corresponding `settled_rx` before processing
/// further messages so late-arriving notifications cannot leak into the
/// next turn.
TurnDone {
turn: u64,
result: Result<(), String>,
done: oneshot::Sender<Result<(), String>>,
done: oneshot::Sender<TurnDonePayload>,
},
}
/// Payload sent through the `done` oneshot inside [`AcpUpdate::TurnDone`].
#[derive(Debug)]
pub struct TurnDonePayload {
pub result: Result<(), String>,
/// The receiver fires this after persisting the turn marker and
/// dispatching any queued prompt.
pub settled: oneshot::Sender<()>,
}
/// ACP adapter selected from the structured session's resolved command.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AcpAdapter {
@@ -249,6 +265,15 @@ pub struct PermissionRequest {
pub response: oneshot::Sender<RequestPermissionOutcome>,
}
/// A blocked ACP elicitation request delivered to the daemon.
#[derive(Debug)]
pub struct ElicitationRequest {
/// The prompt turn that was active when ACP delivered this callback.
pub turn: u64,
pub request: CreateElicitationRequest,
pub response: oneshot::Sender<ElicitationAction>,
}
#[derive(Debug, thiserror::Error)]
pub enum AcpError {
/// The adapter is not on `PATH`. The message names the install command
@@ -301,7 +326,7 @@ enum Command {
Prompt {
text: String,
turn: u64,
done: oneshot::Sender<Result<(), String>>,
done: oneshot::Sender<TurnDonePayload>,
},
SetConfig {
config_id: String,
@@ -374,6 +399,7 @@ pub struct AcpSession {
child_commands: mpsc::UnboundedSender<ChildCommand>,
updates: Mutex<Option<mpsc::UnboundedReceiver<AcpUpdate>>>,
permission_requests: Mutex<Option<mpsc::UnboundedReceiver<PermissionRequest>>>,
elicitation_requests: Mutex<Option<mpsc::UnboundedReceiver<ElicitationRequest>>>,
exit: watch::Receiver<Option<ExitStatus>>,
}
@@ -424,6 +450,13 @@ impl AcpSession {
.take()
}
pub fn take_elicitation_requests(&self) -> Option<mpsc::UnboundedReceiver<ElicitationRequest>> {
self.elicitation_requests
.lock()
.expect("elicitation requests")
.take()
}
/// Send `session/cancel`. Fire-and-forget: a closed connection means the
/// child is already gone, which is the outcome cancel was after.
pub fn cancel(&self) {
@@ -435,7 +468,7 @@ impl AcpSession {
&self,
text: String,
turn: u64,
) -> Result<oneshot::Receiver<Result<(), String>>, String> {
) -> Result<oneshot::Receiver<TurnDonePayload>, String> {
let (done_tx, done_rx) = oneshot::channel();
self.commands
.send(Command::Prompt {
@@ -558,6 +591,7 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
let (updates_tx, updates_rx) = mpsc::unbounded_channel();
let (permission_tx, permission_rx) = mpsc::unbounded_channel();
let (elicitation_tx, elicitation_rx) = mpsc::unbounded_channel();
let (commands_tx, commands_rx) = mpsc::unbounded_channel();
let (ready_tx, ready_rx) = oneshot::channel();
let transport = ByteStreams::new(stdin.compat_write(), stdout.compat());
@@ -570,6 +604,7 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
},
updates_tx,
permission_tx,
elicitation_tx,
commands_rx,
ready_tx,
));
@@ -584,6 +619,7 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
child_commands: child_commands_tx,
updates: Mutex::new(Some(updates_rx)),
permission_requests: Mutex::new(Some(permission_rx)),
elicitation_requests: Mutex::new(Some(elicitation_rx)),
exit: exit_rx,
});
}
@@ -690,6 +726,7 @@ fn client_capabilities() -> ClientCapabilities {
.read_text_file(false)
.write_text_file(false))
.terminal(false)
.elicitation(ElicitationCapabilities::new().form(ElicitationFormCapabilities::default()))
}
fn advertised_config_options(
@@ -792,6 +829,7 @@ async fn run_connection<T>(
config: ConnectionConfig,
updates_tx: mpsc::UnboundedSender<AcpUpdate>,
permission_requests: mpsc::UnboundedSender<PermissionRequest>,
elicitation_requests: mpsc::UnboundedSender<ElicitationRequest>,
mut commands_rx: mpsc::UnboundedReceiver<Command>,
ready_tx: oneshot::Sender<Result<String, ReadyError>>,
) where
@@ -807,6 +845,7 @@ async fn run_connection<T>(
// until a cancelled prompt settles, so this active turn is unambiguous.
let permission_turn = Arc::new(Mutex::new(PermissionTurnState::default()));
let permission_turn_seen = permission_turn.clone();
let elicitation_turn_seen = permission_turn.clone();
let received_updates = updates_tx.clone();
let result = Client
.builder()
@@ -852,6 +891,35 @@ async fn run_connection<T>(
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_request(
async move |request: CreateElicitationRequest, responder, _cx| {
let turn = elicitation_turn_seen
.lock()
.expect("elicitation turn state")
.callback_turn();
let Some(turn) = turn else {
return responder.respond(CreateElicitationResponse::new(
ElicitationAction::Cancel,
));
};
let (response, received) = oneshot::channel();
if elicitation_requests
.send(ElicitationRequest {
turn,
request,
response,
})
.is_err()
{
return responder.respond(CreateElicitationResponse::new(
ElicitationAction::Cancel,
));
}
let action = received.await.unwrap_or(ElicitationAction::Cancel);
responder.respond(CreateElicitationResponse::new(action))
},
agent_client_protocol::on_receive_request!(),
)
.connect_with(transport, async move |cx| {
let init = InitializeRequest::new(ProtocolVersion::V1)
.client_capabilities(client_capabilities())
@@ -999,7 +1067,11 @@ async fn run_connection<T>(
.expect("permission turn state")
.finish(turn);
if let Some(done) = done.lock().expect("prompt sender").take() {
let _ = done.send(Err(error.to_string()));
let (settled_tx, _) = oneshot::channel();
let _ = done.send(TurnDonePayload {
result: Err(error.to_string()),
settled: settled_tx,
});
}
}
}
+19 -5
View File
@@ -70,11 +70,12 @@ pub fn decide(idle_for: Duration, awaiting_input: bool) -> ActivityState {
pub fn decide_structured(
turn_active: bool,
pending_approval: bool,
pending_elicitation: bool,
turn_failed: bool,
) -> ActivityState {
if turn_failed {
ActivityState::Error
} else if pending_approval {
} else if pending_approval || pending_elicitation {
ActivityState::AwaitingInput
} else if turn_active {
ActivityState::Active
@@ -155,13 +156,26 @@ mod tests {
#[test]
fn structured_activity_follows_lifecycle_with_approval_precedence() {
assert_eq!(decide_structured(false, false, false), ActivityState::Idle);
assert_eq!(decide_structured(true, false, false), ActivityState::Active);
assert_eq!(
decide_structured(true, true, false),
decide_structured(false, false, false, false),
ActivityState::Idle
);
assert_eq!(
decide_structured(true, false, false, false),
ActivityState::Active
);
assert_eq!(
decide_structured(true, true, false, false),
ActivityState::AwaitingInput
);
assert_eq!(decide_structured(false, false, true), ActivityState::Error);
assert_eq!(
decide_structured(true, false, true, false),
ActivityState::AwaitingInput
);
assert_eq!(
decide_structured(false, false, false, true),
ActivityState::Error
);
}
#[test]
+53 -2
View File
@@ -22,8 +22,8 @@ use maestro_config::SpawnPreset;
use maestro_protocol::{
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, SetFocusRequest,
StructuredApprovalDecision, StructuredConfigRequest, StructuredEvent, StructuredPromptRequest,
PROTOCOL_VERSION,
StructuredApprovalDecision, StructuredConfigRequest, StructuredElicitationAction,
StructuredEvent, StructuredPromptRequest, PROTOCOL_VERSION,
};
use serde::Deserialize;
use uuid::Uuid;
@@ -80,6 +80,10 @@ pub fn router(state: LocalApiState) -> Router {
.route(
routes::patterns::STRUCTURED_APPROVAL,
post(structured_approval),
)
.route(
routes::patterns::STRUCTURED_ELICITATION,
post(structured_elicitation),
);
#[cfg(feature = "haiku-autoname")]
{
@@ -577,6 +581,53 @@ async fn structured_approval(
}
}
async fn structured_elicitation(
State(s): State<LocalApiState>,
Path((id, nonce)): Path<(Uuid, String)>,
Json(body): Json<StructuredElicitationAction>,
) -> Response {
use maestro_acp::schema::{
ElicitationAcceptAction, ElicitationAction, ElicitationContentValue,
};
let action = match body {
StructuredElicitationAction::Accept { content } => {
let mapped: std::collections::BTreeMap<String, ElicitationContentValue> = content
.into_iter()
.filter_map(|(key, value)| {
let v = match value {
serde_json::Value::String(s) => ElicitationContentValue::String(s),
serde_json::Value::Bool(b) => ElicitationContentValue::Boolean(b),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
ElicitationContentValue::Integer(i)
} else {
ElicitationContentValue::Number(n.as_f64()?)
}
}
_ => return None,
};
Some((key, v))
})
.collect();
ElicitationAction::Accept(ElicitationAcceptAction::new().content(mapped))
}
StructuredElicitationAction::Decline => ElicitationAction::Decline,
StructuredElicitationAction::Cancel => ElicitationAction::Cancel,
};
match s.manager.resolve_acp_elicitation(id, &nonce, action).await {
Ok(Some(())) => StatusCode::NO_CONTENT.into_response(),
Ok(None) => error_response(
StatusCode::CONFLICT,
"not_structured",
"session is not a live structured session",
),
Err(e) => {
let info = e.to_error_info();
error_response(session_error_status(&info.code), &info.code, &info.message)
}
}
}
/// Convenience constructor — pulls the bits `LocalApiState` needs from
/// `DaemonConfig` so callers don't have to assemble it by hand. The project
/// scanner starts with its cache cold; wire
+40
View File
@@ -125,6 +125,7 @@ impl Projection {
crate::activity::decide_structured(
self.active_turn.is_some(),
self.state.pending_approval.is_some(),
self.state.pending_elicitation.is_some(),
self.turn_failed,
)
}
@@ -274,6 +275,45 @@ impl Projection {
})
}
/// Add a daemon-originated elicitation row. The nonce belongs only in
/// the reduced state, not in the row data.
pub fn request_elicitation(&mut self, nonce: String, data: Value) -> ProjectionUpdate {
let row = self.append(
format!("elicitation:{}", uuid::Uuid::new_v4()),
TranscriptRowKind::Elicitation,
data.clone(),
);
self.state.pending_elicitation = Some(serde_json::json!({
"nonce": nonce,
"request": data,
}));
ProjectionUpdate {
transcript: Some(row),
state: self.state(),
}
}
/// Clear an elicitation only when it is still the one the client answered.
pub fn resolve_elicitation(&mut self, nonce: &str) -> Option<ProjectionUpdate> {
let pending = self.state.pending_elicitation.as_ref()?;
if pending.get("nonce").and_then(Value::as_str) != Some(nonce) {
return None;
}
self.state.pending_elicitation = None;
Some(ProjectionUpdate {
transcript: None,
state: self.state(),
})
}
pub fn clear_elicitation(&mut self) -> Option<ProjectionUpdate> {
self.state.pending_elicitation.take()?;
Some(ProjectionUpdate {
transcript: None,
state: self.state(),
})
}
/// Undo an `append`ed row that a caller decides never happened — used
/// when persisting the row's own event fails (#184), so a snapshot taken
/// after the failure cannot show a row durable replay would never
+193 -8
View File
@@ -156,6 +156,7 @@ struct AcpEventStream {
projection: Projection,
projected_live: broadcast::Sender<ProjectionUpdate>,
pending_approvals: HashMap<String, PendingApproval>,
pending_elicitations: HashMap<String, PendingElicitation>,
/// Serializes prompt dispatch with cancellation. A prompt is not merely
/// claimed: it is queued onto ACP before another operation can proceed.
operation: Arc<Mutex<()>>,
@@ -205,6 +206,11 @@ struct PendingApproval {
options: Vec<maestro_acp::schema::PermissionOption>,
}
#[derive(Debug)]
struct PendingElicitation {
response: oneshot::Sender<maestro_acp::schema::ElicitationAction>,
}
/// The outcome accepted by the structured approval endpoint.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApprovalDecision {
@@ -712,6 +718,7 @@ impl SessionManager {
return Ok(false);
}
cancel_pending_approvals(&mut stream);
cancel_pending_elicitations(&mut stream);
let Some(projected) = stream.projection.turn_lifecycle(lifecycle, turn) else {
return Ok(false);
};
@@ -869,11 +876,24 @@ impl SessionManager {
stream.dispatch.queued_turn = Some(turn);
let manager = self.clone();
tokio::spawn(async move {
let lifecycle = match done.await {
Ok(Ok(())) => TurnLifecycle::Finish,
Ok(Err(_)) | Err(_) => TurnLifecycle::Failure,
let (lifecycle, settled) = match done.await {
Ok(payload) => {
let lifecycle = if payload.result.is_ok() {
TurnLifecycle::Finish
} else {
TurnLifecycle::Failure
};
(lifecycle, Some(payload.settled))
}
Err(_) => (TurnLifecycle::Failure, None),
};
manager.finish_queued_acp_turn(id, turn, lifecycle).await;
// Tell the event pump it may resume processing
// notifications — the turn marker is persisted
// and any queued prompt has been dispatched.
if let Some(settled) = settled {
let _ = settled.send(());
}
});
drop(stream);
sync_structured_activity(&self.inner, id).await;
@@ -885,6 +905,7 @@ impl SessionManager {
}
Err(message) => {
cancel_pending_approvals(&mut stream);
cancel_pending_elicitations(&mut stream);
let projected = stream
.projection
.turn_lifecycle(TurnLifecycle::Failure, turn)
@@ -955,6 +976,7 @@ impl SessionManager {
stream.dispatch.settled.notify_waiters();
if stream.projection.active_turn() == Some(turn) {
cancel_pending_approvals(&mut stream);
cancel_pending_elicitations(&mut stream);
if let Some(projected) = stream.projection.turn_lifecycle(lifecycle, turn) {
let projected = persist_turn_update(&mut stream, projected);
stream.broadcast(projected);
@@ -1077,6 +1099,7 @@ impl SessionManager {
let mut stream = event_stream.lock().await;
acp.cancel();
cancel_pending_approvals(&mut stream);
cancel_pending_elicitations(&mut stream);
let cancelled_queued_turn = stream.dispatch.queued_turn;
if let Some(turn) = cancelled_queued_turn {
stream.dispatch.cancelled_turn = Some(turn);
@@ -1149,6 +1172,38 @@ impl SessionManager {
Ok(Some(()))
}
/// Resolve a daemon-issued elicitation nonce. Same idempotency rules as
/// `resolve_acp_approval`: stale/duplicate nonces succeed silently.
pub async fn resolve_acp_elicitation(
&self,
id: Uuid,
nonce: &str,
action: maestro_acp::schema::ElicitationAction,
) -> Result<Option<()>, SessionError> {
let stream = {
let sessions = self.inner.sessions.lock().await;
let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
if !record.is_live_structured() {
return Ok(None);
}
record.acp_events.clone()
};
let Some(stream) = stream else {
return Ok(None);
};
let mut stream = stream.lock().await;
let Some(pending) = stream.pending_elicitations.remove(nonce) else {
return Ok(Some(()));
};
let _ = pending.response.send(action);
if let Some(update) = stream.projection.resolve_elicitation(nonce) {
stream.broadcast(update);
}
drop(stream);
sync_structured_activity(&self.inner, id).await;
Ok(Some(()))
}
async fn cancel_acp_approvals(&self, id: Uuid) {
let stream = self
.inner
@@ -1158,7 +1213,9 @@ impl SessionManager {
.get(&id)
.and_then(|record| record.acp_events.clone());
if let Some(stream) = stream {
cancel_pending_approvals(&mut *stream.lock().await);
let mut events = stream.lock().await;
cancel_pending_approvals(&mut events);
cancel_pending_elicitations(&mut events);
}
}
@@ -1448,6 +1505,7 @@ impl SessionManager {
projection: Projection::default(),
projected_live: projected_tx,
pending_approvals: HashMap::new(),
pending_elicitations: HashMap::new(),
operation: Arc::new(Mutex::new(())),
dispatch: AcpDispatchState::default(),
prompt_queue: VecDeque::new(),
@@ -1601,6 +1659,7 @@ impl SessionManager {
let acp = Arc::new(acp_session);
let done = start_acp_event_pump(&inner, id, &acp, events.clone());
start_acp_permission_pump(&inner, id, &acp, events.clone(), auto_approve);
start_acp_elicitation_pump(&inner, id, &acp, events.clone());
// Drain any prompts queued while the adapter was Starting.
let queued_text = {
let mut sessions = inner.sessions.lock().await;
@@ -1968,6 +2027,7 @@ impl SessionManager {
projection,
projected_live: projected_tx,
pending_approvals: HashMap::new(),
pending_elicitations: HashMap::new(),
operation: Arc::new(Mutex::new(())),
dispatch: AcpDispatchState::default(),
prompt_queue: session.queued_prompts.drain(..).collect(),
@@ -2026,7 +2086,14 @@ impl SessionManager {
);
if let Some((acp, events)) = event_pump {
let done = start_acp_event_pump(&self.inner, id, &acp, events.clone());
start_acp_permission_pump(&self.inner, id, &acp, events, session.auto_approve);
start_acp_permission_pump(
&self.inner,
id,
&acp,
events.clone(),
session.auto_approve,
);
start_acp_elicitation_pump(&self.inner, id, &acp, events);
if let Some(record) = self.inner.sessions.lock().await.get_mut(&id) {
record.acp_event_pump = Some(done);
}
@@ -2443,8 +2510,17 @@ fn start_acp_event_pump(
} => {
// Every notification for this turn has been persisted and
// broadcast above. Signal the done oneshot so
// finish_queued_acp_turn runs with the transcript complete.
let _ = done.send(result);
// finish_queued_acp_turn runs with the transcript complete,
// then wait for it to persist the turn marker before we
// resume — otherwise late-arriving notifications race into
// the next turn.
let (settled_tx, settled_rx) = tokio::sync::oneshot::channel();
let _ = done.send(maestro_acp::TurnDonePayload {
result,
settled: settled_tx,
});
// Block the pump until the turn is fully settled.
let _ = settled_rx.await;
}
}
}
@@ -2458,6 +2534,7 @@ fn apply_persisted_event(projection: &mut Projection, event: &LoggedEvent) -> Pr
PersistedEvent::Notification(notification) => projection.apply(event.seq, notification),
PersistedEvent::User { row }
| PersistedEvent::Approval { row }
| PersistedEvent::Elicitation { row }
| PersistedEvent::Turn { row } => projection.restore_row(row.clone()),
}
}
@@ -2735,6 +2812,110 @@ fn permission_outcome(
)
}
// ---------------------------------------------------------------------------
// Elicitation pump
// ---------------------------------------------------------------------------
fn start_acp_elicitation_pump(
inner: &Arc<Inner>,
id: Uuid,
acp: &Arc<AcpSession>,
events: Arc<Mutex<AcpEventStream>>,
) {
let Some(mut requests) = acp.take_elicitation_requests() else {
return;
};
let weak_inner = Arc::downgrade(inner);
let weak_acp = Arc::downgrade(acp);
tokio::spawn(async move {
while let Some(maestro_acp::ElicitationRequest {
turn,
request,
response,
}) = requests.recv().await
{
let mut events = events.lock().await;
if events.projection.active_turn() != Some(turn) {
let _ = response.send(maestro_acp::schema::ElicitationAction::Cancel);
continue;
}
let outcome = record_elicitation_request(&mut events, request, response);
let fatal = matches!(outcome, RecordOutcome::Fatal);
if let RecordOutcome::Live(update) = outcome {
events.broadcast(*update);
}
drop(events);
if fatal {
tracing::error!(%id, "elicitation history append rollback did not restore consistency");
if let Some(acp) = weak_acp.upgrade() {
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
.await;
}
if let Some(inner) = weak_inner.upgrade() {
mark_session_dead(&inner, id).await;
}
break;
}
if let Some(inner) = weak_inner.upgrade() {
sync_structured_activity(&inner, id).await;
}
}
});
}
fn record_elicitation_request(
events: &mut AcpEventStream,
request: maestro_acp::schema::CreateElicitationRequest,
response: oneshot::Sender<maestro_acp::schema::ElicitationAction>,
) -> RecordOutcome {
let nonce = Uuid::new_v4().to_string();
let data = match serde_json::to_value(&request) {
Ok(value) => value,
Err(_) => {
let _ = response.send(maestro_acp::schema::ElicitationAction::Cancel);
return RecordOutcome::Dropped;
}
};
cancel_pending_elicitations(events);
events
.pending_elicitations
.insert(nonce.clone(), PendingElicitation { response });
let update = events.projection.request_elicitation(nonce, data);
let Some(TranscriptDelta::Append { row }) = &update.transcript else {
return RecordOutcome::Live(Box::new(update));
};
let event = PersistedEvent::Elicitation { row: row.clone() };
match events.log.append(&event) {
Ok(seq) => {
let _ = events.live.send(LoggedEvent { seq, event });
RecordOutcome::Live(Box::new(update))
}
Err(AppendError::Failed(error)) => {
tracing::error!(%error, "elicitation history append failed");
events.projection.rollback_row(&row.id);
cancel_pending_elicitations(events);
RecordOutcome::Dropped
}
Err(fatal @ AppendError::Fatal { .. }) => {
tracing::error!(error = %fatal, "elicitation history append failed unrecoverably");
events.projection.rollback_row(&row.id);
cancel_pending_elicitations(events);
RecordOutcome::Fatal
}
}
}
fn cancel_pending_elicitations(events: &mut AcpEventStream) {
for (_, pending) in events.pending_elicitations.drain() {
let _ = pending
.response
.send(maestro_acp::schema::ElicitationAction::Cancel);
}
if let Some(update) = events.projection.clear_elicitation() {
events.broadcast(update);
}
}
async fn wait_for_event_pump(mut done: watch::Receiver<bool>) {
if !*done.borrow() {
let _ = done.wait_for(|finished| *finished).await;
@@ -3110,6 +3291,7 @@ async fn mark_session_dead_with_worktree_settlement(
if let Some(stream) = stream {
let mut stream = stream.lock().await;
cancel_pending_approvals(&mut stream);
cancel_pending_elicitations(&mut stream);
stream.prompt_queue.clear();
}
tracing::info!(%id, "session process gone — marking dead");
@@ -3211,7 +3393,9 @@ async fn cleanup_record(inner: &Arc<Inner>, id: Uuid, record: SessionRecord, kil
}
if let Some(acp) = &record.acp {
if let Some(stream) = &record.acp_events {
cancel_pending_approvals(&mut *stream.lock().await);
let mut events = stream.lock().await;
cancel_pending_approvals(&mut events);
cancel_pending_elicitations(&mut events);
}
// `session/cancel`, a short wait, then SIGTERM (#134).
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
@@ -3299,6 +3483,7 @@ mod tests {
projection: Projection::default(),
projected_live,
pending_approvals: std::collections::HashMap::new(),
pending_elicitations: std::collections::HashMap::new(),
operation: std::sync::Arc::new(tokio::sync::Mutex::new(())),
dispatch: AcpDispatchState::default(),
prompt_queue: std::collections::VecDeque::new(),
+3
View File
@@ -269,6 +269,9 @@ pub enum PersistedEvent {
Approval {
row: TranscriptRow,
},
Elicitation {
row: TranscriptRow,
},
Turn {
row: TranscriptRow,
},
+1
View File
@@ -105,6 +105,7 @@ fn event_text(event: &maestro_daemon::sidecar::LoggedEvent) -> &str {
.and_then(serde_json::Value::as_str)
.unwrap_or("[turn]"),
maestro_daemon::sidecar::PersistedEvent::Approval { .. } => "[approval]",
maestro_daemon::sidecar::PersistedEvent::Elicitation { .. } => "[elicitation]",
}
}
+18
View File
@@ -806,6 +806,7 @@ pub enum TranscriptRowKind {
ToolCall,
ToolDiff,
Approval,
Elicitation,
Plan,
Notice,
}
@@ -871,6 +872,8 @@ pub struct ReducedState {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pending_approval: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pending_elicitation: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plan: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<Value>,
@@ -927,6 +930,21 @@ pub enum StructuredApprovalDecision {
Deny,
}
/// Operator response to a pending ACP elicitation request.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum StructuredElicitationAction {
/// The operator filled in the form and submitted.
Accept {
#[serde(default)]
content: std::collections::BTreeMap<String, Value>,
},
/// The operator declined to answer.
Decline,
/// The elicitation was cancelled.
Cancel,
}
// ---------------------------------------------------------------------------
// Client ↔ Daemon control messages (text frames on the attach WS)
// ---------------------------------------------------------------------------
+6
View File
@@ -106,6 +106,11 @@ pub fn structured_approval(id: Uuid, nonce: &str) -> String {
format!("{SESSIONS}/{id}/structured/approvals/{nonce}")
}
/// `POST /api/sessions/:id/structured/elicitations/:nonce` — answer ACP elicitation.
pub fn structured_elicitation(id: Uuid, nonce: &str) -> String {
format!("{SESSIONS}/{id}/structured/elicitations/{nonce}")
}
/// Axum-style patterns for the two parameterised routes, so the server
/// registers the same paths [`session`] and [`attach`] build.
pub mod patterns {
@@ -122,6 +127,7 @@ pub mod patterns {
pub const STRUCTURED_CANCEL: &str = "/api/sessions/:id/structured/cancel";
pub const STRUCTURED_CONFIG: &str = "/api/sessions/:id/structured/config";
pub const STRUCTURED_APPROVAL: &str = "/api/sessions/:id/structured/approvals/:nonce";
pub const STRUCTURED_ELICITATION: &str = "/api/sessions/:id/structured/elicitations/:nonce";
}
/// A daemon base URL that could not be turned into a WebSocket base.
+10
View File
@@ -355,6 +355,15 @@ fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
Tone::Muted,
false,
),
TranscriptRowKind::Elicitation => push_wrapped(
&mut out,
row,
"? ",
content_text(row),
row_width,
Tone::Muted,
false,
),
TranscriptRowKind::Plan => {}
}
if out.len() > start
@@ -399,6 +408,7 @@ pub fn copy_text(row: &TranscriptRow) -> String {
.and_then(serde_json::Value::as_str)
.unwrap_or("permission requested")
.into(),
TranscriptRowKind::Elicitation => content_text(row),
TranscriptRowKind::Notice => "[notice]".into(),
TranscriptRowKind::Plan => String::new(),
}
+64 -23
View File
@@ -6,11 +6,11 @@ pane. Alongside `docs/WORKTREES.md`, `docs/SIDEBAR.md` and `docs/RESTART.md`,
read this before changing anything that renders a structured session — both
the web and TUI renderers are written against it.
It covers the eight transcript row kinds, what the daemon decides versus what
It covers the nine transcript row kinds, what the daemon decides versus what
a client decides, the two projections and their delta/cursor semantics, the
ACP handshake and the capabilities it declines, the adapter's install
command, and the approval nonce rules. **Non-goal: the detached runner.** It
does not exist yet, and nothing here describes it.
command, the approval nonce rules, and the elicitation flow. **Non-goal: the
detached runner.** It does not exist yet, and nothing here describes it.
## What a structured session is
@@ -88,10 +88,12 @@ declares, deliberately, almost nothing:
Declining all three keeps the daemon out of the file/terminal surface
entirely — the adapter already has direct access to the session's `cwd`, and
routing every read, write, and shell command through the daemon would only
add a hop with no new capability. Every other capability namespace ACP
defines (session extensions, auth, elicitation) is simply left at its
default; the daemon does not advertise support because neither supported
adapter needs it, not because it was weighed and refused.
add a hop with no new capability.
The daemon does declare `elicitation.form` support. This tells the adapter
that form-mode elicitation (`CreateElicitationRequest`) will be handled and
resolved by the operator, rather than declined outright. See
[Elicitations](#elicitations) below.
The one client method that *is* implemented for real is
`session/request_permission` (#139) — see [Approvals](#approvals) below.
@@ -111,12 +113,12 @@ in the projections below.
Codex and Claude differ in what they emit, not in this lifecycle. Codex maps
shell commands, file changes, MCP calls, web search, image generation/view,
reasoning, plans, and token usage into standard ACP chunks and tool calls;
those fold into the same eight rows. Codex's `available_commands_update` and
those fold into the same nine rows. Codex's `available_commands_update` and
`session_info_update`, like equivalent Claude updates, remain opaque
`Notice` rows. Its `session/load` capability is required for daemon recovery
just as it is for Claude.
## The eight transcript row kinds
## The nine transcript row kinds
`TranscriptRowKind` (`maestro-protocol`) is the alphabet a structured
transcript is written in. Every row has a stable `id` (so later deltas can
@@ -132,6 +134,7 @@ crate.
| `ToolCall` | A `tool_call` / `tool_call_update` pair whose content carries no diff. | `tool:<tool_call_id>` — one row for the call's whole life; updates patch it in place, they never create a second row. |
| `ToolDiff` | The same tool-call machinery, when the tool's content includes a `Diff` block. | Same `tool:<tool_call_id>` identity as `ToolCall`; the *kind* flips between the two as the tool's own content changes, not the row. |
| `Approval` | A daemon-originated permission request (`RequestPermissionRequest`), serialized without its nonce. | `approval:<uuid>` — minted fresh per request, unrelated to the ACP request's own id. |
| `Elicitation` | A daemon-originated elicitation request (`CreateElicitationRequest`), serialized without its nonce. Carries `message` and `requestedSchema` (a JSON Schema object with typed properties). | `elicitation:<uuid>` — minted fresh per request, same lifecycle as `Approval`. |
| `Plan` | A `plan` update — the agent's current step list. | The fixed id `"plan"`. Each new plan **replaces** the row wholesale; entries are not diffed against the previous plan. |
| `Notice` | The raw JSON of any `session/update` variant the ACP dependency already decodes but the projection does not special-case. Maestro also uses a tagged `maestroTurnMeta` notice as the durable footer for a completed, cancelled, or failed turn. | `notice:<seq>` for ACP updates; `turn_meta:<uuid>` for a turn footer. |
@@ -139,7 +142,7 @@ crate.
`agent-client-protocol` dependency's schema already deserializes (it
currently folds `available_commands_update` and `session_info_update`
there) but the projection's `match` doesn't fold into one of the other
seven kinds. It is not a wire-level forward-compatibility seam:
eight kinds. It is not a wire-level forward-compatibility seam:
`SessionUpdate` is an internally-tagged enum with no unknown-variant
fallback, so a `session/update` whose `sessionUpdate` tag this build's
dependency has never heard of — including a variant gated behind an
@@ -174,6 +177,8 @@ choices, not daemon ones.
- Approval nonce minting, secrecy, and the single-active-approval rule (see
below) — a client never sees an ACP permission request before the daemon
has already turned it into an `Approval` row and a nonce.
- Elicitation nonce minting and the single-active-elicitation rule (see
[Elicitations](#elicitations)) — same lifecycle as approvals.
- What happens to an ACP update variant the dependency decodes but nobody has
taught the projection to special-case (falls back to `Notice` rather than
erroring). A variant the dependency itself can't decode is a deserialization
@@ -195,7 +200,8 @@ choices, not daemon ones.
text → group → text → group rather than lumping every tool in a turn into
one box. Text and thought rows stay outside groups as standalone transcript
items — the operator sees all reasoning without expanding. Resolved
approval rows between tools within a run are absorbed into the group. The
approval and elicitation rows between tools within a run are absorbed into
the group. The
collapsed header reads "N tool calls · Label 'arg'" where the label and
argument come from the last tool row's `splitToolTitle` — the operator
sees which command is current at a glance. Opening the group reveals tool
@@ -213,6 +219,9 @@ choices, not daemon ones.
- How the operator is asked to resolve an `Approval` row — the daemon only
exposes the three-way decision (`AllowOnce` / `AllowForSession` / `Deny`),
never the UI that solicits it.
- How the operator is asked to resolve an `Elicitation` row — the daemon
exposes the three actions (`Accept` with content / `Decline` / `Cancel`);
the client decides the form layout and button treatment.
- Scrolling, collapsing, and every other presentation-only behaviour.
## The two projections
@@ -225,8 +234,8 @@ exposes two independent views, both defined in `maestro-protocol`:
It is intentionally always the *whole* transcript, not a windowed one: a
tool row patched much later must still find its original entry.
- **`ReducedState`** — one whole-session snapshot folded over every event
seen: `turn` (the per-turn indicators above), `pending_approval`, `plan`,
`usage`/`context`, `model`, and `mode`. Like `TranscriptSnapshot`, it is
seen: `turn` (the per-turn indicators above), `pending_approval`,
`pending_elicitation`, `plan`, `usage`/`context`, `model`, and `mode`. Like `TranscriptSnapshot`, it is
always full-history, never a delta — a client that only wants "is a turn
active right now" never has to replay anything to answer that.
@@ -323,6 +332,37 @@ pending decision, entirely on the daemon side:
permanent one (`RejectAlways`) only when that is the sole rejection option
offered; if neither is present, it answers `Cancelled`.
## Elicitations
An ACP `elicitation/create` callback becomes an `Elicitation` row plus a
pending decision, mirroring the approval lifecycle:
- **Generation.** The daemon mints a nonce per request (`Uuid::new_v4()`),
same as approvals.
- **Live-only placement.** The nonce lives in `pending_elicitations` and in
`ReducedState.pending_elicitation.nonce`, never in the row's `data`.
- **One live elicitation at a time.** A new elicitation cancels any
prior pending one before installing itself.
- **Resolution.** `POST /api/sessions/:id/structured/elicitations/:nonce`
accepts `{ "action": "accept", "content": { ... } }`,
`{ "action": "decline" }`, or `{ "action": "cancel" }`. The endpoint
always answers `204` for a known, live structured session, same as
approvals. The three actions map to ACP's `ElicitationAction::Accept`
(with the filled-in values as `ElicitationContentValue` entries),
`Decline`, and `Cancel`.
- **Cancellation.** Turn completion, session cancel, and fatal errors
cancel pending elicitations as `Cancel`, same as approvals.
- **Schema.** The row's `data.requestedSchema` is a JSON Schema object
whose properties describe the fields the agent wants filled:
`StringPropertySchema` (with optional `enum`/`oneOf` for multiple-choice),
`BooleanPropertySchema`, `NumberPropertySchema`, `IntegerPropertySchema`.
Claude Code's common case is a single-property string enum — a
multiple-choice question with titled options.
- **Rendering.** Clients render the `message` as the question and the schema
properties as form fields or button groups. The web client detects the
single-property string enum fast path and renders titled buttons. Resolved
elicitations collapse to a compact one-liner.
## Prompt and cancel: bounded liveness
The web composer accepts up to ten files through its attachment picker, file
@@ -364,8 +404,8 @@ allowed to claim the daemon's idea of "the active turn."
- A submitted prompt is appended as a durable `User` row before it claims the turn and
dispatches to ACP, so the transcript never depends on an adapter echoing the
operator's input. If that append fails, the prompt is not dispatched.
- Cancel sends `session/cancel`, clears any pending approval as `Cancelled`,
and ends the turn's client-visible lifecycle immediately. The prompt queue
- Cancel sends `session/cancel`, clears any pending approval and elicitation
as `Cancelled`, and ends the turn's client-visible lifecycle immediately. The prompt queue
is preserved: after the cancelled turn settles, the queued prompts drain as
the next turn. If the session dies instead (settlement timeout), the queue
is cleared. If the cancelled turn was still *dispatched* to ACP (waiting on
@@ -390,8 +430,8 @@ directory: the sidecar itself (`<id>.json`), plus two next to it:
- **`<id>.events.jsonl`** — the unified event log. Each line is either a raw
ACP `SessionNotification` (kept in its original JSON so old logs stay
readable) or a `{"maestroEvent": "user"|"approval", "row": …}` envelope
for a daemon-generated row. Both share the same 1-based line-position cursor. A
readable) or a `{"maestroEvent": "user"|"approval"|"elicitation", "row": …}`
envelope for a daemon-generated row. Both share the same 1-based line-position cursor. A
line a hard kill left half-written is terminated before the next append, so
it keeps its position and is skipped as malformed rather than corrupting
what follows. An append that fails *while the daemon is running* — a
@@ -453,12 +493,13 @@ itself failing) leaves it `Dead` with the failure reason as
`activity_detail` — the same "tell the operator what to fix" pattern as a
missing adapter binary.
Pending approvals and their nonces never survive any of this. They live only
in the in-memory `pending_approvals` map and the live `ReducedState`, both
gone the instant the daemon that held them exits. Whatever ACP permission
request was mid-flight when the old daemon died was already answered
`Cancelled` on that daemon's own shutdown path, so a resumed session always
starts with no pending approval.
Pending approvals and elicitations and their nonces never survive any of
this. They live only in the in-memory `pending_approvals` /
`pending_elicitations` maps and the live `ReducedState`, both gone the
instant the daemon that held them exits. Whatever ACP permission or
elicitation request was mid-flight when the old daemon died was already
answered `Cancelled` on that daemon's own shutdown path, so a resumed
session always starts with no pending approval or elicitation.
See `docs/RESTART.md` for what a *client* observes across a daemon restart,
now that structured sessions exist alongside terminal ones.
+18 -1
View File
@@ -12,6 +12,7 @@ import type {
RecentDirs,
Session,
StructuredApprovalDecision,
StructuredElicitationAction,
StructuredConfigRequest,
StructuredPromptRequest,
Uuid,
@@ -39,7 +40,7 @@ export class ApiRequestError extends Error {
async function jsonOrError<T>(res: Response): Promise<T> {
if (res.ok) {
if (res.status === 204) return undefined as unknown as T;
if (res.status === 204 || res.status === 202) return undefined as unknown as T;
return (await res.json()) as T;
}
try {
@@ -219,4 +220,20 @@ export async function resolveStructuredApproval(
);
}
/** Resolve a pending elicitation. Same idempotency as approvals. */
export async function resolveStructuredElicitation(
id: Uuid,
nonce: string,
action: StructuredElicitationAction,
): Promise<void> {
await jsonOrError(
await fetch(routes.structuredElicitation(id, nonce), {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/json" },
body: JSON.stringify(action),
}),
);
}
export { eventsWsUrl, structuredWsUrl } from "./routes.js";
+9
View File
@@ -11,6 +11,7 @@ import { activeTab, type Dir, type Node } from "../../layout.js";
import { usePreview } from "../../hooks/usePreview.js";
import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js";
import type { Session, Uuid } from "../../types.js";
import { requestToggleDrawer } from "../../uiEvents.js";
import { PaneContainer, type PaneContainerHandle } from "./PaneContainer.js";
import { PaneTitle } from "./PaneTitle.js";
import { TabBar } from "./TabBar.js";
@@ -204,6 +205,14 @@ function Pane({
<div
className="mv-pane-header attach-header flex items-center justify-between gap-sp-2 border-b border-border-strong px-sp-2 py-sp-1 text-fs-12"
>
<button
type="button"
className="sessions-btn mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
title="Sessions"
onClick={() => requestToggleDrawer()}
>
☰
</button>
{leaf.sessionId ? (
<PaneTitle sessionId={leaf.sessionId} session={session} />
) : (
@@ -0,0 +1,229 @@
// An `Elicitation` row. The daemon mints the nonce; this only solicits one
// of the three possible actions (accept, decline, cancel).
import { useState } from "react";
import type {
AcpElicitationProperty,
AcpElicitationSchema,
StructuredElicitationAction,
} from "../../types.js";
export interface ElicitationViewProps {
/** The `CreateElicitationRequest` the row carries — serialized without
* its nonce, which never leaves the daemon's live wire. */
request: {
message?: string;
requestedSchema?: AcpElicitationSchema;
};
/** Whether this is the one live elicitation. */
pending: boolean;
onDecide: (action: StructuredElicitationAction) => Promise<void>;
}
/** For a single-property string enum schema (the common Claude Code
* multiple-choice case), extract the property key and its options. */
function singleSelectEnum(
schema: AcpElicitationSchema | undefined,
): { key: string; options: Array<{ value: string; title: string; description?: string }> } | null {
if (!schema) return null;
const keys = Object.keys(schema.properties);
if (keys.length !== 1) return null;
const key = keys[0];
const prop = schema.properties[key];
if (prop.oneOf && prop.oneOf.length > 0) {
return {
key,
options: prop.oneOf.map((o) => ({
value: o.const,
title: o.title,
description: o.description,
})),
};
}
if (prop.enum && prop.enum.length > 0) {
return {
key,
options: prop.enum.map((v) => ({ value: v, title: v })),
};
}
return null;
}
export function ElicitationView({ request, pending, onDecide }: ElicitationViewProps) {
const [busy, setBusy] = useState(false);
const [formValues, setFormValues] = useState<Record<string, string | number | boolean>>({});
const message = request.message ?? "Input requested";
const schema = request.requestedSchema;
if (!pending) {
return <div className="st-row st-row-elicitation st-elicitation-compact">{message}</div>;
}
const decide = (action: StructuredElicitationAction) => {
setBusy(true);
onDecide(action).catch(() => setBusy(false));
};
const singleSelect = singleSelectEnum(schema);
// Fast path: single-property string enum → button group (the common
// Claude Code multiple-choice question).
if (singleSelect) {
return (
<div className="st-row st-row-elicitation">
<div className="st-elicitation">
<div className="st-elicitation-title">
<span aria-hidden="true">?</span> {message}
</div>
<div className="st-elicitation-options">
{singleSelect.options.map((opt) => (
<button
key={opt.value}
type="button"
className="act"
disabled={busy}
onClick={() =>
decide({ action: "accept", content: { [singleSelect.key]: opt.value } })
}
>
{opt.title}
</button>
))}
</div>
<div className="st-elicitation-acts">
<button
type="button"
className="act deny"
disabled={busy}
onClick={() => decide({ action: "decline" })}
>
Skip
</button>
</div>
</div>
</div>
);
}
// General form: render inputs for each property.
const properties = schema?.properties ?? {};
const propertyKeys = Object.keys(properties);
const requiredSet = new Set(schema?.required ?? []);
const handleSubmit = () => {
decide({ action: "accept", content: formValues });
};
const updateField = (key: string, value: string | number | boolean) => {
setFormValues((prev) => ({ ...prev, [key]: value }));
};
return (
<div className="st-row st-row-elicitation">
<div className="st-elicitation">
<div className="st-elicitation-title">
<span aria-hidden="true">?</span> {message}
</div>
<div className="st-elicitation-form">
{propertyKeys.map((key) => {
const prop = properties[key];
return (
<ElicitationField
key={key}
name={key}
prop={prop}
required={requiredSet.has(key)}
disabled={busy}
value={formValues[key]}
onChange={(v) => updateField(key, v)}
/>
);
})}
</div>
<div className="st-elicitation-acts">
<button type="button" className="act primary" disabled={busy} onClick={handleSubmit}>
Submit
</button>
<button
type="button"
className="act deny"
disabled={busy}
onClick={() => decide({ action: "decline" })}
>
Skip
</button>
</div>
</div>
</div>
);
}
function ElicitationField({
name,
prop,
required,
disabled,
value,
onChange,
}: {
name: string;
prop: AcpElicitationProperty;
required: boolean;
disabled: boolean;
value: string | number | boolean | undefined;
onChange: (value: string | number | boolean) => void;
}) {
const label = prop.title ?? name;
const type = prop.type ?? "string";
if (type === "boolean") {
return (
<label className="st-elicitation-field">
<input
type="checkbox"
checked={typeof value === "boolean" ? value : (prop.default === true)}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
/>
{label}
{prop.description && <span className="st-elicitation-desc">{prop.description}</span>}
</label>
);
}
if (type === "number" || type === "integer") {
return (
<label className="st-elicitation-field">
{label}
{required && <span className="st-elicitation-required">*</span>}
{prop.description && <span className="st-elicitation-desc">{prop.description}</span>}
<input
type="number"
step={type === "integer" ? 1 : "any"}
min={prop.minimum}
max={prop.maximum}
value={typeof value === "number" ? value : ""}
disabled={disabled}
onChange={(e) => onChange(type === "integer" ? parseInt(e.target.value) : parseFloat(e.target.value))}
/>
</label>
);
}
// Default: string
return (
<label className="st-elicitation-field">
{label}
{required && <span className="st-elicitation-required">*</span>}
{prop.description && <span className="st-elicitation-desc">{prop.description}</span>}
<input
type="text"
value={typeof value === "string" ? value : ""}
disabled={disabled}
placeholder={typeof prop.default === "string" ? prop.default : undefined}
onChange={(e) => onChange(e.target.value)}
/>
</label>
);
}
@@ -170,16 +170,14 @@ describe("tool rows and activity groups", () => {
snapshot(socket, [
chunk("user:1", "user", "go"),
row("tool:1", "tool_call", { title: "Read(a.ts)", kind: "read", status: "completed" }),
chunk("agent:1", "agent_text", "still working"),
row("tool:2", "tool_call", { title: "Read(b.ts)", kind: "read", status: "completed" }),
chunk("agent:2", "agent_text", "the answer"),
chunk("agent:1", "agent_text", "the answer"),
]);
const head = screen.getByText(/^2 tool calls/).closest("button")!;
expect(head.getAttribute("aria-expanded")).toBe("false");
// The final answer stays outside the group; the intermediate text is in it.
// The final answer stays outside the group.
expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("the answer"))).toBe(false);
expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("still working"))).toBe(true);
fireEvent.click(head);
expect(head.getAttribute("aria-expanded")).toBe("true");
@@ -21,6 +21,7 @@ import {
import {
cancelStructuredTurn,
resolveStructuredApproval,
resolveStructuredElicitation,
sendStructuredPrompt,
setStructuredConfig,
uploadStructuredFile,
@@ -29,6 +30,7 @@ import { formatContextTokens, formatElapsed } from "./render.js";
import type {
AcpSessionConfigOption,
StructuredApprovalDecision,
StructuredElicitationAction,
TranscriptRow as Row,
Uuid,
} from "../../types.js";
@@ -36,6 +38,7 @@ import { Composer } from "./Composer.js";
import {
commandsFromTranscript,
pendingApprovalRowId as pendingApprovalRowIdOf,
pendingElicitationRowId as pendingElicitationRowIdOf,
transcriptItems,
turnIsActive,
} from "./model.js";
@@ -124,9 +127,10 @@ export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPanePro
const turnActive = turnIsActive(state);
const pendingRowId = useMemo(() => pendingApprovalRowIdOf(rows, state), [rows, state]);
const pendingElicitationRowId = useMemo(() => pendingElicitationRowIdOf(rows, state), [rows, state]);
const items = useMemo(
() => transcriptItems(rows, pendingRowId, turnActive),
[rows, pendingRowId, turnActive],
() => transcriptItems(rows, pendingRowId, turnActive, pendingElicitationRowId),
[rows, pendingRowId, turnActive, pendingElicitationRowId],
);
const commands = useMemo(() => commandsFromTranscript(rows), [rows]);
const configOptions: AcpSessionConfigOption[] = state.config_options?.configOptions ?? [];
@@ -193,6 +197,20 @@ export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPanePro
[sessionId, state.pending_approval?.nonce, pendingRowId],
);
const decideElicitation = useCallback(
async (rowId: string, action: StructuredElicitationAction): Promise<void> => {
const nonce = state.pending_elicitation?.nonce;
if (!nonce || rowId !== pendingElicitationRowId) return;
try {
await resolveStructuredElicitation(sessionId, nonce, action);
} catch (e: unknown) {
setError(String(e));
throw e;
}
},
[sessionId, state.pending_elicitation?.nonce, pendingElicitationRowId],
);
const dispatchPrompt = useCallback(
(text: string, files: File[]) => {
const generation = ++submission.current;
@@ -311,6 +329,8 @@ export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPanePro
row={row}
pendingApproval={row.id === pendingRowId}
onDecide={(decision) => decide(row.id, decision)}
pendingElicitation={row.id === pendingElicitationRowId}
onElicitationDecide={(action) => decideElicitation(row.id, action)}
expanded={expandedRows.has(row.id)}
onExpandedChange={(expanded) => setRowExpanded(row.id, expanded)}
imageUrls={imageUrls.current}
@@ -10,14 +10,17 @@ import { useEffect, useRef, type ReactNode } from "react";
import { renderInlineMarkdown, renderMarkdown, formatElapsed } from "./render.js";
import type {
AcpContentBlock,
AcpElicitationSchema,
AcpPermissionOption,
AcpToolCallContent,
AcpToolCallLike,
StructuredApprovalDecision,
StructuredElicitationAction,
TranscriptRow as Row,
TurnMeta,
} from "../../types.js";
import { ApprovalView } from "./ApprovalView.js";
import { ElicitationView } from "./ElicitationView.js";
import { Copyable } from "./Copyable.js";
import { DiffView } from "./DiffView.js";
import {
@@ -337,6 +340,9 @@ export interface TranscriptRowProps {
* rules). Every other approval row is history. */
pendingApproval?: boolean;
onDecide?: (decision: StructuredApprovalDecision) => Promise<void>;
/** This row is the one live elicitation. */
pendingElicitation?: boolean;
onElicitationDecide?: (action: StructuredElicitationAction) => Promise<void>;
expanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
imageUrls?: Map<string, string>;
@@ -348,6 +354,8 @@ export function TranscriptRow({
row,
pendingApproval = false,
onDecide,
pendingElicitation = false,
onElicitationDecide,
expanded = false,
onExpandedChange,
imageUrls,
@@ -374,6 +382,16 @@ export function TranscriptRow({
/>
);
}
case "elicitation": {
const request = (row.data ?? {}) as { message?: string; requestedSchema?: AcpElicitationSchema };
return (
<ElicitationView
request={request}
pending={pendingElicitation}
onDecide={onElicitationDecide ?? (() => Promise.resolve())}
/>
);
}
case "plan":
return null;
case "notice": {
+15 -5
View File
@@ -134,6 +134,11 @@ export function pendingApprovalRowId(rows: TranscriptRow[], state: ReducedState)
return state.pending_approval ? lastRowIdOfKind(rows, "approval") : null;
}
/** Same for elicitations — one live at a time. */
export function pendingElicitationRowId(rows: TranscriptRow[], state: ReducedState): string | null {
return state.pending_elicitation ? lastRowIdOfKind(rows, "elicitation") : null;
}
export function lastRowIdOfKind(rows: TranscriptRow[], kind: TranscriptRowKind): string | null {
for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === kind) return rows[i].id;
return null;
@@ -168,6 +173,7 @@ export function activityGroupsByTurn(
rows: TranscriptRow[],
pendingApprovalId: string | null,
_turnActive: boolean,
pendingElicitationId: string | null = null,
): Map<string, ActivityGroup> {
const groups = new Map<string, ActivityGroup>();
let turnId = "before-first-message";
@@ -204,8 +210,10 @@ export function activityGroupsByTurn(
flushRun();
continue;
}
const isResolvedApproval = row.kind === "approval" && row.id !== pendingApprovalId;
if (isResolvedApproval) {
const isResolvedInteraction =
(row.kind === "approval" && row.id !== pendingApprovalId) ||
(row.kind === "elicitation" && row.id !== pendingElicitationId);
if (isResolvedInteraction) {
runPending.push(row);
continue;
}
@@ -239,8 +247,9 @@ export function transcriptItems(
rows: TranscriptRow[],
pendingApprovalId: string | null,
turnActive: boolean,
pendingElicitationId: string | null = null,
): TranscriptItem[] {
const groups = activityGroupsByTurn(rows, pendingApprovalId, turnActive);
const groups = activityGroupsByTurn(rows, pendingApprovalId, turnActive, pendingElicitationId);
// Reverse index: row id → group id that contains it.
const rowToGroup = new Map<string, string>();
for (const [groupId, group] of groups) for (const r of group.rows) rowToGroup.set(r.id, groupId);
@@ -259,9 +268,10 @@ export function transcriptItems(
}
if (row.kind === "plan") continue;
if (row.kind === "notice" && isStateOnlyNotice(row)) continue;
// A resolved approval outside a group adds nothing the tool row does not
// already say. A pending one always renders.
// A resolved approval/elicitation outside a group adds nothing the tool
// row does not already say. A pending one always renders.
if (row.kind === "approval" && row.id !== pendingApprovalId) continue;
if (row.kind === "elicitation" && row.id !== pendingElicitationId) continue;
items.push({ type: "row", row });
}
@@ -913,6 +913,90 @@
font-style: italic;
}
/* Elicitation: the structured question the agent asks the operator.
Visually parallel to approvals but accent-tinted instead of warn-tinted. */
.st-elicitation {
border: 1px solid var(--accent);
border-radius: var(--r-2);
background: color-mix(in oklab, var(--accent) 10%, var(--surface));
box-shadow: var(--shadow-1);
padding: var(--sp-3);
display: flex;
flex-direction: column;
gap: var(--sp-2);
min-width: 0;
}
.st-elicitation-title {
color: var(--accent);
font-size: var(--fs-11);
letter-spacing: 0.1em;
text-transform: uppercase;
display: flex;
align-items: center;
gap: var(--sp-2);
}
.st-elicitation-options {
display: flex;
gap: var(--sp-2);
flex-wrap: wrap;
}
.st-elicitation-acts {
display: flex;
gap: var(--sp-2);
flex-wrap: wrap;
}
.st-elicitation-form {
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.st-elicitation-field {
display: flex;
flex-direction: column;
gap: var(--sp-1);
font-size: var(--fs-12);
color: var(--ink);
}
.st-elicitation-field input[type="text"],
.st-elicitation-field input[type="number"] {
height: 30px;
padding: 0 var(--sp-2);
border: 1px solid var(--border);
border-radius: var(--r-1);
background: var(--surface);
color: var(--ink);
font: inherit;
}
.st-elicitation-field input[type="checkbox"] {
margin-right: var(--sp-1);
accent-color: var(--accent);
}
.st-elicitation-required {
color: var(--danger);
}
.st-elicitation-desc {
color: var(--muted);
font-size: var(--fs-11);
}
.st-elicitation-compact {
font-size: var(--fs-11);
color: var(--faint);
}
.st-row-elicitation:hover {
background: var(--surface-2);
}
.act {
appearance: none;
font: inherit;
+6
View File
@@ -67,6 +67,12 @@ export function structuredApproval(id: Uuid, nonce: string): string {
return `${session(id)}/structured/approvals/${encodeURIComponent(nonce)}`;
}
/** `POST /api/sessions/:id/structured/elicitations/:nonce` — answer an
* elicitation request. */
export function structuredElicitation(id: Uuid, nonce: string): string {
return `${session(id)}/structured/elicitations/${encodeURIComponent(nonce)}`;
}
/** Append the `path` query parameter `DIRS` and `GITROOT` accept. */
export function withPathQuery(route: string, value: string): string {
return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`;
+6
View File
@@ -4651,6 +4651,12 @@ input.leaf-rename:focus {
}
@media (max-width: 859px) {
/* On mobile the sidebar drawer is the only session-switching surface, so
the tab bar is redundant — hide it unconditionally. */
.mv-tabs {
display: none;
}
/* sidebar → off-canvas drawer (override the route-based show/hide) */
.app-shell > .sidebar {
display: flex !important;
+31
View File
@@ -395,6 +395,7 @@ export type TranscriptRowKind =
| "tool_call"
| "tool_diff"
| "approval"
| "elicitation"
| "plan"
| "notice";
@@ -435,6 +436,7 @@ export interface TurnState {
export interface ReducedState {
turn: TurnState;
pending_approval?: { nonce: string; request: AcpRequestPermissionRequest } | null;
pending_elicitation?: { nonce: string; message?: string; schema?: AcpElicitationSchema } | null;
plan?: AcpPlan | null;
usage?: AcpUsage | null;
context?: AcpUsage | null;
@@ -486,6 +488,35 @@ export interface AcpSessionConfigOption {
* string (a bracketless Rust enum), not `{decision: ...}`. */
export type StructuredApprovalDecision = "allow_once" | "allow_for_session" | "deny";
/** `POST /api/sessions/:id/structured/elicitations/:nonce` body. */
export type StructuredElicitationAction =
| { action: "accept"; content: Record<string, string | number | boolean> }
| { action: "decline" }
| { action: "cancel" };
/** Property schema within an ACP elicitation request. */
export interface AcpElicitationProperty {
type?: "string" | "number" | "integer" | "boolean";
title?: string;
description?: string;
default?: string | number | boolean;
enum?: string[];
oneOf?: Array<{ const: string; title: string; description?: string }>;
minLength?: number;
maxLength?: number;
minimum?: number;
maximum?: number;
}
/** The `requested_schema` carried by an ACP `elicitation/create` request. */
export interface AcpElicitationSchema {
type?: "object";
title?: string;
description?: string;
properties: Record<string, AcpElicitationProperty>;
required?: string[];
}
// ---- ACP v1 payload shapes (agent-client-protocol schema, camelCase) ----
export interface AcpEmbeddedResource {