From d1b8b0abc16d3c11507373a743761d2b9ebcd3c8 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 13 Sep 2026 02:20:46 +0100 Subject: [PATCH] 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. --- .../java/pt/naps/maestro/ui/MaestroApp.kt | 187 ++++++++--- .../maestro/ui/commandbar/CommandBarModel.kt | 190 +++++++++++ .../maestro/ui/commandbar/CommandBarSheet.kt | 307 ++++++++++++++++++ .../ui/commandbar/CommandBarViewModel.kt | 135 ++++++++ .../ui/commandbar/CommandBarModelTest.kt | 134 ++++++++ 5 files changed, 911 insertions(+), 42 deletions(-) create mode 100644 android/app/src/main/java/pt/naps/maestro/ui/commandbar/CommandBarModel.kt create mode 100644 android/app/src/main/java/pt/naps/maestro/ui/commandbar/CommandBarSheet.kt create mode 100644 android/app/src/main/java/pt/naps/maestro/ui/commandbar/CommandBarViewModel.kt create mode 100644 android/app/src/test/java/pt/naps/maestro/ui/commandbar/CommandBarModelTest.kt diff --git a/android/app/src/main/java/pt/naps/maestro/ui/MaestroApp.kt b/android/app/src/main/java/pt/naps/maestro/ui/MaestroApp.kt index dbec0fc..1ba7c1e 100644 --- a/android/app/src/main/java/pt/naps/maestro/ui/MaestroApp.kt +++ b/android/app/src/main/java/pt/naps/maestro/ui/MaestroApp.kt @@ -6,6 +6,7 @@ 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 @@ -13,6 +14,7 @@ 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 @@ -26,9 +28,21 @@ 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 @@ -42,6 +56,8 @@ 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 @@ -60,6 +76,13 @@ private object Routes { 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 @@ -77,6 +100,8 @@ fun MaestroApp(daemonLink: DaemonLink, prefs: DaemonPrefs) { val linkState by daemonLink.state.collectAsState() val baseUrl by prefs.baseUrl.collectAsState() var showSpawnSheet by remember { mutableStateOf(false) } + var spawnSeed by remember { mutableStateOf(null) } + var showCommandBar by remember { mutableStateOf(false) } val infoClient = remember(baseUrl) { MaestroClient(baseUrl) } var hostname by remember(baseUrl) { mutableStateOf("") } @@ -99,66 +124,144 @@ fun MaestroApp(daemonLink: DaemonLink, prefs: DaemonPrefs) { scope.launch { drawerState.close() } } - ModalNavigationDrawer( - drawerState = drawerState, - drawerContent = { - ModalDrawerSheet(modifier = Modifier.width(320.dp)) { - SidebarContent( - catalogStore = daemonLink.catalog, - onOpenSession = ::openSession, - ) - } - }, + 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 + } + }, ) { - 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 = { showSpawnSheet = true }, + ModalNavigationDrawer( + drawerState = drawerState, + drawerContent = { + ModalDrawerSheet(modifier = Modifier.width(320.dp)) { + SidebarContent( + catalogStore = daemonLink.catalog, + onOpenSession = ::openSession, ) } - 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) }, - ) + }, + ) { + 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 viewModel = remember(infoClient) { SpawnViewModel(infoClient) } + 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 }, + 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. diff --git a/android/app/src/main/java/pt/naps/maestro/ui/commandbar/CommandBarModel.kt b/android/app/src/main/java/pt/naps/maestro/ui/commandbar/CommandBarModel.kt new file mode 100644 index 0000000..a3425c2 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/ui/commandbar/CommandBarModel.kt @@ -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 = 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): List = 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, homeDir: String): List { + val seen = LinkedHashSet() + val out = mutableListOf() + 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 = 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, homeDir: String): List { + 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 { it.score }.thenBy { kindOrder(it.candidate) }) +} + +data class BarGroup(val title: String, val results: List) + +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): List { + val groups = LinkedHashMap>() + for (result in results) { + groups.getOrPut(titleFor(result.candidate)) { mutableListOf() }.add(result) + } + return groups.map { (title, group) -> BarGroup(title, group) } +} diff --git a/android/app/src/main/java/pt/naps/maestro/ui/commandbar/CommandBarSheet.kt b/android/app/src/main/java/pt/naps/maestro/ui/commandbar/CommandBarSheet.kt new file mode 100644 index 0000000..a38a2d9 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/ui/commandbar/CommandBarSheet.kt @@ -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(null) } + var renaming by remember { mutableStateOf(null) } + var confirmingKill by remember { mutableStateOf(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") } }, + ) +} diff --git a/android/app/src/main/java/pt/naps/maestro/ui/commandbar/CommandBarViewModel.kt b/android/app/src/main/java/pt/naps/maestro/ui/commandbar/CommandBarViewModel.kt new file mode 100644 index 0000000..bfd24f0 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/ui/commandbar/CommandBarViewModel.kt @@ -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 = 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 = _state.asStateFlow() + + private var homeDir: String = "" + private var recentDirectories: List = emptyList() + private var probedDirectories: List = 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 { + val seen = LinkedHashSet() + val out = mutableListOf() + 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 = 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)) } + } +} diff --git a/android/app/src/test/java/pt/naps/maestro/ui/commandbar/CommandBarModelTest.kt b/android/app/src/test/java/pt/naps/maestro/ui/commandbar/CommandBarModelTest.kt new file mode 100644 index 0000000..bff5f42 --- /dev/null +++ b/android/app/src/test/java/pt/naps/maestro/ui/commandbar/CommandBarModelTest.kt @@ -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()) + } +}