Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b27dda6c6b | ||
|
|
5bdedc8d23 | ||
|
|
1e628789fb | ||
|
|
3f2473d910 | ||
|
|
b56a35f3ee | ||
|
|
b1f6014a6a | ||
|
|
01db6459cb | ||
|
|
d332c43a9f | ||
|
|
1cc05af323 | ||
|
|
6eb048575c | ||
|
|
5cdc6135f5 | ||
|
|
f6be34ae48 | ||
|
|
4f06d1fc8f | ||
|
|
d1b8b0abc1 | ||
|
|
d8030b59f9 | ||
|
|
0ab4ac3e4b | ||
|
|
c90bdfbe42 | ||
|
|
27b6e647c1 | ||
|
|
0631e69ef0 | ||
|
|
f98c637cd9 | ||
|
|
554182d956 | ||
|
|
391efc72d2 | ||
|
|
cf582be306 | ||
|
|
3403224dec | ||
|
|
a9c3cfe6d0 | ||
|
|
3ef1decbdd | ||
|
|
b22539bd5b | ||
|
|
8f0d73d89c |
@@ -23,6 +23,11 @@ web-typecheck:
|
|||||||
pnpm -C web install --frozen-lockfile
|
pnpm -C web install --frozen-lockfile
|
||||||
pnpm -C web typecheck
|
pnpm -C web typecheck
|
||||||
|
|
||||||
|
# Build the Android debug APK and copy it next to the android/ project root.
|
||||||
|
apk:
|
||||||
|
cd android && ./gradlew assembleDebug
|
||||||
|
cp android/app/build/outputs/apk/debug/app-debug.apk android/app-debug.apk
|
||||||
|
|
||||||
# Full workspace build + tests.
|
# Full workspace build + tests.
|
||||||
test:
|
test:
|
||||||
cargo fmt --all -- --check
|
cargo fmt --all -- --check
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
*.iml
|
*.iml
|
||||||
.gradle/
|
.gradle/
|
||||||
|
.kotlin/
|
||||||
/local.properties
|
/local.properties
|
||||||
/.idea/
|
/.idea/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ android {
|
|||||||
dependencies {
|
dependencies {
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.process)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.androidx.ui)
|
implementation(libs.androidx.ui)
|
||||||
@@ -70,6 +71,9 @@ dependencies {
|
|||||||
implementation(libs.kotlinx.coroutines.android)
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
|
testImplementation(libs.okhttp.mockwebserver)
|
||||||
|
testImplementation(libs.turbine)
|
||||||
|
|
||||||
androidTestImplementation(libs.androidx.test.ext.junit)
|
androidTestImplementation(libs.androidx.test.ext.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
@@ -9,10 +10,12 @@
|
|||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher"
|
android:roundIcon="@mipmap/ic_launcher"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
android:theme="@style/Theme.Maestro">
|
android:theme="@style/Theme.Maestro">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
android:windowSoftInputMode="adjustResize"
|
||||||
android:theme="@style/Theme.Maestro">
|
android:theme="@style/Theme.Maestro">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<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
|
||||||
@@ -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,58 +4,36 @@ import android.os.Bundle
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import pt.naps.maestro.connection.DaemonLink
|
||||||
import androidx.compose.foundation.layout.Column
|
import pt.naps.maestro.connection.DaemonPrefs
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import pt.naps.maestro.ui.MaestroApp
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
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 pt.naps.maestro.ui.theme.MaestroTheme
|
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() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
private lateinit var daemonLink: DaemonLink
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
val prefs = DaemonPrefs(applicationContext)
|
||||||
|
intent?.getStringExtra(DaemonPrefs.EXTRA_DAEMON_URL)?.let { prefs.setBaseUrl(it) }
|
||||||
|
daemonLink = DaemonLink(applicationContext, prefs).also { it.start() }
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
MaestroTheme {
|
MaestroTheme {
|
||||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
MaestroApp(daemonLink = daemonLink, prefs = prefs)
|
||||||
PlaceholderScreen(modifier = Modifier.padding(innerPadding))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
override fun onDestroy() {
|
||||||
fun PlaceholderScreen(modifier: Modifier = Modifier) {
|
daemonLink.close()
|
||||||
Column(
|
super.onDestroy()
|
||||||
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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
private fun PlaceholderScreenPreview() {
|
|
||||||
MaestroTheme {
|
|
||||||
PlaceholderScreen()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exponential backoff for daemon reconnect attempts: doubles from
|
||||||
|
* [initialMs] up to [maxMs], then holds. A phone's radio swinging between
|
||||||
|
* wifi and cellular (or losing signal outright) is a bigger, longer-lived
|
||||||
|
* change than the LAN blips the web/TUI clients ride out with a flat poll,
|
||||||
|
* so this backs off instead of hammering the daemon while nothing changed.
|
||||||
|
*/
|
||||||
|
class Backoff(
|
||||||
|
private val initialMs: Long = INITIAL_MS,
|
||||||
|
private val maxMs: Long = MAX_MS,
|
||||||
|
) {
|
||||||
|
private var currentMs = initialMs
|
||||||
|
|
||||||
|
/** Delay before the next attempt; advances the sequence for the one after. */
|
||||||
|
fun nextDelayMs(): Long {
|
||||||
|
val delay = currentMs
|
||||||
|
currentMs = (currentMs * 2).coerceAtMost(maxMs)
|
||||||
|
return delay
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Back to [initialMs] after a successful connection. */
|
||||||
|
fun reset() {
|
||||||
|
currentMs = initialMs
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val INITIAL_MS = 500L
|
||||||
|
const val MAX_MS = 10_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import pt.naps.maestro.data.CatalogEvent
|
||||||
|
import pt.naps.maestro.data.Session
|
||||||
|
import pt.naps.maestro.data.Uuid
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session catalog folded from `WS /api/events`. A [CatalogEvent.Snapshot]
|
||||||
|
* replaces the map outright; deltas mutate one entry.
|
||||||
|
*/
|
||||||
|
class CatalogStore {
|
||||||
|
private val _sessions = MutableStateFlow<Map<Uuid, Session>>(emptyMap())
|
||||||
|
val sessions: StateFlow<Map<Uuid, Session>> = _sessions.asStateFlow()
|
||||||
|
|
||||||
|
/** Applies one event. Returns the skip count for [CatalogEvent.Lagged] —
|
||||||
|
* the caller re-fetches a full snapshot over REST and calls [replace],
|
||||||
|
* mirroring how the web client reacts to a dropped broadcast frame. */
|
||||||
|
fun apply(event: CatalogEvent): Long? =
|
||||||
|
when (event) {
|
||||||
|
is CatalogEvent.Snapshot -> {
|
||||||
|
_sessions.value = event.sessions.associateBy { it.id }
|
||||||
|
null
|
||||||
|
}
|
||||||
|
is CatalogEvent.SessionAdded -> {
|
||||||
|
_sessions.value = _sessions.value + (event.session.id to event.session)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
is CatalogEvent.SessionUpdated -> {
|
||||||
|
_sessions.value = _sessions.value + (event.session.id to event.session)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
is CatalogEvent.SessionRemoved -> {
|
||||||
|
_sessions.value = _sessions.value - event.id
|
||||||
|
null
|
||||||
|
}
|
||||||
|
is CatalogEvent.Lagged -> event.skipped
|
||||||
|
}
|
||||||
|
|
||||||
|
fun replace(sessions: List<Session>) {
|
||||||
|
_sessions.value = sessions.associateBy { it.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
_sessions.value = emptyMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.Network
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.net.NetworkRequest
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifies [onAvailable] whenever a usable network appears — including the
|
||||||
|
* wifi/cellular handoff, which Android reports as one network disappearing
|
||||||
|
* and another becoming available rather than a single "changed" event.
|
||||||
|
* [DaemonLink] treats every call as "worth reconnecting for" rather than
|
||||||
|
* inspecting which network it is.
|
||||||
|
*/
|
||||||
|
class ConnectivityObserver(
|
||||||
|
context: Context,
|
||||||
|
private val onAvailable: () -> Unit,
|
||||||
|
) {
|
||||||
|
private val connectivityManager =
|
||||||
|
context.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
|
|
||||||
|
private val callback = object : ConnectivityManager.NetworkCallback() {
|
||||||
|
override fun onAvailable(network: Network) = onAvailable.invoke()
|
||||||
|
|
||||||
|
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
|
||||||
|
if (networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||||
|
networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||||
|
) {
|
||||||
|
onAvailable.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var registered = false
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
if (registered) return
|
||||||
|
val request = NetworkRequest.Builder()
|
||||||
|
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
|
.build()
|
||||||
|
connectivityManager.registerNetworkCallback(request, callback)
|
||||||
|
registered = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
if (!registered) return
|
||||||
|
runCatching { connectivityManager.unregisterNetworkCallback(callback) }
|
||||||
|
registered = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
import pt.naps.maestro.data.BuildInfo
|
||||||
|
import pt.naps.maestro.data.DaemonInfo
|
||||||
|
|
||||||
|
/** What happened between two `/api/info` replies. */
|
||||||
|
enum class RestartKind { SAME, RESTARTED, UPGRADED }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare two daemon replies. Mirrors `compareIdentity` in
|
||||||
|
* `web/src/components/daemon/DaemonLink.tsx` / `compare_identity` in
|
||||||
|
* `crates/maestro-tui/src/daemon_link.rs`.
|
||||||
|
*
|
||||||
|
* The commit decides first: a different one is new server code. `instance`
|
||||||
|
* catches everything else — a restart of the same build — and is the only
|
||||||
|
* signal for a daemon built outside a git checkout, where the commit is
|
||||||
|
* always empty and an empty-vs-empty pair must never read as an upgrade.
|
||||||
|
*/
|
||||||
|
fun compareIdentity(before: DaemonInfo, after: DaemonInfo): RestartKind {
|
||||||
|
val a = before.build.commit
|
||||||
|
val b = after.build.commit
|
||||||
|
if (a.isNotEmpty() && b.isNotEmpty() && a != b) return RestartKind.UPGRADED
|
||||||
|
if (before.instance.isNotEmpty() && after.instance.isNotEmpty() && before.instance != after.instance) {
|
||||||
|
return RestartKind.RESTARTED
|
||||||
|
}
|
||||||
|
return RestartKind.SAME
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One line naming a release: `f89be4a716c7 · 2026-09-02`, `(dirty)` when the
|
||||||
|
* build carried uncommitted changes. Mirrors `formatRelease` / `format_release`. */
|
||||||
|
fun formatRelease(build: BuildInfo): String {
|
||||||
|
if (build.commit.isEmpty()) return "unknown release"
|
||||||
|
val parts = mutableListOf(build.commit)
|
||||||
|
if (build.commit_date.length >= 10) parts.add("· ${build.commit_date.take(10)}")
|
||||||
|
if (build.dirty) parts.add("(dirty)")
|
||||||
|
return parts.joinToString(" ")
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import pt.naps.maestro.api.EventsConnection
|
||||||
|
import pt.naps.maestro.api.MaestroClient
|
||||||
|
import pt.naps.maestro.api.MaestroWebSocket
|
||||||
|
import pt.naps.maestro.data.BuildInfo
|
||||||
|
import pt.naps.maestro.data.DaemonInfo
|
||||||
|
|
||||||
|
/** How long a dropped link stays [LinkState.RECONNECTING] before flipping to
|
||||||
|
* [LinkState.DOWN] — long enough to ride out a blip, short enough that a real
|
||||||
|
* outage shows up. Mirrors the grace window in `docs/RESTART.md`. */
|
||||||
|
private const val GRACE_MS = 1_200L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the connection to one daemon for the app's whole life: liveness,
|
||||||
|
* identity, and the session catalog. A Kotlin port of the state machine in
|
||||||
|
* `web/src/components/daemon/DaemonLink.tsx` / `crates/maestro-tui/src/daemon_link.rs`
|
||||||
|
* — see `docs/RESTART.md` for the contract both mirror — with exponential
|
||||||
|
* [Backoff] standing in for their flat poll, since a phone's network changes
|
||||||
|
* are bigger and longer-lived than the LAN blips those clients ride out.
|
||||||
|
*
|
||||||
|
* Everything that can change the link — a probe landing, the events socket
|
||||||
|
* dropping, the app resuming, the network coming back, a new daemon URL —
|
||||||
|
* goes through one [signals] channel consumed by one coroutine, so state
|
||||||
|
* transitions never race each other.
|
||||||
|
*/
|
||||||
|
class DaemonLink(
|
||||||
|
context: Context,
|
||||||
|
private val prefs: DaemonPrefs,
|
||||||
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||||
|
private val httpClient: OkHttpClient = OkHttpClient(),
|
||||||
|
) : DefaultLifecycleObserver {
|
||||||
|
private val appContext = context.applicationContext
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(LinkState.CONNECTING)
|
||||||
|
val state: StateFlow<LinkState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val _build = MutableStateFlow<BuildInfo?>(null)
|
||||||
|
val build: StateFlow<BuildInfo?> = _build.asStateFlow()
|
||||||
|
|
||||||
|
private val _attempts = MutableStateFlow(0)
|
||||||
|
val attempts: StateFlow<Int> = _attempts.asStateFlow()
|
||||||
|
|
||||||
|
val catalog = CatalogStore()
|
||||||
|
|
||||||
|
private val backoff = Backoff()
|
||||||
|
private val signals = Channel<Signal>(Channel.UNLIMITED)
|
||||||
|
private val connectivity = ConnectivityObserver(appContext) { signals.trySend(Signal.NetworkAvailable) }
|
||||||
|
|
||||||
|
private var generation = 0
|
||||||
|
private var currentBaseUrl: String = prefs.baseUrl.value
|
||||||
|
private var client = MaestroClient(currentBaseUrl, httpClient)
|
||||||
|
private var socket = MaestroWebSocket(currentBaseUrl, httpClient)
|
||||||
|
private var identity: DaemonInfo? = null
|
||||||
|
|
||||||
|
private var eventsJob: Job? = null
|
||||||
|
private var eventsConnection: EventsConnection? = null
|
||||||
|
private var retryJob: Job? = null
|
||||||
|
private var graceJob: Job? = null
|
||||||
|
private var loopJob: Job? = null
|
||||||
|
private var started = false
|
||||||
|
|
||||||
|
private sealed class Signal {
|
||||||
|
object Start : Signal()
|
||||||
|
object Resume : Signal()
|
||||||
|
object NetworkAvailable : Signal()
|
||||||
|
object SocketDropped : Signal()
|
||||||
|
data class BaseUrlChanged(val url: String) : Signal()
|
||||||
|
data class ProbeResult(val generation: Int, val info: DaemonInfo?) : Signal()
|
||||||
|
data class Lagged(val skipped: Long) : Signal()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts the link: subscribes to process lifecycle and connectivity, and
|
||||||
|
* kicks off the first probe. Call once. */
|
||||||
|
fun start() {
|
||||||
|
if (started) return
|
||||||
|
started = true
|
||||||
|
loopJob = scope.launch { runLoop() }
|
||||||
|
connectivity.start()
|
||||||
|
scope.launch(Dispatchers.Main.immediate) {
|
||||||
|
ProcessLifecycleOwner.get().lifecycle.addObserver(this@DaemonLink)
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
prefs.baseUrl.collect { url ->
|
||||||
|
if (url != currentBaseUrl) signals.trySend(Signal.BaseUrlChanged(url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
signals.trySend(Signal.Start)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tears everything down. Only needed by tests and by an owner that is
|
||||||
|
* itself going away for good (this link is meant to outlive an Activity). */
|
||||||
|
fun close() {
|
||||||
|
connectivity.stop()
|
||||||
|
cancelRetry()
|
||||||
|
cancelGrace()
|
||||||
|
closeEvents()
|
||||||
|
loopJob?.cancel()
|
||||||
|
signals.close()
|
||||||
|
scope.launch(Dispatchers.Main.immediate) {
|
||||||
|
runCatching { ProcessLifecycleOwner.get().lifecycle.removeObserver(this@DaemonLink) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart(owner: LifecycleOwner) {
|
||||||
|
signals.trySend(Signal.Resume)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runLoop() {
|
||||||
|
for (signal in signals) {
|
||||||
|
when (signal) {
|
||||||
|
is Signal.Start -> probeNow()
|
||||||
|
is Signal.Resume -> {
|
||||||
|
probeNow()
|
||||||
|
ensureEventsConnected()
|
||||||
|
}
|
||||||
|
is Signal.NetworkAvailable -> {
|
||||||
|
cancelRetry()
|
||||||
|
probeNow()
|
||||||
|
}
|
||||||
|
is Signal.SocketDropped -> handleSocketDropped()
|
||||||
|
is Signal.BaseUrlChanged -> handleBaseUrlChanged(signal.url)
|
||||||
|
is Signal.ProbeResult -> {
|
||||||
|
if (signal.generation == generation) handleProbeResult(signal.info)
|
||||||
|
}
|
||||||
|
is Signal.Lagged -> handleLagged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun probeNow() {
|
||||||
|
val gen = generation
|
||||||
|
val currentClient = client
|
||||||
|
scope.launch {
|
||||||
|
val info = runCatching { currentClient.getDaemonInfo() }.getOrNull()
|
||||||
|
signals.trySend(Signal.ProbeResult(gen, info))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleProbeResult(info: DaemonInfo?) {
|
||||||
|
if (info == null) {
|
||||||
|
onProbeFailed()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val before = identity
|
||||||
|
identity = info
|
||||||
|
_build.value = info.build
|
||||||
|
if (before != null && compareIdentity(before, info) == RestartKind.UPGRADED) {
|
||||||
|
_state.value = LinkState.UPDATING
|
||||||
|
fullReconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
backoff.reset()
|
||||||
|
cancelRetry()
|
||||||
|
cancelGrace()
|
||||||
|
_attempts.value = 0
|
||||||
|
_state.value = LinkState.ONLINE
|
||||||
|
ensureEventsConnected()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onProbeFailed() {
|
||||||
|
_attempts.value += 1
|
||||||
|
if (_state.value != LinkState.DOWN && _state.value != LinkState.UPDATING) {
|
||||||
|
_state.value = LinkState.RECONNECTING
|
||||||
|
armGrace()
|
||||||
|
}
|
||||||
|
scheduleRetry()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun armGrace() {
|
||||||
|
if (graceJob?.isActive == true) return
|
||||||
|
graceJob = scope.launch {
|
||||||
|
delay(GRACE_MS)
|
||||||
|
if (_state.value == LinkState.RECONNECTING) _state.value = LinkState.DOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelGrace() {
|
||||||
|
graceJob?.cancel()
|
||||||
|
graceJob = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleRetry() {
|
||||||
|
if (retryJob?.isActive == true) return
|
||||||
|
val delayMs = backoff.nextDelayMs()
|
||||||
|
retryJob = scope.launch {
|
||||||
|
delay(delayMs)
|
||||||
|
retryJob = null
|
||||||
|
probeNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelRetry() {
|
||||||
|
retryJob?.cancel()
|
||||||
|
retryJob = null
|
||||||
|
backoff.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A build-stamp change: drop the events socket and the catalog it fed,
|
||||||
|
* then probe again shortly. The re-probe sees the same identity it just
|
||||||
|
* recorded, so it resolves straight to [LinkState.ONLINE] and reopens
|
||||||
|
* the socket — this *is* the full reconnect. */
|
||||||
|
private fun fullReconnect() {
|
||||||
|
closeEvents()
|
||||||
|
catalog.clear()
|
||||||
|
backoff.reset()
|
||||||
|
scope.launch {
|
||||||
|
delay(200)
|
||||||
|
probeNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleSocketDropped() {
|
||||||
|
if (_state.value == LinkState.DOWN || _state.value == LinkState.UPDATING) return
|
||||||
|
_state.value = LinkState.RECONNECTING
|
||||||
|
armGrace()
|
||||||
|
probeNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleBaseUrlChanged(url: String) {
|
||||||
|
currentBaseUrl = url
|
||||||
|
generation += 1
|
||||||
|
client = MaestroClient(url, httpClient)
|
||||||
|
socket = MaestroWebSocket(url, httpClient)
|
||||||
|
identity = null
|
||||||
|
_build.value = null
|
||||||
|
_attempts.value = 0
|
||||||
|
backoff.reset()
|
||||||
|
cancelRetry()
|
||||||
|
cancelGrace()
|
||||||
|
closeEvents()
|
||||||
|
catalog.clear()
|
||||||
|
_state.value = LinkState.CONNECTING
|
||||||
|
probeNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The broadcast channel dropped frames: re-fetch the full list over REST
|
||||||
|
* rather than trusting the map to still be complete. */
|
||||||
|
private fun handleLagged() {
|
||||||
|
val currentClient = client
|
||||||
|
scope.launch {
|
||||||
|
runCatching { currentClient.listSessions() }.onSuccess { catalog.replace(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureEventsConnected() {
|
||||||
|
if (eventsJob?.isActive == true) return
|
||||||
|
val connection = socket.connectEvents()
|
||||||
|
eventsConnection = connection
|
||||||
|
eventsJob = scope.launch {
|
||||||
|
try {
|
||||||
|
connection.events.collect { event ->
|
||||||
|
val skipped = catalog.apply(event)
|
||||||
|
if (skipped != null) signals.trySend(Signal.Lagged(skipped))
|
||||||
|
}
|
||||||
|
signals.trySend(Signal.SocketDropped)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
signals.trySend(Signal.SocketDropped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun closeEvents() {
|
||||||
|
eventsJob?.cancel()
|
||||||
|
eventsJob = null
|
||||||
|
eventsConnection?.close()
|
||||||
|
eventsConnection = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the daemon base URL lives between launches. A settings screen and
|
||||||
|
* the [EXTRA_DAEMON_URL] intent extra both write through here; [DaemonLink]
|
||||||
|
* reads [baseUrl] and reconnects on change.
|
||||||
|
*/
|
||||||
|
class DaemonPrefs(context: Context) {
|
||||||
|
private val prefs = context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
private val _baseUrl =
|
||||||
|
MutableStateFlow(prefs.getString(KEY_BASE_URL, DEFAULT_BASE_URL) ?: DEFAULT_BASE_URL)
|
||||||
|
val baseUrl: StateFlow<String> = _baseUrl.asStateFlow()
|
||||||
|
|
||||||
|
fun setBaseUrl(url: String) {
|
||||||
|
val trimmed = url.trim()
|
||||||
|
if (trimmed.isEmpty() || trimmed == _baseUrl.value) return
|
||||||
|
prefs.edit { putString(KEY_BASE_URL, trimmed) }
|
||||||
|
_baseUrl.value = trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val FILE = "daemon_prefs"
|
||||||
|
private const val KEY_BASE_URL = "base_url"
|
||||||
|
|
||||||
|
/** Shown until the user points the app at their own daemon. */
|
||||||
|
const val DEFAULT_BASE_URL = "http://192.168.1.x:7070"
|
||||||
|
|
||||||
|
/** Intent extra a launcher may set to point this launch at a specific
|
||||||
|
* daemon without going through settings. Persisted on read, so it
|
||||||
|
* sticks after the launch that carried it. */
|
||||||
|
const val EXTRA_DAEMON_URL = "pt.naps.maestro.EXTRA_DAEMON_URL"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connection lifecycle to the daemon. Mirrors the states in
|
||||||
|
* `web/src/components/daemon/DaemonLink.tsx` / `crates/maestro-tui/src/daemon_link.rs`
|
||||||
|
* (see `docs/RESTART.md`), plus [UPDATING] for a build-stamp change.
|
||||||
|
*
|
||||||
|
* - [CONNECTING] — first probe hasn't landed yet.
|
||||||
|
* - [ONLINE] — the daemon answered and identity is settled.
|
||||||
|
* - [RECONNECTING] — the link dropped, still inside the grace window.
|
||||||
|
* - [DOWN] — unreachable past the grace window; the restart overlay blocks.
|
||||||
|
* - [UPDATING] — the daemon answered on a different commit; reconnecting fresh.
|
||||||
|
*/
|
||||||
|
enum class LinkState { CONNECTING, ONLINE, RECONNECTING, DOWN, UPDATING }
|
||||||
@@ -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,124 @@
|
|||||||
|
package pt.naps.maestro.ui.sidebar
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.InfiniteTransition
|
||||||
|
import androidx.compose.animation.core.LinearOutSlowInEasing
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.keyframes
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import pt.naps.maestro.data.ActivityState
|
||||||
|
|
||||||
|
/** docs/SIDEBAR.md: the rail's width in the web client. Android mirrors it in
|
||||||
|
* density-independent pixels rather than raw pixels. */
|
||||||
|
val ACTIVITY_RAIL_WIDTH = 7.dp
|
||||||
|
|
||||||
|
private val RAIL_ACTIVE = Color(0xFF3DDC84)
|
||||||
|
private val RAIL_IDLE = Color(0xFF9AA0A6)
|
||||||
|
private val RAIL_AWAITING = Color(0xFFF5A623)
|
||||||
|
private val RAIL_ERROR = Color(0xFFE5484D)
|
||||||
|
|
||||||
|
/** Colour per activity state (docs/SIDEBAR.md's colour table): `Starting`
|
||||||
|
* shares `Idle`'s colour, and an unrecognised state degrades to `Idle` so
|
||||||
|
* rendering never fails (Precedence). */
|
||||||
|
fun activityColor(activity: ActivityState): Color =
|
||||||
|
when (activity) {
|
||||||
|
ActivityState.Active -> RAIL_ACTIVE
|
||||||
|
ActivityState.AwaitingInput -> RAIL_AWAITING
|
||||||
|
ActivityState.Error -> RAIL_ERROR
|
||||||
|
ActivityState.Idle, ActivityState.Starting -> RAIL_IDLE
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The accessible name the rail carries, since colour is never the only
|
||||||
|
* signal (docs/SIDEBAR.md). */
|
||||||
|
fun activityStateWord(activity: ActivityState): String =
|
||||||
|
when (activity) {
|
||||||
|
ActivityState.Active -> "Active"
|
||||||
|
ActivityState.Idle -> "Idle"
|
||||||
|
ActivityState.Starting -> "Starting"
|
||||||
|
ActivityState.AwaitingInput -> "Awaiting input"
|
||||||
|
ActivityState.Error -> "Error"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A hard on/off blink, one cycle per 1200ms — mirrors the web rail's
|
||||||
|
* `maestro-rail-blink` keyframes (docs/SIDEBAR.md, web/src/components/sidebar/sidebar.css):
|
||||||
|
* held at full alpha for the first half of the cycle, then snapped to 0 for
|
||||||
|
* the second half, with no crossfade. */
|
||||||
|
@Composable
|
||||||
|
private fun InfiniteTransition.blinkAlpha(): Float {
|
||||||
|
val alpha by animateFloat(
|
||||||
|
initialValue = 1f,
|
||||||
|
targetValue = 0f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = keyframes {
|
||||||
|
durationMillis = 1200
|
||||||
|
1f at 0
|
||||||
|
1f at 599
|
||||||
|
0f at 600
|
||||||
|
0f at 1199
|
||||||
|
},
|
||||||
|
repeatMode = RepeatMode.Restart,
|
||||||
|
),
|
||||||
|
label = "activity-rail-blink",
|
||||||
|
)
|
||||||
|
return alpha
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fast double-flash every 1400ms — mirrors the web rail's
|
||||||
|
* `maestro-rail-flash` keyframes: two dips to quarter alpha early in the
|
||||||
|
* cycle, then held at full alpha for the remainder. */
|
||||||
|
@Composable
|
||||||
|
private fun InfiniteTransition.flashAlpha(): Float {
|
||||||
|
val alpha by animateFloat(
|
||||||
|
initialValue = 1f,
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = keyframes {
|
||||||
|
durationMillis = 1400
|
||||||
|
1f at 0 using LinearOutSlowInEasing
|
||||||
|
0.25f at 210 using LinearOutSlowInEasing
|
||||||
|
1f at 420 using LinearOutSlowInEasing
|
||||||
|
0.25f at 630 using LinearOutSlowInEasing
|
||||||
|
1f at 840 using LinearOutSlowInEasing
|
||||||
|
1f at 1400
|
||||||
|
},
|
||||||
|
repeatMode = RepeatMode.Restart,
|
||||||
|
),
|
||||||
|
label = "activity-rail-flash",
|
||||||
|
)
|
||||||
|
return alpha
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The card's rail: a coloured bar in the gutter, full card height. It paints
|
||||||
|
* for every card, `Idle` included, and carries motion only for states that
|
||||||
|
* need attention — a hard blink for `AwaitingInput`, a fast flash for
|
||||||
|
* `Error`. `Active` and `Idle` stay solid; colour alone is their signal. */
|
||||||
|
@Composable
|
||||||
|
fun ActivityRail(activity: ActivityState, modifier: Modifier = Modifier) {
|
||||||
|
val color by animateColorAsState(targetValue = activityColor(activity), label = "activity-rail-color")
|
||||||
|
val infinite = rememberInfiniteTransition(label = "activity-rail-motion")
|
||||||
|
val alpha = when (activity) {
|
||||||
|
ActivityState.AwaitingInput -> infinite.blinkAlpha()
|
||||||
|
ActivityState.Error -> infinite.flashAlpha()
|
||||||
|
else -> 1f
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.width(ACTIVITY_RAIL_WIDTH)
|
||||||
|
.semantics { contentDescription = activityStateWord(activity) }
|
||||||
|
.background(color.copy(alpha = alpha)),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package pt.naps.maestro.ui.sidebar
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
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.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.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/** docs/SIDEBAR.md: a collapsible directory in the sidebar tree. An expanded
|
||||||
|
* folder's cards are one row away, so it carries no highlight of its own —
|
||||||
|
* only a collapsed header is ever a keyboard-walk stop, but that distinction
|
||||||
|
* belongs to the caller driving the walk, not this composable. */
|
||||||
|
@Composable
|
||||||
|
fun FolderNode(
|
||||||
|
label: String,
|
||||||
|
collapsed: Boolean,
|
||||||
|
onToggle: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
depthPadding: Int = 0,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onToggle)
|
||||||
|
.padding(start = depthPadding.dp, top = 6.dp, bottom = 6.dp, end = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (collapsed) "▸" else "▾",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AnimatedVisibility(visible = !collapsed) {
|
||||||
|
Column { content() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
package pt.naps.maestro.ui.sidebar
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
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.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
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.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
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.InTheWay
|
||||||
|
import pt.naps.maestro.data.Session
|
||||||
|
import pt.naps.maestro.data.SessionStatus
|
||||||
|
|
||||||
|
/** docs/SIDEBAR.md: a card is always two lines behind an activity rail —
|
||||||
|
* line 1 the name, line 2 the adaptive meta line. Long-press opens a context
|
||||||
|
* menu (Rename / Kill / Spawn similar); the daemon confirms rename and kill
|
||||||
|
* only through the callbacks the caller supplies. */
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun SessionCard(
|
||||||
|
session: Session,
|
||||||
|
nowMs: Long,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
depthPadding: Int = 0,
|
||||||
|
onOpen: (Session) -> Unit = {},
|
||||||
|
onRename: (Session, String) -> Unit = { _, _ -> },
|
||||||
|
onKill: (Session) -> Unit = {},
|
||||||
|
onSpawnSimilar: (Session) -> Unit = {},
|
||||||
|
) {
|
||||||
|
var menuExpanded by remember { mutableStateOf(false) }
|
||||||
|
var renaming by remember { mutableStateOf(false) }
|
||||||
|
var confirmingKill by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val running = session.status == SessionStatus.Running
|
||||||
|
val name = sessionName(session)
|
||||||
|
val muted = isProvisionalName(session)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = { onOpen(session) },
|
||||||
|
onLongClick = { menuExpanded = true },
|
||||||
|
)
|
||||||
|
.padding(start = (8 + depthPadding).dp, end = 8.dp, top = 6.dp, bottom = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
ActivityRail(activity = session.activity, modifier = Modifier.height(36.dp))
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = name,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = when {
|
||||||
|
!running -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
muted -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
else -> MaterialTheme.colorScheme.onSurface
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
MetaLine(session = session, nowMs = nowMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Rename") },
|
||||||
|
onClick = {
|
||||||
|
menuExpanded = false
|
||||||
|
renaming = true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Spawn similar") },
|
||||||
|
onClick = {
|
||||||
|
menuExpanded = false
|
||||||
|
onSpawnSimilar(session)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Kill") },
|
||||||
|
onClick = {
|
||||||
|
menuExpanded = false
|
||||||
|
confirmingKill = true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (renaming) {
|
||||||
|
RenameDialog(
|
||||||
|
initial = customSessionName(session) ?: "",
|
||||||
|
onCommit = {
|
||||||
|
renaming = false
|
||||||
|
onRename(session, it)
|
||||||
|
},
|
||||||
|
onCancel = { renaming = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirmingKill) {
|
||||||
|
KillConfirmDialog(
|
||||||
|
session = session,
|
||||||
|
onConfirm = {
|
||||||
|
confirmingKill = false
|
||||||
|
onKill(session)
|
||||||
|
},
|
||||||
|
onCancel = { confirmingKill = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Line 2, left to right: `[duration] [detail] command [diff stat | exited]`
|
||||||
|
* — the line itself is unconditional, only its fragments are adaptive. */
|
||||||
|
@Composable
|
||||||
|
fun MetaLine(session: Session, nowMs: Long, modifier: Modifier = Modifier) {
|
||||||
|
val heldMs = activityHeldMs(session.activity_since, nowMs)
|
||||||
|
val duration = if (heldMs != null && heldMs >= META_LINE_MIN_HOLD_MS) {
|
||||||
|
activityDurationLabel(heldMs)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val blockers = sessionBlockers(session)
|
||||||
|
|
||||||
|
Row(modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (duration != null) {
|
||||||
|
Text(
|
||||||
|
text = duration,
|
||||||
|
color = activityColor(session.activity),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
}
|
||||||
|
if (session.activity_detail != null) {
|
||||||
|
Text(
|
||||||
|
text = session.activity_detail,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = programName(session.command),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
)
|
||||||
|
if (blockers != null) {
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
BlockedFragment(blockers)
|
||||||
|
}
|
||||||
|
if (session.status == SessionStatus.Dead) {
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = "exited",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BlockedFragment(blockers: InTheWay) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
blockerStats(blockers).forEachIndexed { index, stat ->
|
||||||
|
if (index > 0) Spacer(Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
text = stat.text,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = when (stat.kind) {
|
||||||
|
"add" -> MaterialTheme.colorScheme.primary
|
||||||
|
"del" -> MaterialTheme.colorScheme.error
|
||||||
|
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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,161 @@
|
|||||||
|
package pt.naps.maestro.ui.sidebar
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import pt.naps.maestro.data.InTheWay
|
||||||
|
import pt.naps.maestro.data.NameSource
|
||||||
|
import pt.naps.maestro.data.Session
|
||||||
|
|
||||||
|
/** docs/SIDEBAR.md: a state held shorter than this has no duration fragment. */
|
||||||
|
const val META_LINE_MIN_HOLD_MS: Long = 30_000L
|
||||||
|
|
||||||
|
/** Payloads predating #102 default `activity_since` to the Unix epoch;
|
||||||
|
* anything implausibly old is "never observed" rather than a huge duration. */
|
||||||
|
private val PLAUSIBLE_EPOCH_MS = Instant.parse("2010-01-01T00:00:00Z").toEpochMilli()
|
||||||
|
|
||||||
|
/** Milliseconds held in the current activity state, or `null` when `since` is
|
||||||
|
* missing/unparsable/implausible. Mirrors the web's `activityHeldMs`. */
|
||||||
|
fun activityHeldMs(since: String?, nowMs: Long): Long? {
|
||||||
|
if (since.isNullOrEmpty()) return null
|
||||||
|
val t = runCatching { Instant.parse(since).toEpochMilli() }.getOrNull() ?: return null
|
||||||
|
if (t < PLAUSIBLE_EPOCH_MS) return null
|
||||||
|
return (nowMs - t).coerceAtLeast(0L)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `<60s` whole seconds, `<60m` whole minutes, else whole hours — floored
|
||||||
|
* everywhere so the counter never shows a duration the state hasn't reached. */
|
||||||
|
fun activityDurationLabel(heldMs: Long): String {
|
||||||
|
val seconds = heldMs / 1000
|
||||||
|
if (seconds < 60) return "${seconds}s"
|
||||||
|
val minutes = seconds / 60
|
||||||
|
if (minutes < 60) return "${minutes}m"
|
||||||
|
return "${minutes / 60}h"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The session's own name (#125), without the command fallback: `null` for a
|
||||||
|
* session that was never given one. */
|
||||||
|
fun customSessionName(session: Session): String? {
|
||||||
|
val element = session.metadata["name"] ?: return null
|
||||||
|
val primitive = element as? JsonPrimitive ?: return null
|
||||||
|
if (!primitive.isString) return null
|
||||||
|
return primitive.content.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Line 1's name: `metadata.name` if present, else the command line. */
|
||||||
|
fun sessionName(session: Session): String = customSessionName(session) ?: session.command
|
||||||
|
|
||||||
|
/** Where the session's name came from (#131), read leniently: anything
|
||||||
|
* unrecognised — including a session named before the field existed — reads
|
||||||
|
* as [NameSource.User], so an existing name is never dimmed or overwritten. */
|
||||||
|
fun sessionNameSource(session: Session): NameSource {
|
||||||
|
val element = session.metadata["name_source"] as? JsonPrimitive ?: return NameSource.User
|
||||||
|
return when (element.jsonPrimitive.content) {
|
||||||
|
"Generated" -> NameSource.Generated
|
||||||
|
"Haiku" -> NameSource.Haiku
|
||||||
|
else -> NameSource.User
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether line 1 renders the name muted (#131): a handle the daemon
|
||||||
|
* generated at spawn and may still replace with one derived from the first
|
||||||
|
* prompt. */
|
||||||
|
fun isProvisionalName(session: Session): Boolean =
|
||||||
|
customSessionName(session) != null && sessionNameSource(session) == NameSource.Generated
|
||||||
|
|
||||||
|
/** One fragment of the compact blocked-work stat (docs/SIDEBAR.md). `kind`
|
||||||
|
* picks the colour; `text` is what the card prints. */
|
||||||
|
data class BlockerStat(val kind: String, val text: String)
|
||||||
|
|
||||||
|
/** #83: this session's blocked-work breakdown when something actually blocks
|
||||||
|
* an unforced kill; `null` while clear or not yet probed. */
|
||||||
|
fun sessionBlockers(session: Session): InTheWay? {
|
||||||
|
val w = session.blocked_work ?: return null
|
||||||
|
val blocked = w.uncommitted_files > 0 || w.untracked_files > 0 || w.unpushed_commits > 0
|
||||||
|
return if (blocked) w else null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The blocked-work breakdown as diff notation: `+124 -18 ?3 ↑2`. A tree whose
|
||||||
|
* only tracked change carries no line delta falls back to the file count
|
||||||
|
* (`~2`) rather than claiming `+0 -0`. */
|
||||||
|
fun blockerStats(w: InTheWay): List<BlockerStat> {
|
||||||
|
val stats = mutableListOf<BlockerStat>()
|
||||||
|
if (w.insertions > 0 || w.deletions > 0) {
|
||||||
|
if (w.insertions > 0) stats += BlockerStat("add", "+${w.insertions}")
|
||||||
|
if (w.deletions > 0) stats += BlockerStat("del", "-${w.deletions}")
|
||||||
|
} else if (w.uncommitted_files > 0) {
|
||||||
|
stats += BlockerStat("files", "~${w.uncommitted_files}")
|
||||||
|
}
|
||||||
|
if (w.untracked_files > 0) stats += BlockerStat("untracked", "?${w.untracked_files}")
|
||||||
|
if (w.unpushed_commits > 0) stats += BlockerStat("unpushed", "↑${w.unpushed_commits}")
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The spelled-out phrasing shared by the kill refusal and every client's
|
||||||
|
* tooltip, so no two describe the same tree differently. */
|
||||||
|
fun formatInTheWay(w: InTheWay): String {
|
||||||
|
val parts = mutableListOf<String>()
|
||||||
|
fun push(n: Int, unit: String) {
|
||||||
|
if (n <= 0) return
|
||||||
|
parts += if (n == 1) "1 $unit" else "$n ${unit}s"
|
||||||
|
}
|
||||||
|
push(w.uncommitted_files, "uncommitted file")
|
||||||
|
push(w.untracked_files, "untracked file")
|
||||||
|
push(w.unpushed_commits, "unpushed commit")
|
||||||
|
return if (parts.isNotEmpty()) parts.joinToString(", ") else "no unsaved work"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val ENV_ASSIGNMENT = Regex("^[A-Za-z_][A-Za-z0-9_]*=.*$")
|
||||||
|
private val PLAIN_WRAPPERS = setOf("sudo", "env")
|
||||||
|
private val DLX_CAPABLE = setOf("pnpm", "yarn")
|
||||||
|
|
||||||
|
/** The program a command line runs — never its arguments (docs/SIDEBAR.md).
|
||||||
|
* Looks through leading `FOO=bar` assignments, `sudo`/`env` wrappers (and
|
||||||
|
* their own flags), and JS-toolchain launchers (`npx`, `bunx`, `pnpm`/`yarn
|
||||||
|
* dlx`), then basenames what is left. A command that is nothing but wrappers
|
||||||
|
* is returned as written rather than as an empty string. */
|
||||||
|
fun programName(command: String): String {
|
||||||
|
val trimmed = command.trim()
|
||||||
|
val words = trimmed.split(Regex("\\s+")).filter { it.isNotEmpty() }
|
||||||
|
if (words.isEmpty()) return trimmed
|
||||||
|
|
||||||
|
var i = 0
|
||||||
|
while (i < words.size && ENV_ASSIGNMENT.matches(words[i])) i++
|
||||||
|
while (i < words.size && words[i] in PLAIN_WRAPPERS) {
|
||||||
|
i++
|
||||||
|
while (i < words.size && (ENV_ASSIGNMENT.matches(words[i]) || words[i].startsWith("-"))) i++
|
||||||
|
}
|
||||||
|
if (i >= words.size) return basename(words.last())
|
||||||
|
|
||||||
|
when {
|
||||||
|
words[i] in DLX_CAPABLE && words.getOrNull(i + 1) == "dlx" -> i += 2
|
||||||
|
words[i] == "npx" || words[i] == "bunx" -> i += 1
|
||||||
|
words[i] == "bun" && words.getOrNull(i + 1) == "x" -> i += 2
|
||||||
|
}
|
||||||
|
val program = words.getOrNull(i) ?: return basename(words.last())
|
||||||
|
return basename(program)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun basename(path: String): String = path.substringAfterLast('/')
|
||||||
|
|
||||||
|
/** Sessions are grouped by the repository they belong to (#265): a
|
||||||
|
* worktree's `base_repo`, or the session's own `cwd` when it owns none. */
|
||||||
|
fun sessionGroupKey(session: Session): String = session.worktree?.base_repo ?: session.cwd
|
||||||
|
|
||||||
|
/** One folder in the sidebar tree: a repo/cwd group and the sessions in it,
|
||||||
|
* sorted by name. */
|
||||||
|
data class SessionGroup(val key: String, val label: String, val sessions: List<Session>)
|
||||||
|
|
||||||
|
/** Group sessions by [sessionGroupKey], each group sorted by name and the
|
||||||
|
* groups themselves sorted by label. */
|
||||||
|
fun groupSessions(sessions: Collection<Session>): List<SessionGroup> =
|
||||||
|
sessions
|
||||||
|
.groupBy(::sessionGroupKey)
|
||||||
|
.map { (key, members) ->
|
||||||
|
SessionGroup(
|
||||||
|
key = key,
|
||||||
|
label = key.trimEnd('/').substringAfterLast('/').ifEmpty { key },
|
||||||
|
sessions = members.sortedBy { sessionName(it).lowercase() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.sortedBy { it.label.lowercase() }
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package pt.naps.maestro.ui.sidebar
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
|
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.unit.dp
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import pt.naps.maestro.connection.CatalogStore
|
||||||
|
import pt.naps.maestro.data.Session
|
||||||
|
import pt.naps.maestro.data.Uuid
|
||||||
|
|
||||||
|
/** A clock that advances at least once every 30s (docs/SIDEBAR.md), ticked
|
||||||
|
* faster than that so a duration's cutover lands within one visible frame. */
|
||||||
|
@Composable
|
||||||
|
fun rememberTickingNowMs(periodMs: Long = 5_000L): Long {
|
||||||
|
var now by remember { mutableStateOf(System.currentTimeMillis()) }
|
||||||
|
LaunchedEffect(periodMs) {
|
||||||
|
while (true) {
|
||||||
|
delay(periodMs)
|
||||||
|
now = System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return now
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assembles the full sidebar from the [CatalogStore] built in #264: sessions
|
||||||
|
* grouped into collapsible folders by repository, each rendered as a
|
||||||
|
* two-line [SessionCard] behind its [ActivityRail]. */
|
||||||
|
@Composable
|
||||||
|
fun SidebarContent(
|
||||||
|
catalogStore: CatalogStore,
|
||||||
|
onOpenSession: (Session) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onRenameSession: (Uuid, String) -> Unit = { _, _ -> },
|
||||||
|
onKillSession: (Uuid) -> Unit = {},
|
||||||
|
onSpawnSimilar: (Session) -> Unit = {},
|
||||||
|
) {
|
||||||
|
val sessions by catalogStore.sessions.collectAsState()
|
||||||
|
SidebarContent(
|
||||||
|
sessions = sessions.values.toList(),
|
||||||
|
onOpenSession = onOpenSession,
|
||||||
|
modifier = modifier,
|
||||||
|
onRenameSession = onRenameSession,
|
||||||
|
onKillSession = onKillSession,
|
||||||
|
onSpawnSimilar = onSpawnSimilar,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same as the [CatalogStore]-driven overload, but takes the session list
|
||||||
|
* directly — the shape used by tests and previews. */
|
||||||
|
@Composable
|
||||||
|
fun SidebarContent(
|
||||||
|
sessions: List<Session>,
|
||||||
|
onOpenSession: (Session) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onRenameSession: (Uuid, String) -> Unit = { _, _ -> },
|
||||||
|
onKillSession: (Uuid) -> Unit = {},
|
||||||
|
onSpawnSimilar: (Session) -> Unit = {},
|
||||||
|
) {
|
||||||
|
val nowMs = rememberTickingNowMs()
|
||||||
|
val groups = remember(sessions) { groupSessions(sessions) }
|
||||||
|
val collapsed = remember { mutableStateMapOf<String, Boolean>() }
|
||||||
|
|
||||||
|
Column(modifier = modifier.fillMaxSize()) {
|
||||||
|
Text(
|
||||||
|
text = "Sessions",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
)
|
||||||
|
if (groups.isEmpty()) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "No sessions",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
|
for (group in groups) {
|
||||||
|
val isCollapsed = collapsed[group.key] == true
|
||||||
|
item(key = "folder:${group.key}") {
|
||||||
|
FolderNode(
|
||||||
|
label = group.label,
|
||||||
|
collapsed = isCollapsed,
|
||||||
|
onToggle = { collapsed[group.key] = !isCollapsed },
|
||||||
|
depthPadding = 8,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
for (session in group.sessions) {
|
||||||
|
SessionCard(
|
||||||
|
session = session,
|
||||||
|
nowMs = nowMs,
|
||||||
|
depthPadding = 16,
|
||||||
|
onOpen = onOpenSession,
|
||||||
|
onRename = { s, name -> onRenameSession(s.id, name) },
|
||||||
|
onKill = { s -> onKillSession(s.id) },
|
||||||
|
onSpawnSimilar = onSpawnSimilar,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package pt.naps.maestro.ui.spawn
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import pt.naps.maestro.data.CreateSessionRequest
|
||||||
|
import pt.naps.maestro.data.SessionView
|
||||||
|
import pt.naps.maestro.data.SpawnPreset
|
||||||
|
import pt.naps.maestro.data.WorktreeSpec
|
||||||
|
|
||||||
|
/** Tools whose sessions can render as a structured (ACP) transcript instead
|
||||||
|
* of a raw terminal. Mirrors `STRUCTURED_VIEW_TOOLS` in
|
||||||
|
* `web/src/components/spawn/model.ts`. */
|
||||||
|
private val STRUCTURED_VIEW_TOOLS = setOf("claude", "codex")
|
||||||
|
|
||||||
|
/** Commands that just launch another program, looked through to find the
|
||||||
|
* real one: `env -i codex` and `sudo codex` both resolve to `codex`. */
|
||||||
|
private val WRAPPER_PROGRAMS = setOf("env", "sudo", "nohup", "command")
|
||||||
|
|
||||||
|
private val ENV_ASSIGNMENT = Regex("[A-Za-z_][A-Za-z0-9_]*=.*")
|
||||||
|
|
||||||
|
/** Program a command line would run, stripped of leading `FOO=bar`
|
||||||
|
* assignments, known wrappers, its own arguments, and any path prefix. A
|
||||||
|
* simplified stand-in for the shell-grammar-aware `splitCommand` the web
|
||||||
|
* client uses — good enough for the preset commands and the common
|
||||||
|
* `env FOO=1 claude` shapes the spawn form actually produces. */
|
||||||
|
internal fun resolveProgram(command: String): String {
|
||||||
|
val words = command.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
|
||||||
|
var i = 0
|
||||||
|
while (i < words.size) {
|
||||||
|
while (i < words.size && words[i].matches(ENV_ASSIGNMENT)) i++
|
||||||
|
if (i >= words.size) return ""
|
||||||
|
val word = words[i].substringAfterLast('/').lowercase()
|
||||||
|
if (word !in WRAPPER_PROGRAMS) return word
|
||||||
|
i++
|
||||||
|
while (i < words.size && words[i].startsWith("-")) i++
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
fun supportsStructuredView(command: String): Boolean = resolveProgram(command) in STRUCTURED_VIEW_TOOLS
|
||||||
|
|
||||||
|
/** Where a worktree's branch comes from: freshly created (default) or picked
|
||||||
|
* from the repository's existing branches. Mirrors `WorktreeBranchMode` in
|
||||||
|
* `web/src/components/spawn/model.ts`. */
|
||||||
|
enum class WorktreeBranchMode { New, Existing }
|
||||||
|
|
||||||
|
/** The spawn form's current values, gathered at submit time. Mirrors
|
||||||
|
* `SpawnFormValues` in `web/src/components/spawn/model.ts`. */
|
||||||
|
data class SpawnFormValues(
|
||||||
|
val command: String,
|
||||||
|
val cwd: String,
|
||||||
|
val name: String,
|
||||||
|
val structuredView: Boolean,
|
||||||
|
val autoApprove: Boolean,
|
||||||
|
val worktreeEnabled: Boolean,
|
||||||
|
val worktreeBranch: String,
|
||||||
|
val worktreeBranchMode: WorktreeBranchMode,
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface SpawnBuildResult {
|
||||||
|
data class Ok(val request: CreateSessionRequest) : SpawnBuildResult
|
||||||
|
data class Error(val message: String) : SpawnBuildResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turns validated form values into a `POST /api/sessions` body, or an error
|
||||||
|
* message when something required is missing. Mirrors `buildSpawnRequest` in
|
||||||
|
* `web/src/components/spawn/model.ts`. */
|
||||||
|
fun buildSpawnRequest(values: SpawnFormValues, baseRepo: String?): SpawnBuildResult {
|
||||||
|
if (values.cwd.isBlank()) return SpawnBuildResult.Error("Pick a working directory.")
|
||||||
|
if (values.command.isBlank()) return SpawnBuildResult.Error("Pick a command (or fill the custom command).")
|
||||||
|
|
||||||
|
val name = values.name.trim()
|
||||||
|
val metadata = if (name.isNotEmpty()) mapOf("name" to JsonPrimitive(name)) else emptyMap()
|
||||||
|
|
||||||
|
var worktree: WorktreeSpec? = null
|
||||||
|
if (values.worktreeEnabled) {
|
||||||
|
val branch = values.worktreeBranch.trim()
|
||||||
|
if (baseRepo.isNullOrBlank()) {
|
||||||
|
return SpawnBuildResult.Error("Worktree needs a directory inside a git repository.")
|
||||||
|
}
|
||||||
|
if (branch.isEmpty()) {
|
||||||
|
return SpawnBuildResult.Error(
|
||||||
|
if (values.worktreeBranchMode == WorktreeBranchMode.Existing) "Pick an existing branch."
|
||||||
|
else "Worktree requires a branch name.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
worktree = WorktreeSpec(
|
||||||
|
base_repo = baseRepo,
|
||||||
|
branch = branch,
|
||||||
|
create_branch = values.worktreeBranchMode != WorktreeBranchMode.Existing,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return SpawnBuildResult.Ok(
|
||||||
|
CreateSessionRequest(
|
||||||
|
cwd = values.cwd,
|
||||||
|
command = values.command,
|
||||||
|
metadata = metadata,
|
||||||
|
worktree = worktree,
|
||||||
|
view = if (values.structuredView && supportsStructuredView(values.command)) {
|
||||||
|
SessionView.Structured
|
||||||
|
} else {
|
||||||
|
SessionView.Terminal
|
||||||
|
},
|
||||||
|
auto_approve = values.autoApprove,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Built-ins shown when the daemon's `/api/info` presets haven't loaded yet
|
||||||
|
* (or can't be fetched). Mirrors `FALLBACK_PRESETS` in
|
||||||
|
* `web/src/components/spawn/model.ts`, reordered per #269's chip row. */
|
||||||
|
val FALLBACK_PRESETS: List<SpawnPreset> = listOf(
|
||||||
|
SpawnPreset(id = "claude", label = "Claude", command = "claude"),
|
||||||
|
SpawnPreset(id = "codex", label = "Codex", command = "codex"),
|
||||||
|
SpawnPreset(id = "zsh", label = "Zsh", command = "zsh"),
|
||||||
|
SpawnPreset(id = "custom", label = "Custom…", command = ""),
|
||||||
|
)
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
package pt.naps.maestro.ui.spawn
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
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.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.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.SheetState
|
||||||
|
import androidx.compose.material3.Snackbar
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
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.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import pt.naps.maestro.data.BranchInfo
|
||||||
|
import pt.naps.maestro.data.Project
|
||||||
|
import pt.naps.maestro.data.Session
|
||||||
|
import pt.naps.maestro.data.SpawnPreset
|
||||||
|
|
||||||
|
/** Bottom sheet spawn form (#269), matching the web spawn modal's flow:
|
||||||
|
* a preset chip row, a searchable project list, a raw-path autocomplete
|
||||||
|
* fallback, a branch/worktree picker, a session name, and the
|
||||||
|
* structured-view toggle. [SpawnSheet] wires this to a live [SpawnViewModel];
|
||||||
|
* [SpawnSheetContent] is the pure, state-driven body used by previews and
|
||||||
|
* tests. */
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SpawnSheet(
|
||||||
|
viewModel: SpawnViewModel,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSpawned: (Session) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
|
||||||
|
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) {
|
||||||
|
SpawnSheetContent(
|
||||||
|
state = state,
|
||||||
|
onSelectPreset = viewModel::selectPreset,
|
||||||
|
onCustomCommandChange = viewModel::setCustomCommand,
|
||||||
|
onStructuredViewChange = viewModel::setStructuredView,
|
||||||
|
onProjectQueryChange = viewModel::setProjectQuery,
|
||||||
|
onSelectProject = viewModel::selectProject,
|
||||||
|
onCwdChange = viewModel::setCwd,
|
||||||
|
onPickDirSuggestion = viewModel::pickDirSuggestion,
|
||||||
|
onWorktreeEnabledChange = viewModel::setWorktreeEnabled,
|
||||||
|
onBranchModeChange = viewModel::setBranchMode,
|
||||||
|
onNewBranchChange = viewModel::setNewBranch,
|
||||||
|
onExistingBranchChange = viewModel::setExistingBranch,
|
||||||
|
onNameChange = viewModel::setName,
|
||||||
|
onReroll = viewModel::rerollName,
|
||||||
|
onDismissError = viewModel::dismissError,
|
||||||
|
onSubmit = { viewModel.submit(onSpawned) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SpawnSheetContent(
|
||||||
|
state: SpawnUiState,
|
||||||
|
onSelectPreset: (String) -> Unit,
|
||||||
|
onCustomCommandChange: (String) -> Unit,
|
||||||
|
onStructuredViewChange: (Boolean) -> Unit,
|
||||||
|
onProjectQueryChange: (String) -> Unit,
|
||||||
|
onSelectProject: (Project) -> Unit,
|
||||||
|
onCwdChange: (String) -> Unit,
|
||||||
|
onPickDirSuggestion: (String) -> Unit,
|
||||||
|
onWorktreeEnabledChange: (Boolean) -> Unit,
|
||||||
|
onBranchModeChange: (WorktreeBranchMode) -> Unit,
|
||||||
|
onNewBranchChange: (String) -> Unit,
|
||||||
|
onExistingBranchChange: (String) -> Unit,
|
||||||
|
onNameChange: (String) -> Unit,
|
||||||
|
onReroll: () -> Unit,
|
||||||
|
onDismissError: () -> Unit,
|
||||||
|
onSubmit: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
LaunchedEffect(state.error) {
|
||||||
|
val message = state.error ?: return@LaunchedEffect
|
||||||
|
snackbarHostState.showSnackbar(message)
|
||||||
|
onDismissError()
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(bottom = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Text("Spawn session", style = MaterialTheme.typography.titleMedium)
|
||||||
|
|
||||||
|
PresetRow(
|
||||||
|
presets = state.presets,
|
||||||
|
selectedPresetId = state.selectedPresetId,
|
||||||
|
onSelectPreset = onSelectPreset,
|
||||||
|
)
|
||||||
|
if (state.isCustomSelected) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.customCommand,
|
||||||
|
onValueChange = onCustomCommandChange,
|
||||||
|
label = { Text("Command") },
|
||||||
|
placeholder = { Text("bash -l") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ProjectPicker(
|
||||||
|
query = state.projectQuery,
|
||||||
|
onQueryChange = onProjectQueryChange,
|
||||||
|
projects = state.visibleProjects,
|
||||||
|
loading = state.projectsLoading,
|
||||||
|
selectedPath = state.selectedProjectPath,
|
||||||
|
homeDir = state.homeDir,
|
||||||
|
onSelectProject = onSelectProject,
|
||||||
|
)
|
||||||
|
|
||||||
|
DirAutocompleteField(
|
||||||
|
cwd = state.cwd,
|
||||||
|
suggestions = state.dirSuggestions,
|
||||||
|
onCwdChange = onCwdChange,
|
||||||
|
onPickSuggestion = onPickDirSuggestion,
|
||||||
|
)
|
||||||
|
|
||||||
|
BranchSection(
|
||||||
|
enabled = state.worktreeEnabled,
|
||||||
|
onEnabledChange = onWorktreeEnabledChange,
|
||||||
|
baseRepo = state.baseRepo,
|
||||||
|
mode = state.branchMode,
|
||||||
|
onModeChange = onBranchModeChange,
|
||||||
|
newBranch = state.newBranch,
|
||||||
|
onNewBranchChange = onNewBranchChange,
|
||||||
|
branches = state.branches,
|
||||||
|
branchesLoading = state.branchesLoading,
|
||||||
|
existingBranch = state.effectiveExistingBranch,
|
||||||
|
onExistingBranchChange = onExistingBranchChange,
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.name,
|
||||||
|
onValueChange = onNameChange,
|
||||||
|
label = { Text("Session name") },
|
||||||
|
placeholder = { Text("named after your first prompt") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
TextButton(onClick = onReroll) { Text("Reroll") }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (supportsStructuredView(state.command)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Checkbox(checked = state.structuredView, onCheckedChange = onStructuredViewChange)
|
||||||
|
Text("Structured view")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = onSubmit,
|
||||||
|
enabled = !state.submitting,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
if (state.submitting) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.height(18.dp), strokeWidth = 2.dp)
|
||||||
|
} else {
|
||||||
|
Text("Spawn")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SnackbarHost(hostState = snackbarHostState, modifier = Modifier.align(Alignment.BottomCenter)) {
|
||||||
|
Snackbar(snackbarData = it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A row is its own small touch target: tap anywhere in it, not just the
|
||||||
|
* leading control, to select it. */
|
||||||
|
private fun Modifier.clickableRow(enabled: Boolean = true, onClick: () -> Unit): Modifier =
|
||||||
|
this.clickable(enabled = enabled, onClick = onClick)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PresetRow(presets: List<SpawnPreset>, selectedPresetId: String, onSelectPreset: (String) -> Unit) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text("Harness", style = MaterialTheme.typography.labelLarge)
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
for (preset in presets) {
|
||||||
|
val label = if (preset.command.isEmpty()) "Custom…" else preset.label
|
||||||
|
FilterChip(
|
||||||
|
selected = preset.id == selectedPresetId,
|
||||||
|
onClick = { onSelectPreset(preset.id) },
|
||||||
|
label = { Text(label) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProjectPicker(
|
||||||
|
query: String,
|
||||||
|
onQueryChange: (String) -> Unit,
|
||||||
|
projects: List<Project>,
|
||||||
|
loading: Boolean,
|
||||||
|
selectedPath: String?,
|
||||||
|
homeDir: String,
|
||||||
|
onSelectProject: (Project) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text("Project", style = MaterialTheme.typography.labelLarge)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = onQueryChange,
|
||||||
|
placeholder = { Text("Filter projects…") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
when {
|
||||||
|
loading -> Text(
|
||||||
|
"Loading projects…",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
projects.isEmpty() -> Text(
|
||||||
|
"No projects match.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
else -> LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 220.dp)) {
|
||||||
|
items(projects, key = { it.path }) { project ->
|
||||||
|
ProjectRow(
|
||||||
|
project = project,
|
||||||
|
active = project.path == selectedPath,
|
||||||
|
homeDir = homeDir,
|
||||||
|
onClick = { onSelectProject(project) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProjectRow(project: Project, active: Boolean, homeDir: String, onClick: () -> Unit) {
|
||||||
|
val background = if (active) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(MaterialTheme.shapes.small)
|
||||||
|
.background(background)
|
||||||
|
.clickableRow(onClick = onClick)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
Text(project.name, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
Text(
|
||||||
|
abbreviateHome(parentDir(project.path), homeDir),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun abbreviateHome(path: String, homeDir: String): String =
|
||||||
|
if (homeDir.isNotEmpty() && path.startsWith(homeDir)) "~${path.removePrefix(homeDir)}" else path
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DirAutocompleteField(
|
||||||
|
cwd: String,
|
||||||
|
suggestions: List<String>,
|
||||||
|
onCwdChange: (String) -> Unit,
|
||||||
|
onPickSuggestion: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = cwd,
|
||||||
|
onValueChange = onCwdChange,
|
||||||
|
label = { Text("Or type a directory") },
|
||||||
|
placeholder = { Text("/home/user/project") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
if (suggestions.isNotEmpty()) {
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 160.dp)) {
|
||||||
|
items(suggestions) { entry ->
|
||||||
|
Text(
|
||||||
|
text = "$entry/",
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickableRow { onPickSuggestion(entry) }
|
||||||
|
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BranchSection(
|
||||||
|
enabled: Boolean,
|
||||||
|
onEnabledChange: (Boolean) -> Unit,
|
||||||
|
baseRepo: String?,
|
||||||
|
mode: WorktreeBranchMode,
|
||||||
|
onModeChange: (WorktreeBranchMode) -> Unit,
|
||||||
|
newBranch: String,
|
||||||
|
onNewBranchChange: (String) -> Unit,
|
||||||
|
branches: List<BranchInfo>,
|
||||||
|
branchesLoading: Boolean,
|
||||||
|
existingBranch: String,
|
||||||
|
onExistingBranchChange: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Checkbox(checked = enabled, onCheckedChange = onEnabledChange)
|
||||||
|
Text("Create git worktree")
|
||||||
|
}
|
||||||
|
if (!enabled) return@Column
|
||||||
|
|
||||||
|
if (baseRepo == null) {
|
||||||
|
Text(
|
||||||
|
"Choose a directory inside a git repository first.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
return@Column
|
||||||
|
}
|
||||||
|
Text("repo: $baseRepo", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
RadioButton(selected = mode == WorktreeBranchMode.New, onClick = { onModeChange(WorktreeBranchMode.New) })
|
||||||
|
Text("New branch")
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
RadioButton(selected = mode == WorktreeBranchMode.Existing, onClick = { onModeChange(WorktreeBranchMode.Existing) })
|
||||||
|
Text("Existing branch")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode == WorktreeBranchMode.New) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = newBranch,
|
||||||
|
onValueChange = onNewBranchChange,
|
||||||
|
label = { Text("New branch name") },
|
||||||
|
placeholder = { Text("feat/foo") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
when {
|
||||||
|
branchesLoading -> Text(
|
||||||
|
"Loading branches…",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
branches.isEmpty() -> Text(
|
||||||
|
"No local branches found.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
else -> LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 180.dp)) {
|
||||||
|
items(branches, key = { it.name }) { branch ->
|
||||||
|
val held = branch.worktree ?: (if (branch.is_head) "the main checkout" else null)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickableRow(enabled = held == null) { onExistingBranchChange(branch.name) }
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = branch.name == existingBranch,
|
||||||
|
onClick = { onExistingBranchChange(branch.name) },
|
||||||
|
enabled = held == null,
|
||||||
|
)
|
||||||
|
Column {
|
||||||
|
Text(branch.name, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
if (held != null) {
|
||||||
|
Text(
|
||||||
|
"held by $held",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
package pt.naps.maestro.ui.spawn
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
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.MaestroApiException
|
||||||
|
import pt.naps.maestro.api.MaestroClient
|
||||||
|
import pt.naps.maestro.data.BranchInfo
|
||||||
|
import pt.naps.maestro.data.Project
|
||||||
|
import pt.naps.maestro.data.Session
|
||||||
|
import pt.naps.maestro.data.SpawnPreset
|
||||||
|
|
||||||
|
/** Quiet period after the operator types a raw path before `/api/dirs` and
|
||||||
|
* `/api/gitroot` fire (#269). */
|
||||||
|
private const val DIR_AUTOCOMPLETE_DEBOUNCE_MS = 300L
|
||||||
|
|
||||||
|
/** Everything above the last segment of an absolute path: `/a/b/c` -> `/a/b`.
|
||||||
|
* A top-level path keeps `/`, and a trailing slash is ignored. Mirrors
|
||||||
|
* `parentDir` in `web/src/paths.ts`. */
|
||||||
|
internal fun parentDir(path: String): String {
|
||||||
|
val trimmed = if (path.length > 1 && path.endsWith("/")) path.dropLast(1) else path
|
||||||
|
val slash = trimmed.lastIndexOf('/')
|
||||||
|
return when {
|
||||||
|
slash < 0 -> ""
|
||||||
|
slash == 0 -> "/"
|
||||||
|
else -> trimmed.substring(0, slash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Splits a path into its directory and trailing (possibly partial) segment.
|
||||||
|
* Mirrors `splitPath` in `web/src/paths.ts`. */
|
||||||
|
internal fun splitPath(value: String): Pair<String, String> {
|
||||||
|
val slash = value.lastIndexOf('/')
|
||||||
|
return if (slash == -1) "" to value else value.substring(0, slash + 1) to value.substring(slash + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class SpawnUiState(
|
||||||
|
val presets: List<SpawnPreset> = FALLBACK_PRESETS,
|
||||||
|
val selectedPresetId: String = "",
|
||||||
|
val customCommand: String = "",
|
||||||
|
val commandTouched: Boolean = false,
|
||||||
|
val structuredView: Boolean = false,
|
||||||
|
val structuredViewTouched: Boolean = false,
|
||||||
|
val homeDir: String = "",
|
||||||
|
val projects: List<Project> = emptyList(),
|
||||||
|
val projectsLoading: Boolean = true,
|
||||||
|
val projectQuery: String = "",
|
||||||
|
val selectedProjectPath: String? = null,
|
||||||
|
val cwd: String = "",
|
||||||
|
val dirSuggestions: List<String> = emptyList(),
|
||||||
|
val baseRepo: String? = null,
|
||||||
|
val worktreeEnabled: Boolean = false,
|
||||||
|
val branchMode: WorktreeBranchMode = WorktreeBranchMode.New,
|
||||||
|
val newBranch: String = "",
|
||||||
|
val existingBranch: String = "",
|
||||||
|
val branches: List<BranchInfo> = emptyList(),
|
||||||
|
val branchesLoading: Boolean = false,
|
||||||
|
val name: String = "",
|
||||||
|
val submitting: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
) {
|
||||||
|
val activePreset: SpawnPreset? get() = presets.find { it.id == selectedPresetId }
|
||||||
|
val isCustomSelected: Boolean get() = activePreset == null || activePreset?.command == ""
|
||||||
|
val command: String get() = if (isCustomSelected) customCommand.trim() else activePreset?.command.orEmpty()
|
||||||
|
|
||||||
|
val visibleProjects: List<Project>
|
||||||
|
get() {
|
||||||
|
val query = projectQuery.trim().lowercase()
|
||||||
|
val filtered = if (query.isEmpty()) {
|
||||||
|
projects
|
||||||
|
} else {
|
||||||
|
projects.filter { it.name.lowercase().contains(query) || it.path.lowercase().contains(query) }
|
||||||
|
}
|
||||||
|
return filtered.sortedByDescending { runCatching { Instant.parse(it.last_activity) }.getOrNull() }
|
||||||
|
}
|
||||||
|
|
||||||
|
val effectiveExistingBranch: String
|
||||||
|
get() = existingBranch.ifEmpty {
|
||||||
|
branches.firstOrNull { it.worktree == null && !it.is_head }?.name.orEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Form state and API calls behind [SpawnSheet]. A plain class rather than an
|
||||||
|
* `androidx.lifecycle.ViewModel` — the sheet's own `remember` scope is enough
|
||||||
|
* for a short-lived form, and this keeps the class trivially testable with a
|
||||||
|
* `TestScope`, the same shape as [pt.naps.maestro.connection.DaemonLink].
|
||||||
|
*/
|
||||||
|
class SpawnViewModel(
|
||||||
|
private val client: MaestroClient,
|
||||||
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||||
|
) {
|
||||||
|
private val _state = MutableStateFlow(SpawnUiState())
|
||||||
|
val state: StateFlow<SpawnUiState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private var dirJob: Job? = null
|
||||||
|
private var branchesJob: Job? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch {
|
||||||
|
val info = runCatching { client.getDaemonInfo() }.getOrNull()
|
||||||
|
_state.update { s ->
|
||||||
|
val presets = info?.presets?.takeIf { it.isNotEmpty() } ?: FALLBACK_PRESETS
|
||||||
|
val seeded = if (!s.commandTouched && s.selectedPresetId.isEmpty()) {
|
||||||
|
presets.firstOrNull { it.command.isNotEmpty() }?.id ?: s.selectedPresetId
|
||||||
|
} else {
|
||||||
|
s.selectedPresetId
|
||||||
|
}
|
||||||
|
s.copy(homeDir = info?.home_dir.orEmpty(), presets = presets, selectedPresetId = seeded)
|
||||||
|
.withSyncedStructuredView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
val projects = runCatching { client.listProjects() }.getOrDefault(emptyList())
|
||||||
|
_state.update { it.copy(projects = projects, projectsLoading = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectPreset(id: String) {
|
||||||
|
_state.update { it.copy(selectedPresetId = id, commandTouched = true).withSyncedStructuredView() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCustomCommand(text: String) {
|
||||||
|
_state.update { it.copy(customCommand = text, commandTouched = true).withSyncedStructuredView() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setStructuredView(enabled: Boolean) {
|
||||||
|
_state.update { it.copy(structuredView = enabled, structuredViewTouched = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-derives the structured-view checkbox from the command this state
|
||||||
|
* carries, unless the operator has already ticked/unticked it by hand. */
|
||||||
|
private fun SpawnUiState.withSyncedStructuredView(): SpawnUiState =
|
||||||
|
if (structuredViewTouched) this else copy(structuredView = supportsStructuredView(command))
|
||||||
|
|
||||||
|
fun setProjectQuery(query: String) {
|
||||||
|
_state.update { it.copy(projectQuery = query) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectProject(project: Project) {
|
||||||
|
dirJob?.cancel()
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
selectedProjectPath = project.path,
|
||||||
|
cwd = project.path,
|
||||||
|
dirSuggestions = emptyList(),
|
||||||
|
baseRepo = project.base_repo ?: project.path,
|
||||||
|
branches = emptyList(),
|
||||||
|
existingBranch = "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The operator typed a path by hand: clears any project pick, and
|
||||||
|
* schedules a debounced refresh of both the directory-suggestion
|
||||||
|
* dropdown and the git-root probe that resolves [SpawnUiState.baseRepo]. */
|
||||||
|
fun setCwd(raw: String) {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
cwd = raw,
|
||||||
|
selectedProjectPath = null,
|
||||||
|
baseRepo = null,
|
||||||
|
branches = emptyList(),
|
||||||
|
existingBranch = "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
dirJob?.cancel()
|
||||||
|
dirJob = scope.launch {
|
||||||
|
delay(DIR_AUTOCOMPLETE_DEBOUNCE_MS)
|
||||||
|
refreshDirSuggestions(raw)
|
||||||
|
refreshBaseRepo(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Appends [entry] as the next path segment, matching the `cd dir/sub`
|
||||||
|
* drill-in gesture the web `DirAutocomplete` uses. */
|
||||||
|
fun pickDirSuggestion(entry: String) {
|
||||||
|
val (dir, _) = splitPath(_state.value.cwd)
|
||||||
|
setCwd("$dir$entry/")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun refreshDirSuggestions(raw: String) {
|
||||||
|
val (dir, prefix) = splitPath(raw)
|
||||||
|
val entries = runCatching { client.listDir(dir).entries }.getOrDefault(emptyList())
|
||||||
|
if (_state.value.cwd != raw) return // a newer edit superseded this one
|
||||||
|
val filtered = entries.filter { it.startsWith(prefix, ignoreCase = true) }
|
||||||
|
_state.update { it.copy(dirSuggestions = filtered) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun refreshBaseRepo(raw: String) {
|
||||||
|
val root = runCatching { client.gitRoot(raw).root }.getOrNull()
|
||||||
|
if (_state.value.cwd != raw) return // a newer edit superseded this one
|
||||||
|
_state.update { it.copy(baseRepo = root) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setWorktreeEnabled(enabled: Boolean) {
|
||||||
|
_state.update { it.copy(worktreeEnabled = enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setBranchMode(mode: WorktreeBranchMode) {
|
||||||
|
_state.update { it.copy(branchMode = mode) }
|
||||||
|
if (mode == WorktreeBranchMode.Existing) loadBranchesIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setNewBranch(text: String) {
|
||||||
|
_state.update { it.copy(newBranch = text) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setExistingBranch(text: String) {
|
||||||
|
_state.update { it.copy(existingBranch = text) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadBranchesIfNeeded() {
|
||||||
|
val cwd = _state.value.cwd
|
||||||
|
if (cwd.isEmpty() || _state.value.branchesLoading) return
|
||||||
|
branchesJob?.cancel()
|
||||||
|
_state.update { it.copy(branchesLoading = true) }
|
||||||
|
branchesJob = scope.launch {
|
||||||
|
val branches = runCatching { client.listBranches(cwd) }.getOrDefault(emptyList())
|
||||||
|
if (_state.value.cwd != cwd) return@launch // a newer cwd superseded this fetch
|
||||||
|
_state.update { it.copy(branches = branches, branchesLoading = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setName(text: String) {
|
||||||
|
_state.update { it.copy(name = text) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET /api/names?count=1`: replaces the session-name field with a
|
||||||
|
* freshly generated handle. */
|
||||||
|
fun rerollName() {
|
||||||
|
scope.launch {
|
||||||
|
val handle = runCatching { client.fetchNames(1).names.firstOrNull() }.getOrNull()
|
||||||
|
if (handle != null) _state.update { it.copy(name = handle) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validates the form, `POST`s it, and calls [onSuccess] with the new
|
||||||
|
* session. Any failure — validation or API — lands in
|
||||||
|
* [SpawnUiState.error] instead. */
|
||||||
|
fun submit(onSuccess: (Session) -> Unit) {
|
||||||
|
val s = _state.value
|
||||||
|
val values = SpawnFormValues(
|
||||||
|
command = s.command,
|
||||||
|
cwd = s.cwd,
|
||||||
|
name = s.name,
|
||||||
|
structuredView = s.structuredView,
|
||||||
|
autoApprove = s.activePreset?.auto_approve ?: false,
|
||||||
|
worktreeEnabled = s.worktreeEnabled,
|
||||||
|
worktreeBranch = if (s.branchMode == WorktreeBranchMode.Existing) s.effectiveExistingBranch else s.newBranch,
|
||||||
|
worktreeBranchMode = s.branchMode,
|
||||||
|
)
|
||||||
|
when (val built = buildSpawnRequest(values, s.baseRepo)) {
|
||||||
|
is SpawnBuildResult.Error -> _state.update { it.copy(error = built.message) }
|
||||||
|
is SpawnBuildResult.Ok -> {
|
||||||
|
_state.update { it.copy(submitting = true, error = null) }
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val session = client.createSession(built.request)
|
||||||
|
_state.update { it.copy(submitting = false) }
|
||||||
|
onSuccess(session)
|
||||||
|
} catch (e: MaestroApiException) {
|
||||||
|
_state.update { it.copy(submitting = false, error = e.message ?: e.detail) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.update { it.copy(submitting = false, error = e.message ?: "Failed to spawn session.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissError() {
|
||||||
|
_state.update { it.copy(error = null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package pt.naps.maestro.ui.structured
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
import kotlinx.serialization.json.longOrNull
|
||||||
|
import pt.naps.maestro.data.ReducedState
|
||||||
|
import pt.naps.maestro.data.TranscriptRow
|
||||||
|
import pt.naps.maestro.data.TranscriptRowKind
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loose parsing of the opaque ACP JSON a [TranscriptRow.data] /
|
||||||
|
* [ReducedState] field carries (docs/STRUCTURED-VIEW.md: "shaped to
|
||||||
|
* `claude-code-acp`'s ACP v1 schema, not covered by maestro-protocol's own
|
||||||
|
* compatibility guarantee"). Every accessor degrades to a default rather
|
||||||
|
* than throwing on an unexpected shape — ported from `web/src/types.ts`'s
|
||||||
|
* `Acp*` interfaces and `model.ts`'s readers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
internal fun JsonObject.stringField(key: String): String? = (this[key] as? JsonPrimitive)?.contentOrNull
|
||||||
|
|
||||||
|
internal fun JsonObject.longField(key: String): Long? = (this[key] as? JsonPrimitive)?.longOrNull
|
||||||
|
|
||||||
|
sealed class AcpContentBlock {
|
||||||
|
data class Text(val text: String) : AcpContentBlock()
|
||||||
|
data class Image(val mimeType: String?, val data: String?, val uri: String?) : AcpContentBlock()
|
||||||
|
data class Audio(val mimeType: String?, val data: String?) : AcpContentBlock()
|
||||||
|
data class ResourceLink(val name: String?, val uri: String?) : AcpContentBlock()
|
||||||
|
data class Resource(val uri: String?, val text: String?) : AcpContentBlock()
|
||||||
|
object Unknown : AcpContentBlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class AcpToolCallContent {
|
||||||
|
data class Content(val block: AcpContentBlock) : AcpToolCallContent()
|
||||||
|
data class Diff(val path: String, val oldText: String?, val newText: String) : AcpToolCallContent()
|
||||||
|
data class Terminal(val terminalId: String?) : AcpToolCallContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AcpToolCallLike(
|
||||||
|
val title: String? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
val kind: String? = null,
|
||||||
|
val status: String? = null,
|
||||||
|
val content: List<AcpToolCallContent> = emptyList(),
|
||||||
|
val locations: List<String> = emptyList(),
|
||||||
|
val rawInput: JsonElement? = null,
|
||||||
|
val rawOutput: JsonElement? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AcpPermissionOption(val optionId: String, val name: String, val kind: String)
|
||||||
|
|
||||||
|
data class AcpApprovalRequest(val toolCall: AcpToolCallLike, val options: List<AcpPermissionOption>)
|
||||||
|
|
||||||
|
/** `maestroTurnMeta` — the daemon-owned durable footer for a settled turn. */
|
||||||
|
data class TurnMetaPayload(
|
||||||
|
val elapsedMs: Long,
|
||||||
|
val outcome: String,
|
||||||
|
val model: String? = null,
|
||||||
|
val contextTokens: Long? = null,
|
||||||
|
val contextDelta: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun parseContentBlock(element: JsonElement?): AcpContentBlock {
|
||||||
|
val obj = element as? JsonObject ?: return AcpContentBlock.Unknown
|
||||||
|
return when (obj.stringField("type")) {
|
||||||
|
"text" -> AcpContentBlock.Text(obj.stringField("text").orEmpty())
|
||||||
|
"image" -> AcpContentBlock.Image(obj.stringField("mimeType"), obj.stringField("data"), obj.stringField("uri"))
|
||||||
|
"audio" -> AcpContentBlock.Audio(obj.stringField("mimeType"), obj.stringField("data"))
|
||||||
|
"resource_link" -> AcpContentBlock.ResourceLink(obj.stringField("name"), obj.stringField("uri"))
|
||||||
|
"resource" -> {
|
||||||
|
val resource = obj["resource"] as? JsonObject
|
||||||
|
AcpContentBlock.Resource(resource?.stringField("uri"), resource?.stringField("text"))
|
||||||
|
}
|
||||||
|
else -> AcpContentBlock.Unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Content-block text, or a labelled placeholder for anything that isn't
|
||||||
|
* text — never dropped silently. Mirrors `model.ts`'s `contentBlockText`. */
|
||||||
|
fun contentBlockText(block: AcpContentBlock): String = when (block) {
|
||||||
|
is AcpContentBlock.Text -> block.text
|
||||||
|
is AcpContentBlock.Image -> "[image]"
|
||||||
|
is AcpContentBlock.Audio -> "[audio]"
|
||||||
|
is AcpContentBlock.ResourceLink -> "[resource: ${block.name ?: block.uri ?: "linked"}]"
|
||||||
|
is AcpContentBlock.Resource -> "[resource]"
|
||||||
|
AcpContentBlock.Unknown -> ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A `User`/`AgentText`/`AgentThought` row's `data` is a `ContentChunk`:
|
||||||
|
* `{content: ContentBlock, messageId?}`. */
|
||||||
|
fun contentChunkText(data: JsonElement): String {
|
||||||
|
val obj = data as? JsonObject ?: return ""
|
||||||
|
return contentBlockText(parseContentBlock(obj["content"]))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseToolCallContent(element: JsonElement): AcpToolCallContent {
|
||||||
|
val obj = element as? JsonObject ?: return AcpToolCallContent.Terminal(null)
|
||||||
|
return when (obj.stringField("type")) {
|
||||||
|
"content" -> AcpToolCallContent.Content(parseContentBlock(obj["content"]))
|
||||||
|
"diff" -> AcpToolCallContent.Diff(
|
||||||
|
path = obj.stringField("path").orEmpty(),
|
||||||
|
oldText = obj.stringField("oldText"),
|
||||||
|
newText = obj.stringField("newText").orEmpty(),
|
||||||
|
)
|
||||||
|
else -> AcpToolCallContent.Terminal(obj.stringField("terminalId"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A `ToolCall`/`ToolDiff` row's `data`, or an `Approval` row's
|
||||||
|
* `data.toolCall` (`AcpToolCallLike` — both share this shape). */
|
||||||
|
fun parseToolCall(data: JsonElement): AcpToolCallLike {
|
||||||
|
val obj = data as? JsonObject ?: return AcpToolCallLike()
|
||||||
|
return AcpToolCallLike(
|
||||||
|
title = obj.stringField("title"),
|
||||||
|
name = obj.stringField("name"),
|
||||||
|
kind = obj.stringField("kind"),
|
||||||
|
status = obj.stringField("status"),
|
||||||
|
content = (obj["content"] as? JsonArray)?.map(::parseToolCallContent) ?: emptyList(),
|
||||||
|
locations = (obj["locations"] as? JsonArray)
|
||||||
|
?.mapNotNull { (it as? JsonObject)?.stringField("path") }
|
||||||
|
?: emptyList(),
|
||||||
|
rawInput = obj["rawInput"],
|
||||||
|
rawOutput = obj["rawOutput"],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An `Approval` row's `data` — a `RequestPermissionRequest` serialized
|
||||||
|
* without its nonce (docs/STRUCTURED-VIEW.md's "Approvals"). */
|
||||||
|
fun parseApprovalRequest(data: JsonElement): AcpApprovalRequest {
|
||||||
|
val obj = data as? JsonObject ?: return AcpApprovalRequest(AcpToolCallLike(), emptyList())
|
||||||
|
val toolCall = (obj["toolCall"] as? JsonObject)?.let(::parseToolCall) ?: AcpToolCallLike()
|
||||||
|
val options = (obj["options"] as? JsonArray)?.mapNotNull { option ->
|
||||||
|
val o = option as? JsonObject ?: return@mapNotNull null
|
||||||
|
val optionId = o.stringField("optionId") ?: return@mapNotNull null
|
||||||
|
val name = o.stringField("name") ?: return@mapNotNull null
|
||||||
|
val kind = o.stringField("kind") ?: return@mapNotNull null
|
||||||
|
AcpPermissionOption(optionId, name, kind)
|
||||||
|
} ?: emptyList()
|
||||||
|
return AcpApprovalRequest(toolCall, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `maestroTurnMeta` footer a `Notice` row carries when it is a
|
||||||
|
* completed, cancelled or failed turn's durable footer — `null` for every
|
||||||
|
* other notice. */
|
||||||
|
fun parseTurnMeta(row: TranscriptRow): TurnMetaPayload? {
|
||||||
|
if (row.kind != TranscriptRowKind.Notice) return null
|
||||||
|
val obj = row.data as? JsonObject ?: return null
|
||||||
|
val meta = obj["maestroTurnMeta"] as? JsonObject ?: return null
|
||||||
|
val elapsedMs = meta.longField("elapsedMs") ?: return null
|
||||||
|
val outcome = meta.stringField("outcome") ?: return null
|
||||||
|
return TurnMetaPayload(
|
||||||
|
elapsedMs = elapsedMs,
|
||||||
|
outcome = outcome,
|
||||||
|
model = meta.stringField("model"),
|
||||||
|
contextTokens = meta.longField("contextTokens"),
|
||||||
|
contextDelta = meta.longField("contextDelta"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Notices the client consumes as state rather than showing in the
|
||||||
|
* transcript (docs/STRUCTURED-VIEW.md, "A client decides"). */
|
||||||
|
fun isStateOnlyNotice(row: TranscriptRow): Boolean {
|
||||||
|
if (row.kind != TranscriptRowKind.Notice) return false
|
||||||
|
val update = (row.data as? JsonObject)?.stringField("sessionUpdate")
|
||||||
|
return update == "available_commands_update" || update == "session_info_update"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `ReducedState.pending_approval` is `{nonce, request}` while a permission
|
||||||
|
* request is live, absent otherwise. */
|
||||||
|
fun pendingApprovalNonce(state: ReducedState): String? =
|
||||||
|
(state.pending_approval as? JsonObject)?.stringField("nonce")
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package pt.naps.maestro.ui.structured
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import pt.naps.maestro.data.StructuredApprovalDecision
|
||||||
|
|
||||||
|
private val DECISION_LABELS = listOf(
|
||||||
|
StructuredApprovalDecision.AllowOnce to "Allow once",
|
||||||
|
StructuredApprovalDecision.AllowForSession to "Allow for session",
|
||||||
|
StructuredApprovalDecision.Deny to "Deny",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An `Approval` row. The daemon exposes exactly three decisions
|
||||||
|
* (docs/STRUCTURED-VIEW.md) and mints the nonce; this only solicits one of
|
||||||
|
* them. A decision the request offers no matching ACP option for is not
|
||||||
|
* shown — its button would "allow" something that silently resolves
|
||||||
|
* `Cancelled`.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ApprovalCard(
|
||||||
|
request: AcpApprovalRequest,
|
||||||
|
pending: Boolean,
|
||||||
|
busy: Boolean,
|
||||||
|
onDecide: (StructuredApprovalDecision) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val what = request.toolCall.title ?: StructuredModel.toolKindLabel(request.toolCall.kind)
|
||||||
|
|
||||||
|
if (!pending) {
|
||||||
|
Text(
|
||||||
|
text = "$what — approved",
|
||||||
|
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val offered = StructuredModel.offeredDecisions(request.options)
|
||||||
|
val path = request.toolCall.locations.firstOrNull()
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.25f), RoundedCornerShape(8.dp))
|
||||||
|
.padding(12.dp),
|
||||||
|
) {
|
||||||
|
Text("⚠ Permission requested", style = MaterialTheme.typography.labelMedium)
|
||||||
|
Text(what, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
if (path != null) {
|
||||||
|
Text(path, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
if (offered.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
"no decision offered",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Row(modifier = Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
for ((decision, label) in DECISION_LABELS) {
|
||||||
|
if (decision !in offered) continue
|
||||||
|
val isDeny = decision == StructuredApprovalDecision.Deny
|
||||||
|
Button(
|
||||||
|
onClick = { onDecide(decision) },
|
||||||
|
enabled = !busy,
|
||||||
|
colors = if (isDeny) {
|
||||||
|
ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
|
||||||
|
} else {
|
||||||
|
ButtonDefaults.buttonColors()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package pt.naps.maestro.ui.structured
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multi-line draft input plus send/cancel (#267). Sending is the pane's
|
||||||
|
* job — this only hands back the current draft text.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun Composer(
|
||||||
|
draft: String,
|
||||||
|
onDraftChange: (String) -> Unit,
|
||||||
|
onSend: () -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
turnActive: Boolean,
|
||||||
|
sendEnabled: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier.fillMaxWidth().padding(8.dp),
|
||||||
|
verticalAlignment = Alignment.Bottom,
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = draft,
|
||||||
|
onValueChange = onDraftChange,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
placeholder = { Text("Reply…") },
|
||||||
|
minLines = 1,
|
||||||
|
maxLines = 6,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
if (turnActive) {
|
||||||
|
IconButton(onClick = onCancel) {
|
||||||
|
Text(text = "■", color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IconButton(onClick = onSend, enabled = sendEnabled && draft.isNotBlank()) {
|
||||||
|
Text(text = "➤")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package pt.naps.maestro.ui.structured
|
||||||
|
|
||||||
|
/** One rendered line of a [ToolDiff][pt.naps.maestro.data.TranscriptRowKind]
|
||||||
|
* content block. A `Hunk` line is a human summary, not source text; an `Eof`
|
||||||
|
* line marks a missing trailing newline. Ported from `web/src/components/
|
||||||
|
* structured/diff.ts`. */
|
||||||
|
data class DiffLine(val kind: Kind, val text: String) {
|
||||||
|
enum class Kind { Add, Del, Ctx, Hunk, Eof }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Line-level diff for a `Diff` content block (#140). ACP hands over whole
|
||||||
|
* `oldText`/`newText`, not a unified-diff patch, so the coloured hunks a
|
||||||
|
* diff view needs are computed here rather than parsed out of a patch.
|
||||||
|
*/
|
||||||
|
object DiffModel {
|
||||||
|
/** Interior unchanged runs longer than this collapse to a summary line
|
||||||
|
* flanked by this many lines of real context on each side. */
|
||||||
|
private const val CONTEXT = 3
|
||||||
|
|
||||||
|
/** Bounds the O(n·m) LCS table: above this cell count, the changed
|
||||||
|
* middle is shown as one coarse del-then-add block instead of a
|
||||||
|
* fine-grained diff — still correct, just not minimal. */
|
||||||
|
private const val LCS_CELL_LIMIT = 250_000
|
||||||
|
|
||||||
|
private data class SourceLine(val text: String, val terminated: Boolean)
|
||||||
|
|
||||||
|
private fun splitLines(text: String): List<SourceLine> {
|
||||||
|
if (text.isEmpty()) return emptyList()
|
||||||
|
val hasFinalNewline = text.endsWith("\n")
|
||||||
|
val parts = text.split("\n").let { if (hasFinalNewline) it.dropLast(1) else it }
|
||||||
|
return parts.mapIndexed { index, line -> SourceLine(line, index < parts.size - 1 || hasFinalNewline) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Op(val kind: DiffLine.Kind, val line: SourceLine)
|
||||||
|
|
||||||
|
private fun sameLine(a: SourceLine, b: SourceLine) = a.text == b.text && a.terminated == b.terminated
|
||||||
|
|
||||||
|
/** Longest-common-subsequence diff of two line arrays, already trimmed
|
||||||
|
* of any shared prefix/suffix by the caller. */
|
||||||
|
private fun lcsDiff(a: List<SourceLine>, b: List<SourceLine>): List<Op> {
|
||||||
|
val n = a.size
|
||||||
|
val m = b.size
|
||||||
|
if (n == 0) return b.map { Op(DiffLine.Kind.Add, it) }
|
||||||
|
if (m == 0) return a.map { Op(DiffLine.Kind.Del, it) }
|
||||||
|
if (n.toLong() * m.toLong() > LCS_CELL_LIMIT) {
|
||||||
|
return a.map { Op(DiffLine.Kind.Del, it) } + b.map { Op(DiffLine.Kind.Add, it) }
|
||||||
|
}
|
||||||
|
val columns = m + 1
|
||||||
|
val dp = IntArray((n + 1) * columns)
|
||||||
|
for (i in n - 1 downTo 0) {
|
||||||
|
for (j in m - 1 downTo 0) {
|
||||||
|
dp[i * columns + j] = if (sameLine(a[i], b[j])) {
|
||||||
|
dp[(i + 1) * columns + j + 1] + 1
|
||||||
|
} else {
|
||||||
|
maxOf(dp[(i + 1) * columns + j], dp[i * columns + j + 1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val ops = mutableListOf<Op>()
|
||||||
|
var i = 0
|
||||||
|
var j = 0
|
||||||
|
while (i < n && j < m) {
|
||||||
|
when {
|
||||||
|
sameLine(a[i], b[j]) -> {
|
||||||
|
ops.add(Op(DiffLine.Kind.Ctx, a[i]))
|
||||||
|
i++
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
dp[(i + 1) * columns + j] >= dp[i * columns + j + 1] -> {
|
||||||
|
ops.add(Op(DiffLine.Kind.Del, a[i]))
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
ops.add(Op(DiffLine.Kind.Add, b[j]))
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (i < n) ops.add(Op(DiffLine.Kind.Del, a[i++]))
|
||||||
|
while (j < m) ops.add(Op(DiffLine.Kind.Add, b[j++]))
|
||||||
|
return ops
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun diffLines(a: List<SourceLine>, b: List<SourceLine>): List<Op> {
|
||||||
|
var pre = 0
|
||||||
|
while (pre < a.size && pre < b.size && sameLine(a[pre], b[pre])) pre++
|
||||||
|
var suf = 0
|
||||||
|
while (suf < a.size - pre && suf < b.size - pre && sameLine(a[a.size - 1 - suf], b[b.size - 1 - suf])) suf++
|
||||||
|
val prefixCtx = a.subList(0, pre).map { Op(DiffLine.Kind.Ctx, it) }
|
||||||
|
val suffixCtx = a.subList(a.size - suf, a.size).map { Op(DiffLine.Kind.Ctx, it) }
|
||||||
|
val middle = lcsDiff(a.subList(pre, a.size - suf), b.subList(pre, b.size - suf))
|
||||||
|
return prefixCtx + middle + suffixCtx
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collapse a long run of unchanged lines to a summary, keeping up to
|
||||||
|
* [CONTEXT] real lines adjacent to each neighbouring change. */
|
||||||
|
private fun hunk(ops: List<Op>): List<DiffLine> {
|
||||||
|
val out = mutableListOf<DiffLine>()
|
||||||
|
fun push(op: Op) {
|
||||||
|
out.add(DiffLine(op.kind, op.line.text))
|
||||||
|
if (!op.line.terminated) out.add(DiffLine(DiffLine.Kind.Eof, "\\ No newline at end of file"))
|
||||||
|
}
|
||||||
|
var i = 0
|
||||||
|
while (i < ops.size) {
|
||||||
|
if (ops[i].kind != DiffLine.Kind.Ctx) {
|
||||||
|
push(ops[i])
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var j = i
|
||||||
|
while (j < ops.size && ops[j].kind == DiffLine.Kind.Ctx) j++
|
||||||
|
val run = ops.subList(i, j)
|
||||||
|
val wholeDiff = i == 0 && j == ops.size
|
||||||
|
val keepHead = if (wholeDiff || i > 0) CONTEXT else 0
|
||||||
|
val keepTail = if (wholeDiff || j < ops.size) CONTEXT else 0
|
||||||
|
if (run.size <= keepHead + keepTail) {
|
||||||
|
run.forEach(::push)
|
||||||
|
} else {
|
||||||
|
if (keepHead > 0) run.subList(0, keepHead).forEach(::push)
|
||||||
|
val collapsed = run.subList(keepHead, run.size - keepTail)
|
||||||
|
out.add(DiffLine(DiffLine.Kind.Hunk, "⋯ ${collapsed.size} unchanged lines ⋯"))
|
||||||
|
if (collapsed.any { !it.line.terminated }) {
|
||||||
|
out.add(DiffLine(DiffLine.Kind.Eof, "\\ No newline at end of file"))
|
||||||
|
}
|
||||||
|
if (keepTail > 0) run.subList(run.size - keepTail, run.size).forEach(::push)
|
||||||
|
}
|
||||||
|
i = j
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Coloured, hunked diff lines for a `Diff` content block. `oldText`
|
||||||
|
* absent (a new file) renders every line of `newText` as added. */
|
||||||
|
fun computeDiffLines(oldText: String?, newText: String): List<DiffLine> {
|
||||||
|
val newLines = splitLines(newText)
|
||||||
|
if (oldText == null) return hunk(newLines.map { Op(DiffLine.Kind.Add, it) })
|
||||||
|
return hunk(diffLines(splitLines(oldText), newLines))
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Stats(val added: Int, val removed: Int)
|
||||||
|
|
||||||
|
/** `+added -removed` counts straight off the diff — real derived data,
|
||||||
|
* never a fabricated count. */
|
||||||
|
fun diffStats(lines: List<DiffLine>): Stats {
|
||||||
|
var added = 0
|
||||||
|
var removed = 0
|
||||||
|
for (line in lines) when (line.kind) {
|
||||||
|
DiffLine.Kind.Add -> added++
|
||||||
|
DiffLine.Kind.Del -> removed++
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
return Stats(added, removed)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package pt.naps.maestro.ui.structured
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
private val DiffAddColor = Color(0xFF2E7D32)
|
||||||
|
private val DiffAddBackground = Color(0x1A2E7D32)
|
||||||
|
private val DiffDelBackground = Color(0x1AD32F2F)
|
||||||
|
|
||||||
|
/** A `ToolDiff` row's diff content: `+` lines green, `-` lines red
|
||||||
|
* (docs/STRUCTURED-VIEW.md's "diff view" is a client decision). Lines are
|
||||||
|
* pre-computed by [DiffModel]. */
|
||||||
|
@Composable
|
||||||
|
fun DiffView(path: String, oldText: String?, newText: String, modifier: Modifier = Modifier) {
|
||||||
|
val lines = remember(oldText, newText) { DiffModel.computeDiffLines(oldText, newText) }
|
||||||
|
Column(modifier = modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||||
|
Text(
|
||||||
|
text = path,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
for (line in lines) {
|
||||||
|
val (prefix, color, background) = when (line.kind) {
|
||||||
|
DiffLine.Kind.Add -> Triple("+ ", DiffAddColor, DiffAddBackground)
|
||||||
|
DiffLine.Kind.Del -> Triple("- ", MaterialTheme.colorScheme.error, DiffDelBackground)
|
||||||
|
DiffLine.Kind.Ctx -> Triple(" ", MaterialTheme.colorScheme.onSurfaceVariant, Color.Transparent)
|
||||||
|
DiffLine.Kind.Hunk, DiffLine.Kind.Eof -> Triple("", MaterialTheme.colorScheme.onSurfaceVariant, Color.Transparent)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = prefix + line.text,
|
||||||
|
color = color,
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(background)
|
||||||
|
.padding(horizontal = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package pt.naps.maestro.ui.structured
|
||||||
|
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal markdown-to-[AnnotatedString] renderer for agent replies (#267
|
||||||
|
* v1): fenced code blocks, `#` headers, `-`/`*` bullets, and inline
|
||||||
|
* `**bold**`/`*italic*`/`` `code` ``. Not a CommonMark implementation —
|
||||||
|
* good enough for model prose without pulling in a markdown library.
|
||||||
|
*/
|
||||||
|
fun renderSimpleMarkdown(text: String): AnnotatedString = buildAnnotatedString {
|
||||||
|
var inCodeBlock = false
|
||||||
|
val lines = text.split("\n")
|
||||||
|
lines.forEachIndexed { index, rawLine ->
|
||||||
|
when {
|
||||||
|
rawLine.trim().startsWith("```") -> inCodeBlock = !inCodeBlock
|
||||||
|
inCodeBlock -> withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { append(rawLine) }
|
||||||
|
else -> {
|
||||||
|
val header = HEADER_PATTERN.matchEntire(rawLine)
|
||||||
|
val bullet = BULLET_PATTERN.matchEntire(rawLine)
|
||||||
|
when {
|
||||||
|
header != null -> withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||||
|
appendInlineMarkdown(header.groupValues[2])
|
||||||
|
}
|
||||||
|
bullet != null -> {
|
||||||
|
append("• ")
|
||||||
|
appendInlineMarkdown(bullet.groupValues[1])
|
||||||
|
}
|
||||||
|
else -> appendInlineMarkdown(rawLine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (index != lines.lastIndex) append("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val HEADER_PATTERN = Regex("^(#{1,6})\\s+(.*)$")
|
||||||
|
private val BULLET_PATTERN = Regex("^[-*]\\s+(.*)$")
|
||||||
|
|
||||||
|
/** Scans for the next inline marker (`` ` ``, `**`, `*`) and applies its
|
||||||
|
* span style, falling back to plain text for anything before it. */
|
||||||
|
private fun AnnotatedString.Builder.appendInlineMarkdown(text: String) {
|
||||||
|
var i = 0
|
||||||
|
while (i < text.length) {
|
||||||
|
when {
|
||||||
|
text.startsWith("**", i) -> {
|
||||||
|
val end = text.indexOf("**", i + 2)
|
||||||
|
if (end == -1) {
|
||||||
|
append(text.substring(i))
|
||||||
|
i = text.length
|
||||||
|
} else {
|
||||||
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(text.substring(i + 2, end)) }
|
||||||
|
i = end + 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text.startsWith("`", i) -> {
|
||||||
|
val end = text.indexOf("`", i + 1)
|
||||||
|
if (end == -1) {
|
||||||
|
append(text.substring(i))
|
||||||
|
i = text.length
|
||||||
|
} else {
|
||||||
|
withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { append(text.substring(i + 1, end)) }
|
||||||
|
i = end + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text.startsWith("*", i) -> {
|
||||||
|
val end = text.indexOf("*", i + 1)
|
||||||
|
if (end == -1) {
|
||||||
|
append(text.substring(i))
|
||||||
|
i = text.length
|
||||||
|
} else {
|
||||||
|
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { append(text.substring(i + 1, end)) }
|
||||||
|
i = end + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val next = listOf(text.indexOf("**", i), text.indexOf("`", i), text.indexOf("*", i))
|
||||||
|
.filter { it >= 0 }
|
||||||
|
.minOrNull() ?: text.length
|
||||||
|
append(text.substring(i, next))
|
||||||
|
i = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package pt.naps.maestro.ui.structured
|
||||||
|
|
||||||
|
import pt.naps.maestro.data.ReducedState
|
||||||
|
import pt.naps.maestro.data.StructuredApprovalDecision
|
||||||
|
import pt.naps.maestro.data.TranscriptRow
|
||||||
|
import pt.naps.maestro.data.TranscriptRowKind
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure transcript logic for the structured pane (#267), kept out of the
|
||||||
|
* composables so it is testable without Compose. Ported from
|
||||||
|
* `web/src/components/structured/model.ts`; docs/STRUCTURED-VIEW.md is the
|
||||||
|
* contract for what a client, versus the daemon, decides.
|
||||||
|
*/
|
||||||
|
object StructuredModel {
|
||||||
|
private val KIND_LABELS = mapOf(
|
||||||
|
"read" to "Read",
|
||||||
|
"edit" to "Edit",
|
||||||
|
"delete" to "Delete",
|
||||||
|
"move" to "Move",
|
||||||
|
"search" to "Search",
|
||||||
|
"execute" to "Execute",
|
||||||
|
"think" to "Think",
|
||||||
|
"fetch" to "Fetch",
|
||||||
|
"switch_mode" to "Switch mode",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Human label behind a `ToolKind`. An unrecognised/absent kind is
|
||||||
|
* "Tool" — never a blank label. */
|
||||||
|
fun toolKindLabel(kind: String?): String = kind?.let { KIND_LABELS[it] } ?: "Tool"
|
||||||
|
|
||||||
|
data class ToolTitle(val label: String, val arg: String)
|
||||||
|
|
||||||
|
private val TITLE_PATTERN = Regex("^(\\S+)\\(([\\s\\S]*)\\)$")
|
||||||
|
|
||||||
|
/** Split a title into `{label, arg}` when it follows the adapter's own
|
||||||
|
* `Name(argument)` convention; otherwise the whole title is the arg and
|
||||||
|
* `kind` supplies a generic label. */
|
||||||
|
fun splitToolTitle(title: String?, name: String?, kind: String?): ToolTitle {
|
||||||
|
val label = name ?: toolKindLabel(kind)
|
||||||
|
if (title.isNullOrEmpty()) return ToolTitle(label, "")
|
||||||
|
val match = TITLE_PATTERN.matchEntire(title)
|
||||||
|
return if (match != null) {
|
||||||
|
ToolTitle(name ?: match.groupValues[1], match.groupValues[2])
|
||||||
|
} else {
|
||||||
|
ToolTitle(label, title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class StatusMeta(val text: String, val kind: String)
|
||||||
|
|
||||||
|
fun statusMeta(status: String?, hasDiff: Boolean): StatusMeta? = when {
|
||||||
|
status == "in_progress" -> StatusMeta("running", "running")
|
||||||
|
status == "failed" -> StatusMeta("failed", "failed")
|
||||||
|
status == "pending" && !hasDiff -> StatusMeta("pending", "pending")
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun lastRowIdOfKind(rows: List<TranscriptRow>, kind: TranscriptRowKind): String? =
|
||||||
|
rows.lastOrNull { it.kind == kind }?.id
|
||||||
|
|
||||||
|
/** The row id a live `pending_approval` names: the newest `Approval`
|
||||||
|
* row. Only one approval is ever live (docs/STRUCTURED-VIEW.md), so the
|
||||||
|
* last one is the pending one. */
|
||||||
|
fun pendingApprovalRowId(rows: List<TranscriptRow>, state: ReducedState): String? =
|
||||||
|
if (state.pending_approval != null) lastRowIdOfKind(rows, TranscriptRowKind.Approval) else null
|
||||||
|
|
||||||
|
fun turnIsActive(state: ReducedState): Boolean = state.turn.responding || state.turn.thinking
|
||||||
|
|
||||||
|
/** Which decisions a permission request actually backs, by the
|
||||||
|
* `PermissionOptionKind` values it offers. A decision with no matching
|
||||||
|
* option resolves `Cancelled` server-side regardless — offering its
|
||||||
|
* button would let the operator "allow" something that silently
|
||||||
|
* no-ops. */
|
||||||
|
fun offeredDecisions(options: List<AcpPermissionOption>): List<StructuredApprovalDecision> {
|
||||||
|
val kinds = options.map { it.kind }.toSet()
|
||||||
|
val decisions = mutableListOf<StructuredApprovalDecision>()
|
||||||
|
if ("allow_once" in kinds) decisions += StructuredApprovalDecision.AllowOnce
|
||||||
|
if ("allow_always" in kinds) decisions += StructuredApprovalDecision.AllowForSession
|
||||||
|
if ("reject_once" in kinds || "reject_always" in kinds) decisions += StructuredApprovalDecision.Deny
|
||||||
|
return decisions
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ActivityGroup(
|
||||||
|
val toolCount: Int,
|
||||||
|
val rows: List<TranscriptRow>,
|
||||||
|
val lastToolLabel: String,
|
||||||
|
val lastToolArg: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group contiguous tool rows (and resolved approvals between them) into
|
||||||
|
* collapsible blocks. An `AgentText`/`AgentThought` row between tools
|
||||||
|
* splits the run. A group must contain at least two tools to qualify; a
|
||||||
|
* single tool stays standalone. Keyed by `turn:<userId>:<seq>`.
|
||||||
|
*/
|
||||||
|
fun activityGroupsByTurn(rows: List<TranscriptRow>, pendingApprovalId: String?): Map<String, ActivityGroup> {
|
||||||
|
val groups = LinkedHashMap<String, ActivityGroup>()
|
||||||
|
var turnId = "before-first-message"
|
||||||
|
var runSeq = 0
|
||||||
|
var runTools = mutableListOf<TranscriptRow>()
|
||||||
|
var runPending = mutableListOf<TranscriptRow>()
|
||||||
|
|
||||||
|
fun flushRun() {
|
||||||
|
if (runTools.size >= 2) {
|
||||||
|
val gid = "turn:$turnId:$runSeq"
|
||||||
|
val tool = parseToolCall(runTools.last().data)
|
||||||
|
val title = splitToolTitle(tool.title, tool.name, tool.kind)
|
||||||
|
groups[gid] = ActivityGroup(runTools.size, runTools.toList(), title.label, title.arg)
|
||||||
|
runSeq++
|
||||||
|
}
|
||||||
|
runTools = mutableListOf()
|
||||||
|
runPending = mutableListOf()
|
||||||
|
}
|
||||||
|
|
||||||
|
for (row in rows) {
|
||||||
|
when {
|
||||||
|
row.kind == TranscriptRowKind.User -> {
|
||||||
|
flushRun()
|
||||||
|
turnId = row.id
|
||||||
|
runSeq = 0
|
||||||
|
}
|
||||||
|
row.kind == TranscriptRowKind.AgentText || row.kind == TranscriptRowKind.AgentThought -> flushRun()
|
||||||
|
row.kind == TranscriptRowKind.Approval && row.id != pendingApprovalId -> runPending.add(row)
|
||||||
|
row.kind == TranscriptRowKind.ToolCall || row.kind == TranscriptRowKind.ToolDiff -> {
|
||||||
|
runTools.addAll(runPending)
|
||||||
|
runPending = mutableListOf()
|
||||||
|
runTools.add(row)
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushRun()
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One entry of the flattened render list: either a standalone row or a
|
||||||
|
* whole activity group. */
|
||||||
|
sealed class TranscriptItem {
|
||||||
|
data class RowItem(val row: TranscriptRow) : TranscriptItem()
|
||||||
|
data class GroupItem(val id: String, val group: ActivityGroup) : TranscriptItem()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The transcript in render order: rows, with each contiguous tool
|
||||||
|
* stretch folded into a single group item where one exists. `Plan` rows
|
||||||
|
* and state-only notices are dropped (both clients omit `Plan`). A turn
|
||||||
|
* footer only survives as the final item, so old footers stop reading
|
||||||
|
* like transcript content.
|
||||||
|
*/
|
||||||
|
fun transcriptItems(rows: List<TranscriptRow>, pendingApprovalId: String?): List<TranscriptItem> {
|
||||||
|
val groups = activityGroupsByTurn(rows, pendingApprovalId)
|
||||||
|
val rowToGroup = HashMap<String, String>()
|
||||||
|
for ((groupId, group) in groups) for (r in group.rows) rowToGroup[r.id] = groupId
|
||||||
|
|
||||||
|
val items = mutableListOf<TranscriptItem>()
|
||||||
|
val emitted = mutableSetOf<String>()
|
||||||
|
for (row in rows) {
|
||||||
|
val groupId = rowToGroup[row.id]
|
||||||
|
if (groupId != null) {
|
||||||
|
if (emitted.add(groupId)) items.add(TranscriptItem.GroupItem(groupId, groups.getValue(groupId)))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (row.kind == TranscriptRowKind.Plan) continue
|
||||||
|
if (row.kind == TranscriptRowKind.Notice && isStateOnlyNotice(row)) continue
|
||||||
|
if (row.kind == TranscriptRowKind.Approval && row.id != pendingApprovalId) continue
|
||||||
|
items.add(TranscriptItem.RowItem(row))
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.filterIndexed { index, item ->
|
||||||
|
!(item is TranscriptItem.RowItem && parseTurnMeta(item.row) != null) || index == items.size - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun formatElapsed(milliseconds: Long): String {
|
||||||
|
val seconds = maxOf(0L, milliseconds / 1_000)
|
||||||
|
if (seconds < 60) return "${seconds}s"
|
||||||
|
val minutes = seconds / 60
|
||||||
|
val remainder = seconds % 60
|
||||||
|
return if (remainder == 0L) "${minutes}m" else "${minutes}m ${remainder}s"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun compactNumber(value: Long): String {
|
||||||
|
val magnitude = abs(value)
|
||||||
|
return when {
|
||||||
|
magnitude < 1_000 -> value.toString()
|
||||||
|
magnitude < 1_000_000 -> {
|
||||||
|
if (value % 1_000 == 0L) "${value / 1_000}k" else "%.1fk".format(value / 1_000.0)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
if (value % 1_000_000 == 0L) "${value / 1_000_000}m" else "%.1fm".format(value / 1_000_000.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun formatContextTokens(value: Long): String = "${compactNumber(value)} context"
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package pt.naps.maestro.ui.structured
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
import pt.naps.maestro.api.MaestroClient
|
||||||
|
import pt.naps.maestro.api.MaestroWebSocket
|
||||||
|
import pt.naps.maestro.data.StructuredApprovalDecision
|
||||||
|
import pt.naps.maestro.data.Uuid
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen structured pane for one session (issue #267): an ACP
|
||||||
|
* transcript over `WS /api/structured/:id`, the composer that drives
|
||||||
|
* `.../prompt` and `.../cancel`, and the approval controls that answer
|
||||||
|
* `.../approvals/:nonce`. docs/STRUCTURED-VIEW.md is the contract.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun StructuredScreen(
|
||||||
|
daemonBaseUrl: String,
|
||||||
|
sessionId: Uuid,
|
||||||
|
sessionTitle: String,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val client = remember(daemonBaseUrl) { MaestroClient(daemonBaseUrl) }
|
||||||
|
val webSocket = remember(daemonBaseUrl) { MaestroWebSocket(daemonBaseUrl) }
|
||||||
|
val viewModel = remember(sessionId, daemonBaseUrl) { StructuredViewModel(client, webSocket, sessionId) }
|
||||||
|
DisposableEffect(viewModel) { onDispose { viewModel.close() } }
|
||||||
|
|
||||||
|
val uiState by viewModel.state.collectAsState()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(sessionTitle) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Text(text = "←", style = MaterialTheme.typography.titleLarge)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
StructuredPane(
|
||||||
|
state = uiState,
|
||||||
|
onDraftChange = viewModel::setDraft,
|
||||||
|
onSend = viewModel::sendPrompt,
|
||||||
|
onCancel = viewModel::cancelTurn,
|
||||||
|
onDecide = viewModel::resolveApproval,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The pane's body, independent of the screen chrome above — easier to
|
||||||
|
* preview/test without a daemon or a `Scaffold`. */
|
||||||
|
@Composable
|
||||||
|
fun StructuredPane(
|
||||||
|
state: StructuredUiState,
|
||||||
|
onDraftChange: (String) -> Unit,
|
||||||
|
onSend: () -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
onDecide: (rowId: String, decision: StructuredApprovalDecision) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
var expandedRows by remember { mutableStateOf(setOf<String>()) }
|
||||||
|
var expandedGroups by remember { mutableStateOf(setOf<String>()) }
|
||||||
|
var followBottom by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
|
val items = state.items
|
||||||
|
val pendingRowId = state.pendingApprovalRowId
|
||||||
|
|
||||||
|
// Track whether the operator is at (or near) the bottom, so new content
|
||||||
|
// only pulls the view down while they were already following it.
|
||||||
|
LaunchedEffect(listState) {
|
||||||
|
snapshotFlow { listState.layoutInfo }.collect { info ->
|
||||||
|
val last = info.visibleItemsInfo.lastOrNull()
|
||||||
|
followBottom = last == null || last.index >= info.totalItemsCount - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(items, state.activityLabel()) {
|
||||||
|
if (followBottom && items.isNotEmpty()) {
|
||||||
|
listState.scrollToItem(items.size - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp),
|
||||||
|
) {
|
||||||
|
items(
|
||||||
|
items,
|
||||||
|
key = { item ->
|
||||||
|
when (item) {
|
||||||
|
is StructuredModel.TranscriptItem.RowItem -> item.row.id
|
||||||
|
is StructuredModel.TranscriptItem.GroupItem -> item.id
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { item ->
|
||||||
|
when (item) {
|
||||||
|
is StructuredModel.TranscriptItem.RowItem -> TranscriptRowView(
|
||||||
|
row = item.row,
|
||||||
|
pendingApproval = item.row.id == pendingRowId,
|
||||||
|
approvalBusy = state.approvalBusy,
|
||||||
|
expanded = expandedRows.contains(item.row.id),
|
||||||
|
onExpandedChange = { expanded -> expandedRows = toggled(expandedRows, item.row.id, expanded) },
|
||||||
|
onDecide = { decision -> onDecide(item.row.id, decision) },
|
||||||
|
)
|
||||||
|
is StructuredModel.TranscriptItem.GroupItem -> ToolGroup(
|
||||||
|
toolCount = item.group.toolCount,
|
||||||
|
lastToolLabel = item.group.lastToolLabel,
|
||||||
|
lastToolArg = item.group.lastToolArg,
|
||||||
|
expanded = expandedGroups.contains(item.id),
|
||||||
|
onExpandedChange = { expanded -> expandedGroups = toggled(expandedGroups, item.id, expanded) },
|
||||||
|
) {
|
||||||
|
for (row in item.group.rows) {
|
||||||
|
TranscriptRowView(
|
||||||
|
row = row,
|
||||||
|
pendingApproval = row.id == pendingRowId,
|
||||||
|
approvalBusy = state.approvalBusy,
|
||||||
|
expanded = expandedRows.contains(row.id),
|
||||||
|
onExpandedChange = { expanded -> expandedRows = toggled(expandedRows, row.id, expanded) },
|
||||||
|
onDecide = { decision -> onDecide(row.id, decision) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TurnStatusBar(label = state.activityLabel())
|
||||||
|
if (state.error != null) {
|
||||||
|
Text(
|
||||||
|
text = state.error,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Composer(
|
||||||
|
draft = state.draft,
|
||||||
|
onDraftChange = onDraftChange,
|
||||||
|
onSend = onSend,
|
||||||
|
onCancel = onCancel,
|
||||||
|
turnActive = state.turnActive,
|
||||||
|
sendEnabled = !state.posting,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toggled(set: Set<String>, id: String, expanded: Boolean): Set<String> =
|
||||||
|
if (expanded) set + id else set - id
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
package pt.naps.maestro.ui.structured
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import pt.naps.maestro.api.MaestroClient
|
||||||
|
import pt.naps.maestro.api.MaestroWebSocket
|
||||||
|
import pt.naps.maestro.api.StructuredConnection
|
||||||
|
import pt.naps.maestro.data.ReducedState
|
||||||
|
import pt.naps.maestro.data.StructuredApprovalDecision
|
||||||
|
import pt.naps.maestro.data.StructuredEvent
|
||||||
|
import pt.naps.maestro.data.TranscriptDelta
|
||||||
|
import pt.naps.maestro.data.TranscriptRow
|
||||||
|
import pt.naps.maestro.data.Uuid
|
||||||
|
|
||||||
|
enum class StructuredConnectionStatus { Connecting, Open, Closed, Error }
|
||||||
|
|
||||||
|
data class StructuredUiState(
|
||||||
|
val rows: List<TranscriptRow> = emptyList(),
|
||||||
|
val reducedState: ReducedState = ReducedState(),
|
||||||
|
val connection: StructuredConnectionStatus = StructuredConnectionStatus.Connecting,
|
||||||
|
val connectionDetail: String? = null,
|
||||||
|
val draft: String = "",
|
||||||
|
val posting: Boolean = false,
|
||||||
|
val approvalBusy: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
val nowMs: Long = System.currentTimeMillis(),
|
||||||
|
) {
|
||||||
|
val turnActive: Boolean get() = StructuredModel.turnIsActive(reducedState)
|
||||||
|
val pendingApprovalRowId: String? get() = StructuredModel.pendingApprovalRowId(rows, reducedState)
|
||||||
|
val items: List<StructuredModel.TranscriptItem> get() = StructuredModel.transcriptItems(rows, pendingApprovalRowId)
|
||||||
|
|
||||||
|
/** The turn-status strip's label, or `null` while nothing is happening
|
||||||
|
* (docs/STRUCTURED-VIEW.md's turn indicators, rendered as a client
|
||||||
|
* decides). */
|
||||||
|
fun activityLabel(): String? {
|
||||||
|
val turn = reducedState.turn
|
||||||
|
if (!turnActive && !posting) return null
|
||||||
|
val tools = turn.active_tool_calls.size
|
||||||
|
val label = when {
|
||||||
|
posting && !turnActive -> "Sending"
|
||||||
|
turn.thinking -> "Thinking"
|
||||||
|
tools > 0 -> "Running $tools ${if (tools == 1) "tool" else "tools"}"
|
||||||
|
else -> "Responding"
|
||||||
|
}
|
||||||
|
val startedAtMs = turn.started_at?.let { runCatching { java.time.Instant.parse(it).toEpochMilli() }.getOrNull() }
|
||||||
|
val elapsed = startedAtMs?.let { maxOf(0L, nowMs - it) } ?: 0L
|
||||||
|
return "$label · ${StructuredModel.formatElapsed(elapsed)}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the `WS /api/structured/:id` connection and the REST calls the
|
||||||
|
* pane's composer/approval controls make. A plain class over the socket's
|
||||||
|
* `Flow` rather than an `androidx.lifecycle.ViewModel` — same shape as
|
||||||
|
* [pt.naps.maestro.ui.spawn.SpawnViewModel]; the caller's own `remember`
|
||||||
|
* scope owns its lifetime.
|
||||||
|
*/
|
||||||
|
class StructuredViewModel(
|
||||||
|
private val client: MaestroClient,
|
||||||
|
private val webSocket: MaestroWebSocket,
|
||||||
|
private val sessionId: Uuid,
|
||||||
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||||
|
) {
|
||||||
|
private val _state = MutableStateFlow(StructuredUiState())
|
||||||
|
val state: StateFlow<StructuredUiState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private var connection: StructuredConnection? = null
|
||||||
|
private var socketJob: Job? = null
|
||||||
|
private var clockJob: Job? = null
|
||||||
|
|
||||||
|
/** The nonce this view model's own in-flight `resolveApproval` call
|
||||||
|
* targets, so a later projection update can tell "resolved" apart from
|
||||||
|
* "superseded by a new request" without waiting on the POST itself. */
|
||||||
|
private var approvalInFlightNonce: String? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
connect()
|
||||||
|
clockJob = scope.launch {
|
||||||
|
while (true) {
|
||||||
|
delay(1_000)
|
||||||
|
_state.update { it.copy(nowMs = System.currentTimeMillis()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** (Re)opens the socket. Every connect resends the whole transcript
|
||||||
|
* (docs/STRUCTURED-VIEW.md), so state is replaced wholesale rather than
|
||||||
|
* merged into what came before. */
|
||||||
|
fun connect() {
|
||||||
|
socketJob?.cancel()
|
||||||
|
connection?.close()
|
||||||
|
_state.update { it.copy(connection = StructuredConnectionStatus.Connecting, connectionDetail = null) }
|
||||||
|
val conn = webSocket.connectStructured(sessionId)
|
||||||
|
connection = conn
|
||||||
|
socketJob = scope.launch {
|
||||||
|
try {
|
||||||
|
conn.events.collect { event ->
|
||||||
|
_state.update { it.copy(connection = StructuredConnectionStatus.Open) }
|
||||||
|
applyEvent(event)
|
||||||
|
}
|
||||||
|
_state.update { it.copy(connection = StructuredConnectionStatus.Closed) }
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
_state.update { it.copy(connection = StructuredConnectionStatus.Error, connectionDetail = e.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyEvent(event: StructuredEvent) {
|
||||||
|
_state.update { current ->
|
||||||
|
when (event) {
|
||||||
|
is StructuredEvent.TranscriptSnapshotEvent -> current.copy(rows = event.snapshot.rows)
|
||||||
|
is StructuredEvent.ReducedStateEvent -> withReducedState(current, event.state)
|
||||||
|
is StructuredEvent.ProjectionUpdate -> {
|
||||||
|
val rows = event.transcript?.let { applyDelta(current.rows, it) } ?: current.rows
|
||||||
|
val next = withReducedState(current, event.state)
|
||||||
|
val posting = if (StructuredModel.turnIsActive(event.state)) false else next.posting
|
||||||
|
next.copy(rows = rows, posting = posting)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recomputes [StructuredUiState.approvalBusy] against whatever nonce
|
||||||
|
* this view model's own approval call is waiting on — cleared the
|
||||||
|
* moment the daemon's own state no longer names that nonce as pending,
|
||||||
|
* whether because it was answered or superseded. */
|
||||||
|
private fun withReducedState(current: StructuredUiState, state: ReducedState): StructuredUiState {
|
||||||
|
val inFlight = approvalInFlightNonce != null && pendingApprovalNonce(state) == approvalInFlightNonce
|
||||||
|
if (!inFlight) approvalInFlightNonce = null
|
||||||
|
return current.copy(reducedState = state, approvalBusy = inFlight)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyDelta(rows: List<TranscriptRow>, delta: TranscriptDelta): List<TranscriptRow> = when (delta) {
|
||||||
|
is TranscriptDelta.Append -> upsert(rows, delta.row)
|
||||||
|
is TranscriptDelta.Patch -> upsert(rows, delta.row)
|
||||||
|
is TranscriptDelta.Remove -> rows.filterNot { it.id == delta.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun upsert(rows: List<TranscriptRow>, row: TranscriptRow): List<TranscriptRow> {
|
||||||
|
val index = rows.indexOfFirst { it.id == row.id }
|
||||||
|
return if (index == -1) rows + row else rows.toMutableList().also { it[index] = row }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDraft(text: String) {
|
||||||
|
_state.update { it.copy(draft = text) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `POST .../prompt`: accepted at any time — idle or mid-turn, the
|
||||||
|
* daemon queues it server-side (docs/STRUCTURED-VIEW.md), so this never
|
||||||
|
* queues locally. */
|
||||||
|
fun sendPrompt() {
|
||||||
|
val text = _state.value.draft.trim()
|
||||||
|
if (text.isEmpty() || _state.value.posting) return
|
||||||
|
_state.update { it.copy(draft = "", posting = true, error = null) }
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
client.sendStructuredPrompt(sessionId, text)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.update { it.copy(posting = false, draft = text, error = e.message ?: "Failed to send prompt") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelTurn() {
|
||||||
|
_state.update { it.copy(error = null) }
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
client.cancelStructuredTurn(sessionId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.update { it.copy(error = e.message ?: "Failed to cancel turn") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Answers a pending permission request. A no-op — not an error — once
|
||||||
|
* this is no longer the pending approval (the nonce moved on, or
|
||||||
|
* resolved elsewhere), matching the daemon's own always-`204`
|
||||||
|
* semantics. */
|
||||||
|
fun resolveApproval(rowId: String, decision: StructuredApprovalDecision) {
|
||||||
|
val current = _state.value
|
||||||
|
val nonce = pendingApprovalNonce(current.reducedState)
|
||||||
|
if (nonce == null || rowId != current.pendingApprovalRowId) return
|
||||||
|
approvalInFlightNonce = nonce
|
||||||
|
_state.update { it.copy(approvalBusy = true) }
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
client.resolveStructuredApproval(sessionId, nonce, decision)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (approvalInFlightNonce == nonce) {
|
||||||
|
approvalInFlightNonce = null
|
||||||
|
_state.update { it.copy(approvalBusy = false, error = e.message ?: "Failed to resolve approval") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissError() {
|
||||||
|
_state.update { it.copy(error = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
clockJob?.cancel()
|
||||||
|
socketJob?.cancel()
|
||||||
|
connection?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package pt.naps.maestro.ui.structured
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A turn's collapsed activity block: two or more tool rows folded by
|
||||||
|
* [StructuredModel.activityGroupsByTurn]. The header counts tool rows only
|
||||||
|
* and shows the last/current tool label so the operator sees activity at a
|
||||||
|
* glance without expanding (docs/STRUCTURED-VIEW.md). Opening it reveals
|
||||||
|
* tools in chronological order.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ToolGroup(
|
||||||
|
toolCount: Int,
|
||||||
|
lastToolLabel: String,
|
||||||
|
lastToolArg: String,
|
||||||
|
expanded: Boolean,
|
||||||
|
onExpandedChange: (Boolean) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
val summary = if (lastToolArg.isNotEmpty()) {
|
||||||
|
"$toolCount tool calls · $lastToolLabel '$lastToolArg'"
|
||||||
|
} else {
|
||||||
|
"$toolCount tool calls"
|
||||||
|
}
|
||||||
|
Column(modifier = modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onExpandedChange(!expanded) }
|
||||||
|
.padding(vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(if (expanded) "▾" else "›", modifier = Modifier.width(20.dp))
|
||||||
|
Text(
|
||||||
|
text = summary,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (expanded) {
|
||||||
|
Column(modifier = Modifier.padding(start = 20.dp), content = content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
package pt.naps.maestro.ui.structured
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.wrapContentWidth
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import pt.naps.maestro.data.StructuredApprovalDecision
|
||||||
|
import pt.naps.maestro.data.TranscriptRow
|
||||||
|
import pt.naps.maestro.data.TranscriptRowKind
|
||||||
|
|
||||||
|
private val prettyJson = Json { prettyPrint = true }
|
||||||
|
|
||||||
|
/** One transcript row, dispatched by kind (docs/STRUCTURED-VIEW.md fixes
|
||||||
|
* the eight kinds; rendering is entirely a client decision). `Plan` rows
|
||||||
|
* and state-only notices are filtered out upstream by
|
||||||
|
* [StructuredModel.transcriptItems] and never reach here. */
|
||||||
|
@Composable
|
||||||
|
fun TranscriptRowView(
|
||||||
|
row: TranscriptRow,
|
||||||
|
pendingApproval: Boolean,
|
||||||
|
approvalBusy: Boolean,
|
||||||
|
expanded: Boolean,
|
||||||
|
onExpandedChange: (Boolean) -> Unit,
|
||||||
|
onDecide: (StructuredApprovalDecision) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
when (row.kind) {
|
||||||
|
TranscriptRowKind.User -> UserRow(row, modifier)
|
||||||
|
TranscriptRowKind.AgentText -> AgentTextRow(row, modifier)
|
||||||
|
TranscriptRowKind.AgentThought -> AgentThoughtRow(row, expanded, onExpandedChange, modifier)
|
||||||
|
TranscriptRowKind.ToolCall, TranscriptRowKind.ToolDiff -> ToolCallRow(row, expanded, onExpandedChange, modifier)
|
||||||
|
TranscriptRowKind.Approval -> {
|
||||||
|
val request = remember(row.data) { parseApprovalRequest(row.data) }
|
||||||
|
ApprovalCard(request = request, pending = pendingApproval, busy = approvalBusy, onDecide = onDecide, modifier = modifier)
|
||||||
|
}
|
||||||
|
TranscriptRowKind.Plan -> {}
|
||||||
|
TranscriptRowKind.Notice -> {
|
||||||
|
val meta = remember(row.data) { parseTurnMeta(row) }
|
||||||
|
if (meta != null) TurnMetaRow(meta, modifier) else NoticeRow(row, modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun UserRow(row: TranscriptRow, modifier: Modifier = Modifier) {
|
||||||
|
val text = remember(row.data) { contentChunkText(row.data) }
|
||||||
|
if (text.isBlank()) return
|
||||||
|
Box(modifier = modifier.fillMaxWidth().padding(vertical = 4.dp), contentAlignment = Alignment.CenterEnd) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.wrapContentWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer, RoundedCornerShape(12.dp))
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
Text(text, color = MaterialTheme.colorScheme.onPrimaryContainer, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AgentTextRow(row: TranscriptRow, modifier: Modifier = Modifier) {
|
||||||
|
val text = remember(row.data) { contentChunkText(row.data) }
|
||||||
|
if (text.isBlank()) return
|
||||||
|
Text(
|
||||||
|
text = renderSimpleMarkdown(text),
|
||||||
|
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AgentThoughtRow(
|
||||||
|
row: TranscriptRow,
|
||||||
|
expanded: Boolean,
|
||||||
|
onExpandedChange: (Boolean) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val text = remember(row.data) { contentChunkText(row.data).trim() }
|
||||||
|
if (text.isBlank()) return
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onExpandedChange(!expanded) }
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
Text("Thinking", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
maxLines = if (expanded) Int.MAX_VALUE else 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ToolCallRow(
|
||||||
|
row: TranscriptRow,
|
||||||
|
expanded: Boolean,
|
||||||
|
onExpandedChange: (Boolean) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val tool = remember(row.data) { parseToolCall(row.data) }
|
||||||
|
val hasDiff = tool.content.any { it is AcpToolCallContent.Diff }
|
||||||
|
val title = remember(tool) { StructuredModel.splitToolTitle(tool.title, tool.name, tool.kind) }
|
||||||
|
val statusMeta = remember(tool.status, hasDiff) { StructuredModel.statusMeta(tool.status, hasDiff) }
|
||||||
|
val diffSummary = if (hasDiff) remember(tool.content) { diffStatsSummary(tool.content) } else null
|
||||||
|
val disclosable = tool.content.isNotEmpty() || tool.rawInput != null || tool.rawOutput != null
|
||||||
|
val metaText = listOfNotNull(diffSummary, statusMeta?.text).joinToString(" · ")
|
||||||
|
|
||||||
|
Column(modifier = modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.let { if (disclosable) it.clickable { onExpandedChange(!expanded) } else it }
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(title.label, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Medium)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
title.arg,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
|
)
|
||||||
|
if (metaText.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
metaText,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = when (statusMeta?.kind) {
|
||||||
|
"failed" -> MaterialTheme.colorScheme.error
|
||||||
|
"running" -> MaterialTheme.colorScheme.primary
|
||||||
|
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (disclosable && expanded) {
|
||||||
|
Column(modifier = Modifier.padding(start = 8.dp, top = 4.dp)) {
|
||||||
|
for (block in tool.content) {
|
||||||
|
when (block) {
|
||||||
|
is AcpToolCallContent.Content -> {
|
||||||
|
val text = contentBlockText(block.block)
|
||||||
|
if (text.isNotBlank()) {
|
||||||
|
Text(text, style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is AcpToolCallContent.Diff -> DiffView(path = block.path, oldText = block.oldText, newText = block.newText)
|
||||||
|
is AcpToolCallContent.Terminal -> Text(
|
||||||
|
block.terminalId?.let { "Terminal · $it" } ?: "Embedded terminal",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tool.rawInput?.let { ToolDetailBlock("Input", prettyPrintJson(it)) }
|
||||||
|
tool.rawOutput?.let { ToolDetailBlock("Output", prettyPrintJson(it)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ToolDetailBlock(label: String, text: String) {
|
||||||
|
Column(modifier = Modifier.padding(top = 4.dp)) {
|
||||||
|
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Text(text, style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun diffStatsSummary(content: List<AcpToolCallContent>): String {
|
||||||
|
var added = 0
|
||||||
|
var removed = 0
|
||||||
|
for (block in content) {
|
||||||
|
if (block !is AcpToolCallContent.Diff) continue
|
||||||
|
val stats = DiffModel.diffStats(DiffModel.computeDiffLines(block.oldText, block.newText))
|
||||||
|
added += stats.added
|
||||||
|
removed += stats.removed
|
||||||
|
}
|
||||||
|
return "+$added −$removed"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prettyPrintJson(element: JsonElement): String =
|
||||||
|
runCatching { prettyJson.encodeToString(JsonElement.serializer(), element) }.getOrElse { element.toString() }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NoticeRow(row: TranscriptRow, modifier: Modifier = Modifier) {
|
||||||
|
Text(
|
||||||
|
text = "[${row.kind.name}]",
|
||||||
|
modifier = modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TurnMetaRow(meta: TurnMetaPayload, modifier: Modifier = Modifier) {
|
||||||
|
val parts = mutableListOf(StructuredModel.formatElapsed(meta.elapsedMs))
|
||||||
|
when {
|
||||||
|
meta.contextDelta != null -> {
|
||||||
|
val sign = if (meta.contextDelta > 0) "+" else ""
|
||||||
|
parts += "$sign${meta.contextDelta} context tokens"
|
||||||
|
}
|
||||||
|
meta.contextTokens != null -> parts += StructuredModel.formatContextTokens(meta.contextTokens)
|
||||||
|
}
|
||||||
|
meta.model?.let { parts += it }
|
||||||
|
if (meta.outcome != "completed") parts += meta.outcome
|
||||||
|
Text(
|
||||||
|
text = parts.joinToString(" · "),
|
||||||
|
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package pt.naps.maestro.ui.structured
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/** Strip above the composer: responding/thinking/active tool calls plus
|
||||||
|
* elapsed time (docs/STRUCTURED-VIEW.md's turn indicators, drawn as a
|
||||||
|
* client decides). Hidden while nothing is happening. */
|
||||||
|
@Composable
|
||||||
|
fun TurnStatusBar(label: String?, modifier: Modifier = Modifier) {
|
||||||
|
if (label == null) return
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(14.dp), strokeWidth = 2.dp)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package pt.naps.maestro.ui.terminal
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/** One button on the [TerminalKeyBar]. A [Modifier] toggles Ctrl/Alt state
|
||||||
|
* in the terminal page's own JS instead of sending a byte sequence. */
|
||||||
|
sealed class TerminalKeyAction(val label: String) {
|
||||||
|
data class Modifier(val name: String, val display: String) : TerminalKeyAction(display)
|
||||||
|
data class Send(val display: String, val key: String) : TerminalKeyAction(display)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Termux-style extra key row for the on-screen keyboard (issue #266): Tab,
|
||||||
|
* Esc, Ctrl/Alt, and the four cursor arrows. Ctrl/Alt are one-shot modifiers
|
||||||
|
* — armed here, then folded into the very next key (tapped on this bar or
|
||||||
|
* typed on the soft keyboard) by `assets/terminal/index.html`'s own JS.
|
||||||
|
*/
|
||||||
|
val TERMINAL_KEY_ACTIONS: List<TerminalKeyAction> = listOf(
|
||||||
|
TerminalKeyAction.Send("Esc", "Esc"),
|
||||||
|
TerminalKeyAction.Send("Tab", "Tab"),
|
||||||
|
TerminalKeyAction.Modifier("ctrl", "Ctrl"),
|
||||||
|
TerminalKeyAction.Modifier("alt", "Alt"),
|
||||||
|
TerminalKeyAction.Send("↑", "Up"),
|
||||||
|
TerminalKeyAction.Send("↓", "Down"),
|
||||||
|
TerminalKeyAction.Send("←", "Left"),
|
||||||
|
TerminalKeyAction.Send("→", "Right"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TerminalKeyBar(
|
||||||
|
armed: Set<String>,
|
||||||
|
onSend: (String) -> Unit,
|
||||||
|
onToggleModifier: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.horizontalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||||
|
) {
|
||||||
|
for (action in TERMINAL_KEY_ACTIONS) {
|
||||||
|
when (action) {
|
||||||
|
is TerminalKeyAction.Modifier -> {
|
||||||
|
val isArmed = action.name in armed
|
||||||
|
TextButton(onClick = { onToggleModifier(action.name) }) {
|
||||||
|
Text(
|
||||||
|
text = action.display,
|
||||||
|
color = if (isArmed) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is TerminalKeyAction.Send -> {
|
||||||
|
TextButton(onClick = { onSend(action.key) }) {
|
||||||
|
Text(text = action.display)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package pt.naps.maestro.ui.terminal
|
||||||
|
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import pt.naps.maestro.api.Routes
|
||||||
|
import pt.naps.maestro.data.Uuid
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `file:///android_asset/terminal/index.html` URL a [TerminalWebView]
|
||||||
|
* loads for one session. The embedded xterm.js page reads its `ws` query
|
||||||
|
* param and opens that as its own `WS /api/attach/:id` connection directly
|
||||||
|
* — this object only computes the URL, it never touches the socket.
|
||||||
|
*/
|
||||||
|
object TerminalPageUrl {
|
||||||
|
private const val ASSET_URL = "file:///android_asset/terminal/index.html"
|
||||||
|
|
||||||
|
fun build(daemonBaseUrl: String, sessionId: Uuid): String {
|
||||||
|
val wsUrl = Routes.attachWsUrl(daemonBaseUrl, sessionId)
|
||||||
|
val encoded = URLEncoder.encode(wsUrl, "UTF-8")
|
||||||
|
return "$ASSET_URL?ws=$encoded"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package pt.naps.maestro.ui.terminal
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.isImeVisible
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import pt.naps.maestro.data.Uuid
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen terminal pane for one session (issue #266): a [TerminalWebView]
|
||||||
|
* showing xterm.js bound to `WS /api/attach/:id`, plus the native
|
||||||
|
* [TerminalKeyBar] overlaid above the soft keyboard while it's open.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun TerminalScreen(
|
||||||
|
daemonBaseUrl: String,
|
||||||
|
sessionId: Uuid,
|
||||||
|
sessionTitle: String,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val pageUrl = remember(daemonBaseUrl, sessionId) { TerminalPageUrl.build(daemonBaseUrl, sessionId) }
|
||||||
|
var controller by remember { mutableStateOf<TerminalWebViewController?>(null) }
|
||||||
|
var armed by remember { mutableStateOf(emptySet<String>()) }
|
||||||
|
val keyboardOpen = WindowInsets.isImeVisible
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(sessionTitle) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Text(text = "←", style = MaterialTheme.typography.titleLarge)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
) {
|
||||||
|
TerminalWebView(
|
||||||
|
pageUrl = pageUrl,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxSize(),
|
||||||
|
onReady = { controller = it },
|
||||||
|
onModifierConsumed = { name -> armed = armed - name },
|
||||||
|
)
|
||||||
|
if (keyboardOpen) {
|
||||||
|
TerminalKeyBar(
|
||||||
|
armed = armed,
|
||||||
|
onSend = { key ->
|
||||||
|
controller?.sendKey(key)
|
||||||
|
armed = emptySet()
|
||||||
|
},
|
||||||
|
onToggleModifier = { name ->
|
||||||
|
armed = if (name in armed) armed - name else armed + name
|
||||||
|
controller?.toggleModifier(name)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package pt.naps.maestro.ui.terminal
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.webkit.JavascriptInterface
|
||||||
|
import android.webkit.WebSettings
|
||||||
|
import android.webkit.WebView
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/** Matches `#121419` in `assets/terminal/index.html` and the web SPA's own
|
||||||
|
* xterm theme, so the WebView doesn't flash white while the page loads. */
|
||||||
|
private const val TERMINAL_BACKGROUND_ARGB = 0xFF121419.toInt()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the terminal page loaded into a [TerminalWebView] from native code
|
||||||
|
* — the extra key bar's Tab/Esc/arrow buttons and Ctrl/Alt toggles. The page
|
||||||
|
* itself owns the `WS /api/attach/:id` socket and the xterm.js instance;
|
||||||
|
* this controller only calls the `maestro*` bridge functions it exposes.
|
||||||
|
*/
|
||||||
|
class TerminalWebViewController internal constructor(private val webView: WebView) {
|
||||||
|
fun sendKey(key: String) {
|
||||||
|
val arg = JSONObject.quote(key)
|
||||||
|
webView.evaluateJavascript("window.maestroKey && window.maestroKey($arg)", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleModifier(name: String) {
|
||||||
|
val arg = JSONObject.quote(name)
|
||||||
|
webView.evaluateJavascript("window.maestroToggleMod && window.maestroToggleMod($arg)", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun focus() {
|
||||||
|
webView.requestFocus()
|
||||||
|
webView.evaluateJavascript("window.maestroFocus && window.maestroFocus()", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Terminal pane composable for issue #266: a [WebView] loading the bundled
|
||||||
|
* xterm.js page (`assets/terminal/index.html`), which connects straight to
|
||||||
|
* the daemon's `WS /api/attach/:id` for this session. [onModifierConsumed]
|
||||||
|
* fires when the page's own JS folds an armed Ctrl/Alt into a key typed on
|
||||||
|
* the soft keyboard, so the native key bar can clear its toggle to match.
|
||||||
|
*/
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
@Composable
|
||||||
|
fun TerminalWebView(
|
||||||
|
pageUrl: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onReady: (TerminalWebViewController) -> Unit = {},
|
||||||
|
onModifierConsumed: (String) -> Unit = {},
|
||||||
|
) {
|
||||||
|
val mainHandler = remember { Handler(Looper.getMainLooper()) }
|
||||||
|
AndroidView(
|
||||||
|
modifier = modifier,
|
||||||
|
factory = { context ->
|
||||||
|
WebView(context).apply {
|
||||||
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
)
|
||||||
|
settings.javaScriptEnabled = true
|
||||||
|
settings.domStorageEnabled = true
|
||||||
|
settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
||||||
|
setBackgroundColor(TERMINAL_BACKGROUND_ARGB)
|
||||||
|
addJavascriptInterface(
|
||||||
|
object {
|
||||||
|
@JavascriptInterface
|
||||||
|
fun onModConsumed(name: String) {
|
||||||
|
mainHandler.post { onModifierConsumed(name) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AndroidBridge",
|
||||||
|
)
|
||||||
|
onReady(TerminalWebViewController(this))
|
||||||
|
loadUrl(pageUrl)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update = { webView ->
|
||||||
|
if (webView.url != pageUrl) webView.loadUrl(pageUrl)
|
||||||
|
},
|
||||||
|
onRelease = { it.destroy() },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,10 +2,45 @@ package pt.naps.maestro.ui.theme
|
|||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
val Purple80 = Color(0xFFD0BCFF)
|
/** Palette translated from the web SPA's OKLCH design tokens
|
||||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
* (web/src/styles.css) into sRGB. Dark is the default theme; Light is opt-in.
|
||||||
val Pink80 = Color(0xFFEFB8C8)
|
* Semantic mapping (see docs referenced from issue #271):
|
||||||
|
* bg -> background, surface* -> surface layers, ink -> onBackground/onSurface,
|
||||||
|
* muted -> onSurfaceVariant, faint -> outline, accent -> primary,
|
||||||
|
* live -> tertiary, danger -> error, warn -> secondary. */
|
||||||
|
|
||||||
val Purple40 = Color(0xFF6650a4)
|
// ---- Dark theme (default) ----
|
||||||
val PurpleGrey40 = Color(0xFF625b71)
|
val DarkBg = Color(0xFF0E1012)
|
||||||
val Pink40 = Color(0xFF7D5260)
|
val DarkSurface = Color(0xFF16181B)
|
||||||
|
val DarkSurface2 = Color(0xFF1E2125)
|
||||||
|
val DarkSurface3 = Color(0xFF272B2F)
|
||||||
|
val DarkBorder = Color(0xFF2E3136)
|
||||||
|
val DarkBorderStrong = Color(0xFF42474D)
|
||||||
|
val DarkInk = Color(0xFFEBEDEF)
|
||||||
|
val DarkMuted = Color(0xFFA1A5AA)
|
||||||
|
val DarkFaint = Color(0xFF777B7F)
|
||||||
|
val DarkAccent = Color(0xFFE2E5E8)
|
||||||
|
val DarkAccentStrong = Color(0xFFF4F5F7)
|
||||||
|
val DarkAccentInk = Color(0xFF13161C)
|
||||||
|
val DarkLive = Color(0xFF65EA92)
|
||||||
|
val DarkDanger = Color(0xFFFF6F69)
|
||||||
|
val DarkWarn = Color(0xFFEEBC4A)
|
||||||
|
val DarkTermBg = Color(0xFF0B0C0F)
|
||||||
|
|
||||||
|
// ---- Light theme ----
|
||||||
|
val LightBg = Color(0xFFF9FAFB)
|
||||||
|
val LightSurface = Color(0xFFFFFFFF)
|
||||||
|
val LightSurface2 = Color(0xFFF2F3F5)
|
||||||
|
val LightSurface3 = Color(0xFFE6E8EA)
|
||||||
|
val LightBorder = Color(0xFFD9DBDF)
|
||||||
|
val LightBorderStrong = Color(0xFFC1C4C9)
|
||||||
|
val LightInk = Color(0xFF20242A)
|
||||||
|
val LightMuted = Color(0xFF565B62)
|
||||||
|
val LightFaint = Color(0xFF81878D)
|
||||||
|
val LightAccent = Color(0xFF23272B)
|
||||||
|
val LightAccentStrong = Color(0xFF13161B)
|
||||||
|
val LightAccentInk = Color(0xFFF9FAFB)
|
||||||
|
val LightLive = Color(0xFF069A4A)
|
||||||
|
val LightDanger = Color(0xFFD01D21)
|
||||||
|
val LightWarn = Color(0xFFC78200)
|
||||||
|
val LightTermBg = Color(0xFF0B0F14)
|
||||||
|
|||||||
@@ -10,18 +10,71 @@ import androidx.compose.material3.lightColorScheme
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
|
/** Dark scheme (default), translated from the web SPA's dark token set
|
||||||
|
* (web/src/styles.css) — see Color.kt for the token -> role mapping. */
|
||||||
private val DarkColorScheme = darkColorScheme(
|
private val DarkColorScheme = darkColorScheme(
|
||||||
primary = Purple80,
|
background = DarkBg,
|
||||||
secondary = PurpleGrey80,
|
onBackground = DarkInk,
|
||||||
tertiary = Pink80,
|
surface = DarkSurface,
|
||||||
|
onSurface = DarkInk,
|
||||||
|
surfaceVariant = DarkSurface2,
|
||||||
|
onSurfaceVariant = DarkMuted,
|
||||||
|
outline = DarkFaint,
|
||||||
|
outlineVariant = DarkBorder,
|
||||||
|
surfaceTint = DarkAccent,
|
||||||
|
primary = DarkAccent,
|
||||||
|
onPrimary = DarkAccentInk,
|
||||||
|
primaryContainer = DarkSurface3,
|
||||||
|
onPrimaryContainer = DarkInk,
|
||||||
|
secondary = DarkWarn,
|
||||||
|
onSecondary = DarkAccentInk,
|
||||||
|
secondaryContainer = DarkSurface3,
|
||||||
|
onSecondaryContainer = DarkInk,
|
||||||
|
tertiary = DarkLive,
|
||||||
|
onTertiary = DarkAccentInk,
|
||||||
|
tertiaryContainer = DarkSurface3,
|
||||||
|
onTertiaryContainer = DarkInk,
|
||||||
|
error = DarkDanger,
|
||||||
|
onError = DarkAccentInk,
|
||||||
|
errorContainer = DarkSurface3,
|
||||||
|
onErrorContainer = DarkDanger,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Light scheme, opt-in. */
|
||||||
private val LightColorScheme = lightColorScheme(
|
private val LightColorScheme = lightColorScheme(
|
||||||
primary = Purple40,
|
background = LightBg,
|
||||||
secondary = PurpleGrey40,
|
onBackground = LightInk,
|
||||||
tertiary = Pink40,
|
surface = LightSurface,
|
||||||
|
onSurface = LightInk,
|
||||||
|
surfaceVariant = LightSurface2,
|
||||||
|
onSurfaceVariant = LightMuted,
|
||||||
|
outline = LightFaint,
|
||||||
|
outlineVariant = LightBorder,
|
||||||
|
surfaceTint = LightAccent,
|
||||||
|
primary = LightAccent,
|
||||||
|
onPrimary = LightAccentInk,
|
||||||
|
primaryContainer = LightSurface3,
|
||||||
|
onPrimaryContainer = LightInk,
|
||||||
|
secondary = LightWarn,
|
||||||
|
onSecondary = LightAccentInk,
|
||||||
|
secondaryContainer = LightSurface3,
|
||||||
|
onSecondaryContainer = LightInk,
|
||||||
|
tertiary = LightLive,
|
||||||
|
onTertiary = LightAccentInk,
|
||||||
|
tertiaryContainer = LightSurface3,
|
||||||
|
onTertiaryContainer = LightInk,
|
||||||
|
error = LightDanger,
|
||||||
|
onError = LightAccentInk,
|
||||||
|
errorContainer = LightSurface3,
|
||||||
|
onErrorContainer = LightDanger,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Dark is the product default (docs/DESIGN.md); light is opt-in, and dynamic
|
||||||
|
* color (Material You, API 31+) is opt-in on top of that. Edge-to-edge itself
|
||||||
|
* is enabled once in MainActivity#onCreate — [enableEdgeToEdge] draws content
|
||||||
|
* behind transparent system bars for whichever scheme resolves here, and the
|
||||||
|
* default `SystemBarStyle.auto` picks light/dark bar icons from this theme's
|
||||||
|
* [darkTheme] flag, not the system setting. */
|
||||||
@Composable
|
@Composable
|
||||||
fun MaestroTheme(
|
fun MaestroTheme(
|
||||||
darkTheme: Boolean = true,
|
darkTheme: Boolean = true,
|
||||||
|
|||||||
@@ -1,17 +1,99 @@
|
|||||||
package pt.naps.maestro.ui.theme
|
package pt.naps.maestro.ui.theme
|
||||||
|
|
||||||
import androidx.compose.material3.Typography
|
import androidx.compose.material3.Typography
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.font.Font
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import pt.naps.maestro.R
|
||||||
|
|
||||||
|
/** Bundled (SIL OFL, android/licenses/JetBrainsMono-OFL.txt) rather than the
|
||||||
|
* system mono fallback, so the terminal pane and code blocks render the same
|
||||||
|
* face as the web SPA (--font-mono) on every device. */
|
||||||
|
val JetBrainsMono = FontFamily(
|
||||||
|
Font(R.font.jetbrains_mono_regular, FontWeight.Normal),
|
||||||
|
Font(R.font.jetbrains_mono_medium, FontWeight.Medium),
|
||||||
|
Font(R.font.jetbrains_mono_bold, FontWeight.Bold),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** UI chrome uses the platform sans (--font-sans is a system-ui stack on
|
||||||
|
* web); only terminal/code content switches to [JetBrainsMono]. */
|
||||||
|
private val Sans = FontFamily.Default
|
||||||
|
|
||||||
|
/** Scale roughly mirrors the web token steps (web/src/styles.css --fs-11
|
||||||
|
* through --fs-20); role names are Material3's, not the CSS ones. */
|
||||||
val Typography = Typography(
|
val Typography = Typography(
|
||||||
bodyLarge = TextStyle(
|
titleLarge = TextStyle(
|
||||||
fontFamily = FontFamily.Default,
|
fontFamily = Sans,
|
||||||
fontWeight = FontWeight.Normal,
|
fontWeight = FontWeight.SemiBold,
|
||||||
fontSize = 16.sp,
|
fontSize = 20.sp,
|
||||||
|
lineHeight = 26.sp,
|
||||||
|
letterSpacing = 0.sp,
|
||||||
|
),
|
||||||
|
titleMedium = TextStyle(
|
||||||
|
fontFamily = Sans,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 17.sp,
|
||||||
lineHeight = 24.sp,
|
lineHeight = 24.sp,
|
||||||
letterSpacing = 0.5.sp,
|
letterSpacing = 0.sp,
|
||||||
|
),
|
||||||
|
titleSmall = TextStyle(
|
||||||
|
fontFamily = Sans,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
lineHeight = 20.sp,
|
||||||
|
letterSpacing = 0.1.sp,
|
||||||
|
),
|
||||||
|
bodyLarge = TextStyle(
|
||||||
|
fontFamily = Sans,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
lineHeight = 22.sp,
|
||||||
|
letterSpacing = 0.1.sp,
|
||||||
|
),
|
||||||
|
bodyMedium = TextStyle(
|
||||||
|
fontFamily = Sans,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 20.sp,
|
||||||
|
letterSpacing = 0.1.sp,
|
||||||
|
),
|
||||||
|
bodySmall = TextStyle(
|
||||||
|
fontFamily = Sans,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
lineHeight = 18.sp,
|
||||||
|
letterSpacing = 0.1.sp,
|
||||||
|
),
|
||||||
|
labelLarge = TextStyle(
|
||||||
|
fontFamily = Sans,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
lineHeight = 18.sp,
|
||||||
|
letterSpacing = 0.1.sp,
|
||||||
|
),
|
||||||
|
labelMedium = TextStyle(
|
||||||
|
fontFamily = Sans,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
lineHeight = 16.sp,
|
||||||
|
letterSpacing = 0.2.sp,
|
||||||
|
),
|
||||||
|
labelSmall = TextStyle(
|
||||||
|
fontFamily = Sans,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
lineHeight = 14.sp,
|
||||||
|
letterSpacing = 0.2.sp,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** For terminal output and code blocks — callers set this explicitly instead
|
||||||
|
* of a [Typography] role, since mono content isn't part of the M3 type scale. */
|
||||||
|
val MonoTextStyle = TextStyle(
|
||||||
|
fontFamily = JetBrainsMono,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 20.sp,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Matches web/public/icon.svg's background so the adaptive icon reads as
|
||||||
|
the same mark as the PWA icon (issue #272). -->
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="108dp"
|
android:width="108dp"
|
||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="108">
|
||||||
<path
|
<path
|
||||||
android:fillColor="#1B1B1B"
|
android:fillColor="#1A1D24"
|
||||||
android:pathData="M0,0h108v108h-108z" />
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
@@ -1,10 +1,26 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Foreground layer of the adaptive icon: the same concentric-circle mark
|
||||||
|
as web/public/icon.svg, scaled from its 512 viewBox into this 108 one
|
||||||
|
(issue #272). Stays inside the adaptive-icon safe zone (r=35.86 of 54
|
||||||
|
half-width, ~66%). -->
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="108dp"
|
android:width="108dp"
|
||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="108">
|
||||||
<path
|
<path
|
||||||
android:fillColor="#D0BCFF"
|
android:fillColor="#5FE3A1"
|
||||||
android:pathData="M54,24L74,84L64,84L54,52L44,84L34,84Z" />
|
android:pathData="M39.23,54 A14.77,14.77 0 1,0 68.77,54 A14.77,14.77 0 1,0 39.23,54 Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="#5FE3A1"
|
||||||
|
android:strokeAlpha="0.5"
|
||||||
|
android:strokeWidth="2.53"
|
||||||
|
android:pathData="M28.69,54 A25.31,25.31 0 1,0 79.31,54 A25.31,25.31 0 1,0 28.69,54 Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="#5FE3A1"
|
||||||
|
android:strokeAlpha="0.25"
|
||||||
|
android:strokeWidth="2.11"
|
||||||
|
android:pathData="M18.14,54 A35.86,35.86 0 1,0 89.86,54 A35.86,35.86 0 1,0 18.14,54 Z" />
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,9 @@
|
|||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
<resources>
|
||||||
<style name="Theme.Maestro" parent="android:Theme.Material.NoActionBar">
|
<style name="Theme.Maestro" parent="android:Theme.Material.NoActionBar">
|
||||||
<item name="android:statusBarColor" tools:targetApi="21">@android:color/black</item>
|
<!-- No statusBarColor override: MainActivity#onCreate calls
|
||||||
<item name="android:windowBackground">@android:color/black</item>
|
enableEdgeToEdge(), which makes both system bars transparent at
|
||||||
|
runtime (issue #271). Setting one here would only show briefly
|
||||||
|
before that call lands. -->
|
||||||
|
<item name="android:windowBackground">#FF0E1012</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class BackoffTest {
|
||||||
|
@Test
|
||||||
|
fun `doubles from 500ms and caps at 10s`() {
|
||||||
|
val backoff = Backoff(initialMs = 500L, maxMs = 10_000L)
|
||||||
|
val delays = List(8) { backoff.nextDelayMs() }
|
||||||
|
assertEquals(listOf(500L, 1000L, 2000L, 4000L, 8000L, 10_000L, 10_000L, 10_000L), delays)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reset returns to the initial delay`() {
|
||||||
|
val backoff = Backoff(initialMs = 500L, maxMs = 10_000L)
|
||||||
|
repeat(4) { backoff.nextDelayMs() }
|
||||||
|
backoff.reset()
|
||||||
|
assertEquals(500L, backoff.nextDelayMs())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
import pt.naps.maestro.data.CatalogEvent
|
||||||
|
import pt.naps.maestro.data.Session
|
||||||
|
import pt.naps.maestro.data.SessionStatus
|
||||||
|
|
||||||
|
class CatalogStoreTest {
|
||||||
|
private fun session(id: String) = Session(
|
||||||
|
id = id,
|
||||||
|
cwd = "/tmp",
|
||||||
|
command = "bash",
|
||||||
|
status = SessionStatus.Running,
|
||||||
|
created_at = "2026-05-24T12:00:00Z",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `snapshot replaces the map keyed by id`() {
|
||||||
|
val store = CatalogStore()
|
||||||
|
store.apply(CatalogEvent.Snapshot(listOf(session("a"), session("b"))))
|
||||||
|
assertEquals(setOf("a", "b"), store.sessions.value.keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `added and updated upsert one entry`() {
|
||||||
|
val store = CatalogStore()
|
||||||
|
store.apply(CatalogEvent.Snapshot(emptyList()))
|
||||||
|
store.apply(CatalogEvent.SessionAdded(session("a")))
|
||||||
|
assertEquals(1, store.sessions.value.size)
|
||||||
|
|
||||||
|
val renamed = session("a").copy(command = "zsh")
|
||||||
|
store.apply(CatalogEvent.SessionUpdated(renamed))
|
||||||
|
assertEquals("zsh", store.sessions.value.getValue("a").command)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `removed drops the entry`() {
|
||||||
|
val store = CatalogStore()
|
||||||
|
store.apply(CatalogEvent.Snapshot(listOf(session("a"), session("b"))))
|
||||||
|
store.apply(CatalogEvent.SessionRemoved("a"))
|
||||||
|
assertEquals(setOf("b"), store.sessions.value.keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `lagged reports the skip count without touching the map`() {
|
||||||
|
val store = CatalogStore()
|
||||||
|
store.apply(CatalogEvent.Snapshot(listOf(session("a"))))
|
||||||
|
val skipped = store.apply(CatalogEvent.Lagged(3))
|
||||||
|
assertEquals(3L, skipped)
|
||||||
|
assertEquals(setOf("a"), store.sessions.value.keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `non-lagged events report no skip`() {
|
||||||
|
val store = CatalogStore()
|
||||||
|
assertNull(store.apply(CatalogEvent.Snapshot(emptyList())))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
import pt.naps.maestro.data.BuildInfo
|
||||||
|
import pt.naps.maestro.data.DaemonInfo
|
||||||
|
|
||||||
|
class DaemonIdentityTest {
|
||||||
|
private fun info(commit: String = "", instance: String = "") =
|
||||||
|
DaemonInfo(build = BuildInfo(commit = commit), instance = instance)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `same commit and instance is Same`() {
|
||||||
|
val a = info(commit = "abc123", instance = "i1")
|
||||||
|
assertEquals(RestartKind.SAME, compareIdentity(a, a.copy()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `different commit is Upgraded`() {
|
||||||
|
val before = info(commit = "abc123", instance = "i1")
|
||||||
|
val after = info(commit = "def456", instance = "i2")
|
||||||
|
assertEquals(RestartKind.UPGRADED, compareIdentity(before, after))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `same commit but different instance is Restarted`() {
|
||||||
|
val before = info(commit = "abc123", instance = "i1")
|
||||||
|
val after = info(commit = "abc123", instance = "i2")
|
||||||
|
assertEquals(RestartKind.RESTARTED, compareIdentity(before, after))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty commits on both sides never read as an upgrade`() {
|
||||||
|
val before = info(commit = "", instance = "i1")
|
||||||
|
val after = info(commit = "", instance = "i2")
|
||||||
|
assertEquals(RestartKind.RESTARTED, compareIdentity(before, after))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `format release names the commit and short date`() {
|
||||||
|
val build = BuildInfo(commit = "f89be4a716c7", commit_date = "2026-09-02T10:00:00Z", dirty = false)
|
||||||
|
assertEquals("f89be4a716c7 · 2026-09-02", formatRelease(build))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dirty build is flagged`() {
|
||||||
|
val build = BuildInfo(commit = "f89be4a716c7", commit_date = "2026-09-02T10:00:00Z", dirty = true)
|
||||||
|
assertEquals("f89be4a716c7 · 2026-09-02 (dirty)", formatRelease(build))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty commit is unknown release`() {
|
||||||
|
assertEquals("unknown release", formatRelease(BuildInfo()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,190 @@
|
|||||||
|
package pt.naps.maestro.ui.sidebar
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import pt.naps.maestro.data.InTheWay
|
||||||
|
import pt.naps.maestro.data.Session
|
||||||
|
import pt.naps.maestro.data.SessionStatus
|
||||||
|
import pt.naps.maestro.data.SessionWorktree
|
||||||
|
|
||||||
|
private fun session(
|
||||||
|
command: String = "claude",
|
||||||
|
cwd: String = "/home/user/project",
|
||||||
|
metadata: Map<String, JsonPrimitive> = emptyMap(),
|
||||||
|
worktree: SessionWorktree? = null,
|
||||||
|
blockedWork: InTheWay? = null,
|
||||||
|
activitySince: String = "1970-01-01T00:00:00Z",
|
||||||
|
) = Session(
|
||||||
|
id = "s1",
|
||||||
|
cwd = cwd,
|
||||||
|
command = command,
|
||||||
|
metadata = metadata,
|
||||||
|
worktree = worktree,
|
||||||
|
status = SessionStatus.Running,
|
||||||
|
created_at = "2026-01-01T00:00:00Z",
|
||||||
|
blocked_work = blockedWork,
|
||||||
|
activity_since = activitySince,
|
||||||
|
)
|
||||||
|
|
||||||
|
class DurationFormattingTest {
|
||||||
|
@Test
|
||||||
|
fun `floors seconds under a minute`() {
|
||||||
|
assertEquals("30s", activityDurationLabel(30_000))
|
||||||
|
assertEquals("59s", activityDurationLabel(59_999))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `floors minutes under an hour`() {
|
||||||
|
assertEquals("4m", activityDurationLabel(4 * 60_000 + 59_000))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `floors hours with no days unit`() {
|
||||||
|
assertEquals("2h", activityDurationLabel(2 * 3_600_000 + 59 * 60_000))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `held ms is null for missing or implausible since`() {
|
||||||
|
assertNull(activityHeldMs(null, 1_000))
|
||||||
|
assertNull(activityHeldMs("", 1_000))
|
||||||
|
assertNull(activityHeldMs("1970-01-01T00:00:00Z", 1_000))
|
||||||
|
assertNull(activityHeldMs("not-a-timestamp", 1_000))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `held ms is the gap to now for a plausible timestamp`() {
|
||||||
|
val since = "2026-01-01T00:00:00Z"
|
||||||
|
val nowMs = java.time.Instant.parse("2026-01-01T00:00:31Z").toEpochMilli()
|
||||||
|
assertEquals(31_000L, activityHeldMs(since, nowMs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SessionNameTest {
|
||||||
|
@Test
|
||||||
|
fun `falls back to the command when no name is set`() {
|
||||||
|
assertEquals("claude --resume", sessionName(session(command = "claude --resume")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `uses the custom name when set`() {
|
||||||
|
val s = session(metadata = mapOf("name" to JsonPrimitive("api-work")))
|
||||||
|
assertEquals("api-work", sessionName(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty stored name is treated as unset`() {
|
||||||
|
val s = session(metadata = mapOf("name" to JsonPrimitive("")))
|
||||||
|
assertNull(customSessionName(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `generated name is provisional, user and haiku are not`() {
|
||||||
|
val generated = session(
|
||||||
|
metadata = mapOf(
|
||||||
|
"name" to JsonPrimitive("brave-otter"),
|
||||||
|
"name_source" to JsonPrimitive("Generated"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val user = session(
|
||||||
|
metadata = mapOf(
|
||||||
|
"name" to JsonPrimitive("api-work"),
|
||||||
|
"name_source" to JsonPrimitive("User"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val unknownSource = session(
|
||||||
|
metadata = mapOf(
|
||||||
|
"name" to JsonPrimitive("api-work"),
|
||||||
|
"name_source" to JsonPrimitive("something-new"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertTrue(isProvisionalName(generated))
|
||||||
|
assertFalse(isProvisionalName(user))
|
||||||
|
assertFalse(isProvisionalName(unknownSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BlockerStatsTest {
|
||||||
|
@Test
|
||||||
|
fun `renders insertions and deletions in diff notation`() {
|
||||||
|
val w = InTheWay(uncommitted_files = 2, untracked_files = 3, unpushed_commits = 2, insertions = 124, deletions = 18)
|
||||||
|
val stats = blockerStats(w).associate { it.kind to it.text }
|
||||||
|
assertEquals("+124", stats["add"])
|
||||||
|
assertEquals("-18", stats["del"])
|
||||||
|
assertEquals("?3", stats["untracked"])
|
||||||
|
assertEquals("↑2", stats["unpushed"])
|
||||||
|
assertNull(stats["files"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `falls back to a file count when a tracked change carries no line delta`() {
|
||||||
|
val w = InTheWay(uncommitted_files = 2, insertions = 0, deletions = 0)
|
||||||
|
val stats = blockerStats(w)
|
||||||
|
assertEquals(listOf(BlockerStat("files", "~2")), stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `session blockers are null when nothing blocks a kill`() {
|
||||||
|
assertNull(sessionBlockers(session(blockedWork = null)))
|
||||||
|
assertNull(sessionBlockers(session(blockedWork = InTheWay())))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `format in the way spells out zero as no unsaved work`() {
|
||||||
|
assertEquals("no unsaved work", formatInTheWay(InTheWay()))
|
||||||
|
assertEquals("1 uncommitted file, 2 untracked files", formatInTheWay(InTheWay(uncommitted_files = 1, untracked_files = 2)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProgramNameTest {
|
||||||
|
@Test
|
||||||
|
fun `drops arguments and basenames the path`() {
|
||||||
|
assertEquals("claude", programName("/usr/local/bin/claude -p hi"))
|
||||||
|
assertEquals("claude", programName("claude --dangerously-skip-permissions"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `looks through leading env assignments`() {
|
||||||
|
assertEquals("claude", programName("FOO=bar BAZ=qux claude --resume"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `looks through sudo and env wrappers with their own flags`() {
|
||||||
|
assertEquals("claude", programName("sudo claude"))
|
||||||
|
assertEquals("claude", programName("env -i FOO=bar claude"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `looks through js-toolchain launchers`() {
|
||||||
|
assertEquals("claude-code", programName("npx claude-code"))
|
||||||
|
assertEquals("claude", programName("pnpm dlx claude"))
|
||||||
|
assertEquals("claude", programName("bunx claude"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a command that is nothing but wrappers is shown as written`() {
|
||||||
|
assertEquals("sudo", programName("sudo"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class GroupingTest {
|
||||||
|
@Test
|
||||||
|
fun `groups by worktree base_repo when present`() {
|
||||||
|
val a = session(cwd = "/wt/a", worktree = SessionWorktree(path = "/wt/a", base_repo = "/home/user/maestro"))
|
||||||
|
val b = session(cwd = "/wt/b", worktree = SessionWorktree(path = "/wt/b", base_repo = "/home/user/maestro"))
|
||||||
|
val groups = groupSessions(listOf(a, b))
|
||||||
|
assertEquals(1, groups.size)
|
||||||
|
assertEquals("maestro", groups.single().label)
|
||||||
|
assertEquals(2, groups.single().sessions.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `falls back to cwd with no worktree`() {
|
||||||
|
val a = session(cwd = "/home/user/scratch")
|
||||||
|
val groups = groupSessions(listOf(a))
|
||||||
|
assertEquals("scratch", groups.single().label)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package pt.naps.maestro.ui.spawn
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import pt.naps.maestro.data.SessionView
|
||||||
|
|
||||||
|
private val base = SpawnFormValues(
|
||||||
|
command = "claude",
|
||||||
|
cwd = "/home/user/project",
|
||||||
|
name = "amber-fox",
|
||||||
|
structuredView = false,
|
||||||
|
autoApprove = false,
|
||||||
|
worktreeEnabled = false,
|
||||||
|
worktreeBranch = "",
|
||||||
|
worktreeBranchMode = WorktreeBranchMode.New,
|
||||||
|
)
|
||||||
|
|
||||||
|
class SpawnModelTest {
|
||||||
|
@Test
|
||||||
|
fun `builds a plain request with the picked cwd and command`() {
|
||||||
|
val result = buildSpawnRequest(base, null) as SpawnBuildResult.Ok
|
||||||
|
assertEquals("/home/user/project", result.request.cwd)
|
||||||
|
assertEquals("claude", result.request.command)
|
||||||
|
assertEquals(mapOf("name" to JsonPrimitive("amber-fox")), result.request.metadata)
|
||||||
|
assertEquals(SessionView.Terminal, result.request.view)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `omits metadata name when the name is blank`() {
|
||||||
|
val result = buildSpawnRequest(base.copy(name = " "), null) as SpawnBuildResult.Ok
|
||||||
|
assertTrue(result.request.metadata.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `requests structured view for resolved claude and codex commands`() {
|
||||||
|
for (command in listOf("claude", "FOO=1 codex", "env -i codex")) {
|
||||||
|
val result = buildSpawnRequest(base.copy(command = command, structuredView = true), null) as SpawnBuildResult.Ok
|
||||||
|
assertEquals("expected structured view for `$command`", SessionView.Structured, result.request.view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `does not request structured view for unsupported commands`() {
|
||||||
|
val result = buildSpawnRequest(base.copy(command = "zsh", structuredView = true), null) as SpawnBuildResult.Ok
|
||||||
|
assertEquals(SessionView.Terminal, result.request.view)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `attaches a trimmed branch when worktree is enabled`() {
|
||||||
|
val result = buildSpawnRequest(
|
||||||
|
base.copy(worktreeEnabled = true, worktreeBranch = " feat/foo "),
|
||||||
|
"/home/user/project",
|
||||||
|
) as SpawnBuildResult.Ok
|
||||||
|
assertEquals("/home/user/project", result.request.worktree?.base_repo)
|
||||||
|
assertEquals("feat/foo", result.request.worktree?.branch)
|
||||||
|
assertTrue(result.request.worktree?.create_branch == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `checks out an existing branch when the mode is existing`() {
|
||||||
|
val result = buildSpawnRequest(
|
||||||
|
base.copy(worktreeEnabled = true, worktreeBranch = "spawn-v2", worktreeBranchMode = WorktreeBranchMode.Existing),
|
||||||
|
"/home/user/project",
|
||||||
|
) as SpawnBuildResult.Ok
|
||||||
|
assertEquals(false, result.request.worktree?.create_branch)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rejects an existing-mode worktree with no branch picked`() {
|
||||||
|
val result = buildSpawnRequest(
|
||||||
|
base.copy(worktreeEnabled = true, worktreeBranch = "", worktreeBranchMode = WorktreeBranchMode.Existing),
|
||||||
|
"/repo",
|
||||||
|
)
|
||||||
|
assertTrue(result is SpawnBuildResult.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rejects a missing working directory`() {
|
||||||
|
assertTrue(buildSpawnRequest(base.copy(cwd = ""), null) is SpawnBuildResult.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rejects a missing command`() {
|
||||||
|
assertTrue(buildSpawnRequest(base.copy(command = ""), null) is SpawnBuildResult.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rejects a worktree without an inferred base repo`() {
|
||||||
|
val result = buildSpawnRequest(base.copy(worktreeEnabled = true, worktreeBranch = "b"), null)
|
||||||
|
assertTrue(result is SpawnBuildResult.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rejects a worktree without a branch name`() {
|
||||||
|
val result = buildSpawnRequest(base.copy(worktreeEnabled = true, worktreeBranch = " "), "/repo")
|
||||||
|
assertTrue(result is SpawnBuildResult.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resolveProgram strips env assignments and paths`() {
|
||||||
|
assertEquals("codex", resolveProgram("FOO=1 /usr/local/bin/codex --flag"))
|
||||||
|
assertEquals("claude", resolveProgram("claude"))
|
||||||
|
assertEquals("zsh", resolveProgram("zsh -l"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
package pt.naps.maestro.ui.spawn
|
||||||
|
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.ConcurrentLinkedQueue
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import okhttp3.mockwebserver.Dispatcher
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import okhttp3.mockwebserver.RecordedRequest
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import pt.naps.maestro.api.MaestroClient
|
||||||
|
import pt.naps.maestro.data.Project
|
||||||
|
|
||||||
|
/** Routes each enqueued response by request path rather than arrival order —
|
||||||
|
* [SpawnViewModel]'s init fires `/api/info` and `/api/projects` concurrently,
|
||||||
|
* so a plain FIFO queue can hand either endpoint the other's response. */
|
||||||
|
private class RoutedDispatcher : Dispatcher() {
|
||||||
|
private val queues = ConcurrentHashMap<String, ConcurrentLinkedQueue<MockResponse>>()
|
||||||
|
|
||||||
|
fun enqueue(path: String, response: MockResponse) {
|
||||||
|
queues.getOrPut(path) { ConcurrentLinkedQueue() }.add(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||||
|
val path = request.path.orEmpty().substringBefore('?')
|
||||||
|
return queues[path]?.poll() ?: MockResponse().setResponseCode(404)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SpawnViewModelTest {
|
||||||
|
private lateinit var server: MockWebServer
|
||||||
|
private lateinit var dispatcher: RoutedDispatcher
|
||||||
|
private lateinit var client: MaestroClient
|
||||||
|
private lateinit var scope: CoroutineScope
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
dispatcher = RoutedDispatcher()
|
||||||
|
server = MockWebServer()
|
||||||
|
server.dispatcher = dispatcher
|
||||||
|
server.start()
|
||||||
|
client = MaestroClient(server.url("/").toString())
|
||||||
|
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
scope.cancel()
|
||||||
|
server.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jsonResponse(body: String) =
|
||||||
|
MockResponse().setHeader("Content-Type", "application/json").setBody(body)
|
||||||
|
|
||||||
|
/** Enqueues a passing `/api/info` and an empty `/api/projects`, the pair
|
||||||
|
* every test needs before it can observe anything past init. */
|
||||||
|
private fun enqueueInit(infoBody: String = """{"hostname":"h","version":"1","home_dir":"","presets":[],"instance":"i"}""") {
|
||||||
|
dispatcher.enqueue("/api/info", jsonResponse(infoBody))
|
||||||
|
dispatcher.enqueue("/api/projects", jsonResponse("[]"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun newViewModel() = SpawnViewModel(client, scope)
|
||||||
|
|
||||||
|
private suspend fun SpawnViewModel.awaitState(predicate: (SpawnUiState) -> Boolean): SpawnUiState {
|
||||||
|
var result: SpawnUiState? = null
|
||||||
|
state.test {
|
||||||
|
var item = awaitItem()
|
||||||
|
while (!predicate(item)) item = awaitItem()
|
||||||
|
result = item
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
return result ?: error("state predicate never satisfied")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `loads presets from daemon info and defaults to the first one`() = runBlocking {
|
||||||
|
enqueueInit(
|
||||||
|
"""{"hostname":"h","version":"1","home_dir":"/home/x","presets":[
|
||||||
|
{"id":"claude","label":"Claude","command":"claude"},
|
||||||
|
{"id":"custom","label":"Custom…","command":""}
|
||||||
|
],"instance":"i"}""",
|
||||||
|
)
|
||||||
|
|
||||||
|
val vm = newViewModel()
|
||||||
|
val settled = vm.awaitState { it.selectedPresetId == "claude" }
|
||||||
|
|
||||||
|
assertEquals("claude", settled.command)
|
||||||
|
assertTrue("structured view should auto-tick for claude", settled.structuredView)
|
||||||
|
assertEquals("/home/x", settled.homeDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `falls back to built-in presets when daemon info fails`() = runBlocking {
|
||||||
|
dispatcher.enqueue("/api/info", MockResponse().setResponseCode(500))
|
||||||
|
dispatcher.enqueue("/api/projects", jsonResponse("[]"))
|
||||||
|
|
||||||
|
val vm = newViewModel()
|
||||||
|
val settled = vm.awaitState { it.selectedPresetId.isNotEmpty() }
|
||||||
|
|
||||||
|
assertEquals(FALLBACK_PRESETS, settled.presets)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `selecting a preset updates the command and structured view`() = runBlocking {
|
||||||
|
enqueueInit()
|
||||||
|
val vm = newViewModel()
|
||||||
|
vm.awaitState { it.selectedPresetId.isNotEmpty() } // let init settle on "claude"
|
||||||
|
|
||||||
|
vm.selectPreset("zsh")
|
||||||
|
val afterZsh = vm.awaitState { it.selectedPresetId == "zsh" }
|
||||||
|
assertEquals("zsh", afterZsh.command)
|
||||||
|
assertTrue("zsh has no structured view", !afterZsh.structuredView)
|
||||||
|
|
||||||
|
vm.selectPreset("claude")
|
||||||
|
val afterClaude = vm.awaitState { it.selectedPresetId == "claude" }
|
||||||
|
assertTrue(afterClaude.structuredView)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `manual structured view toggle survives a later preset change`() = runBlocking {
|
||||||
|
enqueueInit()
|
||||||
|
val vm = newViewModel()
|
||||||
|
vm.awaitState { it.selectedPresetId == "claude" }
|
||||||
|
|
||||||
|
vm.setStructuredView(false)
|
||||||
|
vm.selectPreset("codex")
|
||||||
|
val after = vm.awaitState { it.selectedPresetId == "codex" }
|
||||||
|
assertTrue("operator's untick should stick", !after.structuredView)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `selecting a project fills cwd and base repo`() = runBlocking {
|
||||||
|
enqueueInit()
|
||||||
|
val vm = newViewModel()
|
||||||
|
vm.awaitState { !it.projectsLoading }
|
||||||
|
|
||||||
|
vm.selectProject(Project(path = "/repo", name = "repo"))
|
||||||
|
val after = vm.awaitState { it.cwd == "/repo" }
|
||||||
|
assertEquals("/repo", after.baseRepo)
|
||||||
|
assertEquals("/repo", after.selectedProjectPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `typing a raw path debounces dir suggestions and base repo`() = runBlocking {
|
||||||
|
enqueueInit()
|
||||||
|
val vm = newViewModel()
|
||||||
|
vm.awaitState { !it.projectsLoading }
|
||||||
|
|
||||||
|
dispatcher.enqueue("/api/dirs", jsonResponse("""{"path":"/home/user","entries":["proj-a","proj-b"]}"""))
|
||||||
|
dispatcher.enqueue("/api/gitroot", jsonResponse("""{"root":"/home/user/proj-a"}"""))
|
||||||
|
|
||||||
|
vm.setCwd("/home/user/pro")
|
||||||
|
val settled = vm.awaitState { it.dirSuggestions.isNotEmpty() }
|
||||||
|
assertEquals(listOf("proj-a", "proj-b"), settled.dirSuggestions)
|
||||||
|
val withRepo = vm.awaitState { it.baseRepo != null }
|
||||||
|
assertEquals("/home/user/proj-a", withRepo.baseRepo)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reroll fills the name field from the daemon`() = runBlocking {
|
||||||
|
enqueueInit()
|
||||||
|
val vm = newViewModel()
|
||||||
|
vm.awaitState { !it.projectsLoading }
|
||||||
|
|
||||||
|
dispatcher.enqueue("/api/names", jsonResponse("""{"names":["amber-fox"]}"""))
|
||||||
|
vm.rerollName()
|
||||||
|
val after = vm.awaitState { it.name == "amber-fox" }
|
||||||
|
assertEquals("amber-fox", after.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `submit rejects a missing working directory without a network call`() = runBlocking {
|
||||||
|
enqueueInit()
|
||||||
|
val vm = newViewModel()
|
||||||
|
vm.awaitState { it.selectedPresetId == "claude" }
|
||||||
|
|
||||||
|
var succeeded = false
|
||||||
|
vm.submit { succeeded = true }
|
||||||
|
val after = vm.awaitState { it.error != null }
|
||||||
|
assertNotNull(after.error)
|
||||||
|
assertTrue(!succeeded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `submit posts the built request and reports the new session`() = runBlocking {
|
||||||
|
enqueueInit()
|
||||||
|
val vm = newViewModel()
|
||||||
|
vm.awaitState { it.selectedPresetId == "claude" }
|
||||||
|
vm.selectProject(Project(path = "/repo", name = "repo"))
|
||||||
|
vm.awaitState { it.cwd == "/repo" }
|
||||||
|
|
||||||
|
dispatcher.enqueue(
|
||||||
|
"/api/sessions",
|
||||||
|
jsonResponse(
|
||||||
|
"""{"id":"s1","cwd":"/repo","command":"claude","status":"Running","created_at":"2026-01-01T00:00:00Z"}""",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
var result: pt.naps.maestro.data.Session? = null
|
||||||
|
vm.submit { result = it }
|
||||||
|
val after = vm.awaitState { !it.submitting && it.error == null }
|
||||||
|
assertNull(after.error)
|
||||||
|
assertEquals("s1", result?.id)
|
||||||
|
|
||||||
|
var request = server.takeRequest()
|
||||||
|
while (request.method != "POST") request = server.takeRequest()
|
||||||
|
assertEquals("/api/sessions", request.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `submit surfaces the daemon's error detail on failure`() = runBlocking {
|
||||||
|
enqueueInit()
|
||||||
|
val vm = newViewModel()
|
||||||
|
vm.awaitState { it.selectedPresetId == "claude" }
|
||||||
|
vm.selectProject(Project(path = "/repo", name = "repo"))
|
||||||
|
vm.awaitState { it.cwd == "/repo" }
|
||||||
|
|
||||||
|
dispatcher.enqueue(
|
||||||
|
"/api/sessions",
|
||||||
|
MockResponse().setResponseCode(400).setHeader("Content-Type", "application/json")
|
||||||
|
.setBody("""{"error":{"code":"bad_request","message":"cwd does not exist"}}"""),
|
||||||
|
)
|
||||||
|
vm.submit {}
|
||||||
|
val after = vm.awaitState { it.error != null }
|
||||||
|
assertTrue(after.error!!.contains("cwd does not exist"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,10 +9,12 @@ navigationCompose = "2.8.2"
|
|||||||
okhttp = "4.12.0"
|
okhttp = "4.12.0"
|
||||||
kotlinxSerializationJson = "1.7.3"
|
kotlinxSerializationJson = "1.7.3"
|
||||||
kotlinxCoroutines = "1.9.0"
|
kotlinxCoroutines = "1.9.0"
|
||||||
|
turbine = "1.1.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||||
|
androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleRuntimeKtx" }
|
||||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
@@ -26,6 +28,9 @@ androidx-navigation-compose = { group = "androidx.navigation", name = "navigatio
|
|||||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
|
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
|
||||||
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
|
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
|
||||||
|
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
|
||||||
|
okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" }
|
||||||
|
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
|
||||||
junit = { group = "junit", name = "junit", version = "4.13.2" }
|
junit = { group = "junit", name = "junit", version = "4.13.2" }
|
||||||
androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version = "1.2.1" }
|
androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version = "1.2.1" }
|
||||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version = "3.6.1" }
|
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version = "3.6.1" }
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -42,12 +42,13 @@ use std::sync::{Arc, Mutex};
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use agent_client_protocol::schema::v1::{
|
use agent_client_protocol::schema::v1::{
|
||||||
CancelNotification, ClientCapabilities, ConfigOptionUpdate, ContentBlock, CurrentModeUpdate,
|
CancelNotification, ClientCapabilities, ConfigOptionUpdate, ContentBlock,
|
||||||
FileSystemCapabilities, Implementation, InitializeRequest, PromptRequest,
|
CreateElicitationRequest, CreateElicitationResponse, CurrentModeUpdate, ElicitationAction,
|
||||||
RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse,
|
ElicitationCapabilities, ElicitationFormCapabilities, FileSystemCapabilities, Implementation,
|
||||||
SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory, SessionConfigOptionValue,
|
InitializeRequest, PromptRequest, RequestPermissionOutcome, RequestPermissionRequest,
|
||||||
SessionConfigSelectOption, SessionId, SessionModeState, SessionNotification, SessionUpdate,
|
RequestPermissionResponse, SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory,
|
||||||
SetSessionConfigOptionRequest, SetSessionModeRequest,
|
SessionConfigOptionValue, SessionConfigSelectOption, SessionId, SessionModeState,
|
||||||
|
SessionNotification, SessionUpdate, SetSessionConfigOptionRequest, SetSessionModeRequest,
|
||||||
};
|
};
|
||||||
use agent_client_protocol::schema::ProtocolVersion;
|
use agent_client_protocol::schema::ProtocolVersion;
|
||||||
use agent_client_protocol::{ByteStreams, Client, JsonRpcRequest, JsonRpcResponse};
|
use agent_client_protocol::{ByteStreams, Client, JsonRpcRequest, JsonRpcResponse};
|
||||||
@@ -69,13 +70,28 @@ pub enum AcpUpdate {
|
|||||||
/// turn precede this marker on the wire, and therefore in this channel.
|
/// turn precede this marker on the wire, and therefore in this channel.
|
||||||
/// The event pump must fire `done` after persisting every preceding
|
/// The event pump must fire `done` after persisting every preceding
|
||||||
/// notification.
|
/// notification.
|
||||||
|
///
|
||||||
|
/// The `done` oneshot carries both the prompt result and a `settled_tx`
|
||||||
|
/// that the receiver fires after the turn marker is persisted. The
|
||||||
|
/// event pump awaits the corresponding `settled_rx` before processing
|
||||||
|
/// further messages so late-arriving notifications cannot leak into the
|
||||||
|
/// next turn.
|
||||||
TurnDone {
|
TurnDone {
|
||||||
turn: u64,
|
turn: u64,
|
||||||
result: Result<(), String>,
|
result: Result<(), String>,
|
||||||
done: oneshot::Sender<Result<(), String>>,
|
done: oneshot::Sender<TurnDonePayload>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Payload sent through the `done` oneshot inside [`AcpUpdate::TurnDone`].
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct TurnDonePayload {
|
||||||
|
pub result: Result<(), String>,
|
||||||
|
/// The receiver fires this after persisting the turn marker and
|
||||||
|
/// dispatching any queued prompt.
|
||||||
|
pub settled: oneshot::Sender<()>,
|
||||||
|
}
|
||||||
|
|
||||||
/// ACP adapter selected from the structured session's resolved command.
|
/// ACP adapter selected from the structured session's resolved command.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum AcpAdapter {
|
pub enum AcpAdapter {
|
||||||
@@ -249,6 +265,15 @@ pub struct PermissionRequest {
|
|||||||
pub response: oneshot::Sender<RequestPermissionOutcome>,
|
pub response: oneshot::Sender<RequestPermissionOutcome>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A blocked ACP elicitation request delivered to the daemon.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ElicitationRequest {
|
||||||
|
/// The prompt turn that was active when ACP delivered this callback.
|
||||||
|
pub turn: u64,
|
||||||
|
pub request: CreateElicitationRequest,
|
||||||
|
pub response: oneshot::Sender<ElicitationAction>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum AcpError {
|
pub enum AcpError {
|
||||||
/// The adapter is not on `PATH`. The message names the install command
|
/// The adapter is not on `PATH`. The message names the install command
|
||||||
@@ -301,7 +326,7 @@ enum Command {
|
|||||||
Prompt {
|
Prompt {
|
||||||
text: String,
|
text: String,
|
||||||
turn: u64,
|
turn: u64,
|
||||||
done: oneshot::Sender<Result<(), String>>,
|
done: oneshot::Sender<TurnDonePayload>,
|
||||||
},
|
},
|
||||||
SetConfig {
|
SetConfig {
|
||||||
config_id: String,
|
config_id: String,
|
||||||
@@ -374,6 +399,7 @@ pub struct AcpSession {
|
|||||||
child_commands: mpsc::UnboundedSender<ChildCommand>,
|
child_commands: mpsc::UnboundedSender<ChildCommand>,
|
||||||
updates: Mutex<Option<mpsc::UnboundedReceiver<AcpUpdate>>>,
|
updates: Mutex<Option<mpsc::UnboundedReceiver<AcpUpdate>>>,
|
||||||
permission_requests: Mutex<Option<mpsc::UnboundedReceiver<PermissionRequest>>>,
|
permission_requests: Mutex<Option<mpsc::UnboundedReceiver<PermissionRequest>>>,
|
||||||
|
elicitation_requests: Mutex<Option<mpsc::UnboundedReceiver<ElicitationRequest>>>,
|
||||||
exit: watch::Receiver<Option<ExitStatus>>,
|
exit: watch::Receiver<Option<ExitStatus>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,6 +450,13 @@ impl AcpSession {
|
|||||||
.take()
|
.take()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn take_elicitation_requests(&self) -> Option<mpsc::UnboundedReceiver<ElicitationRequest>> {
|
||||||
|
self.elicitation_requests
|
||||||
|
.lock()
|
||||||
|
.expect("elicitation requests")
|
||||||
|
.take()
|
||||||
|
}
|
||||||
|
|
||||||
/// Send `session/cancel`. Fire-and-forget: a closed connection means the
|
/// Send `session/cancel`. Fire-and-forget: a closed connection means the
|
||||||
/// child is already gone, which is the outcome cancel was after.
|
/// child is already gone, which is the outcome cancel was after.
|
||||||
pub fn cancel(&self) {
|
pub fn cancel(&self) {
|
||||||
@@ -435,7 +468,7 @@ impl AcpSession {
|
|||||||
&self,
|
&self,
|
||||||
text: String,
|
text: String,
|
||||||
turn: u64,
|
turn: u64,
|
||||||
) -> Result<oneshot::Receiver<Result<(), String>>, String> {
|
) -> Result<oneshot::Receiver<TurnDonePayload>, String> {
|
||||||
let (done_tx, done_rx) = oneshot::channel();
|
let (done_tx, done_rx) = oneshot::channel();
|
||||||
self.commands
|
self.commands
|
||||||
.send(Command::Prompt {
|
.send(Command::Prompt {
|
||||||
@@ -558,6 +591,7 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
|
|||||||
|
|
||||||
let (updates_tx, updates_rx) = mpsc::unbounded_channel();
|
let (updates_tx, updates_rx) = mpsc::unbounded_channel();
|
||||||
let (permission_tx, permission_rx) = mpsc::unbounded_channel();
|
let (permission_tx, permission_rx) = mpsc::unbounded_channel();
|
||||||
|
let (elicitation_tx, elicitation_rx) = mpsc::unbounded_channel();
|
||||||
let (commands_tx, commands_rx) = mpsc::unbounded_channel();
|
let (commands_tx, commands_rx) = mpsc::unbounded_channel();
|
||||||
let (ready_tx, ready_rx) = oneshot::channel();
|
let (ready_tx, ready_rx) = oneshot::channel();
|
||||||
let transport = ByteStreams::new(stdin.compat_write(), stdout.compat());
|
let transport = ByteStreams::new(stdin.compat_write(), stdout.compat());
|
||||||
@@ -570,6 +604,7 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
|
|||||||
},
|
},
|
||||||
updates_tx,
|
updates_tx,
|
||||||
permission_tx,
|
permission_tx,
|
||||||
|
elicitation_tx,
|
||||||
commands_rx,
|
commands_rx,
|
||||||
ready_tx,
|
ready_tx,
|
||||||
));
|
));
|
||||||
@@ -584,6 +619,7 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
|
|||||||
child_commands: child_commands_tx,
|
child_commands: child_commands_tx,
|
||||||
updates: Mutex::new(Some(updates_rx)),
|
updates: Mutex::new(Some(updates_rx)),
|
||||||
permission_requests: Mutex::new(Some(permission_rx)),
|
permission_requests: Mutex::new(Some(permission_rx)),
|
||||||
|
elicitation_requests: Mutex::new(Some(elicitation_rx)),
|
||||||
exit: exit_rx,
|
exit: exit_rx,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -690,6 +726,7 @@ fn client_capabilities() -> ClientCapabilities {
|
|||||||
.read_text_file(false)
|
.read_text_file(false)
|
||||||
.write_text_file(false))
|
.write_text_file(false))
|
||||||
.terminal(false)
|
.terminal(false)
|
||||||
|
.elicitation(ElicitationCapabilities::new().form(ElicitationFormCapabilities::default()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn advertised_config_options(
|
fn advertised_config_options(
|
||||||
@@ -792,6 +829,7 @@ async fn run_connection<T>(
|
|||||||
config: ConnectionConfig,
|
config: ConnectionConfig,
|
||||||
updates_tx: mpsc::UnboundedSender<AcpUpdate>,
|
updates_tx: mpsc::UnboundedSender<AcpUpdate>,
|
||||||
permission_requests: mpsc::UnboundedSender<PermissionRequest>,
|
permission_requests: mpsc::UnboundedSender<PermissionRequest>,
|
||||||
|
elicitation_requests: mpsc::UnboundedSender<ElicitationRequest>,
|
||||||
mut commands_rx: mpsc::UnboundedReceiver<Command>,
|
mut commands_rx: mpsc::UnboundedReceiver<Command>,
|
||||||
ready_tx: oneshot::Sender<Result<String, ReadyError>>,
|
ready_tx: oneshot::Sender<Result<String, ReadyError>>,
|
||||||
) where
|
) where
|
||||||
@@ -807,6 +845,7 @@ async fn run_connection<T>(
|
|||||||
// until a cancelled prompt settles, so this active turn is unambiguous.
|
// until a cancelled prompt settles, so this active turn is unambiguous.
|
||||||
let permission_turn = Arc::new(Mutex::new(PermissionTurnState::default()));
|
let permission_turn = Arc::new(Mutex::new(PermissionTurnState::default()));
|
||||||
let permission_turn_seen = permission_turn.clone();
|
let permission_turn_seen = permission_turn.clone();
|
||||||
|
let elicitation_turn_seen = permission_turn.clone();
|
||||||
let received_updates = updates_tx.clone();
|
let received_updates = updates_tx.clone();
|
||||||
let result = Client
|
let result = Client
|
||||||
.builder()
|
.builder()
|
||||||
@@ -852,6 +891,35 @@ async fn run_connection<T>(
|
|||||||
},
|
},
|
||||||
agent_client_protocol::on_receive_request!(),
|
agent_client_protocol::on_receive_request!(),
|
||||||
)
|
)
|
||||||
|
.on_receive_request(
|
||||||
|
async move |request: CreateElicitationRequest, responder, _cx| {
|
||||||
|
let turn = elicitation_turn_seen
|
||||||
|
.lock()
|
||||||
|
.expect("elicitation turn state")
|
||||||
|
.callback_turn();
|
||||||
|
let Some(turn) = turn else {
|
||||||
|
return responder.respond(CreateElicitationResponse::new(
|
||||||
|
ElicitationAction::Cancel,
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let (response, received) = oneshot::channel();
|
||||||
|
if elicitation_requests
|
||||||
|
.send(ElicitationRequest {
|
||||||
|
turn,
|
||||||
|
request,
|
||||||
|
response,
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return responder.respond(CreateElicitationResponse::new(
|
||||||
|
ElicitationAction::Cancel,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let action = received.await.unwrap_or(ElicitationAction::Cancel);
|
||||||
|
responder.respond(CreateElicitationResponse::new(action))
|
||||||
|
},
|
||||||
|
agent_client_protocol::on_receive_request!(),
|
||||||
|
)
|
||||||
.connect_with(transport, async move |cx| {
|
.connect_with(transport, async move |cx| {
|
||||||
let init = InitializeRequest::new(ProtocolVersion::V1)
|
let init = InitializeRequest::new(ProtocolVersion::V1)
|
||||||
.client_capabilities(client_capabilities())
|
.client_capabilities(client_capabilities())
|
||||||
@@ -999,7 +1067,11 @@ async fn run_connection<T>(
|
|||||||
.expect("permission turn state")
|
.expect("permission turn state")
|
||||||
.finish(turn);
|
.finish(turn);
|
||||||
if let Some(done) = done.lock().expect("prompt sender").take() {
|
if let Some(done) = done.lock().expect("prompt sender").take() {
|
||||||
let _ = done.send(Err(error.to_string()));
|
let (settled_tx, _) = oneshot::channel();
|
||||||
|
let _ = done.send(TurnDonePayload {
|
||||||
|
result: Err(error.to_string()),
|
||||||
|
settled: settled_tx,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,11 +70,12 @@ pub fn decide(idle_for: Duration, awaiting_input: bool) -> ActivityState {
|
|||||||
pub fn decide_structured(
|
pub fn decide_structured(
|
||||||
turn_active: bool,
|
turn_active: bool,
|
||||||
pending_approval: bool,
|
pending_approval: bool,
|
||||||
|
pending_elicitation: bool,
|
||||||
turn_failed: bool,
|
turn_failed: bool,
|
||||||
) -> ActivityState {
|
) -> ActivityState {
|
||||||
if turn_failed {
|
if turn_failed {
|
||||||
ActivityState::Error
|
ActivityState::Error
|
||||||
} else if pending_approval {
|
} else if pending_approval || pending_elicitation {
|
||||||
ActivityState::AwaitingInput
|
ActivityState::AwaitingInput
|
||||||
} else if turn_active {
|
} else if turn_active {
|
||||||
ActivityState::Active
|
ActivityState::Active
|
||||||
@@ -155,13 +156,26 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn structured_activity_follows_lifecycle_with_approval_precedence() {
|
fn structured_activity_follows_lifecycle_with_approval_precedence() {
|
||||||
assert_eq!(decide_structured(false, false, false), ActivityState::Idle);
|
|
||||||
assert_eq!(decide_structured(true, false, false), ActivityState::Active);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
decide_structured(true, true, false),
|
decide_structured(false, false, false, false),
|
||||||
|
ActivityState::Idle
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decide_structured(true, false, false, false),
|
||||||
|
ActivityState::Active
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decide_structured(true, true, false, false),
|
||||||
ActivityState::AwaitingInput
|
ActivityState::AwaitingInput
|
||||||
);
|
);
|
||||||
assert_eq!(decide_structured(false, false, true), ActivityState::Error);
|
assert_eq!(
|
||||||
|
decide_structured(true, false, true, false),
|
||||||
|
ActivityState::AwaitingInput
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decide_structured(false, false, false, true),
|
||||||
|
ActivityState::Error
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ use maestro_config::SpawnPreset;
|
|||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
|
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
|
||||||
RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, SetFocusRequest,
|
RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, SetFocusRequest,
|
||||||
StructuredApprovalDecision, StructuredConfigRequest, StructuredEvent, StructuredPromptRequest,
|
StructuredApprovalDecision, StructuredConfigRequest, StructuredElicitationAction,
|
||||||
PROTOCOL_VERSION,
|
StructuredEvent, StructuredPromptRequest, PROTOCOL_VERSION,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -80,6 +80,10 @@ pub fn router(state: LocalApiState) -> Router {
|
|||||||
.route(
|
.route(
|
||||||
routes::patterns::STRUCTURED_APPROVAL,
|
routes::patterns::STRUCTURED_APPROVAL,
|
||||||
post(structured_approval),
|
post(structured_approval),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
routes::patterns::STRUCTURED_ELICITATION,
|
||||||
|
post(structured_elicitation),
|
||||||
);
|
);
|
||||||
#[cfg(feature = "haiku-autoname")]
|
#[cfg(feature = "haiku-autoname")]
|
||||||
{
|
{
|
||||||
@@ -577,6 +581,53 @@ async fn structured_approval(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn structured_elicitation(
|
||||||
|
State(s): State<LocalApiState>,
|
||||||
|
Path((id, nonce)): Path<(Uuid, String)>,
|
||||||
|
Json(body): Json<StructuredElicitationAction>,
|
||||||
|
) -> Response {
|
||||||
|
use maestro_acp::schema::{
|
||||||
|
ElicitationAcceptAction, ElicitationAction, ElicitationContentValue,
|
||||||
|
};
|
||||||
|
let action = match body {
|
||||||
|
StructuredElicitationAction::Accept { content } => {
|
||||||
|
let mapped: std::collections::BTreeMap<String, ElicitationContentValue> = content
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(key, value)| {
|
||||||
|
let v = match value {
|
||||||
|
serde_json::Value::String(s) => ElicitationContentValue::String(s),
|
||||||
|
serde_json::Value::Bool(b) => ElicitationContentValue::Boolean(b),
|
||||||
|
serde_json::Value::Number(n) => {
|
||||||
|
if let Some(i) = n.as_i64() {
|
||||||
|
ElicitationContentValue::Integer(i)
|
||||||
|
} else {
|
||||||
|
ElicitationContentValue::Number(n.as_f64()?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some((key, v))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
ElicitationAction::Accept(ElicitationAcceptAction::new().content(mapped))
|
||||||
|
}
|
||||||
|
StructuredElicitationAction::Decline => ElicitationAction::Decline,
|
||||||
|
StructuredElicitationAction::Cancel => ElicitationAction::Cancel,
|
||||||
|
};
|
||||||
|
match s.manager.resolve_acp_elicitation(id, &nonce, action).await {
|
||||||
|
Ok(Some(())) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Ok(None) => error_response(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"not_structured",
|
||||||
|
"session is not a live structured session",
|
||||||
|
),
|
||||||
|
Err(e) => {
|
||||||
|
let info = e.to_error_info();
|
||||||
|
error_response(session_error_status(&info.code), &info.code, &info.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Convenience constructor — pulls the bits `LocalApiState` needs from
|
/// Convenience constructor — pulls the bits `LocalApiState` needs from
|
||||||
/// `DaemonConfig` so callers don't have to assemble it by hand. The project
|
/// `DaemonConfig` so callers don't have to assemble it by hand. The project
|
||||||
/// scanner starts with its cache cold; wire
|
/// scanner starts with its cache cold; wire
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ impl Projection {
|
|||||||
crate::activity::decide_structured(
|
crate::activity::decide_structured(
|
||||||
self.active_turn.is_some(),
|
self.active_turn.is_some(),
|
||||||
self.state.pending_approval.is_some(),
|
self.state.pending_approval.is_some(),
|
||||||
|
self.state.pending_elicitation.is_some(),
|
||||||
self.turn_failed,
|
self.turn_failed,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -274,6 +275,45 @@ impl Projection {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add a daemon-originated elicitation row. The nonce belongs only in
|
||||||
|
/// the reduced state, not in the row data.
|
||||||
|
pub fn request_elicitation(&mut self, nonce: String, data: Value) -> ProjectionUpdate {
|
||||||
|
let row = self.append(
|
||||||
|
format!("elicitation:{}", uuid::Uuid::new_v4()),
|
||||||
|
TranscriptRowKind::Elicitation,
|
||||||
|
data.clone(),
|
||||||
|
);
|
||||||
|
self.state.pending_elicitation = Some(serde_json::json!({
|
||||||
|
"nonce": nonce,
|
||||||
|
"request": data,
|
||||||
|
}));
|
||||||
|
ProjectionUpdate {
|
||||||
|
transcript: Some(row),
|
||||||
|
state: self.state(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear an elicitation only when it is still the one the client answered.
|
||||||
|
pub fn resolve_elicitation(&mut self, nonce: &str) -> Option<ProjectionUpdate> {
|
||||||
|
let pending = self.state.pending_elicitation.as_ref()?;
|
||||||
|
if pending.get("nonce").and_then(Value::as_str) != Some(nonce) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.state.pending_elicitation = None;
|
||||||
|
Some(ProjectionUpdate {
|
||||||
|
transcript: None,
|
||||||
|
state: self.state(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_elicitation(&mut self) -> Option<ProjectionUpdate> {
|
||||||
|
self.state.pending_elicitation.take()?;
|
||||||
|
Some(ProjectionUpdate {
|
||||||
|
transcript: None,
|
||||||
|
state: self.state(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Undo an `append`ed row that a caller decides never happened — used
|
/// Undo an `append`ed row that a caller decides never happened — used
|
||||||
/// when persisting the row's own event fails (#184), so a snapshot taken
|
/// when persisting the row's own event fails (#184), so a snapshot taken
|
||||||
/// after the failure cannot show a row durable replay would never
|
/// after the failure cannot show a row durable replay would never
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ struct AcpEventStream {
|
|||||||
projection: Projection,
|
projection: Projection,
|
||||||
projected_live: broadcast::Sender<ProjectionUpdate>,
|
projected_live: broadcast::Sender<ProjectionUpdate>,
|
||||||
pending_approvals: HashMap<String, PendingApproval>,
|
pending_approvals: HashMap<String, PendingApproval>,
|
||||||
|
pending_elicitations: HashMap<String, PendingElicitation>,
|
||||||
/// Serializes prompt dispatch with cancellation. A prompt is not merely
|
/// Serializes prompt dispatch with cancellation. A prompt is not merely
|
||||||
/// claimed: it is queued onto ACP before another operation can proceed.
|
/// claimed: it is queued onto ACP before another operation can proceed.
|
||||||
operation: Arc<Mutex<()>>,
|
operation: Arc<Mutex<()>>,
|
||||||
@@ -205,6 +206,11 @@ struct PendingApproval {
|
|||||||
options: Vec<maestro_acp::schema::PermissionOption>,
|
options: Vec<maestro_acp::schema::PermissionOption>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct PendingElicitation {
|
||||||
|
response: oneshot::Sender<maestro_acp::schema::ElicitationAction>,
|
||||||
|
}
|
||||||
|
|
||||||
/// The outcome accepted by the structured approval endpoint.
|
/// The outcome accepted by the structured approval endpoint.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum ApprovalDecision {
|
pub enum ApprovalDecision {
|
||||||
@@ -712,6 +718,7 @@ impl SessionManager {
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
cancel_pending_approvals(&mut stream);
|
cancel_pending_approvals(&mut stream);
|
||||||
|
cancel_pending_elicitations(&mut stream);
|
||||||
let Some(projected) = stream.projection.turn_lifecycle(lifecycle, turn) else {
|
let Some(projected) = stream.projection.turn_lifecycle(lifecycle, turn) else {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
@@ -869,11 +876,24 @@ impl SessionManager {
|
|||||||
stream.dispatch.queued_turn = Some(turn);
|
stream.dispatch.queued_turn = Some(turn);
|
||||||
let manager = self.clone();
|
let manager = self.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let lifecycle = match done.await {
|
let (lifecycle, settled) = match done.await {
|
||||||
Ok(Ok(())) => TurnLifecycle::Finish,
|
Ok(payload) => {
|
||||||
Ok(Err(_)) | Err(_) => TurnLifecycle::Failure,
|
let lifecycle = if payload.result.is_ok() {
|
||||||
|
TurnLifecycle::Finish
|
||||||
|
} else {
|
||||||
|
TurnLifecycle::Failure
|
||||||
|
};
|
||||||
|
(lifecycle, Some(payload.settled))
|
||||||
|
}
|
||||||
|
Err(_) => (TurnLifecycle::Failure, None),
|
||||||
};
|
};
|
||||||
manager.finish_queued_acp_turn(id, turn, lifecycle).await;
|
manager.finish_queued_acp_turn(id, turn, lifecycle).await;
|
||||||
|
// Tell the event pump it may resume processing
|
||||||
|
// notifications — the turn marker is persisted
|
||||||
|
// and any queued prompt has been dispatched.
|
||||||
|
if let Some(settled) = settled {
|
||||||
|
let _ = settled.send(());
|
||||||
|
}
|
||||||
});
|
});
|
||||||
drop(stream);
|
drop(stream);
|
||||||
sync_structured_activity(&self.inner, id).await;
|
sync_structured_activity(&self.inner, id).await;
|
||||||
@@ -885,6 +905,7 @@ impl SessionManager {
|
|||||||
}
|
}
|
||||||
Err(message) => {
|
Err(message) => {
|
||||||
cancel_pending_approvals(&mut stream);
|
cancel_pending_approvals(&mut stream);
|
||||||
|
cancel_pending_elicitations(&mut stream);
|
||||||
let projected = stream
|
let projected = stream
|
||||||
.projection
|
.projection
|
||||||
.turn_lifecycle(TurnLifecycle::Failure, turn)
|
.turn_lifecycle(TurnLifecycle::Failure, turn)
|
||||||
@@ -955,6 +976,7 @@ impl SessionManager {
|
|||||||
stream.dispatch.settled.notify_waiters();
|
stream.dispatch.settled.notify_waiters();
|
||||||
if stream.projection.active_turn() == Some(turn) {
|
if stream.projection.active_turn() == Some(turn) {
|
||||||
cancel_pending_approvals(&mut stream);
|
cancel_pending_approvals(&mut stream);
|
||||||
|
cancel_pending_elicitations(&mut stream);
|
||||||
if let Some(projected) = stream.projection.turn_lifecycle(lifecycle, turn) {
|
if let Some(projected) = stream.projection.turn_lifecycle(lifecycle, turn) {
|
||||||
let projected = persist_turn_update(&mut stream, projected);
|
let projected = persist_turn_update(&mut stream, projected);
|
||||||
stream.broadcast(projected);
|
stream.broadcast(projected);
|
||||||
@@ -1077,6 +1099,7 @@ impl SessionManager {
|
|||||||
let mut stream = event_stream.lock().await;
|
let mut stream = event_stream.lock().await;
|
||||||
acp.cancel();
|
acp.cancel();
|
||||||
cancel_pending_approvals(&mut stream);
|
cancel_pending_approvals(&mut stream);
|
||||||
|
cancel_pending_elicitations(&mut stream);
|
||||||
let cancelled_queued_turn = stream.dispatch.queued_turn;
|
let cancelled_queued_turn = stream.dispatch.queued_turn;
|
||||||
if let Some(turn) = cancelled_queued_turn {
|
if let Some(turn) = cancelled_queued_turn {
|
||||||
stream.dispatch.cancelled_turn = Some(turn);
|
stream.dispatch.cancelled_turn = Some(turn);
|
||||||
@@ -1149,6 +1172,38 @@ impl SessionManager {
|
|||||||
Ok(Some(()))
|
Ok(Some(()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve a daemon-issued elicitation nonce. Same idempotency rules as
|
||||||
|
/// `resolve_acp_approval`: stale/duplicate nonces succeed silently.
|
||||||
|
pub async fn resolve_acp_elicitation(
|
||||||
|
&self,
|
||||||
|
id: Uuid,
|
||||||
|
nonce: &str,
|
||||||
|
action: maestro_acp::schema::ElicitationAction,
|
||||||
|
) -> Result<Option<()>, SessionError> {
|
||||||
|
let stream = {
|
||||||
|
let sessions = self.inner.sessions.lock().await;
|
||||||
|
let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
|
||||||
|
if !record.is_live_structured() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
record.acp_events.clone()
|
||||||
|
};
|
||||||
|
let Some(stream) = stream else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let mut stream = stream.lock().await;
|
||||||
|
let Some(pending) = stream.pending_elicitations.remove(nonce) else {
|
||||||
|
return Ok(Some(()));
|
||||||
|
};
|
||||||
|
let _ = pending.response.send(action);
|
||||||
|
if let Some(update) = stream.projection.resolve_elicitation(nonce) {
|
||||||
|
stream.broadcast(update);
|
||||||
|
}
|
||||||
|
drop(stream);
|
||||||
|
sync_structured_activity(&self.inner, id).await;
|
||||||
|
Ok(Some(()))
|
||||||
|
}
|
||||||
|
|
||||||
async fn cancel_acp_approvals(&self, id: Uuid) {
|
async fn cancel_acp_approvals(&self, id: Uuid) {
|
||||||
let stream = self
|
let stream = self
|
||||||
.inner
|
.inner
|
||||||
@@ -1158,7 +1213,9 @@ impl SessionManager {
|
|||||||
.get(&id)
|
.get(&id)
|
||||||
.and_then(|record| record.acp_events.clone());
|
.and_then(|record| record.acp_events.clone());
|
||||||
if let Some(stream) = stream {
|
if let Some(stream) = stream {
|
||||||
cancel_pending_approvals(&mut *stream.lock().await);
|
let mut events = stream.lock().await;
|
||||||
|
cancel_pending_approvals(&mut events);
|
||||||
|
cancel_pending_elicitations(&mut events);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1448,6 +1505,7 @@ impl SessionManager {
|
|||||||
projection: Projection::default(),
|
projection: Projection::default(),
|
||||||
projected_live: projected_tx,
|
projected_live: projected_tx,
|
||||||
pending_approvals: HashMap::new(),
|
pending_approvals: HashMap::new(),
|
||||||
|
pending_elicitations: HashMap::new(),
|
||||||
operation: Arc::new(Mutex::new(())),
|
operation: Arc::new(Mutex::new(())),
|
||||||
dispatch: AcpDispatchState::default(),
|
dispatch: AcpDispatchState::default(),
|
||||||
prompt_queue: VecDeque::new(),
|
prompt_queue: VecDeque::new(),
|
||||||
@@ -1601,6 +1659,7 @@ impl SessionManager {
|
|||||||
let acp = Arc::new(acp_session);
|
let acp = Arc::new(acp_session);
|
||||||
let done = start_acp_event_pump(&inner, id, &acp, events.clone());
|
let done = start_acp_event_pump(&inner, id, &acp, events.clone());
|
||||||
start_acp_permission_pump(&inner, id, &acp, events.clone(), auto_approve);
|
start_acp_permission_pump(&inner, id, &acp, events.clone(), auto_approve);
|
||||||
|
start_acp_elicitation_pump(&inner, id, &acp, events.clone());
|
||||||
// Drain any prompts queued while the adapter was Starting.
|
// Drain any prompts queued while the adapter was Starting.
|
||||||
let queued_text = {
|
let queued_text = {
|
||||||
let mut sessions = inner.sessions.lock().await;
|
let mut sessions = inner.sessions.lock().await;
|
||||||
@@ -1968,6 +2027,7 @@ impl SessionManager {
|
|||||||
projection,
|
projection,
|
||||||
projected_live: projected_tx,
|
projected_live: projected_tx,
|
||||||
pending_approvals: HashMap::new(),
|
pending_approvals: HashMap::new(),
|
||||||
|
pending_elicitations: HashMap::new(),
|
||||||
operation: Arc::new(Mutex::new(())),
|
operation: Arc::new(Mutex::new(())),
|
||||||
dispatch: AcpDispatchState::default(),
|
dispatch: AcpDispatchState::default(),
|
||||||
prompt_queue: session.queued_prompts.drain(..).collect(),
|
prompt_queue: session.queued_prompts.drain(..).collect(),
|
||||||
@@ -2026,7 +2086,14 @@ impl SessionManager {
|
|||||||
);
|
);
|
||||||
if let Some((acp, events)) = event_pump {
|
if let Some((acp, events)) = event_pump {
|
||||||
let done = start_acp_event_pump(&self.inner, id, &acp, events.clone());
|
let done = start_acp_event_pump(&self.inner, id, &acp, events.clone());
|
||||||
start_acp_permission_pump(&self.inner, id, &acp, events, session.auto_approve);
|
start_acp_permission_pump(
|
||||||
|
&self.inner,
|
||||||
|
id,
|
||||||
|
&acp,
|
||||||
|
events.clone(),
|
||||||
|
session.auto_approve,
|
||||||
|
);
|
||||||
|
start_acp_elicitation_pump(&self.inner, id, &acp, events);
|
||||||
if let Some(record) = self.inner.sessions.lock().await.get_mut(&id) {
|
if let Some(record) = self.inner.sessions.lock().await.get_mut(&id) {
|
||||||
record.acp_event_pump = Some(done);
|
record.acp_event_pump = Some(done);
|
||||||
}
|
}
|
||||||
@@ -2443,8 +2510,17 @@ fn start_acp_event_pump(
|
|||||||
} => {
|
} => {
|
||||||
// Every notification for this turn has been persisted and
|
// Every notification for this turn has been persisted and
|
||||||
// broadcast above. Signal the done oneshot so
|
// broadcast above. Signal the done oneshot so
|
||||||
// finish_queued_acp_turn runs with the transcript complete.
|
// finish_queued_acp_turn runs with the transcript complete,
|
||||||
let _ = done.send(result);
|
// then wait for it to persist the turn marker before we
|
||||||
|
// resume — otherwise late-arriving notifications race into
|
||||||
|
// the next turn.
|
||||||
|
let (settled_tx, settled_rx) = tokio::sync::oneshot::channel();
|
||||||
|
let _ = done.send(maestro_acp::TurnDonePayload {
|
||||||
|
result,
|
||||||
|
settled: settled_tx,
|
||||||
|
});
|
||||||
|
// Block the pump until the turn is fully settled.
|
||||||
|
let _ = settled_rx.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2458,6 +2534,7 @@ fn apply_persisted_event(projection: &mut Projection, event: &LoggedEvent) -> Pr
|
|||||||
PersistedEvent::Notification(notification) => projection.apply(event.seq, notification),
|
PersistedEvent::Notification(notification) => projection.apply(event.seq, notification),
|
||||||
PersistedEvent::User { row }
|
PersistedEvent::User { row }
|
||||||
| PersistedEvent::Approval { row }
|
| PersistedEvent::Approval { row }
|
||||||
|
| PersistedEvent::Elicitation { row }
|
||||||
| PersistedEvent::Turn { row } => projection.restore_row(row.clone()),
|
| PersistedEvent::Turn { row } => projection.restore_row(row.clone()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2735,6 +2812,110 @@ fn permission_outcome(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Elicitation pump
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn start_acp_elicitation_pump(
|
||||||
|
inner: &Arc<Inner>,
|
||||||
|
id: Uuid,
|
||||||
|
acp: &Arc<AcpSession>,
|
||||||
|
events: Arc<Mutex<AcpEventStream>>,
|
||||||
|
) {
|
||||||
|
let Some(mut requests) = acp.take_elicitation_requests() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let weak_inner = Arc::downgrade(inner);
|
||||||
|
let weak_acp = Arc::downgrade(acp);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Some(maestro_acp::ElicitationRequest {
|
||||||
|
turn,
|
||||||
|
request,
|
||||||
|
response,
|
||||||
|
}) = requests.recv().await
|
||||||
|
{
|
||||||
|
let mut events = events.lock().await;
|
||||||
|
if events.projection.active_turn() != Some(turn) {
|
||||||
|
let _ = response.send(maestro_acp::schema::ElicitationAction::Cancel);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let outcome = record_elicitation_request(&mut events, request, response);
|
||||||
|
let fatal = matches!(outcome, RecordOutcome::Fatal);
|
||||||
|
if let RecordOutcome::Live(update) = outcome {
|
||||||
|
events.broadcast(*update);
|
||||||
|
}
|
||||||
|
drop(events);
|
||||||
|
if fatal {
|
||||||
|
tracing::error!(%id, "elicitation history append rollback did not restore consistency");
|
||||||
|
if let Some(acp) = weak_acp.upgrade() {
|
||||||
|
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
if let Some(inner) = weak_inner.upgrade() {
|
||||||
|
mark_session_dead(&inner, id).await;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let Some(inner) = weak_inner.upgrade() {
|
||||||
|
sync_structured_activity(&inner, id).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_elicitation_request(
|
||||||
|
events: &mut AcpEventStream,
|
||||||
|
request: maestro_acp::schema::CreateElicitationRequest,
|
||||||
|
response: oneshot::Sender<maestro_acp::schema::ElicitationAction>,
|
||||||
|
) -> RecordOutcome {
|
||||||
|
let nonce = Uuid::new_v4().to_string();
|
||||||
|
let data = match serde_json::to_value(&request) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => {
|
||||||
|
let _ = response.send(maestro_acp::schema::ElicitationAction::Cancel);
|
||||||
|
return RecordOutcome::Dropped;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
cancel_pending_elicitations(events);
|
||||||
|
events
|
||||||
|
.pending_elicitations
|
||||||
|
.insert(nonce.clone(), PendingElicitation { response });
|
||||||
|
let update = events.projection.request_elicitation(nonce, data);
|
||||||
|
let Some(TranscriptDelta::Append { row }) = &update.transcript else {
|
||||||
|
return RecordOutcome::Live(Box::new(update));
|
||||||
|
};
|
||||||
|
let event = PersistedEvent::Elicitation { row: row.clone() };
|
||||||
|
match events.log.append(&event) {
|
||||||
|
Ok(seq) => {
|
||||||
|
let _ = events.live.send(LoggedEvent { seq, event });
|
||||||
|
RecordOutcome::Live(Box::new(update))
|
||||||
|
}
|
||||||
|
Err(AppendError::Failed(error)) => {
|
||||||
|
tracing::error!(%error, "elicitation history append failed");
|
||||||
|
events.projection.rollback_row(&row.id);
|
||||||
|
cancel_pending_elicitations(events);
|
||||||
|
RecordOutcome::Dropped
|
||||||
|
}
|
||||||
|
Err(fatal @ AppendError::Fatal { .. }) => {
|
||||||
|
tracing::error!(error = %fatal, "elicitation history append failed unrecoverably");
|
||||||
|
events.projection.rollback_row(&row.id);
|
||||||
|
cancel_pending_elicitations(events);
|
||||||
|
RecordOutcome::Fatal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancel_pending_elicitations(events: &mut AcpEventStream) {
|
||||||
|
for (_, pending) in events.pending_elicitations.drain() {
|
||||||
|
let _ = pending
|
||||||
|
.response
|
||||||
|
.send(maestro_acp::schema::ElicitationAction::Cancel);
|
||||||
|
}
|
||||||
|
if let Some(update) = events.projection.clear_elicitation() {
|
||||||
|
events.broadcast(update);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn wait_for_event_pump(mut done: watch::Receiver<bool>) {
|
async fn wait_for_event_pump(mut done: watch::Receiver<bool>) {
|
||||||
if !*done.borrow() {
|
if !*done.borrow() {
|
||||||
let _ = done.wait_for(|finished| *finished).await;
|
let _ = done.wait_for(|finished| *finished).await;
|
||||||
@@ -3110,6 +3291,7 @@ async fn mark_session_dead_with_worktree_settlement(
|
|||||||
if let Some(stream) = stream {
|
if let Some(stream) = stream {
|
||||||
let mut stream = stream.lock().await;
|
let mut stream = stream.lock().await;
|
||||||
cancel_pending_approvals(&mut stream);
|
cancel_pending_approvals(&mut stream);
|
||||||
|
cancel_pending_elicitations(&mut stream);
|
||||||
stream.prompt_queue.clear();
|
stream.prompt_queue.clear();
|
||||||
}
|
}
|
||||||
tracing::info!(%id, "session process gone — marking dead");
|
tracing::info!(%id, "session process gone — marking dead");
|
||||||
@@ -3211,7 +3393,9 @@ async fn cleanup_record(inner: &Arc<Inner>, id: Uuid, record: SessionRecord, kil
|
|||||||
}
|
}
|
||||||
if let Some(acp) = &record.acp {
|
if let Some(acp) = &record.acp {
|
||||||
if let Some(stream) = &record.acp_events {
|
if let Some(stream) = &record.acp_events {
|
||||||
cancel_pending_approvals(&mut *stream.lock().await);
|
let mut events = stream.lock().await;
|
||||||
|
cancel_pending_approvals(&mut events);
|
||||||
|
cancel_pending_elicitations(&mut events);
|
||||||
}
|
}
|
||||||
// `session/cancel`, a short wait, then SIGTERM (#134).
|
// `session/cancel`, a short wait, then SIGTERM (#134).
|
||||||
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
|
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
|
||||||
@@ -3299,6 +3483,7 @@ mod tests {
|
|||||||
projection: Projection::default(),
|
projection: Projection::default(),
|
||||||
projected_live,
|
projected_live,
|
||||||
pending_approvals: std::collections::HashMap::new(),
|
pending_approvals: std::collections::HashMap::new(),
|
||||||
|
pending_elicitations: std::collections::HashMap::new(),
|
||||||
operation: std::sync::Arc::new(tokio::sync::Mutex::new(())),
|
operation: std::sync::Arc::new(tokio::sync::Mutex::new(())),
|
||||||
dispatch: AcpDispatchState::default(),
|
dispatch: AcpDispatchState::default(),
|
||||||
prompt_queue: std::collections::VecDeque::new(),
|
prompt_queue: std::collections::VecDeque::new(),
|
||||||
|
|||||||
@@ -269,6 +269,9 @@ pub enum PersistedEvent {
|
|||||||
Approval {
|
Approval {
|
||||||
row: TranscriptRow,
|
row: TranscriptRow,
|
||||||
},
|
},
|
||||||
|
Elicitation {
|
||||||
|
row: TranscriptRow,
|
||||||
|
},
|
||||||
Turn {
|
Turn {
|
||||||
row: TranscriptRow,
|
row: TranscriptRow,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ fn event_text(event: &maestro_daemon::sidecar::LoggedEvent) -> &str {
|
|||||||
.and_then(serde_json::Value::as_str)
|
.and_then(serde_json::Value::as_str)
|
||||||
.unwrap_or("[turn]"),
|
.unwrap_or("[turn]"),
|
||||||
maestro_daemon::sidecar::PersistedEvent::Approval { .. } => "[approval]",
|
maestro_daemon::sidecar::PersistedEvent::Approval { .. } => "[approval]",
|
||||||
|
maestro_daemon::sidecar::PersistedEvent::Elicitation { .. } => "[elicitation]",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -806,6 +806,7 @@ pub enum TranscriptRowKind {
|
|||||||
ToolCall,
|
ToolCall,
|
||||||
ToolDiff,
|
ToolDiff,
|
||||||
Approval,
|
Approval,
|
||||||
|
Elicitation,
|
||||||
Plan,
|
Plan,
|
||||||
Notice,
|
Notice,
|
||||||
}
|
}
|
||||||
@@ -871,6 +872,8 @@ pub struct ReducedState {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub pending_approval: Option<Value>,
|
pub pending_approval: Option<Value>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub pending_elicitation: Option<Value>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub plan: Option<Value>,
|
pub plan: Option<Value>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub usage: Option<Value>,
|
pub usage: Option<Value>,
|
||||||
@@ -927,6 +930,21 @@ pub enum StructuredApprovalDecision {
|
|||||||
Deny,
|
Deny,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Operator response to a pending ACP elicitation request.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "action", rename_all = "snake_case")]
|
||||||
|
pub enum StructuredElicitationAction {
|
||||||
|
/// The operator filled in the form and submitted.
|
||||||
|
Accept {
|
||||||
|
#[serde(default)]
|
||||||
|
content: std::collections::BTreeMap<String, Value>,
|
||||||
|
},
|
||||||
|
/// The operator declined to answer.
|
||||||
|
Decline,
|
||||||
|
/// The elicitation was cancelled.
|
||||||
|
Cancel,
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Client ↔ Daemon control messages (text frames on the attach WS)
|
// Client ↔ Daemon control messages (text frames on the attach WS)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ pub fn structured_approval(id: Uuid, nonce: &str) -> String {
|
|||||||
format!("{SESSIONS}/{id}/structured/approvals/{nonce}")
|
format!("{SESSIONS}/{id}/structured/approvals/{nonce}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `POST /api/sessions/:id/structured/elicitations/:nonce` — answer ACP elicitation.
|
||||||
|
pub fn structured_elicitation(id: Uuid, nonce: &str) -> String {
|
||||||
|
format!("{SESSIONS}/{id}/structured/elicitations/{nonce}")
|
||||||
|
}
|
||||||
|
|
||||||
/// Axum-style patterns for the two parameterised routes, so the server
|
/// Axum-style patterns for the two parameterised routes, so the server
|
||||||
/// registers the same paths [`session`] and [`attach`] build.
|
/// registers the same paths [`session`] and [`attach`] build.
|
||||||
pub mod patterns {
|
pub mod patterns {
|
||||||
@@ -122,6 +127,7 @@ pub mod patterns {
|
|||||||
pub const STRUCTURED_CANCEL: &str = "/api/sessions/:id/structured/cancel";
|
pub const STRUCTURED_CANCEL: &str = "/api/sessions/:id/structured/cancel";
|
||||||
pub const STRUCTURED_CONFIG: &str = "/api/sessions/:id/structured/config";
|
pub const STRUCTURED_CONFIG: &str = "/api/sessions/:id/structured/config";
|
||||||
pub const STRUCTURED_APPROVAL: &str = "/api/sessions/:id/structured/approvals/:nonce";
|
pub const STRUCTURED_APPROVAL: &str = "/api/sessions/:id/structured/approvals/:nonce";
|
||||||
|
pub const STRUCTURED_ELICITATION: &str = "/api/sessions/:id/structured/elicitations/:nonce";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A daemon base URL that could not be turned into a WebSocket base.
|
/// A daemon base URL that could not be turned into a WebSocket base.
|
||||||
|
|||||||
@@ -355,6 +355,15 @@ fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
|
|||||||
Tone::Muted,
|
Tone::Muted,
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
|
TranscriptRowKind::Elicitation => push_wrapped(
|
||||||
|
&mut out,
|
||||||
|
row,
|
||||||
|
"? ",
|
||||||
|
content_text(row),
|
||||||
|
row_width,
|
||||||
|
Tone::Muted,
|
||||||
|
false,
|
||||||
|
),
|
||||||
TranscriptRowKind::Plan => {}
|
TranscriptRowKind::Plan => {}
|
||||||
}
|
}
|
||||||
if out.len() > start
|
if out.len() > start
|
||||||
@@ -399,6 +408,7 @@ pub fn copy_text(row: &TranscriptRow) -> String {
|
|||||||
.and_then(serde_json::Value::as_str)
|
.and_then(serde_json::Value::as_str)
|
||||||
.unwrap_or("permission requested")
|
.unwrap_or("permission requested")
|
||||||
.into(),
|
.into(),
|
||||||
|
TranscriptRowKind::Elicitation => content_text(row),
|
||||||
TranscriptRowKind::Notice => "[notice]".into(),
|
TranscriptRowKind::Notice => "[notice]".into(),
|
||||||
TranscriptRowKind::Plan => String::new(),
|
TranscriptRowKind::Plan => String::new(),
|
||||||
}
|
}
|
||||||
|
|||||||
+64
-23
@@ -6,11 +6,11 @@ pane. Alongside `docs/WORKTREES.md`, `docs/SIDEBAR.md` and `docs/RESTART.md`,
|
|||||||
read this before changing anything that renders a structured session — both
|
read this before changing anything that renders a structured session — both
|
||||||
the web and TUI renderers are written against it.
|
the web and TUI renderers are written against it.
|
||||||
|
|
||||||
It covers the eight transcript row kinds, what the daemon decides versus what
|
It covers the nine transcript row kinds, what the daemon decides versus what
|
||||||
a client decides, the two projections and their delta/cursor semantics, the
|
a client decides, the two projections and their delta/cursor semantics, the
|
||||||
ACP handshake and the capabilities it declines, the adapter's install
|
ACP handshake and the capabilities it declines, the adapter's install
|
||||||
command, and the approval nonce rules. **Non-goal: the detached runner.** It
|
command, the approval nonce rules, and the elicitation flow. **Non-goal: the
|
||||||
does not exist yet, and nothing here describes it.
|
detached runner.** It does not exist yet, and nothing here describes it.
|
||||||
|
|
||||||
## What a structured session is
|
## What a structured session is
|
||||||
|
|
||||||
@@ -88,10 +88,12 @@ declares, deliberately, almost nothing:
|
|||||||
Declining all three keeps the daemon out of the file/terminal surface
|
Declining all three keeps the daemon out of the file/terminal surface
|
||||||
entirely — the adapter already has direct access to the session's `cwd`, and
|
entirely — the adapter already has direct access to the session's `cwd`, and
|
||||||
routing every read, write, and shell command through the daemon would only
|
routing every read, write, and shell command through the daemon would only
|
||||||
add a hop with no new capability. Every other capability namespace ACP
|
add a hop with no new capability.
|
||||||
defines (session extensions, auth, elicitation) is simply left at its
|
|
||||||
default; the daemon does not advertise support because neither supported
|
The daemon does declare `elicitation.form` support. This tells the adapter
|
||||||
adapter needs it, not because it was weighed and refused.
|
that form-mode elicitation (`CreateElicitationRequest`) will be handled and
|
||||||
|
resolved by the operator, rather than declined outright. See
|
||||||
|
[Elicitations](#elicitations) below.
|
||||||
|
|
||||||
The one client method that *is* implemented for real is
|
The one client method that *is* implemented for real is
|
||||||
`session/request_permission` (#139) — see [Approvals](#approvals) below.
|
`session/request_permission` (#139) — see [Approvals](#approvals) below.
|
||||||
@@ -111,12 +113,12 @@ in the projections below.
|
|||||||
Codex and Claude differ in what they emit, not in this lifecycle. Codex maps
|
Codex and Claude differ in what they emit, not in this lifecycle. Codex maps
|
||||||
shell commands, file changes, MCP calls, web search, image generation/view,
|
shell commands, file changes, MCP calls, web search, image generation/view,
|
||||||
reasoning, plans, and token usage into standard ACP chunks and tool calls;
|
reasoning, plans, and token usage into standard ACP chunks and tool calls;
|
||||||
those fold into the same eight rows. Codex's `available_commands_update` and
|
those fold into the same nine rows. Codex's `available_commands_update` and
|
||||||
`session_info_update`, like equivalent Claude updates, remain opaque
|
`session_info_update`, like equivalent Claude updates, remain opaque
|
||||||
`Notice` rows. Its `session/load` capability is required for daemon recovery
|
`Notice` rows. Its `session/load` capability is required for daemon recovery
|
||||||
just as it is for Claude.
|
just as it is for Claude.
|
||||||
|
|
||||||
## The eight transcript row kinds
|
## The nine transcript row kinds
|
||||||
|
|
||||||
`TranscriptRowKind` (`maestro-protocol`) is the alphabet a structured
|
`TranscriptRowKind` (`maestro-protocol`) is the alphabet a structured
|
||||||
transcript is written in. Every row has a stable `id` (so later deltas can
|
transcript is written in. Every row has a stable `id` (so later deltas can
|
||||||
@@ -132,6 +134,7 @@ crate.
|
|||||||
| `ToolCall` | A `tool_call` / `tool_call_update` pair whose content carries no diff. | `tool:<tool_call_id>` — one row for the call's whole life; updates patch it in place, they never create a second row. |
|
| `ToolCall` | A `tool_call` / `tool_call_update` pair whose content carries no diff. | `tool:<tool_call_id>` — one row for the call's whole life; updates patch it in place, they never create a second row. |
|
||||||
| `ToolDiff` | The same tool-call machinery, when the tool's content includes a `Diff` block. | Same `tool:<tool_call_id>` identity as `ToolCall`; the *kind* flips between the two as the tool's own content changes, not the row. |
|
| `ToolDiff` | The same tool-call machinery, when the tool's content includes a `Diff` block. | Same `tool:<tool_call_id>` identity as `ToolCall`; the *kind* flips between the two as the tool's own content changes, not the row. |
|
||||||
| `Approval` | A daemon-originated permission request (`RequestPermissionRequest`), serialized without its nonce. | `approval:<uuid>` — minted fresh per request, unrelated to the ACP request's own id. |
|
| `Approval` | A daemon-originated permission request (`RequestPermissionRequest`), serialized without its nonce. | `approval:<uuid>` — minted fresh per request, unrelated to the ACP request's own id. |
|
||||||
|
| `Elicitation` | A daemon-originated elicitation request (`CreateElicitationRequest`), serialized without its nonce. Carries `message` and `requestedSchema` (a JSON Schema object with typed properties). | `elicitation:<uuid>` — minted fresh per request, same lifecycle as `Approval`. |
|
||||||
| `Plan` | A `plan` update — the agent's current step list. | The fixed id `"plan"`. Each new plan **replaces** the row wholesale; entries are not diffed against the previous plan. |
|
| `Plan` | A `plan` update — the agent's current step list. | The fixed id `"plan"`. Each new plan **replaces** the row wholesale; entries are not diffed against the previous plan. |
|
||||||
| `Notice` | The raw JSON of any `session/update` variant the ACP dependency already decodes but the projection does not special-case. Maestro also uses a tagged `maestroTurnMeta` notice as the durable footer for a completed, cancelled, or failed turn. | `notice:<seq>` for ACP updates; `turn_meta:<uuid>` for a turn footer. |
|
| `Notice` | The raw JSON of any `session/update` variant the ACP dependency already decodes but the projection does not special-case. Maestro also uses a tagged `maestroTurnMeta` notice as the durable footer for a completed, cancelled, or failed turn. | `notice:<seq>` for ACP updates; `turn_meta:<uuid>` for a turn footer. |
|
||||||
|
|
||||||
@@ -139,7 +142,7 @@ crate.
|
|||||||
`agent-client-protocol` dependency's schema already deserializes (it
|
`agent-client-protocol` dependency's schema already deserializes (it
|
||||||
currently folds `available_commands_update` and `session_info_update`
|
currently folds `available_commands_update` and `session_info_update`
|
||||||
there) but the projection's `match` doesn't fold into one of the other
|
there) but the projection's `match` doesn't fold into one of the other
|
||||||
seven kinds. It is not a wire-level forward-compatibility seam:
|
eight kinds. It is not a wire-level forward-compatibility seam:
|
||||||
`SessionUpdate` is an internally-tagged enum with no unknown-variant
|
`SessionUpdate` is an internally-tagged enum with no unknown-variant
|
||||||
fallback, so a `session/update` whose `sessionUpdate` tag this build's
|
fallback, so a `session/update` whose `sessionUpdate` tag this build's
|
||||||
dependency has never heard of — including a variant gated behind an
|
dependency has never heard of — including a variant gated behind an
|
||||||
@@ -174,6 +177,8 @@ choices, not daemon ones.
|
|||||||
- Approval nonce minting, secrecy, and the single-active-approval rule (see
|
- Approval nonce minting, secrecy, and the single-active-approval rule (see
|
||||||
below) — a client never sees an ACP permission request before the daemon
|
below) — a client never sees an ACP permission request before the daemon
|
||||||
has already turned it into an `Approval` row and a nonce.
|
has already turned it into an `Approval` row and a nonce.
|
||||||
|
- Elicitation nonce minting and the single-active-elicitation rule (see
|
||||||
|
[Elicitations](#elicitations)) — same lifecycle as approvals.
|
||||||
- What happens to an ACP update variant the dependency decodes but nobody has
|
- What happens to an ACP update variant the dependency decodes but nobody has
|
||||||
taught the projection to special-case (falls back to `Notice` rather than
|
taught the projection to special-case (falls back to `Notice` rather than
|
||||||
erroring). A variant the dependency itself can't decode is a deserialization
|
erroring). A variant the dependency itself can't decode is a deserialization
|
||||||
@@ -195,7 +200,8 @@ choices, not daemon ones.
|
|||||||
text → group → text → group rather than lumping every tool in a turn into
|
text → group → text → group rather than lumping every tool in a turn into
|
||||||
one box. Text and thought rows stay outside groups as standalone transcript
|
one box. Text and thought rows stay outside groups as standalone transcript
|
||||||
items — the operator sees all reasoning without expanding. Resolved
|
items — the operator sees all reasoning without expanding. Resolved
|
||||||
approval rows between tools within a run are absorbed into the group. The
|
approval and elicitation rows between tools within a run are absorbed into
|
||||||
|
the group. The
|
||||||
collapsed header reads "N tool calls · Label 'arg'" where the label and
|
collapsed header reads "N tool calls · Label 'arg'" where the label and
|
||||||
argument come from the last tool row's `splitToolTitle` — the operator
|
argument come from the last tool row's `splitToolTitle` — the operator
|
||||||
sees which command is current at a glance. Opening the group reveals tool
|
sees which command is current at a glance. Opening the group reveals tool
|
||||||
@@ -213,6 +219,9 @@ choices, not daemon ones.
|
|||||||
- How the operator is asked to resolve an `Approval` row — the daemon only
|
- How the operator is asked to resolve an `Approval` row — the daemon only
|
||||||
exposes the three-way decision (`AllowOnce` / `AllowForSession` / `Deny`),
|
exposes the three-way decision (`AllowOnce` / `AllowForSession` / `Deny`),
|
||||||
never the UI that solicits it.
|
never the UI that solicits it.
|
||||||
|
- How the operator is asked to resolve an `Elicitation` row — the daemon
|
||||||
|
exposes the three actions (`Accept` with content / `Decline` / `Cancel`);
|
||||||
|
the client decides the form layout and button treatment.
|
||||||
- Scrolling, collapsing, and every other presentation-only behaviour.
|
- Scrolling, collapsing, and every other presentation-only behaviour.
|
||||||
|
|
||||||
## The two projections
|
## The two projections
|
||||||
@@ -225,8 +234,8 @@ exposes two independent views, both defined in `maestro-protocol`:
|
|||||||
It is intentionally always the *whole* transcript, not a windowed one: a
|
It is intentionally always the *whole* transcript, not a windowed one: a
|
||||||
tool row patched much later must still find its original entry.
|
tool row patched much later must still find its original entry.
|
||||||
- **`ReducedState`** — one whole-session snapshot folded over every event
|
- **`ReducedState`** — one whole-session snapshot folded over every event
|
||||||
seen: `turn` (the per-turn indicators above), `pending_approval`, `plan`,
|
seen: `turn` (the per-turn indicators above), `pending_approval`,
|
||||||
`usage`/`context`, `model`, and `mode`. Like `TranscriptSnapshot`, it is
|
`pending_elicitation`, `plan`, `usage`/`context`, `model`, and `mode`. Like `TranscriptSnapshot`, it is
|
||||||
always full-history, never a delta — a client that only wants "is a turn
|
always full-history, never a delta — a client that only wants "is a turn
|
||||||
active right now" never has to replay anything to answer that.
|
active right now" never has to replay anything to answer that.
|
||||||
|
|
||||||
@@ -323,6 +332,37 @@ pending decision, entirely on the daemon side:
|
|||||||
permanent one (`RejectAlways`) only when that is the sole rejection option
|
permanent one (`RejectAlways`) only when that is the sole rejection option
|
||||||
offered; if neither is present, it answers `Cancelled`.
|
offered; if neither is present, it answers `Cancelled`.
|
||||||
|
|
||||||
|
## Elicitations
|
||||||
|
|
||||||
|
An ACP `elicitation/create` callback becomes an `Elicitation` row plus a
|
||||||
|
pending decision, mirroring the approval lifecycle:
|
||||||
|
|
||||||
|
- **Generation.** The daemon mints a nonce per request (`Uuid::new_v4()`),
|
||||||
|
same as approvals.
|
||||||
|
- **Live-only placement.** The nonce lives in `pending_elicitations` and in
|
||||||
|
`ReducedState.pending_elicitation.nonce`, never in the row's `data`.
|
||||||
|
- **One live elicitation at a time.** A new elicitation cancels any
|
||||||
|
prior pending one before installing itself.
|
||||||
|
- **Resolution.** `POST /api/sessions/:id/structured/elicitations/:nonce`
|
||||||
|
accepts `{ "action": "accept", "content": { ... } }`,
|
||||||
|
`{ "action": "decline" }`, or `{ "action": "cancel" }`. The endpoint
|
||||||
|
always answers `204` for a known, live structured session, same as
|
||||||
|
approvals. The three actions map to ACP's `ElicitationAction::Accept`
|
||||||
|
(with the filled-in values as `ElicitationContentValue` entries),
|
||||||
|
`Decline`, and `Cancel`.
|
||||||
|
- **Cancellation.** Turn completion, session cancel, and fatal errors
|
||||||
|
cancel pending elicitations as `Cancel`, same as approvals.
|
||||||
|
- **Schema.** The row's `data.requestedSchema` is a JSON Schema object
|
||||||
|
whose properties describe the fields the agent wants filled:
|
||||||
|
`StringPropertySchema` (with optional `enum`/`oneOf` for multiple-choice),
|
||||||
|
`BooleanPropertySchema`, `NumberPropertySchema`, `IntegerPropertySchema`.
|
||||||
|
Claude Code's common case is a single-property string enum — a
|
||||||
|
multiple-choice question with titled options.
|
||||||
|
- **Rendering.** Clients render the `message` as the question and the schema
|
||||||
|
properties as form fields or button groups. The web client detects the
|
||||||
|
single-property string enum fast path and renders titled buttons. Resolved
|
||||||
|
elicitations collapse to a compact one-liner.
|
||||||
|
|
||||||
## Prompt and cancel: bounded liveness
|
## Prompt and cancel: bounded liveness
|
||||||
|
|
||||||
The web composer accepts up to ten files through its attachment picker, file
|
The web composer accepts up to ten files through its attachment picker, file
|
||||||
@@ -364,8 +404,8 @@ allowed to claim the daemon's idea of "the active turn."
|
|||||||
- A submitted prompt is appended as a durable `User` row before it claims the turn and
|
- A submitted prompt is appended as a durable `User` row before it claims the turn and
|
||||||
dispatches to ACP, so the transcript never depends on an adapter echoing the
|
dispatches to ACP, so the transcript never depends on an adapter echoing the
|
||||||
operator's input. If that append fails, the prompt is not dispatched.
|
operator's input. If that append fails, the prompt is not dispatched.
|
||||||
- Cancel sends `session/cancel`, clears any pending approval as `Cancelled`,
|
- Cancel sends `session/cancel`, clears any pending approval and elicitation
|
||||||
and ends the turn's client-visible lifecycle immediately. The prompt queue
|
as `Cancelled`, and ends the turn's client-visible lifecycle immediately. The prompt queue
|
||||||
is preserved: after the cancelled turn settles, the queued prompts drain as
|
is preserved: after the cancelled turn settles, the queued prompts drain as
|
||||||
the next turn. If the session dies instead (settlement timeout), the queue
|
the next turn. If the session dies instead (settlement timeout), the queue
|
||||||
is cleared. If the cancelled turn was still *dispatched* to ACP (waiting on
|
is cleared. If the cancelled turn was still *dispatched* to ACP (waiting on
|
||||||
@@ -390,8 +430,8 @@ directory: the sidecar itself (`<id>.json`), plus two next to it:
|
|||||||
|
|
||||||
- **`<id>.events.jsonl`** — the unified event log. Each line is either a raw
|
- **`<id>.events.jsonl`** — the unified event log. Each line is either a raw
|
||||||
ACP `SessionNotification` (kept in its original JSON so old logs stay
|
ACP `SessionNotification` (kept in its original JSON so old logs stay
|
||||||
readable) or a `{"maestroEvent": "user"|"approval", "row": …}` envelope
|
readable) or a `{"maestroEvent": "user"|"approval"|"elicitation", "row": …}`
|
||||||
for a daemon-generated row. Both share the same 1-based line-position cursor. A
|
envelope for a daemon-generated row. Both share the same 1-based line-position cursor. A
|
||||||
line a hard kill left half-written is terminated before the next append, so
|
line a hard kill left half-written is terminated before the next append, so
|
||||||
it keeps its position and is skipped as malformed rather than corrupting
|
it keeps its position and is skipped as malformed rather than corrupting
|
||||||
what follows. An append that fails *while the daemon is running* — a
|
what follows. An append that fails *while the daemon is running* — a
|
||||||
@@ -453,12 +493,13 @@ itself failing) leaves it `Dead` with the failure reason as
|
|||||||
`activity_detail` — the same "tell the operator what to fix" pattern as a
|
`activity_detail` — the same "tell the operator what to fix" pattern as a
|
||||||
missing adapter binary.
|
missing adapter binary.
|
||||||
|
|
||||||
Pending approvals and their nonces never survive any of this. They live only
|
Pending approvals and elicitations and their nonces never survive any of
|
||||||
in the in-memory `pending_approvals` map and the live `ReducedState`, both
|
this. They live only in the in-memory `pending_approvals` /
|
||||||
gone the instant the daemon that held them exits. Whatever ACP permission
|
`pending_elicitations` maps and the live `ReducedState`, both gone the
|
||||||
request was mid-flight when the old daemon died was already answered
|
instant the daemon that held them exits. Whatever ACP permission or
|
||||||
`Cancelled` on that daemon's own shutdown path, so a resumed session always
|
elicitation request was mid-flight when the old daemon died was already
|
||||||
starts with no pending approval.
|
answered `Cancelled` on that daemon's own shutdown path, so a resumed
|
||||||
|
session always starts with no pending approval or elicitation.
|
||||||
|
|
||||||
See `docs/RESTART.md` for what a *client* observes across a daemon restart,
|
See `docs/RESTART.md` for what a *client* observes across a daemon restart,
|
||||||
now that structured sessions exist alongside terminal ones.
|
now that structured sessions exist alongside terminal ones.
|
||||||
|
|||||||
+18
-1
@@ -12,6 +12,7 @@ import type {
|
|||||||
RecentDirs,
|
RecentDirs,
|
||||||
Session,
|
Session,
|
||||||
StructuredApprovalDecision,
|
StructuredApprovalDecision,
|
||||||
|
StructuredElicitationAction,
|
||||||
StructuredConfigRequest,
|
StructuredConfigRequest,
|
||||||
StructuredPromptRequest,
|
StructuredPromptRequest,
|
||||||
Uuid,
|
Uuid,
|
||||||
@@ -39,7 +40,7 @@ export class ApiRequestError extends Error {
|
|||||||
|
|
||||||
async function jsonOrError<T>(res: Response): Promise<T> {
|
async function jsonOrError<T>(res: Response): Promise<T> {
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
if (res.status === 204) return undefined as unknown as T;
|
if (res.status === 204 || res.status === 202) return undefined as unknown as T;
|
||||||
return (await res.json()) as T;
|
return (await res.json()) as T;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -219,4 +220,20 @@ export async function resolveStructuredApproval(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve a pending elicitation. Same idempotency as approvals. */
|
||||||
|
export async function resolveStructuredElicitation(
|
||||||
|
id: Uuid,
|
||||||
|
nonce: string,
|
||||||
|
action: StructuredElicitationAction,
|
||||||
|
): Promise<void> {
|
||||||
|
await jsonOrError(
|
||||||
|
await fetch(routes.structuredElicitation(id, nonce), {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(action),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export { eventsWsUrl, structuredWsUrl } from "./routes.js";
|
export { eventsWsUrl, structuredWsUrl } from "./routes.js";
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { activeTab, type Dir, type Node } from "../../layout.js";
|
|||||||
import { usePreview } from "../../hooks/usePreview.js";
|
import { usePreview } from "../../hooks/usePreview.js";
|
||||||
import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js";
|
import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js";
|
||||||
import type { Session, Uuid } from "../../types.js";
|
import type { Session, Uuid } from "../../types.js";
|
||||||
|
import { requestToggleDrawer } from "../../uiEvents.js";
|
||||||
import { PaneContainer, type PaneContainerHandle } from "./PaneContainer.js";
|
import { PaneContainer, type PaneContainerHandle } from "./PaneContainer.js";
|
||||||
import { PaneTitle } from "./PaneTitle.js";
|
import { PaneTitle } from "./PaneTitle.js";
|
||||||
import { TabBar } from "./TabBar.js";
|
import { TabBar } from "./TabBar.js";
|
||||||
@@ -204,6 +205,14 @@ function Pane({
|
|||||||
<div
|
<div
|
||||||
className="mv-pane-header attach-header flex items-center justify-between gap-sp-2 border-b border-border-strong px-sp-2 py-sp-1 text-fs-12"
|
className="mv-pane-header attach-header flex items-center justify-between gap-sp-2 border-b border-border-strong px-sp-2 py-sp-1 text-fs-12"
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sessions-btn mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
|
||||||
|
title="Sessions"
|
||||||
|
onClick={() => requestToggleDrawer()}
|
||||||
|
>
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
{leaf.sessionId ? (
|
{leaf.sessionId ? (
|
||||||
<PaneTitle sessionId={leaf.sessionId} session={session} />
|
<PaneTitle sessionId={leaf.sessionId} session={session} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
// An `Elicitation` row. The daemon mints the nonce; this only solicits one
|
||||||
|
// of the three possible actions (accept, decline, cancel).
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AcpElicitationProperty,
|
||||||
|
AcpElicitationSchema,
|
||||||
|
StructuredElicitationAction,
|
||||||
|
} from "../../types.js";
|
||||||
|
|
||||||
|
export interface ElicitationViewProps {
|
||||||
|
/** The `CreateElicitationRequest` the row carries — serialized without
|
||||||
|
* its nonce, which never leaves the daemon's live wire. */
|
||||||
|
request: {
|
||||||
|
message?: string;
|
||||||
|
requestedSchema?: AcpElicitationSchema;
|
||||||
|
};
|
||||||
|
/** Whether this is the one live elicitation. */
|
||||||
|
pending: boolean;
|
||||||
|
onDecide: (action: StructuredElicitationAction) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** For a single-property string enum schema (the common Claude Code
|
||||||
|
* multiple-choice case), extract the property key and its options. */
|
||||||
|
function singleSelectEnum(
|
||||||
|
schema: AcpElicitationSchema | undefined,
|
||||||
|
): { key: string; options: Array<{ value: string; title: string; description?: string }> } | null {
|
||||||
|
if (!schema) return null;
|
||||||
|
const keys = Object.keys(schema.properties);
|
||||||
|
if (keys.length !== 1) return null;
|
||||||
|
const key = keys[0];
|
||||||
|
const prop = schema.properties[key];
|
||||||
|
if (prop.oneOf && prop.oneOf.length > 0) {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
options: prop.oneOf.map((o) => ({
|
||||||
|
value: o.const,
|
||||||
|
title: o.title,
|
||||||
|
description: o.description,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (prop.enum && prop.enum.length > 0) {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
options: prop.enum.map((v) => ({ value: v, title: v })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ElicitationView({ request, pending, onDecide }: ElicitationViewProps) {
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [formValues, setFormValues] = useState<Record<string, string | number | boolean>>({});
|
||||||
|
const message = request.message ?? "Input requested";
|
||||||
|
const schema = request.requestedSchema;
|
||||||
|
|
||||||
|
if (!pending) {
|
||||||
|
return <div className="st-row st-row-elicitation st-elicitation-compact">{message}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decide = (action: StructuredElicitationAction) => {
|
||||||
|
setBusy(true);
|
||||||
|
onDecide(action).catch(() => setBusy(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const singleSelect = singleSelectEnum(schema);
|
||||||
|
|
||||||
|
// Fast path: single-property string enum → button group (the common
|
||||||
|
// Claude Code multiple-choice question).
|
||||||
|
if (singleSelect) {
|
||||||
|
return (
|
||||||
|
<div className="st-row st-row-elicitation">
|
||||||
|
<div className="st-elicitation">
|
||||||
|
<div className="st-elicitation-title">
|
||||||
|
<span aria-hidden="true">?</span> {message}
|
||||||
|
</div>
|
||||||
|
<div className="st-elicitation-options">
|
||||||
|
{singleSelect.options.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
className="act"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
decide({ action: "accept", content: { [singleSelect.key]: opt.value } })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{opt.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="st-elicitation-acts">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="act deny"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => decide({ action: "decline" })}
|
||||||
|
>
|
||||||
|
Skip
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// General form: render inputs for each property.
|
||||||
|
const properties = schema?.properties ?? {};
|
||||||
|
const propertyKeys = Object.keys(properties);
|
||||||
|
const requiredSet = new Set(schema?.required ?? []);
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
decide({ action: "accept", content: formValues });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateField = (key: string, value: string | number | boolean) => {
|
||||||
|
setFormValues((prev) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="st-row st-row-elicitation">
|
||||||
|
<div className="st-elicitation">
|
||||||
|
<div className="st-elicitation-title">
|
||||||
|
<span aria-hidden="true">?</span> {message}
|
||||||
|
</div>
|
||||||
|
<div className="st-elicitation-form">
|
||||||
|
{propertyKeys.map((key) => {
|
||||||
|
const prop = properties[key];
|
||||||
|
return (
|
||||||
|
<ElicitationField
|
||||||
|
key={key}
|
||||||
|
name={key}
|
||||||
|
prop={prop}
|
||||||
|
required={requiredSet.has(key)}
|
||||||
|
disabled={busy}
|
||||||
|
value={formValues[key]}
|
||||||
|
onChange={(v) => updateField(key, v)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="st-elicitation-acts">
|
||||||
|
<button type="button" className="act primary" disabled={busy} onClick={handleSubmit}>
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="act deny"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => decide({ action: "decline" })}
|
||||||
|
>
|
||||||
|
Skip
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ElicitationField({
|
||||||
|
name,
|
||||||
|
prop,
|
||||||
|
required,
|
||||||
|
disabled,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
prop: AcpElicitationProperty;
|
||||||
|
required: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
value: string | number | boolean | undefined;
|
||||||
|
onChange: (value: string | number | boolean) => void;
|
||||||
|
}) {
|
||||||
|
const label = prop.title ?? name;
|
||||||
|
const type = prop.type ?? "string";
|
||||||
|
|
||||||
|
if (type === "boolean") {
|
||||||
|
return (
|
||||||
|
<label className="st-elicitation-field">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={typeof value === "boolean" ? value : (prop.default === true)}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
{prop.description && <span className="st-elicitation-desc">{prop.description}</span>}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "number" || type === "integer") {
|
||||||
|
return (
|
||||||
|
<label className="st-elicitation-field">
|
||||||
|
{label}
|
||||||
|
{required && <span className="st-elicitation-required">*</span>}
|
||||||
|
{prop.description && <span className="st-elicitation-desc">{prop.description}</span>}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step={type === "integer" ? 1 : "any"}
|
||||||
|
min={prop.minimum}
|
||||||
|
max={prop.maximum}
|
||||||
|
value={typeof value === "number" ? value : ""}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onChange(type === "integer" ? parseInt(e.target.value) : parseFloat(e.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: string
|
||||||
|
return (
|
||||||
|
<label className="st-elicitation-field">
|
||||||
|
{label}
|
||||||
|
{required && <span className="st-elicitation-required">*</span>}
|
||||||
|
{prop.description && <span className="st-elicitation-desc">{prop.description}</span>}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={typeof value === "string" ? value : ""}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={typeof prop.default === "string" ? prop.default : undefined}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -170,16 +170,14 @@ describe("tool rows and activity groups", () => {
|
|||||||
snapshot(socket, [
|
snapshot(socket, [
|
||||||
chunk("user:1", "user", "go"),
|
chunk("user:1", "user", "go"),
|
||||||
row("tool:1", "tool_call", { title: "Read(a.ts)", kind: "read", status: "completed" }),
|
row("tool:1", "tool_call", { title: "Read(a.ts)", kind: "read", status: "completed" }),
|
||||||
chunk("agent:1", "agent_text", "still working"),
|
|
||||||
row("tool:2", "tool_call", { title: "Read(b.ts)", kind: "read", status: "completed" }),
|
row("tool:2", "tool_call", { title: "Read(b.ts)", kind: "read", status: "completed" }),
|
||||||
chunk("agent:2", "agent_text", "the answer"),
|
chunk("agent:1", "agent_text", "the answer"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const head = screen.getByText(/^2 tool calls/).closest("button")!;
|
const head = screen.getByText(/^2 tool calls/).closest("button")!;
|
||||||
expect(head.getAttribute("aria-expanded")).toBe("false");
|
expect(head.getAttribute("aria-expanded")).toBe("false");
|
||||||
// The final answer stays outside the group; the intermediate text is in it.
|
// The final answer stays outside the group.
|
||||||
expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("the answer"))).toBe(false);
|
expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("the answer"))).toBe(false);
|
||||||
expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("still working"))).toBe(true);
|
|
||||||
|
|
||||||
fireEvent.click(head);
|
fireEvent.click(head);
|
||||||
expect(head.getAttribute("aria-expanded")).toBe("true");
|
expect(head.getAttribute("aria-expanded")).toBe("true");
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
cancelStructuredTurn,
|
cancelStructuredTurn,
|
||||||
resolveStructuredApproval,
|
resolveStructuredApproval,
|
||||||
|
resolveStructuredElicitation,
|
||||||
sendStructuredPrompt,
|
sendStructuredPrompt,
|
||||||
setStructuredConfig,
|
setStructuredConfig,
|
||||||
uploadStructuredFile,
|
uploadStructuredFile,
|
||||||
@@ -29,6 +30,7 @@ import { formatContextTokens, formatElapsed } from "./render.js";
|
|||||||
import type {
|
import type {
|
||||||
AcpSessionConfigOption,
|
AcpSessionConfigOption,
|
||||||
StructuredApprovalDecision,
|
StructuredApprovalDecision,
|
||||||
|
StructuredElicitationAction,
|
||||||
TranscriptRow as Row,
|
TranscriptRow as Row,
|
||||||
Uuid,
|
Uuid,
|
||||||
} from "../../types.js";
|
} from "../../types.js";
|
||||||
@@ -36,6 +38,7 @@ import { Composer } from "./Composer.js";
|
|||||||
import {
|
import {
|
||||||
commandsFromTranscript,
|
commandsFromTranscript,
|
||||||
pendingApprovalRowId as pendingApprovalRowIdOf,
|
pendingApprovalRowId as pendingApprovalRowIdOf,
|
||||||
|
pendingElicitationRowId as pendingElicitationRowIdOf,
|
||||||
transcriptItems,
|
transcriptItems,
|
||||||
turnIsActive,
|
turnIsActive,
|
||||||
} from "./model.js";
|
} from "./model.js";
|
||||||
@@ -124,9 +127,10 @@ export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPanePro
|
|||||||
|
|
||||||
const turnActive = turnIsActive(state);
|
const turnActive = turnIsActive(state);
|
||||||
const pendingRowId = useMemo(() => pendingApprovalRowIdOf(rows, state), [rows, state]);
|
const pendingRowId = useMemo(() => pendingApprovalRowIdOf(rows, state), [rows, state]);
|
||||||
|
const pendingElicitationRowId = useMemo(() => pendingElicitationRowIdOf(rows, state), [rows, state]);
|
||||||
const items = useMemo(
|
const items = useMemo(
|
||||||
() => transcriptItems(rows, pendingRowId, turnActive),
|
() => transcriptItems(rows, pendingRowId, turnActive, pendingElicitationRowId),
|
||||||
[rows, pendingRowId, turnActive],
|
[rows, pendingRowId, turnActive, pendingElicitationRowId],
|
||||||
);
|
);
|
||||||
const commands = useMemo(() => commandsFromTranscript(rows), [rows]);
|
const commands = useMemo(() => commandsFromTranscript(rows), [rows]);
|
||||||
const configOptions: AcpSessionConfigOption[] = state.config_options?.configOptions ?? [];
|
const configOptions: AcpSessionConfigOption[] = state.config_options?.configOptions ?? [];
|
||||||
@@ -193,6 +197,20 @@ export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPanePro
|
|||||||
[sessionId, state.pending_approval?.nonce, pendingRowId],
|
[sessionId, state.pending_approval?.nonce, pendingRowId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const decideElicitation = useCallback(
|
||||||
|
async (rowId: string, action: StructuredElicitationAction): Promise<void> => {
|
||||||
|
const nonce = state.pending_elicitation?.nonce;
|
||||||
|
if (!nonce || rowId !== pendingElicitationRowId) return;
|
||||||
|
try {
|
||||||
|
await resolveStructuredElicitation(sessionId, nonce, action);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(String(e));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sessionId, state.pending_elicitation?.nonce, pendingElicitationRowId],
|
||||||
|
);
|
||||||
|
|
||||||
const dispatchPrompt = useCallback(
|
const dispatchPrompt = useCallback(
|
||||||
(text: string, files: File[]) => {
|
(text: string, files: File[]) => {
|
||||||
const generation = ++submission.current;
|
const generation = ++submission.current;
|
||||||
@@ -311,6 +329,8 @@ export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPanePro
|
|||||||
row={row}
|
row={row}
|
||||||
pendingApproval={row.id === pendingRowId}
|
pendingApproval={row.id === pendingRowId}
|
||||||
onDecide={(decision) => decide(row.id, decision)}
|
onDecide={(decision) => decide(row.id, decision)}
|
||||||
|
pendingElicitation={row.id === pendingElicitationRowId}
|
||||||
|
onElicitationDecide={(action) => decideElicitation(row.id, action)}
|
||||||
expanded={expandedRows.has(row.id)}
|
expanded={expandedRows.has(row.id)}
|
||||||
onExpandedChange={(expanded) => setRowExpanded(row.id, expanded)}
|
onExpandedChange={(expanded) => setRowExpanded(row.id, expanded)}
|
||||||
imageUrls={imageUrls.current}
|
imageUrls={imageUrls.current}
|
||||||
|
|||||||
@@ -10,14 +10,17 @@ import { useEffect, useRef, type ReactNode } from "react";
|
|||||||
import { renderInlineMarkdown, renderMarkdown, formatElapsed } from "./render.js";
|
import { renderInlineMarkdown, renderMarkdown, formatElapsed } from "./render.js";
|
||||||
import type {
|
import type {
|
||||||
AcpContentBlock,
|
AcpContentBlock,
|
||||||
|
AcpElicitationSchema,
|
||||||
AcpPermissionOption,
|
AcpPermissionOption,
|
||||||
AcpToolCallContent,
|
AcpToolCallContent,
|
||||||
AcpToolCallLike,
|
AcpToolCallLike,
|
||||||
StructuredApprovalDecision,
|
StructuredApprovalDecision,
|
||||||
|
StructuredElicitationAction,
|
||||||
TranscriptRow as Row,
|
TranscriptRow as Row,
|
||||||
TurnMeta,
|
TurnMeta,
|
||||||
} from "../../types.js";
|
} from "../../types.js";
|
||||||
import { ApprovalView } from "./ApprovalView.js";
|
import { ApprovalView } from "./ApprovalView.js";
|
||||||
|
import { ElicitationView } from "./ElicitationView.js";
|
||||||
import { Copyable } from "./Copyable.js";
|
import { Copyable } from "./Copyable.js";
|
||||||
import { DiffView } from "./DiffView.js";
|
import { DiffView } from "./DiffView.js";
|
||||||
import {
|
import {
|
||||||
@@ -337,6 +340,9 @@ export interface TranscriptRowProps {
|
|||||||
* rules). Every other approval row is history. */
|
* rules). Every other approval row is history. */
|
||||||
pendingApproval?: boolean;
|
pendingApproval?: boolean;
|
||||||
onDecide?: (decision: StructuredApprovalDecision) => Promise<void>;
|
onDecide?: (decision: StructuredApprovalDecision) => Promise<void>;
|
||||||
|
/** This row is the one live elicitation. */
|
||||||
|
pendingElicitation?: boolean;
|
||||||
|
onElicitationDecide?: (action: StructuredElicitationAction) => Promise<void>;
|
||||||
expanded?: boolean;
|
expanded?: boolean;
|
||||||
onExpandedChange?: (expanded: boolean) => void;
|
onExpandedChange?: (expanded: boolean) => void;
|
||||||
imageUrls?: Map<string, string>;
|
imageUrls?: Map<string, string>;
|
||||||
@@ -348,6 +354,8 @@ export function TranscriptRow({
|
|||||||
row,
|
row,
|
||||||
pendingApproval = false,
|
pendingApproval = false,
|
||||||
onDecide,
|
onDecide,
|
||||||
|
pendingElicitation = false,
|
||||||
|
onElicitationDecide,
|
||||||
expanded = false,
|
expanded = false,
|
||||||
onExpandedChange,
|
onExpandedChange,
|
||||||
imageUrls,
|
imageUrls,
|
||||||
@@ -374,6 +382,16 @@ export function TranscriptRow({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
case "elicitation": {
|
||||||
|
const request = (row.data ?? {}) as { message?: string; requestedSchema?: AcpElicitationSchema };
|
||||||
|
return (
|
||||||
|
<ElicitationView
|
||||||
|
request={request}
|
||||||
|
pending={pendingElicitation}
|
||||||
|
onDecide={onElicitationDecide ?? (() => Promise.resolve())}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
case "plan":
|
case "plan":
|
||||||
return null;
|
return null;
|
||||||
case "notice": {
|
case "notice": {
|
||||||
|
|||||||
@@ -134,6 +134,11 @@ export function pendingApprovalRowId(rows: TranscriptRow[], state: ReducedState)
|
|||||||
return state.pending_approval ? lastRowIdOfKind(rows, "approval") : null;
|
return state.pending_approval ? lastRowIdOfKind(rows, "approval") : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Same for elicitations — one live at a time. */
|
||||||
|
export function pendingElicitationRowId(rows: TranscriptRow[], state: ReducedState): string | null {
|
||||||
|
return state.pending_elicitation ? lastRowIdOfKind(rows, "elicitation") : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function lastRowIdOfKind(rows: TranscriptRow[], kind: TranscriptRowKind): string | null {
|
export function lastRowIdOfKind(rows: TranscriptRow[], kind: TranscriptRowKind): string | null {
|
||||||
for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === kind) return rows[i].id;
|
for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === kind) return rows[i].id;
|
||||||
return null;
|
return null;
|
||||||
@@ -168,6 +173,7 @@ export function activityGroupsByTurn(
|
|||||||
rows: TranscriptRow[],
|
rows: TranscriptRow[],
|
||||||
pendingApprovalId: string | null,
|
pendingApprovalId: string | null,
|
||||||
_turnActive: boolean,
|
_turnActive: boolean,
|
||||||
|
pendingElicitationId: string | null = null,
|
||||||
): Map<string, ActivityGroup> {
|
): Map<string, ActivityGroup> {
|
||||||
const groups = new Map<string, ActivityGroup>();
|
const groups = new Map<string, ActivityGroup>();
|
||||||
let turnId = "before-first-message";
|
let turnId = "before-first-message";
|
||||||
@@ -204,8 +210,10 @@ export function activityGroupsByTurn(
|
|||||||
flushRun();
|
flushRun();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const isResolvedApproval = row.kind === "approval" && row.id !== pendingApprovalId;
|
const isResolvedInteraction =
|
||||||
if (isResolvedApproval) {
|
(row.kind === "approval" && row.id !== pendingApprovalId) ||
|
||||||
|
(row.kind === "elicitation" && row.id !== pendingElicitationId);
|
||||||
|
if (isResolvedInteraction) {
|
||||||
runPending.push(row);
|
runPending.push(row);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -239,8 +247,9 @@ export function transcriptItems(
|
|||||||
rows: TranscriptRow[],
|
rows: TranscriptRow[],
|
||||||
pendingApprovalId: string | null,
|
pendingApprovalId: string | null,
|
||||||
turnActive: boolean,
|
turnActive: boolean,
|
||||||
|
pendingElicitationId: string | null = null,
|
||||||
): TranscriptItem[] {
|
): TranscriptItem[] {
|
||||||
const groups = activityGroupsByTurn(rows, pendingApprovalId, turnActive);
|
const groups = activityGroupsByTurn(rows, pendingApprovalId, turnActive, pendingElicitationId);
|
||||||
// Reverse index: row id → group id that contains it.
|
// Reverse index: row id → group id that contains it.
|
||||||
const rowToGroup = new Map<string, string>();
|
const rowToGroup = new Map<string, string>();
|
||||||
for (const [groupId, group] of groups) for (const r of group.rows) rowToGroup.set(r.id, groupId);
|
for (const [groupId, group] of groups) for (const r of group.rows) rowToGroup.set(r.id, groupId);
|
||||||
@@ -259,9 +268,10 @@ export function transcriptItems(
|
|||||||
}
|
}
|
||||||
if (row.kind === "plan") continue;
|
if (row.kind === "plan") continue;
|
||||||
if (row.kind === "notice" && isStateOnlyNotice(row)) continue;
|
if (row.kind === "notice" && isStateOnlyNotice(row)) continue;
|
||||||
// A resolved approval outside a group adds nothing the tool row does not
|
// A resolved approval/elicitation outside a group adds nothing the tool
|
||||||
// already say. A pending one always renders.
|
// row does not already say. A pending one always renders.
|
||||||
if (row.kind === "approval" && row.id !== pendingApprovalId) continue;
|
if (row.kind === "approval" && row.id !== pendingApprovalId) continue;
|
||||||
|
if (row.kind === "elicitation" && row.id !== pendingElicitationId) continue;
|
||||||
items.push({ type: "row", row });
|
items.push({ type: "row", row });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -913,6 +913,90 @@
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Elicitation: the structured question the agent asks the operator.
|
||||||
|
Visually parallel to approvals but accent-tinted instead of warn-tinted. */
|
||||||
|
.st-elicitation {
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: var(--r-2);
|
||||||
|
background: color-mix(in oklab, var(--accent) 10%, var(--surface));
|
||||||
|
box-shadow: var(--shadow-1);
|
||||||
|
padding: var(--sp-3);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-elicitation-title {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: var(--fs-11);
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-elicitation-options {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-elicitation-acts {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-elicitation-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-elicitation-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-1);
|
||||||
|
font-size: var(--fs-12);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-elicitation-field input[type="text"],
|
||||||
|
.st-elicitation-field input[type="number"] {
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 var(--sp-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-1);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-elicitation-field input[type="checkbox"] {
|
||||||
|
margin-right: var(--sp-1);
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-elicitation-required {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-elicitation-desc {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--fs-11);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-elicitation-compact {
|
||||||
|
font-size: var(--fs-11);
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-row-elicitation:hover {
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
.act {
|
.act {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
|
|||||||
@@ -67,6 +67,12 @@ export function structuredApproval(id: Uuid, nonce: string): string {
|
|||||||
return `${session(id)}/structured/approvals/${encodeURIComponent(nonce)}`;
|
return `${session(id)}/structured/approvals/${encodeURIComponent(nonce)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `POST /api/sessions/:id/structured/elicitations/:nonce` — answer an
|
||||||
|
* elicitation request. */
|
||||||
|
export function structuredElicitation(id: Uuid, nonce: string): string {
|
||||||
|
return `${session(id)}/structured/elicitations/${encodeURIComponent(nonce)}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Append the `path` query parameter `DIRS` and `GITROOT` accept. */
|
/** Append the `path` query parameter `DIRS` and `GITROOT` accept. */
|
||||||
export function withPathQuery(route: string, value: string): string {
|
export function withPathQuery(route: string, value: string): string {
|
||||||
return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`;
|
return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`;
|
||||||
|
|||||||
@@ -4651,6 +4651,12 @@ input.leaf-rename:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 859px) {
|
@media (max-width: 859px) {
|
||||||
|
/* On mobile the sidebar drawer is the only session-switching surface, so
|
||||||
|
the tab bar is redundant — hide it unconditionally. */
|
||||||
|
.mv-tabs {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* sidebar → off-canvas drawer (override the route-based show/hide) */
|
/* sidebar → off-canvas drawer (override the route-based show/hide) */
|
||||||
.app-shell > .sidebar {
|
.app-shell > .sidebar {
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
|
|||||||
@@ -395,6 +395,7 @@ export type TranscriptRowKind =
|
|||||||
| "tool_call"
|
| "tool_call"
|
||||||
| "tool_diff"
|
| "tool_diff"
|
||||||
| "approval"
|
| "approval"
|
||||||
|
| "elicitation"
|
||||||
| "plan"
|
| "plan"
|
||||||
| "notice";
|
| "notice";
|
||||||
|
|
||||||
@@ -435,6 +436,7 @@ export interface TurnState {
|
|||||||
export interface ReducedState {
|
export interface ReducedState {
|
||||||
turn: TurnState;
|
turn: TurnState;
|
||||||
pending_approval?: { nonce: string; request: AcpRequestPermissionRequest } | null;
|
pending_approval?: { nonce: string; request: AcpRequestPermissionRequest } | null;
|
||||||
|
pending_elicitation?: { nonce: string; message?: string; schema?: AcpElicitationSchema } | null;
|
||||||
plan?: AcpPlan | null;
|
plan?: AcpPlan | null;
|
||||||
usage?: AcpUsage | null;
|
usage?: AcpUsage | null;
|
||||||
context?: AcpUsage | null;
|
context?: AcpUsage | null;
|
||||||
@@ -486,6 +488,35 @@ export interface AcpSessionConfigOption {
|
|||||||
* string (a bracketless Rust enum), not `{decision: ...}`. */
|
* string (a bracketless Rust enum), not `{decision: ...}`. */
|
||||||
export type StructuredApprovalDecision = "allow_once" | "allow_for_session" | "deny";
|
export type StructuredApprovalDecision = "allow_once" | "allow_for_session" | "deny";
|
||||||
|
|
||||||
|
/** `POST /api/sessions/:id/structured/elicitations/:nonce` body. */
|
||||||
|
export type StructuredElicitationAction =
|
||||||
|
| { action: "accept"; content: Record<string, string | number | boolean> }
|
||||||
|
| { action: "decline" }
|
||||||
|
| { action: "cancel" };
|
||||||
|
|
||||||
|
/** Property schema within an ACP elicitation request. */
|
||||||
|
export interface AcpElicitationProperty {
|
||||||
|
type?: "string" | "number" | "integer" | "boolean";
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
default?: string | number | boolean;
|
||||||
|
enum?: string[];
|
||||||
|
oneOf?: Array<{ const: string; title: string; description?: string }>;
|
||||||
|
minLength?: number;
|
||||||
|
maxLength?: number;
|
||||||
|
minimum?: number;
|
||||||
|
maximum?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `requested_schema` carried by an ACP `elicitation/create` request. */
|
||||||
|
export interface AcpElicitationSchema {
|
||||||
|
type?: "object";
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
properties: Record<string, AcpElicitationProperty>;
|
||||||
|
required?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
// ---- ACP v1 payload shapes (agent-client-protocol schema, camelCase) ----
|
// ---- ACP v1 payload shapes (agent-client-protocol schema, camelCase) ----
|
||||||
|
|
||||||
export interface AcpEmbeddedResource {
|
export interface AcpEmbeddedResource {
|
||||||
|
|||||||
Reference in New Issue
Block a user