Compare commits

..
Author SHA1 Message Date
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
7 changed files with 1133 additions and 119 deletions
@@ -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)) }
}
}
@@ -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())
}
}