Compare commits

...
Author SHA1 Message Date
Miguel Palhas c90bdfbe42 feat(maestro): open terminal pane from a tapped sidebar session
Tapping a session in the sidebar now opens TerminalScreen full-screen
instead of just closing the drawer. windowSoftInputMode=adjustResize
lets WindowInsets.isImeVisible drive the key bar's visibility.
2026-09-13 01:07:32 +01:00
Miguel Palhas 27b6e647c1 feat(maestro): add Android terminal pane composable
TerminalWebView wraps a WebView around the bundled xterm.js page, with
a JS bridge (maestroKey/maestroToggleMod/maestroFocus) the native key
bar drives; onModifierConsumed reports back when the page's own JS
folds an armed Ctrl/Alt into a soft-keyboard keystroke, so the native
toggle stays in sync. TerminalScreen composes the WebView with the key
bar, showing the bar only while WindowInsets.isImeVisible.
2026-09-13 01:07:23 +01:00
Miguel Palhas 0631e69ef0 feat(maestro): add Android terminal extra key bar composable
Termux-style row for the soft keyboard (issue #266): Tab, Esc, one-shot
Ctrl/Alt modifiers, and the four cursor arrows, scrollable horizontally
for narrow screens.
2026-09-13 01:07:15 +01:00
Miguel Palhas f98c637cd9 feat(maestro): add Android terminal attach URL builder
TerminalPageUrl builds the file:///android_asset/terminal/index.html
URL for one session, with the WS /api/attach/:id URL Routes already
knows how to build passed through as an encoded query param.
2026-09-13 01:07:07 +01:00
Miguel Palhas 554182d956 feat(maestro): bundle xterm.js terminal page for Android WebView pane
Vendors @xterm/xterm 5.5.0 and @xterm/addon-fit 0.10.0 as static assets
plus a small HTML/JS page that owns the WS /api/attach/:id connection
directly, the same shape as the web SPA's TerminalPane.tsx. Bundled
locally so the pane only needs LAN reachability to the daemon, not
internet access to a CDN.
2026-09-13 01:06:59 +01:00
Miguel Palhas 391efc72d2 feat(maestro): add SpawnSheet bottom sheet composable (#269)
Preset chip row, searchable project list, debounced directory
autocomplete, and a branch/worktree picker matching the web spawn
modal, wired to SpawnViewModel and a self-contained snackbar for
submit errors.
2026-09-13 00:54:02 +01:00
Miguel Palhas cf582be306 feat(maestro): add spawn form model and view model (#269)
Pure request-building logic ported from web/src/components/spawn/model.ts,
plus a testable SpawnViewModel wiring presets, projects, debounced dir
autocomplete, branch loading, and session creation to MaestroClient.
2026-09-13 00:49:55 +01:00
19 changed files with 1938 additions and 24 deletions
+3
View File
@@ -71,6 +71,9 @@ dependencies {
implementation(libs.kotlinx.coroutines.android)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.okhttp.mockwebserver)
testImplementation(libs.turbine)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.espresso.core)
+1
View File
@@ -15,6 +15,7 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.Maestro">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -0,0 +1,165 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<link rel="stylesheet" href="vendor/xterm.css" />
<style>
html, body, #terminal { height: 100%; margin: 0; padding: 0; background: #121419; overflow: hidden; }
.xterm { padding: 4px; }
</style>
</head>
<body>
<div id="terminal"></div>
<script src="vendor/xterm.js"></script>
<script src="vendor/addon-fit.js"></script>
<script>
// Owns the `WS /api/attach/:id` connection directly, the same shape as
// web/src/components/terminal/TerminalPane.tsx — PTY bytes in binary
// frames both ways, `ClientControl` JSON in text frames. The native
// Android side (TerminalWebView.kt) never touches the socket; it only
// calls the maestro* bridge functions below for the extra key bar.
var params = new URLSearchParams(location.search);
var wsUrl = params.get("ws");
var term = new Terminal({
cursorBlink: true,
fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
fontSize: 13,
convertEol: false,
scrollback: 2000,
theme: {
background: "#121419",
foreground: "#e6e8ec",
cursor: "#5fe3a1",
cursorAccent: "#121419",
selectionBackground: "#2b6b4f",
},
});
var fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
var container = document.getElementById("terminal");
term.open(container);
fitAddon.fit();
var ws = null;
var awaitingReplay = true;
var armed = { ctrl: false, alt: false };
function sendControl(msg) {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
}
function sendInput(data) {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(new TextEncoder().encode(data));
}
function connect() {
awaitingReplay = true;
ws = new WebSocket(wsUrl);
ws.binaryType = "arraybuffer";
ws.onopen = function () {
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
};
ws.onmessage = function (ev) {
if (typeof ev.data === "string") return; // control frame, nothing to render
var replay = awaitingReplay;
awaitingReplay = false;
term.write(new Uint8Array(ev.data), function () {
if (replay) term.scrollToBottom();
});
};
ws.onclose = function (ev) {
if (ev.code === 1000) return;
term.write("\r\n\x1b[31m[disconnected]\x1b[0m\r\n");
};
}
// Ctrl maps to the C0 control range (A-Z, [, \, ], ^, _); Alt prefixes ESC
// — the same encoding buildKeyBar/applyModsToChar use on the web client.
function applyModsToChar(ch, ctrl, alt) {
var out = ch;
if (ctrl) {
var code = ch.toUpperCase().charCodeAt(0);
if (code >= 64 && code <= 95) out = String.fromCharCode(code - 64);
}
if (alt) out = "\x1b" + out;
return out;
}
// CSI cursor sequence with the modifier parameter xterm expects:
// `1;<mod>` where 3=alt, 5=ctrl, 7=ctrl+alt.
function cursorSeq(final, ctrl, alt) {
if (!ctrl && !alt) return "\x1b[" + final;
var mod = 1 + (alt ? 2 : 0) + (ctrl ? 4 : 0);
return "\x1b[1;" + mod + final;
}
term.onData(function (data) {
if ((armed.ctrl || armed.alt) && data.length === 1) {
var hadCtrl = armed.ctrl;
var hadAlt = armed.alt;
sendInput(applyModsToChar(data, hadCtrl, hadAlt));
armed.ctrl = false;
armed.alt = false;
if (window.AndroidBridge) {
if (hadCtrl) AndroidBridge.onModConsumed("ctrl");
if (hadAlt) AndroidBridge.onModConsumed("alt");
}
} else {
sendInput(data);
}
});
term.onResize(function (size) {
sendControl({ type: "Resize", cols: size.cols, rows: size.rows });
});
container.addEventListener("click", function () {
term.focus();
});
var ro = new ResizeObserver(function () {
try {
fitAddon.fit();
} catch (e) {
// container not measurable yet (e.g. mid-teardown) — next observation retries
}
});
ro.observe(container);
// Bridge called from TerminalWebViewController via evaluateJavascript —
// the native extra key bar's Tab/Esc/arrow buttons and Ctrl/Alt toggles.
window.maestroKey = function (key) {
var ctrl = armed.ctrl;
var alt = armed.alt;
var seq;
switch (key) {
case "Tab": seq = "\t"; break;
case "Esc": seq = "\x1b"; break;
case "Up": seq = cursorSeq("A", ctrl, alt); break;
case "Down": seq = cursorSeq("B", ctrl, alt); break;
case "Right": seq = cursorSeq("C", ctrl, alt); break;
case "Left": seq = cursorSeq("D", ctrl, alt); break;
default: return;
}
sendInput(seq);
armed.ctrl = false;
armed.alt = false;
};
window.maestroToggleMod = function (name) {
if (name === "ctrl" || name === "alt") armed[name] = !armed[name];
};
window.maestroFocus = function () {
term.focus();
};
connect();
</script>
</body>
</html>
@@ -0,0 +1,2 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
//# sourceMappingURL=addon-fit.js.map
+218
View File
@@ -0,0 +1,218 @@
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
* https://github.com/chjj/term.js
* @license MIT
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* Originally forked from (with the author's permission):
* Fabrice Bellard's javascript vt100 for jslinux:
* http://bellard.org/jslinux/
* Copyright (c) 2011 Fabrice Bellard
* The original design remains. The terminal itself
* has been extended to include xterm CSI codes, among
* other features.
*/
/**
* Default styles for xterm.js
*/
.xterm {
cursor: text;
position: relative;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
}
.xterm.focus,
.xterm:focus {
outline: none;
}
.xterm .xterm-helpers {
position: absolute;
top: 0;
/**
* The z-index of the helpers must be higher than the canvases in order for
* IMEs to appear on top.
*/
z-index: 5;
}
.xterm .xterm-helper-textarea {
padding: 0;
border: 0;
margin: 0;
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
position: absolute;
opacity: 0;
left: -9999em;
top: 0;
width: 0;
height: 0;
z-index: -5;
/** Prevent wrapping so the IME appears against the textarea at the correct position */
white-space: nowrap;
overflow: hidden;
resize: none;
}
.xterm .composition-view {
/* TODO: Composition position got messed up somewhere */
background: #000;
color: #FFF;
display: none;
position: absolute;
white-space: nowrap;
z-index: 1;
}
.xterm .composition-view.active {
display: block;
}
.xterm .xterm-viewport {
/* On OS X this is required in order for the scroll bar to appear fully opaque */
background-color: #000;
overflow-y: scroll;
cursor: default;
position: absolute;
right: 0;
left: 0;
top: 0;
bottom: 0;
}
.xterm .xterm-screen {
position: relative;
}
.xterm .xterm-screen canvas {
position: absolute;
left: 0;
top: 0;
}
.xterm .xterm-scroll-area {
visibility: hidden;
}
.xterm-char-measure-element {
display: inline-block;
visibility: hidden;
position: absolute;
top: 0;
left: -9999em;
line-height: normal;
}
.xterm.enable-mouse-events {
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
cursor: default;
}
.xterm.xterm-cursor-pointer,
.xterm .xterm-cursor-pointer {
cursor: pointer;
}
.xterm.column-select.focus {
/* Column selection mode */
cursor: crosshair;
}
.xterm .xterm-accessibility:not(.debug),
.xterm .xterm-message {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
z-index: 10;
color: transparent;
pointer-events: none;
}
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
color: transparent;
}
.xterm .xterm-accessibility-tree {
user-select: text;
white-space: pre;
}
.xterm .live-region {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.xterm-dim {
/* Dim should not apply to background, so the opacity of the foreground color is applied
* explicitly in the generated class and reset to 1 here */
opacity: 1 !important;
}
.xterm-underline-1 { text-decoration: underline; }
.xterm-underline-2 { text-decoration: double underline; }
.xterm-underline-3 { text-decoration: wavy underline; }
.xterm-underline-4 { text-decoration: dotted underline; }
.xterm-underline-5 { text-decoration: dashed underline; }
.xterm-overline {
text-decoration: overline;
}
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
.xterm-strikethrough {
text-decoration: line-through;
}
.xterm-screen .xterm-decoration-container .xterm-decoration {
z-index: 6;
position: absolute;
}
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
z-index: 7;
}
.xterm-decoration-overview-ruler {
z-index: 8;
position: absolute;
top: 0;
right: 0;
pointer-events: none;
}
.xterm-decoration-top {
z-index: 2;
position: relative;
}
File diff suppressed because one or more lines are too long
@@ -22,7 +22,10 @@ import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -32,7 +35,10 @@ import kotlinx.coroutines.launch
import pt.naps.maestro.connection.DaemonLink
import pt.naps.maestro.connection.DaemonPrefs
import pt.naps.maestro.connection.LinkState
import pt.naps.maestro.data.Session
import pt.naps.maestro.ui.sidebar.SidebarContent
import pt.naps.maestro.ui.sidebar.sessionName
import pt.naps.maestro.ui.terminal.TerminalScreen
import pt.naps.maestro.ui.theme.MaestroTheme
class MainActivity : ComponentActivity() {
@@ -50,34 +56,49 @@ class MainActivity : ComponentActivity() {
setContent {
MaestroTheme {
val linkState by daemonLink.state.collectAsState()
val baseUrl by prefs.baseUrl.collectAsState()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
var openSession by remember { mutableStateOf<Session?>(null) }
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
ModalDrawerSheet(modifier = Modifier.width(320.dp)) {
SidebarContent(
catalogStore = daemonLink.catalog,
onOpenSession = { scope.launch { drawerState.close() } },
)
}
},
) {
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text("Maestro") },
navigationIcon = {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Text("☰")
}
},
)
val session = openSession
if (session != null) {
TerminalScreen(
daemonBaseUrl = baseUrl,
sessionId = session.id,
sessionTitle = sessionName(session),
onBack = { openSession = null },
)
} else {
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
ModalDrawerSheet(modifier = Modifier.width(320.dp)) {
SidebarContent(
catalogStore = daemonLink.catalog,
onOpenSession = { s ->
openSession = s
scope.launch { drawerState.close() }
},
)
}
},
) { innerPadding ->
PlaceholderScreen(modifier = Modifier.padding(innerPadding), linkState = linkState)
) {
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text("Maestro") },
navigationIcon = {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Text("☰")
}
},
)
},
) { innerPadding ->
PlaceholderScreen(modifier = Modifier.padding(innerPadding), linkState = linkState)
}
}
}
}
@@ -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,76 @@
package pt.naps.maestro.ui.terminal
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/** One button on the [TerminalKeyBar]. A [Modifier] toggles Ctrl/Alt state
* in the terminal page's own JS instead of sending a byte sequence. */
sealed class TerminalKeyAction(val label: String) {
data class Modifier(val name: String, val display: String) : TerminalKeyAction(display)
data class Send(val display: String, val key: String) : TerminalKeyAction(display)
}
/**
* Termux-style extra key row for the on-screen keyboard (issue #266): Tab,
* Esc, Ctrl/Alt, and the four cursor arrows. Ctrl/Alt are one-shot modifiers
* — armed here, then folded into the very next key (tapped on this bar or
* typed on the soft keyboard) by `assets/terminal/index.html`'s own JS.
*/
val TERMINAL_KEY_ACTIONS: List<TerminalKeyAction> = listOf(
TerminalKeyAction.Send("Esc", "Esc"),
TerminalKeyAction.Send("Tab", "Tab"),
TerminalKeyAction.Modifier("ctrl", "Ctrl"),
TerminalKeyAction.Modifier("alt", "Alt"),
TerminalKeyAction.Send("↑", "Up"),
TerminalKeyAction.Send("↓", "Down"),
TerminalKeyAction.Send("←", "Left"),
TerminalKeyAction.Send("→", "Right"),
)
@Composable
fun TerminalKeyBar(
armed: Set<String>,
onSend: (String) -> Unit,
onToggleModifier: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.background(MaterialTheme.colorScheme.surfaceVariant)
.horizontalScroll(rememberScrollState())
.padding(horizontal = 4.dp, vertical = 2.dp),
) {
for (action in TERMINAL_KEY_ACTIONS) {
when (action) {
is TerminalKeyAction.Modifier -> {
val isArmed = action.name in armed
TextButton(onClick = { onToggleModifier(action.name) }) {
Text(
text = action.display,
color = if (isArmed) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
is TerminalKeyAction.Send -> {
TextButton(onClick = { onSend(action.key) }) {
Text(text = action.display)
}
}
}
}
}
}
@@ -0,0 +1,21 @@
package pt.naps.maestro.ui.terminal
import java.net.URLEncoder
import pt.naps.maestro.api.Routes
import pt.naps.maestro.data.Uuid
/**
* The `file:///android_asset/terminal/index.html` URL a [TerminalWebView]
* loads for one session. The embedded xterm.js page reads its `ws` query
* param and opens that as its own `WS /api/attach/:id` connection directly
* — this object only computes the URL, it never touches the socket.
*/
object TerminalPageUrl {
private const val ASSET_URL = "file:///android_asset/terminal/index.html"
fun build(daemonBaseUrl: String, sessionId: Uuid): String {
val wsUrl = Routes.attachWsUrl(daemonBaseUrl, sessionId)
val encoded = URLEncoder.encode(wsUrl, "UTF-8")
return "$ASSET_URL?ws=$encoded"
}
}
@@ -0,0 +1,83 @@
package pt.naps.maestro.ui.terminal
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.isImeVisible
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import pt.naps.maestro.data.Uuid
/**
* Full-screen terminal pane for one session (issue #266): a [TerminalWebView]
* showing xterm.js bound to `WS /api/attach/:id`, plus the native
* [TerminalKeyBar] overlaid above the soft keyboard while it's open.
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun TerminalScreen(
daemonBaseUrl: String,
sessionId: Uuid,
sessionTitle: String,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
val pageUrl = remember(daemonBaseUrl, sessionId) { TerminalPageUrl.build(daemonBaseUrl, sessionId) }
var controller by remember { mutableStateOf<TerminalWebViewController?>(null) }
var armed by remember { mutableStateOf(emptySet<String>()) }
val keyboardOpen = WindowInsets.isImeVisible
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text(sessionTitle) },
navigationIcon = {
IconButton(onClick = onBack) {
Text(text = "←", style = MaterialTheme.typography.titleLarge)
}
},
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
TerminalWebView(
pageUrl = pageUrl,
modifier = Modifier
.weight(1f)
.fillMaxSize(),
onReady = { controller = it },
onModifierConsumed = { name -> armed = armed - name },
)
if (keyboardOpen) {
TerminalKeyBar(
armed = armed,
onSend = { key ->
controller?.sendKey(key)
armed = emptySet()
},
onToggleModifier = { name ->
armed = if (name in armed) armed - name else armed + name
controller?.toggleModifier(name)
},
)
}
}
}
}
@@ -0,0 +1,89 @@
package pt.naps.maestro.ui.terminal
import android.annotation.SuppressLint
import android.os.Handler
import android.os.Looper
import android.view.ViewGroup
import android.webkit.JavascriptInterface
import android.webkit.WebSettings
import android.webkit.WebView
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import org.json.JSONObject
/** Matches `#121419` in `assets/terminal/index.html` and the web SPA's own
* xterm theme, so the WebView doesn't flash white while the page loads. */
private const val TERMINAL_BACKGROUND_ARGB = 0xFF121419.toInt()
/**
* Drives the terminal page loaded into a [TerminalWebView] from native code
* — the extra key bar's Tab/Esc/arrow buttons and Ctrl/Alt toggles. The page
* itself owns the `WS /api/attach/:id` socket and the xterm.js instance;
* this controller only calls the `maestro*` bridge functions it exposes.
*/
class TerminalWebViewController internal constructor(private val webView: WebView) {
fun sendKey(key: String) {
val arg = JSONObject.quote(key)
webView.evaluateJavascript("window.maestroKey && window.maestroKey($arg)", null)
}
fun toggleModifier(name: String) {
val arg = JSONObject.quote(name)
webView.evaluateJavascript("window.maestroToggleMod && window.maestroToggleMod($arg)", null)
}
fun focus() {
webView.requestFocus()
webView.evaluateJavascript("window.maestroFocus && window.maestroFocus()", null)
}
}
/**
* Terminal pane composable for issue #266: a [WebView] loading the bundled
* xterm.js page (`assets/terminal/index.html`), which connects straight to
* the daemon's `WS /api/attach/:id` for this session. [onModifierConsumed]
* fires when the page's own JS folds an armed Ctrl/Alt into a key typed on
* the soft keyboard, so the native key bar can clear its toggle to match.
*/
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun TerminalWebView(
pageUrl: String,
modifier: Modifier = Modifier,
onReady: (TerminalWebViewController) -> Unit = {},
onModifierConsumed: (String) -> Unit = {},
) {
val mainHandler = remember { Handler(Looper.getMainLooper()) }
AndroidView(
modifier = modifier,
factory = { context ->
WebView(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
setBackgroundColor(TERMINAL_BACKGROUND_ARGB)
addJavascriptInterface(
object {
@JavascriptInterface
fun onModConsumed(name: String) {
mainHandler.post { onModifierConsumed(name) }
}
},
"AndroidBridge",
)
onReady(TerminalWebViewController(this))
loadUrl(pageUrl)
}
},
update = { webView ->
if (webView.url != pageUrl) webView.loadUrl(pageUrl)
},
onRelease = { it.destroy() },
)
}
@@ -0,0 +1,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,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"))
}
}
+4
View File
@@ -9,6 +9,7 @@ navigationCompose = "2.8.2"
okhttp = "4.12.0"
kotlinxSerializationJson = "1.7.3"
kotlinxCoroutines = "1.9.0"
turbine = "1.1.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -27,6 +28,9 @@ androidx-navigation-compose = { group = "androidx.navigation", name = "navigatio
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-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" }
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" }