From 27b6e647c19b0febd916114364d9287d2c3370e6 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 13 Sep 2026 01:07:23 +0100 Subject: [PATCH] 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. --- .../maestro/ui/terminal/TerminalScreen.kt | 83 +++++++++++++++++ .../maestro/ui/terminal/TerminalWebView.kt | 89 +++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 android/app/src/main/java/pt/naps/maestro/ui/terminal/TerminalScreen.kt create mode 100644 android/app/src/main/java/pt/naps/maestro/ui/terminal/TerminalWebView.kt diff --git a/android/app/src/main/java/pt/naps/maestro/ui/terminal/TerminalScreen.kt b/android/app/src/main/java/pt/naps/maestro/ui/terminal/TerminalScreen.kt new file mode 100644 index 0000000..853cf12 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/ui/terminal/TerminalScreen.kt @@ -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(null) } + var armed by remember { mutableStateOf(emptySet()) } + 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) + }, + ) + } + } + } +} diff --git a/android/app/src/main/java/pt/naps/maestro/ui/terminal/TerminalWebView.kt b/android/app/src/main/java/pt/naps/maestro/ui/terminal/TerminalWebView.kt new file mode 100644 index 0000000..d689e54 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/ui/terminal/TerminalWebView.kt @@ -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() }, + ) +}