Compare commits

..
Author SHA1 Message Date
Miguel Palhas b1f6014a6a Merge remote-tracking branch 'origin/main' into tritone-2
ci / web (push) Failing after 6s
ci / flake (push) Successful in 4m3s
ci / rust (push) Successful in 7m55s
2026-09-14 11:07:43 +01:00
Miguel PalhasandClaude Opus 4.6 01db6459cb fix(maestro): block event pump until turn is settled
The adapter returns the session/prompt response before the agent
finishes working.  Late-arriving notifications raced past the turn
marker and leaked into the next turn, making each response appear
one prompt behind.

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-13 01:58:44 +01:00
23 changed files with 1542 additions and 164 deletions
+5
View File
@@ -23,6 +23,11 @@ web-typecheck:
pnpm -C web install --frozen-lockfile
pnpm -C web typecheck
# Build the Android debug APK and copy it next to the android/ project root.
apk:
cd android && ./gradlew assembleDebug
cp android/app/build/outputs/apk/debug/app-debug.apk android/app-debug.apk
# Full workspace build + tests.
test:
cargo fmt --all -- --check
+1
View File
@@ -1,5 +1,6 @@
*.iml
.gradle/
.kotlin/
/local.properties
/.idea/
.DS_Store
@@ -4,49 +4,19 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.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
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import pt.naps.maestro.connection.DaemonLink
import pt.naps.maestro.connection.DaemonPrefs
import pt.naps.maestro.connection.LinkState
import pt.naps.maestro.data.Session
import pt.naps.maestro.data.SessionView
import pt.naps.maestro.ui.sidebar.SidebarContent
import pt.naps.maestro.ui.sidebar.sessionName
import pt.naps.maestro.ui.structured.StructuredScreen
import pt.naps.maestro.ui.terminal.TerminalScreen
import pt.naps.maestro.ui.MaestroApp
import pt.naps.maestro.ui.theme.MaestroTheme
/**
* Single activity for the whole app (#268): owns the [DaemonLink] for its
* lifetime and hosts [MaestroApp], which carries the drawer, navigation and
* every pane.
*/
class MainActivity : ComponentActivity() {
private lateinit var daemonLink: DaemonLink
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
@@ -57,59 +27,7 @@ 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) }
val session = openSession
if (session != null && session.view == SessionView.Structured) {
StructuredScreen(
daemonBaseUrl = baseUrl,
sessionId = session.id,
sessionTitle = sessionName(session),
onBack = { openSession = null },
)
} else 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() }
},
)
}
},
) {
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text("Maestro") },
navigationIcon = {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Text("☰")
}
},
)
},
) { innerPadding ->
PlaceholderScreen(modifier = Modifier.padding(innerPadding), linkState = linkState)
}
}
}
MaestroApp(daemonLink = daemonLink, prefs = prefs)
}
}
}
@@ -119,33 +37,3 @@ class MainActivity : ComponentActivity() {
super.onDestroy()
}
}
@Composable
fun PlaceholderScreen(modifier: Modifier = Modifier, linkState: LinkState = LinkState.CONNECTING) {
Column(
modifier = modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "Maestro",
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = stringResource(id = R.string.placeholder_message),
)
Text(
text = "Daemon: ${linkState.name.lowercase()}",
)
}
}
@Preview(showBackground = true)
@Composable
private fun PlaceholderScreenPreview() {
MaestroTheme {
PlaceholderScreen()
}
}
@@ -0,0 +1,298 @@
package pt.naps.maestro.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Text
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusTarget
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isCtrlPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import kotlinx.coroutines.launch
import pt.naps.maestro.api.MaestroClient
import pt.naps.maestro.connection.DaemonLink
import pt.naps.maestro.connection.DaemonPrefs
import pt.naps.maestro.connection.LinkState
import pt.naps.maestro.data.Session
import pt.naps.maestro.data.SessionView
import pt.naps.maestro.ui.commandbar.CommandBarSheet
import pt.naps.maestro.ui.commandbar.CommandBarViewModel
import pt.naps.maestro.ui.sidebar.SidebarContent
import pt.naps.maestro.ui.sidebar.sessionName
import pt.naps.maestro.ui.spawn.SpawnSheet
import pt.naps.maestro.ui.spawn.SpawnViewModel
import pt.naps.maestro.ui.structured.StructuredScreen
import pt.naps.maestro.ui.terminal.TerminalScreen
/** Route names for the single-activity nav graph (#268). */
private object Routes {
const val WELCOME = "welcome"
private const val SESSION_ID_ARG = "sessionId"
const val TERMINAL = "terminal/{$SESSION_ID_ARG}"
const val STRUCTURED = "structured/{$SESSION_ID_ARG}"
fun terminal(id: String) = "terminal/$id"
fun structured(id: String) = "structured/$id"
}
/** What to pre-fill the spawn form with when it was opened from the command
* bar (#270) rather than from a blank "Spawn session" tap. */
private sealed interface SpawnSeed {
data class Similar(val session: Session) : SpawnSeed
data class Directory(val path: String) : SpawnSeed
}
/**
* Top-level app structure (#268): a persistent [ModalNavigationDrawer] around
* a single-back-stack [NavHost], tying together the sidebar (#264/#265), the
* terminal pane (#266), the structured pane (#267), and the spawn form
* (#269) that were each built standalone.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MaestroApp(daemonLink: DaemonLink, prefs: DaemonPrefs) {
val navController = rememberNavController()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val sessions by daemonLink.catalog.sessions.collectAsState()
val linkState by daemonLink.state.collectAsState()
val baseUrl by prefs.baseUrl.collectAsState()
var showSpawnSheet by remember { mutableStateOf(false) }
var spawnSeed by remember { mutableStateOf<SpawnSeed?>(null) }
var showCommandBar by remember { mutableStateOf(false) }
val infoClient = remember(baseUrl) { MaestroClient(baseUrl) }
var hostname by remember(baseUrl) { mutableStateOf("") }
LaunchedEffect(linkState, infoClient) {
if (linkState == LinkState.ONLINE) {
hostname = runCatching { infoClient.getDaemonInfo().hostname }.getOrDefault(hostname)
}
}
fun openSession(session: Session) {
val route = if (session.view == SessionView.Structured) {
Routes.structured(session.id)
} else {
Routes.terminal(session.id)
}
navController.navigate(route) {
popUpTo(Routes.WELCOME)
launchSingleTop = true
}
scope.launch { drawerState.close() }
}
fun spawnNew() {
spawnSeed = null
showSpawnSheet = true
}
fun spawnInDirectory(path: String) {
spawnSeed = SpawnSeed.Directory(path)
showSpawnSheet = true
}
fun spawnSimilar(session: Session) {
spawnSeed = SpawnSeed.Similar(session)
showSpawnSheet = true
}
// Ctrl-K opens the bar from anywhere (docs/COMMAND-BAR.md); a focusable
// root keeps the shortcut live except while something else (the bar's own
// search field, a text input) holds focus instead.
val rootFocusRequester = remember { FocusRequester() }
LaunchedEffect(showCommandBar) {
if (!showCommandBar) rootFocusRequester.requestFocus()
}
Box(
modifier = Modifier
.fillMaxSize()
.focusRequester(rootFocusRequester)
.focusTarget()
.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.isCtrlPressed && event.key == Key.K) {
showCommandBar = true
true
} else {
false
}
},
) {
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
ModalDrawerSheet(modifier = Modifier.width(320.dp)) {
SidebarContent(
catalogStore = daemonLink.catalog,
onOpenSession = ::openSession,
)
}
},
) {
Column(modifier = Modifier.fillMaxWidth()) {
ConnectionStatusBar(linkState = linkState, hostname = hostname)
NavHost(navController = navController, startDestination = Routes.WELCOME) {
composable(Routes.WELCOME) {
WelcomeScreen(
onOpenSessions = { scope.launch { drawerState.open() } },
onSpawnSession = { spawnNew() },
)
}
composable(
Routes.TERMINAL,
arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
) { backStackEntry ->
val id = backStackEntry.arguments?.getString("sessionId") ?: return@composable
TerminalScreen(
daemonBaseUrl = baseUrl,
sessionId = id,
sessionTitle = sessions[id]?.let(::sessionName) ?: id,
onBack = { navController.popBackStack(Routes.WELCOME, inclusive = false) },
)
}
composable(
Routes.STRUCTURED,
arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
) { backStackEntry ->
val id = backStackEntry.arguments?.getString("sessionId") ?: return@composable
StructuredScreen(
daemonBaseUrl = baseUrl,
sessionId = id,
sessionTitle = sessions[id]?.let(::sessionName) ?: id,
onBack = { navController.popBackStack(Routes.WELCOME, inclusive = false) },
)
}
}
}
}
FloatingActionButton(
onClick = { showCommandBar = true },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp)
.semantics { contentDescription = "Search sessions and commands" },
) {
Text("⌕")
}
}
if (showSpawnSheet) {
val seed = spawnSeed
val viewModel = remember(infoClient, seed) {
SpawnViewModel(infoClient).also {
when (seed) {
is SpawnSeed.Similar -> {
it.setCwd(seed.session.cwd)
it.setCustomCommand(seed.session.command)
}
is SpawnSeed.Directory -> it.setCwd(seed.path)
null -> {}
}
}
}
SpawnSheet(
viewModel = viewModel,
onDismiss = {
showSpawnSheet = false
spawnSeed = null
},
onSpawned = { session ->
showSpawnSheet = false
spawnSeed = null
openSession(session)
},
)
}
if (showCommandBar) {
val commandBarViewModel = remember(daemonLink, infoClient) {
CommandBarViewModel(daemonLink.catalog, infoClient)
}
CommandBarSheet(
viewModel = commandBarViewModel,
onDismiss = { showCommandBar = false },
onOpenSession = ::openSession,
onSpawnNew = ::spawnNew,
onSpawnDirectory = ::spawnInDirectory,
onSpawnSimilar = ::spawnSimilar,
)
}
// Declared last so it sits above the NavHost's own back handling on the
// dispatcher stack: a drawer open over a session must close first, not
// pop the session back to Welcome.
BackHandler(enabled = drawerState.isOpen) {
scope.launch { drawerState.close() }
}
}
/** Connection indicator (green online / red down) plus the daemon hostname
* from `/api/info`, visible above every route. */
@Composable
private fun ConnectionStatusBar(linkState: LinkState, hostname: String) {
val online = linkState == LinkState.ONLINE
val dotColor = if (online) Color(0xFF2E7D32) else Color(0xFFC62828)
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 16.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(dotColor),
)
Text(
text = hostname.ifEmpty { linkState.name.lowercase() },
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -0,0 +1,62 @@
package pt.naps.maestro.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import pt.naps.maestro.ui.theme.MaestroTheme
/**
* Shown when no session is open (#268): the app's resting state, reached at
* launch and every time a session closes or the operator backs out of one.
*/
@Composable
fun WelcomeScreen(
onOpenSessions: () -> Unit,
onSpawnSession: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = "Maestro", style = MaterialTheme.typography.displaySmall)
Spacer(Modifier.height(8.dp))
Text(
text = "No session open",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(32.dp))
Button(onClick = onOpenSessions, modifier = Modifier.fillMaxWidth(0.7f)) {
Text("Sessions")
}
Spacer(Modifier.height(12.dp))
OutlinedButton(onClick = onSpawnSession, modifier = Modifier.fillMaxWidth(0.7f)) {
Text("Spawn session")
}
}
}
@Preview(showBackground = true)
@Composable
private fun WelcomeScreenPreview() {
MaestroTheme {
WelcomeScreen(onOpenSessions = {}, onSpawnSession = {})
}
}
@@ -0,0 +1,190 @@
package pt.naps.maestro.ui.commandbar
import java.time.Instant
import pt.naps.maestro.data.Session
import pt.naps.maestro.data.SessionStatus
import pt.naps.maestro.ui.sidebar.programName
import pt.naps.maestro.ui.sidebar.sessionName
/** The command bar's pure model (docs/COMMAND-BAR.md): candidates, the sigil
* grammar, and the ranking. Android's v1 matcher is deliberately a simple
* weighted substring/fuzzy scorer, not the scored dynamic-program matcher
* `web`/#146 and the TUI/#147 share — see the issue for why a lighter
* matcher was chosen for the first cut. */
sealed interface BarCandidate {
data class SessionItem(val session: Session) : BarCandidate
data class CommandItem(val command: BarCommand) : BarCandidate
data class DirectoryItem(val path: String, val display: String) : BarCandidate
}
data class BarCommand(val id: String, val label: String)
/** The bar's fixed `>` registry for v1: the app has no keymap/command palette
* of its own yet, so spawning is the one action worth reaching from anywhere. */
val BAR_COMMANDS: List<BarCommand> = listOf(BarCommand(id = "spawn", label = "Spawn session"))
sealed interface BarSigil {
object Commands : BarSigil
object Directories : BarSigil
}
data class ParsedBarQuery(val sigil: BarSigil?, val query: String)
/** A sigil is only a sigil in first position (docs/COMMAND-BAR.md) — typed
* anywhere else, or backspaced away, it is an ordinary query character. */
fun parseBarQuery(raw: String): ParsedBarQuery = when (raw.firstOrNull()) {
'>' -> ParsedBarQuery(BarSigil.Commands, raw.substring(1))
'~' -> ParsedBarQuery(BarSigil.Directories, raw.substring(1))
else -> ParsedBarQuery(null, raw)
}
private fun instantMs(iso: String?): Long? {
if (iso.isNullOrEmpty()) return null
return runCatching { Instant.parse(iso).toEpochMilli() }.getOrNull()
}
/** Empty-query session order (docs/COMMAND-BAR.md, "Empty query"): most
* recently focused first; a session never focused sorts after every session
* that has been, most recently created first among those; a dead session
* sorts last regardless of recency. */
fun orderSessionsForBar(sessions: List<Session>): List<Session> = sessions.sortedWith(
Comparator { a, b ->
val deadA = if (a.status == SessionStatus.Dead) 1 else 0
val deadB = if (b.status == SessionStatus.Dead) 1 else 0
if (deadA != deadB) return@Comparator deadA - deadB
val focusA = instantMs(a.last_focused_at)
val focusB = instantMs(b.last_focused_at)
if ((focusA == null) != (focusB == null)) return@Comparator if (focusA == null) 1 else -1
if (focusA != null && focusB != null && focusA != focusB) return@Comparator focusB.compareTo(focusA)
(instantMs(b.created_at) ?: 0L).compareTo(instantMs(a.created_at) ?: 0L)
},
)
/** `~/dev/api`, not `/home/me/dev/api` — the abbreviation the sidebar and the
* spawn form both use. */
fun abbreviateCwd(path: String, homeDir: String): String = when {
homeDir.isEmpty() -> path
path == homeDir -> "~"
path.startsWith("$homeDir/") -> "~" + path.removePrefix(homeDir)
else -> path
}
/** The inverse of [abbreviateCwd], for a path the operator typed. */
fun expandHome(path: String, homeDir: String): String = when {
homeDir.isEmpty() -> path
path == "~" -> homeDir
path.startsWith("~/") -> homeDir + path.removePrefix("~")
else -> path
}
/** `~`'s candidates, deduped by the directory they spawn in and keeping the
* first mention of each. */
fun directoryCandidates(paths: List<String>, homeDir: String): List<BarCandidate.DirectoryItem> {
val seen = LinkedHashSet<String>()
val out = mutableListOf<BarCandidate.DirectoryItem>()
for (path in paths) {
if (path.isEmpty() || !seen.add(path)) continue
out += BarCandidate.DirectoryItem(path = path, display = abbreviateCwd(path, homeDir))
}
return out
}
private data class BarField(val name: String, val text: String, val weight: Int)
/** Field weights, ported from docs/COMMAND-BAR.md's candidate table — kept so
* an exact `tool` match can still outrank a weaker `name` match, even though
* the per-character scoring underneath is simplified. */
private fun fieldsFor(candidate: BarCandidate, homeDir: String): List<BarField> = when (candidate) {
is BarCandidate.SessionItem -> {
val session = candidate.session
val directory = session.worktree?.base_repo ?: session.cwd
listOfNotNull(
BarField("name", sessionName(session), 0),
BarField("directory", abbreviateCwd(directory, homeDir), -24),
session.command.takeIf { it.isNotEmpty() }?.let { BarField("command", it, -32) },
BarField("tool", programName(session.command), -40),
)
}
is BarCandidate.CommandItem -> listOf(BarField("label", candidate.command.label, 0))
is BarCandidate.DirectoryItem -> listOf(BarField("path", candidate.display, 0))
}
private const val MATCH_EXACT = 100
private const val MATCH_PREFIX = 70
private const val MATCH_CONTAINS = 50
private const val MATCH_FUZZY = 20
/** Case-insensitive subsequence: every character of [query] appears in [text]
* in order, not necessarily adjacent. */
private fun isSubsequence(query: String, text: String): Boolean {
var i = 0
for (c in text) {
if (i >= query.length) break
if (c == query[i]) i++
}
return i == query.length
}
private fun fieldMatchScore(query: String, text: String): Int? {
val q = query.lowercase()
val t = text.lowercase()
return when {
t == q -> MATCH_EXACT
t.startsWith(q) -> MATCH_PREFIX
t.contains(q) -> MATCH_CONTAINS
isSubsequence(q, t) -> MATCH_FUZZY
else -> null
}
}
private fun kindOrder(candidate: BarCandidate): Int = when (candidate) {
is BarCandidate.SessionItem -> 0
is BarCandidate.CommandItem -> 1
is BarCandidate.DirectoryItem -> 2
}
data class BarResult(val candidate: BarCandidate, val score: Int, val matchedField: String?)
/** Ranks [candidates] against [query]. An empty query matches everything at
* score 0 in input order (docs/COMMAND-BAR.md, "Empty query"); the bar's own
* rules decide what is in the candidate list at that point. */
fun rankCandidates(query: String, candidates: List<BarCandidate>, homeDir: String): List<BarResult> {
val trimmed = query.trim()
if (trimmed.isEmpty()) return candidates.map { BarResult(it, 0, null) }
val results = candidates.mapNotNull { candidate ->
var best: BarResult? = null
for (field in fieldsFor(candidate, homeDir)) {
val matchScore = fieldMatchScore(trimmed, field.text) ?: continue
val total = matchScore + field.weight
if (best == null || total > best.score) best = BarResult(candidate, total, field.name)
}
best
}
// Stable sort: ties keep the caller's order (sessions most-recently-focused
// first, commands in registry order), matching the shared matcher's last
// tie-break without reproducing its length/codepoint rules.
return results.sortedWith(compareByDescending<BarResult> { it.score }.thenBy { kindOrder(it.candidate) })
}
data class BarGroup(val title: String, val results: List<BarResult>)
private fun titleFor(candidate: BarCandidate): String = when (candidate) {
is BarCandidate.SessionItem -> "Sessions"
is BarCandidate.CommandItem -> "Commands"
is BarCandidate.DirectoryItem -> "Directories"
}
/** Buckets a ranked list by kind for rendering. Grouping is a rendering of the
* ranking, never a reordering of it: groups appear in the order their
* best-scoring member does, because [results] is already rank-ordered and a
* `LinkedHashMap` keeps first-seen order. */
fun groupResults(results: List<BarResult>): List<BarGroup> {
val groups = LinkedHashMap<String, MutableList<BarResult>>()
for (result in results) {
groups.getOrPut(titleFor(result.candidate)) { mutableListOf() }.add(result)
}
return groups.map { (title, group) -> BarGroup(title, group) }
}
@@ -0,0 +1,307 @@
package pt.naps.maestro.ui.commandbar
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import pt.naps.maestro.data.Session
import pt.naps.maestro.ui.sidebar.ActivityRail
import pt.naps.maestro.ui.sidebar.MetaLine
import pt.naps.maestro.ui.sidebar.customSessionName
import pt.naps.maestro.ui.sidebar.formatInTheWay
import pt.naps.maestro.ui.sidebar.rememberTickingNowMs
import pt.naps.maestro.ui.sidebar.sessionBlockers
import pt.naps.maestro.ui.sidebar.sessionName
/** #270's `ctrl-k` command bar (docs/COMMAND-BAR.md): a search field over a
* bottom sheet, sessions and the `>` registry mixed by default, `>` and `~`
* narrowing to one kind. Tap opens a result; long-press on a session opens
* its action menu (rename, kill, spawn similar) — the bar picks the action
* and hands off to the flow that already implements it, it never
* reimplements one. */
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun CommandBarSheet(
viewModel: CommandBarViewModel,
onDismiss: () -> Unit,
onOpenSession: (Session) -> Unit,
onSpawnNew: () -> Unit,
onSpawnDirectory: (String) -> Unit,
onSpawnSimilar: (Session) -> Unit,
modifier: Modifier = Modifier,
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
val state by viewModel.state.collectAsState()
val nowMs = rememberTickingNowMs()
val focusRequester = remember { FocusRequester() }
var actionSession by remember { mutableStateOf<Session?>(null) }
var renaming by remember { mutableStateOf<Session?>(null) }
var confirmingKill by remember { mutableStateOf<Session?>(null) }
fun activate(candidate: BarCandidate) {
when (candidate) {
is BarCandidate.SessionItem -> onOpenSession(candidate.session)
is BarCandidate.CommandItem -> onSpawnNew()
is BarCandidate.DirectoryItem -> onSpawnDirectory(candidate.path)
}
onDismiss()
}
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) {
Column(Modifier.fillMaxWidth().heightIn(max = 560.dp)) {
OutlinedTextField(
value = state.query,
onValueChange = viewModel::onQueryChange,
placeholder = { Text(placeholderFor(state.sigil)) },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
.focusRequester(focusRequester),
)
LaunchedEffect(Unit) { focusRequester.requestFocus() }
if (state.groups.isEmpty()) {
Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) {
Text(
"No matches",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
}
} else {
val showHeaders = state.sigil == null
LazyColumn(Modifier.fillMaxWidth()) {
for (group in state.groups) {
if (showHeaders) {
item(key = "header:${group.title}") {
Text(
text = group.title,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
)
}
}
items(group.results, key = { rowKey(it.candidate) }) { result ->
BarRow(
candidate = result.candidate,
nowMs = nowMs,
onClick = { activate(result.candidate) },
onLongClick = {
val session = (result.candidate as? BarCandidate.SessionItem)?.session
if (session != null) actionSession = session
},
)
}
}
}
}
}
}
actionSession?.let { session ->
SessionActionMenu(
session = session,
onDismiss = { actionSession = null },
onRename = {
actionSession = null
renaming = session
},
onKill = {
actionSession = null
confirmingKill = session
},
onSpawnSimilar = {
actionSession = null
onDismiss()
onSpawnSimilar(session)
},
)
}
renaming?.let { session ->
RenameDialog(
initial = customSessionName(session) ?: "",
onCommit = {
renaming = null
viewModel.rename(session, it)
},
onCancel = { renaming = null },
)
}
confirmingKill?.let { session ->
KillConfirmDialog(
session = session,
onConfirm = {
confirmingKill = null
viewModel.kill(session)
},
onCancel = { confirmingKill = null },
)
}
}
private fun placeholderFor(sigil: BarSigil?): String = when (sigil) {
BarSigil.Commands -> "Run a command…"
BarSigil.Directories -> "Spawn in a directory…"
null -> "Go to a session…"
}
private fun rowKey(candidate: BarCandidate): String = when (candidate) {
is BarCandidate.SessionItem -> "session:${candidate.session.id}"
is BarCandidate.CommandItem -> "command:${candidate.command.id}"
is BarCandidate.DirectoryItem -> "directory:${candidate.path}"
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun BarRow(
candidate: BarCandidate,
nowMs: Long,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
when (candidate) {
is BarCandidate.SessionItem -> {
ActivityRail(activity = candidate.session.activity, modifier = Modifier.height(36.dp))
Spacer(Modifier.width(8.dp))
Column(Modifier.fillMaxWidth()) {
Text(
text = sessionName(candidate.session),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium,
)
MetaLine(session = candidate.session, nowMs = nowMs)
}
}
is BarCandidate.CommandItem -> {
Text(
text = candidate.command.label,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.fillMaxWidth(),
)
}
is BarCandidate.DirectoryItem -> {
Text(
text = candidate.display,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
/** Long-press's action menu (docs/COMMAND-BAR.md, "Drill-in"), collapsed from
* the desktop clients' fuzzy-searchable action page to a plain dialog — a
* touch long-press already named the one session being acted on, so there is
* nothing left to search. */
@Composable
private fun SessionActionMenu(
session: Session,
onDismiss: () -> Unit,
onRename: () -> Unit,
onKill: () -> Unit,
onSpawnSimilar: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(sessionName(session)) },
text = {
Column {
TextButton(onClick = onRename, modifier = Modifier.fillMaxWidth()) { Text("Rename") }
TextButton(onClick = onSpawnSimilar, modifier = Modifier.fillMaxWidth()) { Text("Spawn similar") }
TextButton(onClick = onKill, modifier = Modifier.fillMaxWidth()) { Text("Kill") }
}
},
confirmButton = {},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
@Composable
private fun RenameDialog(initial: String, onCommit: (String) -> Unit, onCancel: () -> Unit) {
var text by remember { mutableStateOf(initial) }
AlertDialog(
onDismissRequest = onCancel,
title = { Text("Rename session") },
text = {
OutlinedTextField(
value = text,
onValueChange = { text = it },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
)
},
confirmButton = { TextButton(onClick = { onCommit(text.trim()) }) { Text("Rename") } },
dismissButton = { TextButton(onClick = onCancel) { Text("Cancel") } },
)
}
@Composable
private fun KillConfirmDialog(session: Session, onConfirm: () -> Unit, onCancel: () -> Unit) {
val blockers = sessionBlockers(session)
AlertDialog(
onDismissRequest = onCancel,
title = { Text("Kill “${sessionName(session)}”?") },
text = {
Text(
if (blockers != null) {
"This worktree has unsaved work: ${formatInTheWay(blockers)}. Killing may be refused unless forced."
} else {
"This ends the session's process. This can't be undone."
},
)
},
confirmButton = { TextButton(onClick = onConfirm) { Text("Kill") } },
dismissButton = { TextButton(onClick = onCancel) { Text("Cancel") } },
)
}
@@ -0,0 +1,135 @@
package pt.naps.maestro.ui.commandbar
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import pt.naps.maestro.api.MaestroClient
import pt.naps.maestro.connection.CatalogStore
import pt.naps.maestro.data.Session
/** Quiet period before a typed `~` path is probed over `GET /api/dirs`,
* mirroring the spawn form's own autocomplete debounce. */
private const val PROBE_DEBOUNCE_MS = 250L
data class CommandBarUiState(
val query: String = "",
val sigil: BarSigil? = null,
val groups: List<BarGroup> = emptyList(),
)
/**
* Search logic behind [pt.naps.maestro.ui.commandbar.CommandBarSheet]
* (#270): folds the live session catalog, the fixed `>` command registry and
* the `~` directory list into one ranked, grouped result set as the query
* changes. A plain class, not an `androidx.lifecycle.ViewModel` — same shape
* as [pt.naps.maestro.ui.spawn.SpawnViewModel], which this otherwise mirrors.
*/
class CommandBarViewModel(
private val catalogStore: CatalogStore,
private val client: MaestroClient,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
) {
private val _state = MutableStateFlow(CommandBarUiState())
val state: StateFlow<CommandBarUiState> = _state.asStateFlow()
private var homeDir: String = ""
private var recentDirectories: List<BarCandidate.DirectoryItem> = emptyList()
private var probedDirectories: List<BarCandidate.DirectoryItem> = emptyList()
private var recentDirectoriesLoaded = false
private var probeJob: Job? = null
init {
scope.launch {
homeDir = runCatching { client.getDaemonInfo().home_dir }.getOrDefault("")
recompute()
}
scope.launch {
catalogStore.sessions.collect { recompute() }
}
}
fun onQueryChange(raw: String) {
_state.update { it.copy(query = raw, sigil = parseBarQuery(raw).sigil) }
val parsed = parseBarQuery(raw)
if (parsed.sigil is BarSigil.Directories) {
ensureRecentDirectoriesLoaded()
probeTypedDirectory(parsed.query)
}
recompute()
}
/** `PATCH /api/sessions/:id`. Fire-and-forget: the catalog reflects the
* rename over the events socket like any other client's edit. */
fun rename(session: Session, name: String) {
scope.launch { runCatching { client.renameSession(session.id, name) } }
}
/** `DELETE /api/sessions/:id`, unforced — the same default the sidebar's
* own kill confirmation uses. */
fun kill(session: Session) {
scope.launch { runCatching { client.killSession(session.id) } }
}
private fun ensureRecentDirectoriesLoaded() {
if (recentDirectoriesLoaded) return
recentDirectoriesLoaded = true
scope.launch {
val recents = runCatching { client.recentDirs().dirs.map { it.cwd } }.getOrDefault(emptyList())
val projects = runCatching { client.listProjects().map { it.path } }.getOrDefault(emptyList())
recentDirectories = directoryCandidates(recents + projects, homeDir)
recompute()
}
}
/** Fills the `~` list in as a path is typed, the way the spawn form's own
* autocomplete does: the directory named so far is listed and its
* children join the candidates. */
private fun probeTypedDirectory(query: String) {
val slash = query.lastIndexOf('/')
if (slash < 0) {
probedDirectories = emptyList()
probeJob?.cancel()
return
}
val dir = expandHome(query.substring(0, slash + 1), homeDir)
probeJob?.cancel()
probeJob = scope.launch {
delay(PROBE_DEBOUNCE_MS)
val entries = runCatching { client.listDir(dir).entries }.getOrDefault(emptyList())
probedDirectories = directoryCandidates(entries.map { "$dir$it" }, homeDir)
recompute()
}
}
private fun directoryListCandidates(): List<BarCandidate> {
val seen = LinkedHashSet<String>()
val out = mutableListOf<BarCandidate.DirectoryItem>()
for (candidate in recentDirectories + probedDirectories) {
if (seen.add(candidate.path)) out += candidate
}
return out
}
private fun recompute() {
val raw = _state.value.query
val parsed = parseBarQuery(raw)
val candidates: List<BarCandidate> = when (parsed.sigil) {
is BarSigil.Commands -> BAR_COMMANDS.map { BarCandidate.CommandItem(it) }
is BarSigil.Directories -> directoryListCandidates()
null -> {
val sessions = orderSessionsForBar(catalogStore.sessions.value.values.toList())
.map { BarCandidate.SessionItem(it) }
if (parsed.query.isEmpty()) sessions else sessions + BAR_COMMANDS.map { BarCandidate.CommandItem(it) }
}
}
val ranked = rankCandidates(parsed.query, candidates, homeDir)
_state.update { it.copy(groups = groupResults(ranked)) }
}
}
@@ -2,12 +2,12 @@ package pt.naps.maestro.ui.sidebar
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.InfiniteTransition
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
@@ -52,18 +52,51 @@ fun activityStateWord(activity: ActivityState): String =
ActivityState.Error -> "Error"
}
/** A held bar that pulses its alpha between 1 and [minAlpha] every
* [periodMs] — the blink/flash motion `AwaitingInput`/`Error` earn. */
/** A hard on/off blink, one cycle per 1200ms — mirrors the web rail's
* `maestro-rail-blink` keyframes (docs/SIDEBAR.md, web/src/components/sidebar/sidebar.css):
* held at full alpha for the first half of the cycle, then snapped to 0 for
* the second half, with no crossfade. */
@Composable
private fun InfiniteTransition.pulsingAlpha(minAlpha: Float, periodMs: Int): Float {
private fun InfiniteTransition.blinkAlpha(): Float {
val alpha by animateFloat(
initialValue = 1f,
targetValue = minAlpha,
targetValue = 0f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = periodMs, easing = LinearEasing),
repeatMode = RepeatMode.Reverse,
animation = keyframes {
durationMillis = 1200
1f at 0
1f at 599
0f at 600
0f at 1199
},
repeatMode = RepeatMode.Restart,
),
label = "activity-rail-alpha",
label = "activity-rail-blink",
)
return alpha
}
/** A fast double-flash every 1400ms — mirrors the web rail's
* `maestro-rail-flash` keyframes: two dips to quarter alpha early in the
* cycle, then held at full alpha for the remainder. */
@Composable
private fun InfiniteTransition.flashAlpha(): Float {
val alpha by animateFloat(
initialValue = 1f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 1400
1f at 0 using LinearOutSlowInEasing
0.25f at 210 using LinearOutSlowInEasing
1f at 420 using LinearOutSlowInEasing
0.25f at 630 using LinearOutSlowInEasing
1f at 840 using LinearOutSlowInEasing
1f at 1400
},
repeatMode = RepeatMode.Restart,
),
label = "activity-rail-flash",
)
return alpha
}
@@ -77,8 +110,8 @@ fun ActivityRail(activity: ActivityState, modifier: Modifier = Modifier) {
val color by animateColorAsState(targetValue = activityColor(activity), label = "activity-rail-color")
val infinite = rememberInfiniteTransition(label = "activity-rail-motion")
val alpha = when (activity) {
ActivityState.AwaitingInput -> infinite.pulsingAlpha(minAlpha = 0.35f, periodMs = 600)
ActivityState.Error -> infinite.pulsingAlpha(minAlpha = 0.25f, periodMs = 220)
ActivityState.AwaitingInput -> infinite.blinkAlpha()
ActivityState.Error -> infinite.flashAlpha()
else -> 1f
}
Box(
@@ -2,10 +2,45 @@ package pt.naps.maestro.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
/** Palette translated from the web SPA's OKLCH design tokens
* (web/src/styles.css) into sRGB. Dark is the default theme; Light is opt-in.
* Semantic mapping (see docs referenced from issue #271):
* bg -> background, surface* -> surface layers, ink -> onBackground/onSurface,
* muted -> onSurfaceVariant, faint -> outline, accent -> primary,
* live -> tertiary, danger -> error, warn -> secondary. */
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
// ---- Dark theme (default) ----
val DarkBg = Color(0xFF0E1012)
val DarkSurface = Color(0xFF16181B)
val DarkSurface2 = Color(0xFF1E2125)
val DarkSurface3 = Color(0xFF272B2F)
val DarkBorder = Color(0xFF2E3136)
val DarkBorderStrong = Color(0xFF42474D)
val DarkInk = Color(0xFFEBEDEF)
val DarkMuted = Color(0xFFA1A5AA)
val DarkFaint = Color(0xFF777B7F)
val DarkAccent = Color(0xFFE2E5E8)
val DarkAccentStrong = Color(0xFFF4F5F7)
val DarkAccentInk = Color(0xFF13161C)
val DarkLive = Color(0xFF65EA92)
val DarkDanger = Color(0xFFFF6F69)
val DarkWarn = Color(0xFFEEBC4A)
val DarkTermBg = Color(0xFF0B0C0F)
// ---- Light theme ----
val LightBg = Color(0xFFF9FAFB)
val LightSurface = Color(0xFFFFFFFF)
val LightSurface2 = Color(0xFFF2F3F5)
val LightSurface3 = Color(0xFFE6E8EA)
val LightBorder = Color(0xFFD9DBDF)
val LightBorderStrong = Color(0xFFC1C4C9)
val LightInk = Color(0xFF20242A)
val LightMuted = Color(0xFF565B62)
val LightFaint = Color(0xFF81878D)
val LightAccent = Color(0xFF23272B)
val LightAccentStrong = Color(0xFF13161B)
val LightAccentInk = Color(0xFFF9FAFB)
val LightLive = Color(0xFF069A4A)
val LightDanger = Color(0xFFD01D21)
val LightWarn = Color(0xFFC78200)
val LightTermBg = Color(0xFF0B0F14)
@@ -10,18 +10,71 @@ import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
/** Dark scheme (default), translated from the web SPA's dark token set
* (web/src/styles.css) — see Color.kt for the token -> role mapping. */
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80,
background = DarkBg,
onBackground = DarkInk,
surface = DarkSurface,
onSurface = DarkInk,
surfaceVariant = DarkSurface2,
onSurfaceVariant = DarkMuted,
outline = DarkFaint,
outlineVariant = DarkBorder,
surfaceTint = DarkAccent,
primary = DarkAccent,
onPrimary = DarkAccentInk,
primaryContainer = DarkSurface3,
onPrimaryContainer = DarkInk,
secondary = DarkWarn,
onSecondary = DarkAccentInk,
secondaryContainer = DarkSurface3,
onSecondaryContainer = DarkInk,
tertiary = DarkLive,
onTertiary = DarkAccentInk,
tertiaryContainer = DarkSurface3,
onTertiaryContainer = DarkInk,
error = DarkDanger,
onError = DarkAccentInk,
errorContainer = DarkSurface3,
onErrorContainer = DarkDanger,
)
/** Light scheme, opt-in. */
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40,
background = LightBg,
onBackground = LightInk,
surface = LightSurface,
onSurface = LightInk,
surfaceVariant = LightSurface2,
onSurfaceVariant = LightMuted,
outline = LightFaint,
outlineVariant = LightBorder,
surfaceTint = LightAccent,
primary = LightAccent,
onPrimary = LightAccentInk,
primaryContainer = LightSurface3,
onPrimaryContainer = LightInk,
secondary = LightWarn,
onSecondary = LightAccentInk,
secondaryContainer = LightSurface3,
onSecondaryContainer = LightInk,
tertiary = LightLive,
onTertiary = LightAccentInk,
tertiaryContainer = LightSurface3,
onTertiaryContainer = LightInk,
error = LightDanger,
onError = LightAccentInk,
errorContainer = LightSurface3,
onErrorContainer = LightDanger,
)
/** Dark is the product default (docs/DESIGN.md); light is opt-in, and dynamic
* color (Material You, API 31+) is opt-in on top of that. Edge-to-edge itself
* is enabled once in MainActivity#onCreate — [enableEdgeToEdge] draws content
* behind transparent system bars for whichever scheme resolves here, and the
* default `SystemBarStyle.auto` picks light/dark bar icons from this theme's
* [darkTheme] flag, not the system setting. */
@Composable
fun MaestroTheme(
darkTheme: Boolean = true,
@@ -1,17 +1,99 @@
package pt.naps.maestro.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.sp
import pt.naps.maestro.R
/** Bundled (SIL OFL, android/licenses/JetBrainsMono-OFL.txt) rather than the
* system mono fallback, so the terminal pane and code blocks render the same
* face as the web SPA (--font-mono) on every device. */
val JetBrainsMono = FontFamily(
Font(R.font.jetbrains_mono_regular, FontWeight.Normal),
Font(R.font.jetbrains_mono_medium, FontWeight.Medium),
Font(R.font.jetbrains_mono_bold, FontWeight.Bold),
)
/** UI chrome uses the platform sans (--font-sans is a system-ui stack on
* web); only terminal/code content switches to [JetBrainsMono]. */
private val Sans = FontFamily.Default
/** Scale roughly mirrors the web token steps (web/src/styles.css --fs-11
* through --fs-20); role names are Material3's, not the CSS ones. */
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
titleLarge = TextStyle(
fontFamily = Sans,
fontWeight = FontWeight.SemiBold,
fontSize = 20.sp,
lineHeight = 26.sp,
letterSpacing = 0.sp,
),
titleMedium = TextStyle(
fontFamily = Sans,
fontWeight = FontWeight.Medium,
fontSize = 17.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp,
letterSpacing = 0.sp,
),
titleSmall = TextStyle(
fontFamily = Sans,
fontWeight = FontWeight.Medium,
fontSize = 15.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
),
bodyLarge = TextStyle(
fontFamily = Sans,
fontWeight = FontWeight.Normal,
fontSize = 15.sp,
lineHeight = 22.sp,
letterSpacing = 0.1.sp,
),
bodyMedium = TextStyle(
fontFamily = Sans,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
),
bodySmall = TextStyle(
fontFamily = Sans,
fontWeight = FontWeight.Normal,
fontSize = 13.sp,
lineHeight = 18.sp,
letterSpacing = 0.1.sp,
),
labelLarge = TextStyle(
fontFamily = Sans,
fontWeight = FontWeight.Medium,
fontSize = 13.sp,
lineHeight = 18.sp,
letterSpacing = 0.1.sp,
),
labelMedium = TextStyle(
fontFamily = Sans,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.2.sp,
),
labelSmall = TextStyle(
fontFamily = Sans,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 14.sp,
letterSpacing = 0.2.sp,
),
)
/** For terminal output and code blocks — callers set this explicitly instead
* of a [Typography] role, since mono content isn't part of the M3 type scale. */
val MonoTextStyle = TextStyle(
fontFamily = JetBrainsMono,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
)
@@ -1,10 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Matches web/public/icon.svg's background so the adaptive icon reads as
the same mark as the PWA icon (issue #272). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#1B1B1B"
android:fillColor="#1A1D24"
android:pathData="M0,0h108v108h-108z" />
</vector>
@@ -1,10 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Foreground layer of the adaptive icon: the same concentric-circle mark
as web/public/icon.svg, scaled from its 512 viewBox into this 108 one
(issue #272). Stays inside the adaptive-icon safe zone (r=35.86 of 54
half-width, ~66%). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#D0BCFF"
android:pathData="M54,24L74,84L64,84L54,52L44,84L34,84Z" />
android:fillColor="#5FE3A1"
android:pathData="M39.23,54 A14.77,14.77 0 1,0 68.77,54 A14.77,14.77 0 1,0 39.23,54 Z" />
<path
android:fillColor="#00000000"
android:strokeColor="#5FE3A1"
android:strokeAlpha="0.5"
android:strokeWidth="2.53"
android:pathData="M28.69,54 A25.31,25.31 0 1,0 79.31,54 A25.31,25.31 0 1,0 28.69,54 Z" />
<path
android:fillColor="#00000000"
android:strokeColor="#5FE3A1"
android:strokeAlpha="0.25"
android:strokeWidth="2.11"
android:pathData="M18.14,54 A35.86,35.86 0 1,0 89.86,54 A35.86,35.86 0 1,0 18.14,54 Z" />
</vector>
+6 -3
View File
@@ -1,6 +1,9 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<resources>
<style name="Theme.Maestro" parent="android:Theme.Material.NoActionBar">
<item name="android:statusBarColor" tools:targetApi="21">@android:color/black</item>
<item name="android:windowBackground">@android:color/black</item>
<!-- No statusBarColor override: MainActivity#onCreate calls
enableEdgeToEdge(), which makes both system bars transparent at
runtime (issue #271). Setting one here would only show briefly
before that call lands. -->
<item name="android:windowBackground">#FF0E1012</item>
</style>
</resources>
@@ -0,0 +1,134 @@
package pt.naps.maestro.ui.commandbar
import kotlinx.serialization.json.JsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import pt.naps.maestro.data.Session
import pt.naps.maestro.data.SessionStatus
private fun session(
id: String,
name: String = id,
command: String = "bash",
cwd: String = "/home/user/project",
status: SessionStatus = SessionStatus.Running,
createdAt: String = "2026-01-01T00:00:00Z",
lastFocusedAt: String? = null,
) = Session(
id = id,
cwd = cwd,
command = command,
metadata = mapOf("name" to JsonPrimitive(name)),
status = status,
created_at = createdAt,
last_focused_at = lastFocusedAt,
)
class CommandBarModelTest {
@Test
fun `a sigil is only read in first position`() {
assertEquals(BarSigil.Commands, parseBarQuery(">quit").sigil)
assertEquals("quit", parseBarQuery(">quit").query)
assertEquals(BarSigil.Directories, parseBarQuery("~/dev").sigil)
assertEquals("/dev", parseBarQuery("~/dev").query)
assertNull(parseBarQuery("foo>bar").sigil)
assertEquals("foo>bar", parseBarQuery("foo>bar").query)
}
@Test
fun `backspacing past a sigil returns the mixed list`() {
assertNull(parseBarQuery("").sigil)
assertEquals("", parseBarQuery("").query)
}
@Test
fun `most recently focused session sorts first`() {
val a = session("a", lastFocusedAt = "2026-01-01T00:00:00Z")
val b = session("b", lastFocusedAt = "2026-01-02T00:00:00Z")
assertEquals(listOf(b, a), orderSessionsForBar(listOf(a, b)))
}
@Test
fun `a never-focused session sorts after every focused one`() {
val focused = session("a", lastFocusedAt = "2026-01-01T00:00:00Z")
val neverFocused = session("b", createdAt = "2026-01-05T00:00:00Z")
assertEquals(listOf(focused, neverFocused), orderSessionsForBar(listOf(neverFocused, focused)))
}
@Test
fun `among never-focused sessions the most recently created sorts first`() {
val older = session("a", createdAt = "2026-01-01T00:00:00Z")
val newer = session("b", createdAt = "2026-01-02T00:00:00Z")
assertEquals(listOf(newer, older), orderSessionsForBar(listOf(older, newer)))
}
@Test
fun `dead sessions sort last regardless of recency`() {
val dead = session("a", status = SessionStatus.Dead, lastFocusedAt = "2026-01-09T00:00:00Z")
val alive = session("b", lastFocusedAt = "2026-01-01T00:00:00Z")
assertEquals(listOf(alive, dead), orderSessionsForBar(listOf(dead, alive)))
}
@Test
fun `abbreviateCwd replaces the home prefix`() {
assertEquals("~/dev/api", abbreviateCwd("/home/user/dev/api", "/home/user"))
assertEquals("~", abbreviateCwd("/home/user", "/home/user"))
assertEquals("/var/lib", abbreviateCwd("/var/lib", "/home/user"))
}
@Test
fun `expandHome inverts abbreviateCwd`() {
assertEquals("/home/user/dev/api", expandHome("~/dev/api", "/home/user"))
assertEquals("/home/user", expandHome("~", "/home/user"))
}
@Test
fun `an empty query matches every candidate at score 0 in input order`() {
val a = BarCandidate.SessionItem(session("a"))
val b = BarCandidate.SessionItem(session("b"))
val results = rankCandidates("", listOf(a, b), homeDir = "")
assertEquals(listOf(a, b), results.map { it.candidate })
assertTrue(results.all { it.score == 0 })
}
@Test
fun `a query matching nothing returns an empty list`() {
val candidates = listOf(BarCandidate.SessionItem(session("a", command = "bash")))
assertTrue(rankCandidates("zzz", candidates, homeDir = "").isEmpty())
}
@Test
fun `an exact name match outranks a session found through its directory`() {
val api = BarCandidate.SessionItem(session("api", cwd = "/home/user/web"))
val worker = BarCandidate.SessionItem(session("worker", cwd = "/home/user/api"))
val results = rankCandidates("api", listOf(api, worker), homeDir = "")
assertEquals(listOf(api, worker), results.map { it.candidate })
assertEquals("name", results[0].matchedField)
assertEquals("directory", results[1].matchedField)
}
@Test
fun `a session beats a command of equal score, kind order tie-break`() {
val sessionItem = BarCandidate.SessionItem(session("kill"))
val command = BarCandidate.CommandItem(BarCommand(id = "kill", label = "kill"))
val results = rankCandidates("kill", listOf(command, sessionItem), homeDir = "")
assertEquals(listOf(sessionItem, command), results.map { it.candidate })
}
@Test
fun `groups are ordered by their best-scoring member and drop empty kinds`() {
val sessionItem = BarCandidate.SessionItem(session("web-search"))
val command = BarCandidate.CommandItem(BarCommand(id = "spawn", label = "Spawn session"))
val ranked = rankCandidates("web", listOf(command, sessionItem), homeDir = "")
val groups = groupResults(ranked)
assertEquals(listOf("Sessions"), groups.map { it.title })
}
@Test
fun `a fuzzy subsequence still matches out of order characters`() {
val candidate = BarCandidate.SessionItem(session("maestro-tui"))
assertTrue(rankCandidates("mtu", listOf(candidate), homeDir = "").isNotEmpty())
}
}
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+23 -4
View File
@@ -69,13 +69,28 @@ pub enum AcpUpdate {
/// turn precede this marker on the wire, and therefore in this channel.
/// The event pump must fire `done` after persisting every preceding
/// notification.
///
/// The `done` oneshot carries both the prompt result and a `settled_tx`
/// that the receiver fires after the turn marker is persisted. The
/// event pump awaits the corresponding `settled_rx` before processing
/// further messages so late-arriving notifications cannot leak into the
/// next turn.
TurnDone {
turn: u64,
result: Result<(), String>,
done: oneshot::Sender<Result<(), String>>,
done: oneshot::Sender<TurnDonePayload>,
},
}
/// Payload sent through the `done` oneshot inside [`AcpUpdate::TurnDone`].
#[derive(Debug)]
pub struct TurnDonePayload {
pub result: Result<(), String>,
/// The receiver fires this after persisting the turn marker and
/// dispatching any queued prompt.
pub settled: oneshot::Sender<()>,
}
/// ACP adapter selected from the structured session's resolved command.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AcpAdapter {
@@ -301,7 +316,7 @@ enum Command {
Prompt {
text: String,
turn: u64,
done: oneshot::Sender<Result<(), String>>,
done: oneshot::Sender<TurnDonePayload>,
},
SetConfig {
config_id: String,
@@ -435,7 +450,7 @@ impl AcpSession {
&self,
text: String,
turn: u64,
) -> Result<oneshot::Receiver<Result<(), String>>, String> {
) -> Result<oneshot::Receiver<TurnDonePayload>, String> {
let (done_tx, done_rx) = oneshot::channel();
self.commands
.send(Command::Prompt {
@@ -999,7 +1014,11 @@ async fn run_connection<T>(
.expect("permission turn state")
.finish(turn);
if let Some(done) = done.lock().expect("prompt sender").take() {
let _ = done.send(Err(error.to_string()));
let (settled_tx, _) = oneshot::channel();
let _ = done.send(TurnDonePayload {
result: Err(error.to_string()),
settled: settled_tx,
});
}
}
}
+27 -5
View File
@@ -869,11 +869,24 @@ impl SessionManager {
stream.dispatch.queued_turn = Some(turn);
let manager = self.clone();
tokio::spawn(async move {
let lifecycle = match done.await {
Ok(Ok(())) => TurnLifecycle::Finish,
Ok(Err(_)) | Err(_) => TurnLifecycle::Failure,
let (lifecycle, settled) = match done.await {
Ok(payload) => {
let lifecycle = if payload.result.is_ok() {
TurnLifecycle::Finish
} else {
TurnLifecycle::Failure
};
(lifecycle, Some(payload.settled))
}
Err(_) => (TurnLifecycle::Failure, None),
};
manager.finish_queued_acp_turn(id, turn, lifecycle).await;
// Tell the event pump it may resume processing
// notifications — the turn marker is persisted
// and any queued prompt has been dispatched.
if let Some(settled) = settled {
let _ = settled.send(());
}
});
drop(stream);
sync_structured_activity(&self.inner, id).await;
@@ -2443,8 +2456,17 @@ fn start_acp_event_pump(
} => {
// Every notification for this turn has been persisted and
// broadcast above. Signal the done oneshot so
// finish_queued_acp_turn runs with the transcript complete.
let _ = done.send(result);
// finish_queued_acp_turn runs with the transcript complete,
// then wait for it to persist the turn marker before we
// resume — otherwise late-arriving notifications race into
// the next turn.
let (settled_tx, settled_rx) = tokio::sync::oneshot::channel();
let _ = done.send(maestro_acp::TurnDonePayload {
result,
settled: settled_tx,
});
// Block the pump until the turn is fully settled.
let _ = settled_rx.await;
}
}
}
+1 -1
View File
@@ -39,7 +39,7 @@ export class ApiRequestError extends Error {
async function jsonOrError<T>(res: Response): Promise<T> {
if (res.ok) {
if (res.status === 204) return undefined as unknown as T;
if (res.status === 204 || res.status === 202) return undefined as unknown as T;
return (await res.json()) as T;
}
try {