Compare commits

..
Author SHA1 Message Date
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
35 changed files with 3992 additions and 89 deletions
+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)) }
}
}
@@ -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() },
)
}
@@ -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"))
}
}