Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b27dda6c6b | ||
|
|
5bdedc8d23 | ||
|
|
1e628789fb | ||
|
|
3f2473d910 | ||
|
|
b56a35f3ee | ||
|
|
b1f6014a6a | ||
|
|
01db6459cb | ||
|
|
d332c43a9f | ||
|
|
1cc05af323 | ||
|
|
6eb048575c | ||
|
|
5cdc6135f5 | ||
|
|
f6be34ae48 | ||
|
|
4f06d1fc8f | ||
|
|
d1b8b0abc1 |
@@ -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,5 +1,6 @@
|
||||
*.iml
|
||||
.gradle/
|
||||
.kotlin/
|
||||
/local.properties
|
||||
/.idea/
|
||||
.DS_Store
|
||||
|
||||
@@ -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<SpawnSeed?>(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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -42,12 +42,13 @@ use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use agent_client_protocol::schema::v1::{
|
||||
CancelNotification, ClientCapabilities, ConfigOptionUpdate, ContentBlock, CurrentModeUpdate,
|
||||
FileSystemCapabilities, Implementation, InitializeRequest, PromptRequest,
|
||||
RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse,
|
||||
SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory, SessionConfigOptionValue,
|
||||
SessionConfigSelectOption, SessionId, SessionModeState, SessionNotification, SessionUpdate,
|
||||
SetSessionConfigOptionRequest, SetSessionModeRequest,
|
||||
CancelNotification, ClientCapabilities, ConfigOptionUpdate, ContentBlock,
|
||||
CreateElicitationRequest, CreateElicitationResponse, CurrentModeUpdate, ElicitationAction,
|
||||
ElicitationCapabilities, ElicitationFormCapabilities, FileSystemCapabilities, Implementation,
|
||||
InitializeRequest, PromptRequest, RequestPermissionOutcome, RequestPermissionRequest,
|
||||
RequestPermissionResponse, SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory,
|
||||
SessionConfigOptionValue, SessionConfigSelectOption, SessionId, SessionModeState,
|
||||
SessionNotification, SessionUpdate, SetSessionConfigOptionRequest, SetSessionModeRequest,
|
||||
};
|
||||
use agent_client_protocol::schema::ProtocolVersion;
|
||||
use agent_client_protocol::{ByteStreams, Client, JsonRpcRequest, JsonRpcResponse};
|
||||
@@ -69,13 +70,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 {
|
||||
@@ -249,6 +265,15 @@ pub struct PermissionRequest {
|
||||
pub response: oneshot::Sender<RequestPermissionOutcome>,
|
||||
}
|
||||
|
||||
/// A blocked ACP elicitation request delivered to the daemon.
|
||||
#[derive(Debug)]
|
||||
pub struct ElicitationRequest {
|
||||
/// The prompt turn that was active when ACP delivered this callback.
|
||||
pub turn: u64,
|
||||
pub request: CreateElicitationRequest,
|
||||
pub response: oneshot::Sender<ElicitationAction>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AcpError {
|
||||
/// The adapter is not on `PATH`. The message names the install command
|
||||
@@ -301,7 +326,7 @@ enum Command {
|
||||
Prompt {
|
||||
text: String,
|
||||
turn: u64,
|
||||
done: oneshot::Sender<Result<(), String>>,
|
||||
done: oneshot::Sender<TurnDonePayload>,
|
||||
},
|
||||
SetConfig {
|
||||
config_id: String,
|
||||
@@ -374,6 +399,7 @@ pub struct AcpSession {
|
||||
child_commands: mpsc::UnboundedSender<ChildCommand>,
|
||||
updates: Mutex<Option<mpsc::UnboundedReceiver<AcpUpdate>>>,
|
||||
permission_requests: Mutex<Option<mpsc::UnboundedReceiver<PermissionRequest>>>,
|
||||
elicitation_requests: Mutex<Option<mpsc::UnboundedReceiver<ElicitationRequest>>>,
|
||||
exit: watch::Receiver<Option<ExitStatus>>,
|
||||
}
|
||||
|
||||
@@ -424,6 +450,13 @@ impl AcpSession {
|
||||
.take()
|
||||
}
|
||||
|
||||
pub fn take_elicitation_requests(&self) -> Option<mpsc::UnboundedReceiver<ElicitationRequest>> {
|
||||
self.elicitation_requests
|
||||
.lock()
|
||||
.expect("elicitation requests")
|
||||
.take()
|
||||
}
|
||||
|
||||
/// Send `session/cancel`. Fire-and-forget: a closed connection means the
|
||||
/// child is already gone, which is the outcome cancel was after.
|
||||
pub fn cancel(&self) {
|
||||
@@ -435,7 +468,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 {
|
||||
@@ -558,6 +591,7 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
|
||||
|
||||
let (updates_tx, updates_rx) = mpsc::unbounded_channel();
|
||||
let (permission_tx, permission_rx) = mpsc::unbounded_channel();
|
||||
let (elicitation_tx, elicitation_rx) = mpsc::unbounded_channel();
|
||||
let (commands_tx, commands_rx) = mpsc::unbounded_channel();
|
||||
let (ready_tx, ready_rx) = oneshot::channel();
|
||||
let transport = ByteStreams::new(stdin.compat_write(), stdout.compat());
|
||||
@@ -570,6 +604,7 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
|
||||
},
|
||||
updates_tx,
|
||||
permission_tx,
|
||||
elicitation_tx,
|
||||
commands_rx,
|
||||
ready_tx,
|
||||
));
|
||||
@@ -584,6 +619,7 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
|
||||
child_commands: child_commands_tx,
|
||||
updates: Mutex::new(Some(updates_rx)),
|
||||
permission_requests: Mutex::new(Some(permission_rx)),
|
||||
elicitation_requests: Mutex::new(Some(elicitation_rx)),
|
||||
exit: exit_rx,
|
||||
});
|
||||
}
|
||||
@@ -690,6 +726,7 @@ fn client_capabilities() -> ClientCapabilities {
|
||||
.read_text_file(false)
|
||||
.write_text_file(false))
|
||||
.terminal(false)
|
||||
.elicitation(ElicitationCapabilities::new().form(ElicitationFormCapabilities::default()))
|
||||
}
|
||||
|
||||
fn advertised_config_options(
|
||||
@@ -792,6 +829,7 @@ async fn run_connection<T>(
|
||||
config: ConnectionConfig,
|
||||
updates_tx: mpsc::UnboundedSender<AcpUpdate>,
|
||||
permission_requests: mpsc::UnboundedSender<PermissionRequest>,
|
||||
elicitation_requests: mpsc::UnboundedSender<ElicitationRequest>,
|
||||
mut commands_rx: mpsc::UnboundedReceiver<Command>,
|
||||
ready_tx: oneshot::Sender<Result<String, ReadyError>>,
|
||||
) where
|
||||
@@ -807,6 +845,7 @@ async fn run_connection<T>(
|
||||
// until a cancelled prompt settles, so this active turn is unambiguous.
|
||||
let permission_turn = Arc::new(Mutex::new(PermissionTurnState::default()));
|
||||
let permission_turn_seen = permission_turn.clone();
|
||||
let elicitation_turn_seen = permission_turn.clone();
|
||||
let received_updates = updates_tx.clone();
|
||||
let result = Client
|
||||
.builder()
|
||||
@@ -852,6 +891,35 @@ async fn run_connection<T>(
|
||||
},
|
||||
agent_client_protocol::on_receive_request!(),
|
||||
)
|
||||
.on_receive_request(
|
||||
async move |request: CreateElicitationRequest, responder, _cx| {
|
||||
let turn = elicitation_turn_seen
|
||||
.lock()
|
||||
.expect("elicitation turn state")
|
||||
.callback_turn();
|
||||
let Some(turn) = turn else {
|
||||
return responder.respond(CreateElicitationResponse::new(
|
||||
ElicitationAction::Cancel,
|
||||
));
|
||||
};
|
||||
let (response, received) = oneshot::channel();
|
||||
if elicitation_requests
|
||||
.send(ElicitationRequest {
|
||||
turn,
|
||||
request,
|
||||
response,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
return responder.respond(CreateElicitationResponse::new(
|
||||
ElicitationAction::Cancel,
|
||||
));
|
||||
}
|
||||
let action = received.await.unwrap_or(ElicitationAction::Cancel);
|
||||
responder.respond(CreateElicitationResponse::new(action))
|
||||
},
|
||||
agent_client_protocol::on_receive_request!(),
|
||||
)
|
||||
.connect_with(transport, async move |cx| {
|
||||
let init = InitializeRequest::new(ProtocolVersion::V1)
|
||||
.client_capabilities(client_capabilities())
|
||||
@@ -999,7 +1067,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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,11 +70,12 @@ pub fn decide(idle_for: Duration, awaiting_input: bool) -> ActivityState {
|
||||
pub fn decide_structured(
|
||||
turn_active: bool,
|
||||
pending_approval: bool,
|
||||
pending_elicitation: bool,
|
||||
turn_failed: bool,
|
||||
) -> ActivityState {
|
||||
if turn_failed {
|
||||
ActivityState::Error
|
||||
} else if pending_approval {
|
||||
} else if pending_approval || pending_elicitation {
|
||||
ActivityState::AwaitingInput
|
||||
} else if turn_active {
|
||||
ActivityState::Active
|
||||
@@ -155,13 +156,26 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn structured_activity_follows_lifecycle_with_approval_precedence() {
|
||||
assert_eq!(decide_structured(false, false, false), ActivityState::Idle);
|
||||
assert_eq!(decide_structured(true, false, false), ActivityState::Active);
|
||||
assert_eq!(
|
||||
decide_structured(true, true, false),
|
||||
decide_structured(false, false, false, false),
|
||||
ActivityState::Idle
|
||||
);
|
||||
assert_eq!(
|
||||
decide_structured(true, false, false, false),
|
||||
ActivityState::Active
|
||||
);
|
||||
assert_eq!(
|
||||
decide_structured(true, true, false, false),
|
||||
ActivityState::AwaitingInput
|
||||
);
|
||||
assert_eq!(decide_structured(false, false, true), ActivityState::Error);
|
||||
assert_eq!(
|
||||
decide_structured(true, false, true, false),
|
||||
ActivityState::AwaitingInput
|
||||
);
|
||||
assert_eq!(
|
||||
decide_structured(false, false, false, true),
|
||||
ActivityState::Error
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -22,8 +22,8 @@ use maestro_config::SpawnPreset;
|
||||
use maestro_protocol::{
|
||||
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
|
||||
RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, SetFocusRequest,
|
||||
StructuredApprovalDecision, StructuredConfigRequest, StructuredEvent, StructuredPromptRequest,
|
||||
PROTOCOL_VERSION,
|
||||
StructuredApprovalDecision, StructuredConfigRequest, StructuredElicitationAction,
|
||||
StructuredEvent, StructuredPromptRequest, PROTOCOL_VERSION,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
@@ -80,6 +80,10 @@ pub fn router(state: LocalApiState) -> Router {
|
||||
.route(
|
||||
routes::patterns::STRUCTURED_APPROVAL,
|
||||
post(structured_approval),
|
||||
)
|
||||
.route(
|
||||
routes::patterns::STRUCTURED_ELICITATION,
|
||||
post(structured_elicitation),
|
||||
);
|
||||
#[cfg(feature = "haiku-autoname")]
|
||||
{
|
||||
@@ -577,6 +581,53 @@ async fn structured_approval(
|
||||
}
|
||||
}
|
||||
|
||||
async fn structured_elicitation(
|
||||
State(s): State<LocalApiState>,
|
||||
Path((id, nonce)): Path<(Uuid, String)>,
|
||||
Json(body): Json<StructuredElicitationAction>,
|
||||
) -> Response {
|
||||
use maestro_acp::schema::{
|
||||
ElicitationAcceptAction, ElicitationAction, ElicitationContentValue,
|
||||
};
|
||||
let action = match body {
|
||||
StructuredElicitationAction::Accept { content } => {
|
||||
let mapped: std::collections::BTreeMap<String, ElicitationContentValue> = content
|
||||
.into_iter()
|
||||
.filter_map(|(key, value)| {
|
||||
let v = match value {
|
||||
serde_json::Value::String(s) => ElicitationContentValue::String(s),
|
||||
serde_json::Value::Bool(b) => ElicitationContentValue::Boolean(b),
|
||||
serde_json::Value::Number(n) => {
|
||||
if let Some(i) = n.as_i64() {
|
||||
ElicitationContentValue::Integer(i)
|
||||
} else {
|
||||
ElicitationContentValue::Number(n.as_f64()?)
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some((key, v))
|
||||
})
|
||||
.collect();
|
||||
ElicitationAction::Accept(ElicitationAcceptAction::new().content(mapped))
|
||||
}
|
||||
StructuredElicitationAction::Decline => ElicitationAction::Decline,
|
||||
StructuredElicitationAction::Cancel => ElicitationAction::Cancel,
|
||||
};
|
||||
match s.manager.resolve_acp_elicitation(id, &nonce, action).await {
|
||||
Ok(Some(())) => StatusCode::NO_CONTENT.into_response(),
|
||||
Ok(None) => error_response(
|
||||
StatusCode::CONFLICT,
|
||||
"not_structured",
|
||||
"session is not a live structured session",
|
||||
),
|
||||
Err(e) => {
|
||||
let info = e.to_error_info();
|
||||
error_response(session_error_status(&info.code), &info.code, &info.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience constructor — pulls the bits `LocalApiState` needs from
|
||||
/// `DaemonConfig` so callers don't have to assemble it by hand. The project
|
||||
/// scanner starts with its cache cold; wire
|
||||
|
||||
@@ -125,6 +125,7 @@ impl Projection {
|
||||
crate::activity::decide_structured(
|
||||
self.active_turn.is_some(),
|
||||
self.state.pending_approval.is_some(),
|
||||
self.state.pending_elicitation.is_some(),
|
||||
self.turn_failed,
|
||||
)
|
||||
}
|
||||
@@ -274,6 +275,45 @@ impl Projection {
|
||||
})
|
||||
}
|
||||
|
||||
/// Add a daemon-originated elicitation row. The nonce belongs only in
|
||||
/// the reduced state, not in the row data.
|
||||
pub fn request_elicitation(&mut self, nonce: String, data: Value) -> ProjectionUpdate {
|
||||
let row = self.append(
|
||||
format!("elicitation:{}", uuid::Uuid::new_v4()),
|
||||
TranscriptRowKind::Elicitation,
|
||||
data.clone(),
|
||||
);
|
||||
self.state.pending_elicitation = Some(serde_json::json!({
|
||||
"nonce": nonce,
|
||||
"request": data,
|
||||
}));
|
||||
ProjectionUpdate {
|
||||
transcript: Some(row),
|
||||
state: self.state(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear an elicitation only when it is still the one the client answered.
|
||||
pub fn resolve_elicitation(&mut self, nonce: &str) -> Option<ProjectionUpdate> {
|
||||
let pending = self.state.pending_elicitation.as_ref()?;
|
||||
if pending.get("nonce").and_then(Value::as_str) != Some(nonce) {
|
||||
return None;
|
||||
}
|
||||
self.state.pending_elicitation = None;
|
||||
Some(ProjectionUpdate {
|
||||
transcript: None,
|
||||
state: self.state(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn clear_elicitation(&mut self) -> Option<ProjectionUpdate> {
|
||||
self.state.pending_elicitation.take()?;
|
||||
Some(ProjectionUpdate {
|
||||
transcript: None,
|
||||
state: self.state(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Undo an `append`ed row that a caller decides never happened — used
|
||||
/// when persisting the row's own event fails (#184), so a snapshot taken
|
||||
/// after the failure cannot show a row durable replay would never
|
||||
|
||||
@@ -156,6 +156,7 @@ struct AcpEventStream {
|
||||
projection: Projection,
|
||||
projected_live: broadcast::Sender<ProjectionUpdate>,
|
||||
pending_approvals: HashMap<String, PendingApproval>,
|
||||
pending_elicitations: HashMap<String, PendingElicitation>,
|
||||
/// Serializes prompt dispatch with cancellation. A prompt is not merely
|
||||
/// claimed: it is queued onto ACP before another operation can proceed.
|
||||
operation: Arc<Mutex<()>>,
|
||||
@@ -205,6 +206,11 @@ struct PendingApproval {
|
||||
options: Vec<maestro_acp::schema::PermissionOption>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PendingElicitation {
|
||||
response: oneshot::Sender<maestro_acp::schema::ElicitationAction>,
|
||||
}
|
||||
|
||||
/// The outcome accepted by the structured approval endpoint.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ApprovalDecision {
|
||||
@@ -712,6 +718,7 @@ impl SessionManager {
|
||||
return Ok(false);
|
||||
}
|
||||
cancel_pending_approvals(&mut stream);
|
||||
cancel_pending_elicitations(&mut stream);
|
||||
let Some(projected) = stream.projection.turn_lifecycle(lifecycle, turn) else {
|
||||
return Ok(false);
|
||||
};
|
||||
@@ -869,11 +876,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;
|
||||
@@ -885,6 +905,7 @@ impl SessionManager {
|
||||
}
|
||||
Err(message) => {
|
||||
cancel_pending_approvals(&mut stream);
|
||||
cancel_pending_elicitations(&mut stream);
|
||||
let projected = stream
|
||||
.projection
|
||||
.turn_lifecycle(TurnLifecycle::Failure, turn)
|
||||
@@ -955,6 +976,7 @@ impl SessionManager {
|
||||
stream.dispatch.settled.notify_waiters();
|
||||
if stream.projection.active_turn() == Some(turn) {
|
||||
cancel_pending_approvals(&mut stream);
|
||||
cancel_pending_elicitations(&mut stream);
|
||||
if let Some(projected) = stream.projection.turn_lifecycle(lifecycle, turn) {
|
||||
let projected = persist_turn_update(&mut stream, projected);
|
||||
stream.broadcast(projected);
|
||||
@@ -1077,6 +1099,7 @@ impl SessionManager {
|
||||
let mut stream = event_stream.lock().await;
|
||||
acp.cancel();
|
||||
cancel_pending_approvals(&mut stream);
|
||||
cancel_pending_elicitations(&mut stream);
|
||||
let cancelled_queued_turn = stream.dispatch.queued_turn;
|
||||
if let Some(turn) = cancelled_queued_turn {
|
||||
stream.dispatch.cancelled_turn = Some(turn);
|
||||
@@ -1149,6 +1172,38 @@ impl SessionManager {
|
||||
Ok(Some(()))
|
||||
}
|
||||
|
||||
/// Resolve a daemon-issued elicitation nonce. Same idempotency rules as
|
||||
/// `resolve_acp_approval`: stale/duplicate nonces succeed silently.
|
||||
pub async fn resolve_acp_elicitation(
|
||||
&self,
|
||||
id: Uuid,
|
||||
nonce: &str,
|
||||
action: maestro_acp::schema::ElicitationAction,
|
||||
) -> Result<Option<()>, SessionError> {
|
||||
let stream = {
|
||||
let sessions = self.inner.sessions.lock().await;
|
||||
let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
|
||||
if !record.is_live_structured() {
|
||||
return Ok(None);
|
||||
}
|
||||
record.acp_events.clone()
|
||||
};
|
||||
let Some(stream) = stream else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut stream = stream.lock().await;
|
||||
let Some(pending) = stream.pending_elicitations.remove(nonce) else {
|
||||
return Ok(Some(()));
|
||||
};
|
||||
let _ = pending.response.send(action);
|
||||
if let Some(update) = stream.projection.resolve_elicitation(nonce) {
|
||||
stream.broadcast(update);
|
||||
}
|
||||
drop(stream);
|
||||
sync_structured_activity(&self.inner, id).await;
|
||||
Ok(Some(()))
|
||||
}
|
||||
|
||||
async fn cancel_acp_approvals(&self, id: Uuid) {
|
||||
let stream = self
|
||||
.inner
|
||||
@@ -1158,7 +1213,9 @@ impl SessionManager {
|
||||
.get(&id)
|
||||
.and_then(|record| record.acp_events.clone());
|
||||
if let Some(stream) = stream {
|
||||
cancel_pending_approvals(&mut *stream.lock().await);
|
||||
let mut events = stream.lock().await;
|
||||
cancel_pending_approvals(&mut events);
|
||||
cancel_pending_elicitations(&mut events);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1448,6 +1505,7 @@ impl SessionManager {
|
||||
projection: Projection::default(),
|
||||
projected_live: projected_tx,
|
||||
pending_approvals: HashMap::new(),
|
||||
pending_elicitations: HashMap::new(),
|
||||
operation: Arc::new(Mutex::new(())),
|
||||
dispatch: AcpDispatchState::default(),
|
||||
prompt_queue: VecDeque::new(),
|
||||
@@ -1601,6 +1659,7 @@ impl SessionManager {
|
||||
let acp = Arc::new(acp_session);
|
||||
let done = start_acp_event_pump(&inner, id, &acp, events.clone());
|
||||
start_acp_permission_pump(&inner, id, &acp, events.clone(), auto_approve);
|
||||
start_acp_elicitation_pump(&inner, id, &acp, events.clone());
|
||||
// Drain any prompts queued while the adapter was Starting.
|
||||
let queued_text = {
|
||||
let mut sessions = inner.sessions.lock().await;
|
||||
@@ -1968,6 +2027,7 @@ impl SessionManager {
|
||||
projection,
|
||||
projected_live: projected_tx,
|
||||
pending_approvals: HashMap::new(),
|
||||
pending_elicitations: HashMap::new(),
|
||||
operation: Arc::new(Mutex::new(())),
|
||||
dispatch: AcpDispatchState::default(),
|
||||
prompt_queue: session.queued_prompts.drain(..).collect(),
|
||||
@@ -2026,7 +2086,14 @@ impl SessionManager {
|
||||
);
|
||||
if let Some((acp, events)) = event_pump {
|
||||
let done = start_acp_event_pump(&self.inner, id, &acp, events.clone());
|
||||
start_acp_permission_pump(&self.inner, id, &acp, events, session.auto_approve);
|
||||
start_acp_permission_pump(
|
||||
&self.inner,
|
||||
id,
|
||||
&acp,
|
||||
events.clone(),
|
||||
session.auto_approve,
|
||||
);
|
||||
start_acp_elicitation_pump(&self.inner, id, &acp, events);
|
||||
if let Some(record) = self.inner.sessions.lock().await.get_mut(&id) {
|
||||
record.acp_event_pump = Some(done);
|
||||
}
|
||||
@@ -2443,8 +2510,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2458,6 +2534,7 @@ fn apply_persisted_event(projection: &mut Projection, event: &LoggedEvent) -> Pr
|
||||
PersistedEvent::Notification(notification) => projection.apply(event.seq, notification),
|
||||
PersistedEvent::User { row }
|
||||
| PersistedEvent::Approval { row }
|
||||
| PersistedEvent::Elicitation { row }
|
||||
| PersistedEvent::Turn { row } => projection.restore_row(row.clone()),
|
||||
}
|
||||
}
|
||||
@@ -2735,6 +2812,110 @@ fn permission_outcome(
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Elicitation pump
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn start_acp_elicitation_pump(
|
||||
inner: &Arc<Inner>,
|
||||
id: Uuid,
|
||||
acp: &Arc<AcpSession>,
|
||||
events: Arc<Mutex<AcpEventStream>>,
|
||||
) {
|
||||
let Some(mut requests) = acp.take_elicitation_requests() else {
|
||||
return;
|
||||
};
|
||||
let weak_inner = Arc::downgrade(inner);
|
||||
let weak_acp = Arc::downgrade(acp);
|
||||
tokio::spawn(async move {
|
||||
while let Some(maestro_acp::ElicitationRequest {
|
||||
turn,
|
||||
request,
|
||||
response,
|
||||
}) = requests.recv().await
|
||||
{
|
||||
let mut events = events.lock().await;
|
||||
if events.projection.active_turn() != Some(turn) {
|
||||
let _ = response.send(maestro_acp::schema::ElicitationAction::Cancel);
|
||||
continue;
|
||||
}
|
||||
let outcome = record_elicitation_request(&mut events, request, response);
|
||||
let fatal = matches!(outcome, RecordOutcome::Fatal);
|
||||
if let RecordOutcome::Live(update) = outcome {
|
||||
events.broadcast(*update);
|
||||
}
|
||||
drop(events);
|
||||
if fatal {
|
||||
tracing::error!(%id, "elicitation history append rollback did not restore consistency");
|
||||
if let Some(acp) = weak_acp.upgrade() {
|
||||
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
|
||||
.await;
|
||||
}
|
||||
if let Some(inner) = weak_inner.upgrade() {
|
||||
mark_session_dead(&inner, id).await;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if let Some(inner) = weak_inner.upgrade() {
|
||||
sync_structured_activity(&inner, id).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn record_elicitation_request(
|
||||
events: &mut AcpEventStream,
|
||||
request: maestro_acp::schema::CreateElicitationRequest,
|
||||
response: oneshot::Sender<maestro_acp::schema::ElicitationAction>,
|
||||
) -> RecordOutcome {
|
||||
let nonce = Uuid::new_v4().to_string();
|
||||
let data = match serde_json::to_value(&request) {
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
let _ = response.send(maestro_acp::schema::ElicitationAction::Cancel);
|
||||
return RecordOutcome::Dropped;
|
||||
}
|
||||
};
|
||||
cancel_pending_elicitations(events);
|
||||
events
|
||||
.pending_elicitations
|
||||
.insert(nonce.clone(), PendingElicitation { response });
|
||||
let update = events.projection.request_elicitation(nonce, data);
|
||||
let Some(TranscriptDelta::Append { row }) = &update.transcript else {
|
||||
return RecordOutcome::Live(Box::new(update));
|
||||
};
|
||||
let event = PersistedEvent::Elicitation { row: row.clone() };
|
||||
match events.log.append(&event) {
|
||||
Ok(seq) => {
|
||||
let _ = events.live.send(LoggedEvent { seq, event });
|
||||
RecordOutcome::Live(Box::new(update))
|
||||
}
|
||||
Err(AppendError::Failed(error)) => {
|
||||
tracing::error!(%error, "elicitation history append failed");
|
||||
events.projection.rollback_row(&row.id);
|
||||
cancel_pending_elicitations(events);
|
||||
RecordOutcome::Dropped
|
||||
}
|
||||
Err(fatal @ AppendError::Fatal { .. }) => {
|
||||
tracing::error!(error = %fatal, "elicitation history append failed unrecoverably");
|
||||
events.projection.rollback_row(&row.id);
|
||||
cancel_pending_elicitations(events);
|
||||
RecordOutcome::Fatal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel_pending_elicitations(events: &mut AcpEventStream) {
|
||||
for (_, pending) in events.pending_elicitations.drain() {
|
||||
let _ = pending
|
||||
.response
|
||||
.send(maestro_acp::schema::ElicitationAction::Cancel);
|
||||
}
|
||||
if let Some(update) = events.projection.clear_elicitation() {
|
||||
events.broadcast(update);
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_event_pump(mut done: watch::Receiver<bool>) {
|
||||
if !*done.borrow() {
|
||||
let _ = done.wait_for(|finished| *finished).await;
|
||||
@@ -3110,6 +3291,7 @@ async fn mark_session_dead_with_worktree_settlement(
|
||||
if let Some(stream) = stream {
|
||||
let mut stream = stream.lock().await;
|
||||
cancel_pending_approvals(&mut stream);
|
||||
cancel_pending_elicitations(&mut stream);
|
||||
stream.prompt_queue.clear();
|
||||
}
|
||||
tracing::info!(%id, "session process gone — marking dead");
|
||||
@@ -3211,7 +3393,9 @@ async fn cleanup_record(inner: &Arc<Inner>, id: Uuid, record: SessionRecord, kil
|
||||
}
|
||||
if let Some(acp) = &record.acp {
|
||||
if let Some(stream) = &record.acp_events {
|
||||
cancel_pending_approvals(&mut *stream.lock().await);
|
||||
let mut events = stream.lock().await;
|
||||
cancel_pending_approvals(&mut events);
|
||||
cancel_pending_elicitations(&mut events);
|
||||
}
|
||||
// `session/cancel`, a short wait, then SIGTERM (#134).
|
||||
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
|
||||
@@ -3299,6 +3483,7 @@ mod tests {
|
||||
projection: Projection::default(),
|
||||
projected_live,
|
||||
pending_approvals: std::collections::HashMap::new(),
|
||||
pending_elicitations: std::collections::HashMap::new(),
|
||||
operation: std::sync::Arc::new(tokio::sync::Mutex::new(())),
|
||||
dispatch: AcpDispatchState::default(),
|
||||
prompt_queue: std::collections::VecDeque::new(),
|
||||
|
||||
@@ -269,6 +269,9 @@ pub enum PersistedEvent {
|
||||
Approval {
|
||||
row: TranscriptRow,
|
||||
},
|
||||
Elicitation {
|
||||
row: TranscriptRow,
|
||||
},
|
||||
Turn {
|
||||
row: TranscriptRow,
|
||||
},
|
||||
|
||||
@@ -105,6 +105,7 @@ fn event_text(event: &maestro_daemon::sidecar::LoggedEvent) -> &str {
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("[turn]"),
|
||||
maestro_daemon::sidecar::PersistedEvent::Approval { .. } => "[approval]",
|
||||
maestro_daemon::sidecar::PersistedEvent::Elicitation { .. } => "[elicitation]",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -806,6 +806,7 @@ pub enum TranscriptRowKind {
|
||||
ToolCall,
|
||||
ToolDiff,
|
||||
Approval,
|
||||
Elicitation,
|
||||
Plan,
|
||||
Notice,
|
||||
}
|
||||
@@ -871,6 +872,8 @@ pub struct ReducedState {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pending_approval: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pending_elicitation: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub plan: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub usage: Option<Value>,
|
||||
@@ -927,6 +930,21 @@ pub enum StructuredApprovalDecision {
|
||||
Deny,
|
||||
}
|
||||
|
||||
/// Operator response to a pending ACP elicitation request.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "action", rename_all = "snake_case")]
|
||||
pub enum StructuredElicitationAction {
|
||||
/// The operator filled in the form and submitted.
|
||||
Accept {
|
||||
#[serde(default)]
|
||||
content: std::collections::BTreeMap<String, Value>,
|
||||
},
|
||||
/// The operator declined to answer.
|
||||
Decline,
|
||||
/// The elicitation was cancelled.
|
||||
Cancel,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client ↔ Daemon control messages (text frames on the attach WS)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -106,6 +106,11 @@ pub fn structured_approval(id: Uuid, nonce: &str) -> String {
|
||||
format!("{SESSIONS}/{id}/structured/approvals/{nonce}")
|
||||
}
|
||||
|
||||
/// `POST /api/sessions/:id/structured/elicitations/:nonce` — answer ACP elicitation.
|
||||
pub fn structured_elicitation(id: Uuid, nonce: &str) -> String {
|
||||
format!("{SESSIONS}/{id}/structured/elicitations/{nonce}")
|
||||
}
|
||||
|
||||
/// Axum-style patterns for the two parameterised routes, so the server
|
||||
/// registers the same paths [`session`] and [`attach`] build.
|
||||
pub mod patterns {
|
||||
@@ -122,6 +127,7 @@ pub mod patterns {
|
||||
pub const STRUCTURED_CANCEL: &str = "/api/sessions/:id/structured/cancel";
|
||||
pub const STRUCTURED_CONFIG: &str = "/api/sessions/:id/structured/config";
|
||||
pub const STRUCTURED_APPROVAL: &str = "/api/sessions/:id/structured/approvals/:nonce";
|
||||
pub const STRUCTURED_ELICITATION: &str = "/api/sessions/:id/structured/elicitations/:nonce";
|
||||
}
|
||||
|
||||
/// A daemon base URL that could not be turned into a WebSocket base.
|
||||
|
||||
@@ -355,6 +355,15 @@ fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
|
||||
Tone::Muted,
|
||||
false,
|
||||
),
|
||||
TranscriptRowKind::Elicitation => push_wrapped(
|
||||
&mut out,
|
||||
row,
|
||||
"? ",
|
||||
content_text(row),
|
||||
row_width,
|
||||
Tone::Muted,
|
||||
false,
|
||||
),
|
||||
TranscriptRowKind::Plan => {}
|
||||
}
|
||||
if out.len() > start
|
||||
@@ -399,6 +408,7 @@ pub fn copy_text(row: &TranscriptRow) -> String {
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("permission requested")
|
||||
.into(),
|
||||
TranscriptRowKind::Elicitation => content_text(row),
|
||||
TranscriptRowKind::Notice => "[notice]".into(),
|
||||
TranscriptRowKind::Plan => String::new(),
|
||||
}
|
||||
|
||||
+64
-23
@@ -6,11 +6,11 @@ pane. Alongside `docs/WORKTREES.md`, `docs/SIDEBAR.md` and `docs/RESTART.md`,
|
||||
read this before changing anything that renders a structured session — both
|
||||
the web and TUI renderers are written against it.
|
||||
|
||||
It covers the eight transcript row kinds, what the daemon decides versus what
|
||||
It covers the nine transcript row kinds, what the daemon decides versus what
|
||||
a client decides, the two projections and their delta/cursor semantics, the
|
||||
ACP handshake and the capabilities it declines, the adapter's install
|
||||
command, and the approval nonce rules. **Non-goal: the detached runner.** It
|
||||
does not exist yet, and nothing here describes it.
|
||||
command, the approval nonce rules, and the elicitation flow. **Non-goal: the
|
||||
detached runner.** It does not exist yet, and nothing here describes it.
|
||||
|
||||
## What a structured session is
|
||||
|
||||
@@ -88,10 +88,12 @@ declares, deliberately, almost nothing:
|
||||
Declining all three keeps the daemon out of the file/terminal surface
|
||||
entirely — the adapter already has direct access to the session's `cwd`, and
|
||||
routing every read, write, and shell command through the daemon would only
|
||||
add a hop with no new capability. Every other capability namespace ACP
|
||||
defines (session extensions, auth, elicitation) is simply left at its
|
||||
default; the daemon does not advertise support because neither supported
|
||||
adapter needs it, not because it was weighed and refused.
|
||||
add a hop with no new capability.
|
||||
|
||||
The daemon does declare `elicitation.form` support. This tells the adapter
|
||||
that form-mode elicitation (`CreateElicitationRequest`) will be handled and
|
||||
resolved by the operator, rather than declined outright. See
|
||||
[Elicitations](#elicitations) below.
|
||||
|
||||
The one client method that *is* implemented for real is
|
||||
`session/request_permission` (#139) — see [Approvals](#approvals) below.
|
||||
@@ -111,12 +113,12 @@ in the projections below.
|
||||
Codex and Claude differ in what they emit, not in this lifecycle. Codex maps
|
||||
shell commands, file changes, MCP calls, web search, image generation/view,
|
||||
reasoning, plans, and token usage into standard ACP chunks and tool calls;
|
||||
those fold into the same eight rows. Codex's `available_commands_update` and
|
||||
those fold into the same nine rows. Codex's `available_commands_update` and
|
||||
`session_info_update`, like equivalent Claude updates, remain opaque
|
||||
`Notice` rows. Its `session/load` capability is required for daemon recovery
|
||||
just as it is for Claude.
|
||||
|
||||
## The eight transcript row kinds
|
||||
## The nine transcript row kinds
|
||||
|
||||
`TranscriptRowKind` (`maestro-protocol`) is the alphabet a structured
|
||||
transcript is written in. Every row has a stable `id` (so later deltas can
|
||||
@@ -132,6 +134,7 @@ crate.
|
||||
| `ToolCall` | A `tool_call` / `tool_call_update` pair whose content carries no diff. | `tool:<tool_call_id>` — one row for the call's whole life; updates patch it in place, they never create a second row. |
|
||||
| `ToolDiff` | The same tool-call machinery, when the tool's content includes a `Diff` block. | Same `tool:<tool_call_id>` identity as `ToolCall`; the *kind* flips between the two as the tool's own content changes, not the row. |
|
||||
| `Approval` | A daemon-originated permission request (`RequestPermissionRequest`), serialized without its nonce. | `approval:<uuid>` — minted fresh per request, unrelated to the ACP request's own id. |
|
||||
| `Elicitation` | A daemon-originated elicitation request (`CreateElicitationRequest`), serialized without its nonce. Carries `message` and `requestedSchema` (a JSON Schema object with typed properties). | `elicitation:<uuid>` — minted fresh per request, same lifecycle as `Approval`. |
|
||||
| `Plan` | A `plan` update — the agent's current step list. | The fixed id `"plan"`. Each new plan **replaces** the row wholesale; entries are not diffed against the previous plan. |
|
||||
| `Notice` | The raw JSON of any `session/update` variant the ACP dependency already decodes but the projection does not special-case. Maestro also uses a tagged `maestroTurnMeta` notice as the durable footer for a completed, cancelled, or failed turn. | `notice:<seq>` for ACP updates; `turn_meta:<uuid>` for a turn footer. |
|
||||
|
||||
@@ -139,7 +142,7 @@ crate.
|
||||
`agent-client-protocol` dependency's schema already deserializes (it
|
||||
currently folds `available_commands_update` and `session_info_update`
|
||||
there) but the projection's `match` doesn't fold into one of the other
|
||||
seven kinds. It is not a wire-level forward-compatibility seam:
|
||||
eight kinds. It is not a wire-level forward-compatibility seam:
|
||||
`SessionUpdate` is an internally-tagged enum with no unknown-variant
|
||||
fallback, so a `session/update` whose `sessionUpdate` tag this build's
|
||||
dependency has never heard of — including a variant gated behind an
|
||||
@@ -174,6 +177,8 @@ choices, not daemon ones.
|
||||
- Approval nonce minting, secrecy, and the single-active-approval rule (see
|
||||
below) — a client never sees an ACP permission request before the daemon
|
||||
has already turned it into an `Approval` row and a nonce.
|
||||
- Elicitation nonce minting and the single-active-elicitation rule (see
|
||||
[Elicitations](#elicitations)) — same lifecycle as approvals.
|
||||
- What happens to an ACP update variant the dependency decodes but nobody has
|
||||
taught the projection to special-case (falls back to `Notice` rather than
|
||||
erroring). A variant the dependency itself can't decode is a deserialization
|
||||
@@ -195,7 +200,8 @@ choices, not daemon ones.
|
||||
text → group → text → group rather than lumping every tool in a turn into
|
||||
one box. Text and thought rows stay outside groups as standalone transcript
|
||||
items — the operator sees all reasoning without expanding. Resolved
|
||||
approval rows between tools within a run are absorbed into the group. The
|
||||
approval and elicitation rows between tools within a run are absorbed into
|
||||
the group. The
|
||||
collapsed header reads "N tool calls · Label 'arg'" where the label and
|
||||
argument come from the last tool row's `splitToolTitle` — the operator
|
||||
sees which command is current at a glance. Opening the group reveals tool
|
||||
@@ -213,6 +219,9 @@ choices, not daemon ones.
|
||||
- How the operator is asked to resolve an `Approval` row — the daemon only
|
||||
exposes the three-way decision (`AllowOnce` / `AllowForSession` / `Deny`),
|
||||
never the UI that solicits it.
|
||||
- How the operator is asked to resolve an `Elicitation` row — the daemon
|
||||
exposes the three actions (`Accept` with content / `Decline` / `Cancel`);
|
||||
the client decides the form layout and button treatment.
|
||||
- Scrolling, collapsing, and every other presentation-only behaviour.
|
||||
|
||||
## The two projections
|
||||
@@ -225,8 +234,8 @@ exposes two independent views, both defined in `maestro-protocol`:
|
||||
It is intentionally always the *whole* transcript, not a windowed one: a
|
||||
tool row patched much later must still find its original entry.
|
||||
- **`ReducedState`** — one whole-session snapshot folded over every event
|
||||
seen: `turn` (the per-turn indicators above), `pending_approval`, `plan`,
|
||||
`usage`/`context`, `model`, and `mode`. Like `TranscriptSnapshot`, it is
|
||||
seen: `turn` (the per-turn indicators above), `pending_approval`,
|
||||
`pending_elicitation`, `plan`, `usage`/`context`, `model`, and `mode`. Like `TranscriptSnapshot`, it is
|
||||
always full-history, never a delta — a client that only wants "is a turn
|
||||
active right now" never has to replay anything to answer that.
|
||||
|
||||
@@ -323,6 +332,37 @@ pending decision, entirely on the daemon side:
|
||||
permanent one (`RejectAlways`) only when that is the sole rejection option
|
||||
offered; if neither is present, it answers `Cancelled`.
|
||||
|
||||
## Elicitations
|
||||
|
||||
An ACP `elicitation/create` callback becomes an `Elicitation` row plus a
|
||||
pending decision, mirroring the approval lifecycle:
|
||||
|
||||
- **Generation.** The daemon mints a nonce per request (`Uuid::new_v4()`),
|
||||
same as approvals.
|
||||
- **Live-only placement.** The nonce lives in `pending_elicitations` and in
|
||||
`ReducedState.pending_elicitation.nonce`, never in the row's `data`.
|
||||
- **One live elicitation at a time.** A new elicitation cancels any
|
||||
prior pending one before installing itself.
|
||||
- **Resolution.** `POST /api/sessions/:id/structured/elicitations/:nonce`
|
||||
accepts `{ "action": "accept", "content": { ... } }`,
|
||||
`{ "action": "decline" }`, or `{ "action": "cancel" }`. The endpoint
|
||||
always answers `204` for a known, live structured session, same as
|
||||
approvals. The three actions map to ACP's `ElicitationAction::Accept`
|
||||
(with the filled-in values as `ElicitationContentValue` entries),
|
||||
`Decline`, and `Cancel`.
|
||||
- **Cancellation.** Turn completion, session cancel, and fatal errors
|
||||
cancel pending elicitations as `Cancel`, same as approvals.
|
||||
- **Schema.** The row's `data.requestedSchema` is a JSON Schema object
|
||||
whose properties describe the fields the agent wants filled:
|
||||
`StringPropertySchema` (with optional `enum`/`oneOf` for multiple-choice),
|
||||
`BooleanPropertySchema`, `NumberPropertySchema`, `IntegerPropertySchema`.
|
||||
Claude Code's common case is a single-property string enum — a
|
||||
multiple-choice question with titled options.
|
||||
- **Rendering.** Clients render the `message` as the question and the schema
|
||||
properties as form fields or button groups. The web client detects the
|
||||
single-property string enum fast path and renders titled buttons. Resolved
|
||||
elicitations collapse to a compact one-liner.
|
||||
|
||||
## Prompt and cancel: bounded liveness
|
||||
|
||||
The web composer accepts up to ten files through its attachment picker, file
|
||||
@@ -364,8 +404,8 @@ allowed to claim the daemon's idea of "the active turn."
|
||||
- A submitted prompt is appended as a durable `User` row before it claims the turn and
|
||||
dispatches to ACP, so the transcript never depends on an adapter echoing the
|
||||
operator's input. If that append fails, the prompt is not dispatched.
|
||||
- Cancel sends `session/cancel`, clears any pending approval as `Cancelled`,
|
||||
and ends the turn's client-visible lifecycle immediately. The prompt queue
|
||||
- Cancel sends `session/cancel`, clears any pending approval and elicitation
|
||||
as `Cancelled`, and ends the turn's client-visible lifecycle immediately. The prompt queue
|
||||
is preserved: after the cancelled turn settles, the queued prompts drain as
|
||||
the next turn. If the session dies instead (settlement timeout), the queue
|
||||
is cleared. If the cancelled turn was still *dispatched* to ACP (waiting on
|
||||
@@ -390,8 +430,8 @@ directory: the sidecar itself (`<id>.json`), plus two next to it:
|
||||
|
||||
- **`<id>.events.jsonl`** — the unified event log. Each line is either a raw
|
||||
ACP `SessionNotification` (kept in its original JSON so old logs stay
|
||||
readable) or a `{"maestroEvent": "user"|"approval", "row": …}` envelope
|
||||
for a daemon-generated row. Both share the same 1-based line-position cursor. A
|
||||
readable) or a `{"maestroEvent": "user"|"approval"|"elicitation", "row": …}`
|
||||
envelope for a daemon-generated row. Both share the same 1-based line-position cursor. A
|
||||
line a hard kill left half-written is terminated before the next append, so
|
||||
it keeps its position and is skipped as malformed rather than corrupting
|
||||
what follows. An append that fails *while the daemon is running* — a
|
||||
@@ -453,12 +493,13 @@ itself failing) leaves it `Dead` with the failure reason as
|
||||
`activity_detail` — the same "tell the operator what to fix" pattern as a
|
||||
missing adapter binary.
|
||||
|
||||
Pending approvals and their nonces never survive any of this. They live only
|
||||
in the in-memory `pending_approvals` map and the live `ReducedState`, both
|
||||
gone the instant the daemon that held them exits. Whatever ACP permission
|
||||
request was mid-flight when the old daemon died was already answered
|
||||
`Cancelled` on that daemon's own shutdown path, so a resumed session always
|
||||
starts with no pending approval.
|
||||
Pending approvals and elicitations and their nonces never survive any of
|
||||
this. They live only in the in-memory `pending_approvals` /
|
||||
`pending_elicitations` maps and the live `ReducedState`, both gone the
|
||||
instant the daemon that held them exits. Whatever ACP permission or
|
||||
elicitation request was mid-flight when the old daemon died was already
|
||||
answered `Cancelled` on that daemon's own shutdown path, so a resumed
|
||||
session always starts with no pending approval or elicitation.
|
||||
|
||||
See `docs/RESTART.md` for what a *client* observes across a daemon restart,
|
||||
now that structured sessions exist alongside terminal ones.
|
||||
|
||||
+18
-1
@@ -12,6 +12,7 @@ import type {
|
||||
RecentDirs,
|
||||
Session,
|
||||
StructuredApprovalDecision,
|
||||
StructuredElicitationAction,
|
||||
StructuredConfigRequest,
|
||||
StructuredPromptRequest,
|
||||
Uuid,
|
||||
@@ -39,7 +40,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 {
|
||||
@@ -219,4 +220,20 @@ export async function resolveStructuredApproval(
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve a pending elicitation. Same idempotency as approvals. */
|
||||
export async function resolveStructuredElicitation(
|
||||
id: Uuid,
|
||||
nonce: string,
|
||||
action: StructuredElicitationAction,
|
||||
): Promise<void> {
|
||||
await jsonOrError(
|
||||
await fetch(routes.structuredElicitation(id, nonce), {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(action),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export { eventsWsUrl, structuredWsUrl } from "./routes.js";
|
||||
|
||||
@@ -11,6 +11,7 @@ import { activeTab, type Dir, type Node } from "../../layout.js";
|
||||
import { usePreview } from "../../hooks/usePreview.js";
|
||||
import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js";
|
||||
import type { Session, Uuid } from "../../types.js";
|
||||
import { requestToggleDrawer } from "../../uiEvents.js";
|
||||
import { PaneContainer, type PaneContainerHandle } from "./PaneContainer.js";
|
||||
import { PaneTitle } from "./PaneTitle.js";
|
||||
import { TabBar } from "./TabBar.js";
|
||||
@@ -204,6 +205,14 @@ function Pane({
|
||||
<div
|
||||
className="mv-pane-header attach-header flex items-center justify-between gap-sp-2 border-b border-border-strong px-sp-2 py-sp-1 text-fs-12"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="sessions-btn mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
|
||||
title="Sessions"
|
||||
onClick={() => requestToggleDrawer()}
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
{leaf.sessionId ? (
|
||||
<PaneTitle sessionId={leaf.sessionId} session={session} />
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
// An `Elicitation` row. The daemon mints the nonce; this only solicits one
|
||||
// of the three possible actions (accept, decline, cancel).
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import type {
|
||||
AcpElicitationProperty,
|
||||
AcpElicitationSchema,
|
||||
StructuredElicitationAction,
|
||||
} from "../../types.js";
|
||||
|
||||
export interface ElicitationViewProps {
|
||||
/** The `CreateElicitationRequest` the row carries — serialized without
|
||||
* its nonce, which never leaves the daemon's live wire. */
|
||||
request: {
|
||||
message?: string;
|
||||
requestedSchema?: AcpElicitationSchema;
|
||||
};
|
||||
/** Whether this is the one live elicitation. */
|
||||
pending: boolean;
|
||||
onDecide: (action: StructuredElicitationAction) => Promise<void>;
|
||||
}
|
||||
|
||||
/** For a single-property string enum schema (the common Claude Code
|
||||
* multiple-choice case), extract the property key and its options. */
|
||||
function singleSelectEnum(
|
||||
schema: AcpElicitationSchema | undefined,
|
||||
): { key: string; options: Array<{ value: string; title: string; description?: string }> } | null {
|
||||
if (!schema) return null;
|
||||
const keys = Object.keys(schema.properties);
|
||||
if (keys.length !== 1) return null;
|
||||
const key = keys[0];
|
||||
const prop = schema.properties[key];
|
||||
if (prop.oneOf && prop.oneOf.length > 0) {
|
||||
return {
|
||||
key,
|
||||
options: prop.oneOf.map((o) => ({
|
||||
value: o.const,
|
||||
title: o.title,
|
||||
description: o.description,
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (prop.enum && prop.enum.length > 0) {
|
||||
return {
|
||||
key,
|
||||
options: prop.enum.map((v) => ({ value: v, title: v })),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ElicitationView({ request, pending, onDecide }: ElicitationViewProps) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [formValues, setFormValues] = useState<Record<string, string | number | boolean>>({});
|
||||
const message = request.message ?? "Input requested";
|
||||
const schema = request.requestedSchema;
|
||||
|
||||
if (!pending) {
|
||||
return <div className="st-row st-row-elicitation st-elicitation-compact">{message}</div>;
|
||||
}
|
||||
|
||||
const decide = (action: StructuredElicitationAction) => {
|
||||
setBusy(true);
|
||||
onDecide(action).catch(() => setBusy(false));
|
||||
};
|
||||
|
||||
const singleSelect = singleSelectEnum(schema);
|
||||
|
||||
// Fast path: single-property string enum → button group (the common
|
||||
// Claude Code multiple-choice question).
|
||||
if (singleSelect) {
|
||||
return (
|
||||
<div className="st-row st-row-elicitation">
|
||||
<div className="st-elicitation">
|
||||
<div className="st-elicitation-title">
|
||||
<span aria-hidden="true">?</span> {message}
|
||||
</div>
|
||||
<div className="st-elicitation-options">
|
||||
{singleSelect.options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className="act"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
decide({ action: "accept", content: { [singleSelect.key]: opt.value } })
|
||||
}
|
||||
>
|
||||
{opt.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="st-elicitation-acts">
|
||||
<button
|
||||
type="button"
|
||||
className="act deny"
|
||||
disabled={busy}
|
||||
onClick={() => decide({ action: "decline" })}
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// General form: render inputs for each property.
|
||||
const properties = schema?.properties ?? {};
|
||||
const propertyKeys = Object.keys(properties);
|
||||
const requiredSet = new Set(schema?.required ?? []);
|
||||
|
||||
const handleSubmit = () => {
|
||||
decide({ action: "accept", content: formValues });
|
||||
};
|
||||
|
||||
const updateField = (key: string, value: string | number | boolean) => {
|
||||
setFormValues((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="st-row st-row-elicitation">
|
||||
<div className="st-elicitation">
|
||||
<div className="st-elicitation-title">
|
||||
<span aria-hidden="true">?</span> {message}
|
||||
</div>
|
||||
<div className="st-elicitation-form">
|
||||
{propertyKeys.map((key) => {
|
||||
const prop = properties[key];
|
||||
return (
|
||||
<ElicitationField
|
||||
key={key}
|
||||
name={key}
|
||||
prop={prop}
|
||||
required={requiredSet.has(key)}
|
||||
disabled={busy}
|
||||
value={formValues[key]}
|
||||
onChange={(v) => updateField(key, v)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="st-elicitation-acts">
|
||||
<button type="button" className="act primary" disabled={busy} onClick={handleSubmit}>
|
||||
Submit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="act deny"
|
||||
disabled={busy}
|
||||
onClick={() => decide({ action: "decline" })}
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ElicitationField({
|
||||
name,
|
||||
prop,
|
||||
required,
|
||||
disabled,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
name: string;
|
||||
prop: AcpElicitationProperty;
|
||||
required: boolean;
|
||||
disabled: boolean;
|
||||
value: string | number | boolean | undefined;
|
||||
onChange: (value: string | number | boolean) => void;
|
||||
}) {
|
||||
const label = prop.title ?? name;
|
||||
const type = prop.type ?? "string";
|
||||
|
||||
if (type === "boolean") {
|
||||
return (
|
||||
<label className="st-elicitation-field">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={typeof value === "boolean" ? value : (prop.default === true)}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
{label}
|
||||
{prop.description && <span className="st-elicitation-desc">{prop.description}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "number" || type === "integer") {
|
||||
return (
|
||||
<label className="st-elicitation-field">
|
||||
{label}
|
||||
{required && <span className="st-elicitation-required">*</span>}
|
||||
{prop.description && <span className="st-elicitation-desc">{prop.description}</span>}
|
||||
<input
|
||||
type="number"
|
||||
step={type === "integer" ? 1 : "any"}
|
||||
min={prop.minimum}
|
||||
max={prop.maximum}
|
||||
value={typeof value === "number" ? value : ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(type === "integer" ? parseInt(e.target.value) : parseFloat(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// Default: string
|
||||
return (
|
||||
<label className="st-elicitation-field">
|
||||
{label}
|
||||
{required && <span className="st-elicitation-required">*</span>}
|
||||
{prop.description && <span className="st-elicitation-desc">{prop.description}</span>}
|
||||
<input
|
||||
type="text"
|
||||
value={typeof value === "string" ? value : ""}
|
||||
disabled={disabled}
|
||||
placeholder={typeof prop.default === "string" ? prop.default : undefined}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -170,16 +170,14 @@ describe("tool rows and activity groups", () => {
|
||||
snapshot(socket, [
|
||||
chunk("user:1", "user", "go"),
|
||||
row("tool:1", "tool_call", { title: "Read(a.ts)", kind: "read", status: "completed" }),
|
||||
chunk("agent:1", "agent_text", "still working"),
|
||||
row("tool:2", "tool_call", { title: "Read(b.ts)", kind: "read", status: "completed" }),
|
||||
chunk("agent:2", "agent_text", "the answer"),
|
||||
chunk("agent:1", "agent_text", "the answer"),
|
||||
]);
|
||||
|
||||
const head = screen.getByText(/^2 tool calls/).closest("button")!;
|
||||
expect(head.getAttribute("aria-expanded")).toBe("false");
|
||||
// The final answer stays outside the group; the intermediate text is in it.
|
||||
// The final answer stays outside the group.
|
||||
expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("the answer"))).toBe(false);
|
||||
expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("still working"))).toBe(true);
|
||||
|
||||
fireEvent.click(head);
|
||||
expect(head.getAttribute("aria-expanded")).toBe("true");
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import {
|
||||
cancelStructuredTurn,
|
||||
resolveStructuredApproval,
|
||||
resolveStructuredElicitation,
|
||||
sendStructuredPrompt,
|
||||
setStructuredConfig,
|
||||
uploadStructuredFile,
|
||||
@@ -29,6 +30,7 @@ import { formatContextTokens, formatElapsed } from "./render.js";
|
||||
import type {
|
||||
AcpSessionConfigOption,
|
||||
StructuredApprovalDecision,
|
||||
StructuredElicitationAction,
|
||||
TranscriptRow as Row,
|
||||
Uuid,
|
||||
} from "../../types.js";
|
||||
@@ -36,6 +38,7 @@ import { Composer } from "./Composer.js";
|
||||
import {
|
||||
commandsFromTranscript,
|
||||
pendingApprovalRowId as pendingApprovalRowIdOf,
|
||||
pendingElicitationRowId as pendingElicitationRowIdOf,
|
||||
transcriptItems,
|
||||
turnIsActive,
|
||||
} from "./model.js";
|
||||
@@ -124,9 +127,10 @@ export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPanePro
|
||||
|
||||
const turnActive = turnIsActive(state);
|
||||
const pendingRowId = useMemo(() => pendingApprovalRowIdOf(rows, state), [rows, state]);
|
||||
const pendingElicitationRowId = useMemo(() => pendingElicitationRowIdOf(rows, state), [rows, state]);
|
||||
const items = useMemo(
|
||||
() => transcriptItems(rows, pendingRowId, turnActive),
|
||||
[rows, pendingRowId, turnActive],
|
||||
() => transcriptItems(rows, pendingRowId, turnActive, pendingElicitationRowId),
|
||||
[rows, pendingRowId, turnActive, pendingElicitationRowId],
|
||||
);
|
||||
const commands = useMemo(() => commandsFromTranscript(rows), [rows]);
|
||||
const configOptions: AcpSessionConfigOption[] = state.config_options?.configOptions ?? [];
|
||||
@@ -193,6 +197,20 @@ export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPanePro
|
||||
[sessionId, state.pending_approval?.nonce, pendingRowId],
|
||||
);
|
||||
|
||||
const decideElicitation = useCallback(
|
||||
async (rowId: string, action: StructuredElicitationAction): Promise<void> => {
|
||||
const nonce = state.pending_elicitation?.nonce;
|
||||
if (!nonce || rowId !== pendingElicitationRowId) return;
|
||||
try {
|
||||
await resolveStructuredElicitation(sessionId, nonce, action);
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[sessionId, state.pending_elicitation?.nonce, pendingElicitationRowId],
|
||||
);
|
||||
|
||||
const dispatchPrompt = useCallback(
|
||||
(text: string, files: File[]) => {
|
||||
const generation = ++submission.current;
|
||||
@@ -311,6 +329,8 @@ export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPanePro
|
||||
row={row}
|
||||
pendingApproval={row.id === pendingRowId}
|
||||
onDecide={(decision) => decide(row.id, decision)}
|
||||
pendingElicitation={row.id === pendingElicitationRowId}
|
||||
onElicitationDecide={(action) => decideElicitation(row.id, action)}
|
||||
expanded={expandedRows.has(row.id)}
|
||||
onExpandedChange={(expanded) => setRowExpanded(row.id, expanded)}
|
||||
imageUrls={imageUrls.current}
|
||||
|
||||
@@ -10,14 +10,17 @@ import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { renderInlineMarkdown, renderMarkdown, formatElapsed } from "./render.js";
|
||||
import type {
|
||||
AcpContentBlock,
|
||||
AcpElicitationSchema,
|
||||
AcpPermissionOption,
|
||||
AcpToolCallContent,
|
||||
AcpToolCallLike,
|
||||
StructuredApprovalDecision,
|
||||
StructuredElicitationAction,
|
||||
TranscriptRow as Row,
|
||||
TurnMeta,
|
||||
} from "../../types.js";
|
||||
import { ApprovalView } from "./ApprovalView.js";
|
||||
import { ElicitationView } from "./ElicitationView.js";
|
||||
import { Copyable } from "./Copyable.js";
|
||||
import { DiffView } from "./DiffView.js";
|
||||
import {
|
||||
@@ -337,6 +340,9 @@ export interface TranscriptRowProps {
|
||||
* rules). Every other approval row is history. */
|
||||
pendingApproval?: boolean;
|
||||
onDecide?: (decision: StructuredApprovalDecision) => Promise<void>;
|
||||
/** This row is the one live elicitation. */
|
||||
pendingElicitation?: boolean;
|
||||
onElicitationDecide?: (action: StructuredElicitationAction) => Promise<void>;
|
||||
expanded?: boolean;
|
||||
onExpandedChange?: (expanded: boolean) => void;
|
||||
imageUrls?: Map<string, string>;
|
||||
@@ -348,6 +354,8 @@ export function TranscriptRow({
|
||||
row,
|
||||
pendingApproval = false,
|
||||
onDecide,
|
||||
pendingElicitation = false,
|
||||
onElicitationDecide,
|
||||
expanded = false,
|
||||
onExpandedChange,
|
||||
imageUrls,
|
||||
@@ -374,6 +382,16 @@ export function TranscriptRow({
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "elicitation": {
|
||||
const request = (row.data ?? {}) as { message?: string; requestedSchema?: AcpElicitationSchema };
|
||||
return (
|
||||
<ElicitationView
|
||||
request={request}
|
||||
pending={pendingElicitation}
|
||||
onDecide={onElicitationDecide ?? (() => Promise.resolve())}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "plan":
|
||||
return null;
|
||||
case "notice": {
|
||||
|
||||
@@ -134,6 +134,11 @@ export function pendingApprovalRowId(rows: TranscriptRow[], state: ReducedState)
|
||||
return state.pending_approval ? lastRowIdOfKind(rows, "approval") : null;
|
||||
}
|
||||
|
||||
/** Same for elicitations — one live at a time. */
|
||||
export function pendingElicitationRowId(rows: TranscriptRow[], state: ReducedState): string | null {
|
||||
return state.pending_elicitation ? lastRowIdOfKind(rows, "elicitation") : null;
|
||||
}
|
||||
|
||||
export function lastRowIdOfKind(rows: TranscriptRow[], kind: TranscriptRowKind): string | null {
|
||||
for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === kind) return rows[i].id;
|
||||
return null;
|
||||
@@ -168,6 +173,7 @@ export function activityGroupsByTurn(
|
||||
rows: TranscriptRow[],
|
||||
pendingApprovalId: string | null,
|
||||
_turnActive: boolean,
|
||||
pendingElicitationId: string | null = null,
|
||||
): Map<string, ActivityGroup> {
|
||||
const groups = new Map<string, ActivityGroup>();
|
||||
let turnId = "before-first-message";
|
||||
@@ -204,8 +210,10 @@ export function activityGroupsByTurn(
|
||||
flushRun();
|
||||
continue;
|
||||
}
|
||||
const isResolvedApproval = row.kind === "approval" && row.id !== pendingApprovalId;
|
||||
if (isResolvedApproval) {
|
||||
const isResolvedInteraction =
|
||||
(row.kind === "approval" && row.id !== pendingApprovalId) ||
|
||||
(row.kind === "elicitation" && row.id !== pendingElicitationId);
|
||||
if (isResolvedInteraction) {
|
||||
runPending.push(row);
|
||||
continue;
|
||||
}
|
||||
@@ -239,8 +247,9 @@ export function transcriptItems(
|
||||
rows: TranscriptRow[],
|
||||
pendingApprovalId: string | null,
|
||||
turnActive: boolean,
|
||||
pendingElicitationId: string | null = null,
|
||||
): TranscriptItem[] {
|
||||
const groups = activityGroupsByTurn(rows, pendingApprovalId, turnActive);
|
||||
const groups = activityGroupsByTurn(rows, pendingApprovalId, turnActive, pendingElicitationId);
|
||||
// Reverse index: row id → group id that contains it.
|
||||
const rowToGroup = new Map<string, string>();
|
||||
for (const [groupId, group] of groups) for (const r of group.rows) rowToGroup.set(r.id, groupId);
|
||||
@@ -259,9 +268,10 @@ export function transcriptItems(
|
||||
}
|
||||
if (row.kind === "plan") continue;
|
||||
if (row.kind === "notice" && isStateOnlyNotice(row)) continue;
|
||||
// A resolved approval outside a group adds nothing the tool row does not
|
||||
// already say. A pending one always renders.
|
||||
// A resolved approval/elicitation outside a group adds nothing the tool
|
||||
// row does not already say. A pending one always renders.
|
||||
if (row.kind === "approval" && row.id !== pendingApprovalId) continue;
|
||||
if (row.kind === "elicitation" && row.id !== pendingElicitationId) continue;
|
||||
items.push({ type: "row", row });
|
||||
}
|
||||
|
||||
|
||||
@@ -913,6 +913,90 @@
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Elicitation: the structured question the agent asks the operator.
|
||||
Visually parallel to approvals but accent-tinted instead of warn-tinted. */
|
||||
.st-elicitation {
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--r-2);
|
||||
background: color-mix(in oklab, var(--accent) 10%, var(--surface));
|
||||
box-shadow: var(--shadow-1);
|
||||
padding: var(--sp-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.st-elicitation-title {
|
||||
color: var(--accent);
|
||||
font-size: var(--fs-11);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.st-elicitation-options {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.st-elicitation-acts {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.st-elicitation-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.st-elicitation-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-1);
|
||||
font-size: var(--fs-12);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.st-elicitation-field input[type="text"],
|
||||
.st-elicitation-field input[type="number"] {
|
||||
height: 30px;
|
||||
padding: 0 var(--sp-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-1);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.st-elicitation-field input[type="checkbox"] {
|
||||
margin-right: var(--sp-1);
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.st-elicitation-required {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.st-elicitation-desc {
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-11);
|
||||
}
|
||||
|
||||
.st-elicitation-compact {
|
||||
font-size: var(--fs-11);
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.st-row-elicitation:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.act {
|
||||
appearance: none;
|
||||
font: inherit;
|
||||
|
||||
@@ -67,6 +67,12 @@ export function structuredApproval(id: Uuid, nonce: string): string {
|
||||
return `${session(id)}/structured/approvals/${encodeURIComponent(nonce)}`;
|
||||
}
|
||||
|
||||
/** `POST /api/sessions/:id/structured/elicitations/:nonce` — answer an
|
||||
* elicitation request. */
|
||||
export function structuredElicitation(id: Uuid, nonce: string): string {
|
||||
return `${session(id)}/structured/elicitations/${encodeURIComponent(nonce)}`;
|
||||
}
|
||||
|
||||
/** Append the `path` query parameter `DIRS` and `GITROOT` accept. */
|
||||
export function withPathQuery(route: string, value: string): string {
|
||||
return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`;
|
||||
|
||||
@@ -4651,6 +4651,12 @@ input.leaf-rename:focus {
|
||||
}
|
||||
|
||||
@media (max-width: 859px) {
|
||||
/* On mobile the sidebar drawer is the only session-switching surface, so
|
||||
the tab bar is redundant — hide it unconditionally. */
|
||||
.mv-tabs {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* sidebar → off-canvas drawer (override the route-based show/hide) */
|
||||
.app-shell > .sidebar {
|
||||
display: flex !important;
|
||||
|
||||
@@ -395,6 +395,7 @@ export type TranscriptRowKind =
|
||||
| "tool_call"
|
||||
| "tool_diff"
|
||||
| "approval"
|
||||
| "elicitation"
|
||||
| "plan"
|
||||
| "notice";
|
||||
|
||||
@@ -435,6 +436,7 @@ export interface TurnState {
|
||||
export interface ReducedState {
|
||||
turn: TurnState;
|
||||
pending_approval?: { nonce: string; request: AcpRequestPermissionRequest } | null;
|
||||
pending_elicitation?: { nonce: string; message?: string; schema?: AcpElicitationSchema } | null;
|
||||
plan?: AcpPlan | null;
|
||||
usage?: AcpUsage | null;
|
||||
context?: AcpUsage | null;
|
||||
@@ -486,6 +488,35 @@ export interface AcpSessionConfigOption {
|
||||
* string (a bracketless Rust enum), not `{decision: ...}`. */
|
||||
export type StructuredApprovalDecision = "allow_once" | "allow_for_session" | "deny";
|
||||
|
||||
/** `POST /api/sessions/:id/structured/elicitations/:nonce` body. */
|
||||
export type StructuredElicitationAction =
|
||||
| { action: "accept"; content: Record<string, string | number | boolean> }
|
||||
| { action: "decline" }
|
||||
| { action: "cancel" };
|
||||
|
||||
/** Property schema within an ACP elicitation request. */
|
||||
export interface AcpElicitationProperty {
|
||||
type?: "string" | "number" | "integer" | "boolean";
|
||||
title?: string;
|
||||
description?: string;
|
||||
default?: string | number | boolean;
|
||||
enum?: string[];
|
||||
oneOf?: Array<{ const: string; title: string; description?: string }>;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
}
|
||||
|
||||
/** The `requested_schema` carried by an ACP `elicitation/create` request. */
|
||||
export interface AcpElicitationSchema {
|
||||
type?: "object";
|
||||
title?: string;
|
||||
description?: string;
|
||||
properties: Record<string, AcpElicitationProperty>;
|
||||
required?: string[];
|
||||
}
|
||||
|
||||
// ---- ACP v1 payload shapes (agent-client-protocol schema, camelCase) ----
|
||||
|
||||
export interface AcpEmbeddedResource {
|
||||
|
||||
Reference in New Issue
Block a user