feat(maestro): add sidebar session formatting helpers

Pure Kotlin ports of the web/TUI SIDEBAR.md logic for the Android
sidebar: duration floor-format, program-name extraction (env/sudo/env
wrappers, npx/pnpm/yarn dlx, path), diff-stat notation, and
name/name_source/provisional-name reads. Covered by unit tests so they
run without an Android SDK.
This commit is contained in:
Miguel Palhas
2026-09-13 00:39:09 +01:00
parent b22539bd5b
commit 3ef1decbdd
2 changed files with 351 additions and 0 deletions
@@ -0,0 +1,161 @@
package pt.naps.maestro.ui.sidebar
import java.time.Instant
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonPrimitive
import pt.naps.maestro.data.InTheWay
import pt.naps.maestro.data.NameSource
import pt.naps.maestro.data.Session
/** docs/SIDEBAR.md: a state held shorter than this has no duration fragment. */
const val META_LINE_MIN_HOLD_MS: Long = 30_000L
/** Payloads predating #102 default `activity_since` to the Unix epoch;
* anything implausibly old is "never observed" rather than a huge duration. */
private val PLAUSIBLE_EPOCH_MS = Instant.parse("2010-01-01T00:00:00Z").toEpochMilli()
/** Milliseconds held in the current activity state, or `null` when `since` is
* missing/unparsable/implausible. Mirrors the web's `activityHeldMs`. */
fun activityHeldMs(since: String?, nowMs: Long): Long? {
if (since.isNullOrEmpty()) return null
val t = runCatching { Instant.parse(since).toEpochMilli() }.getOrNull() ?: return null
if (t < PLAUSIBLE_EPOCH_MS) return null
return (nowMs - t).coerceAtLeast(0L)
}
/** `<60s` whole seconds, `<60m` whole minutes, else whole hours — floored
* everywhere so the counter never shows a duration the state hasn't reached. */
fun activityDurationLabel(heldMs: Long): String {
val seconds = heldMs / 1000
if (seconds < 60) return "${seconds}s"
val minutes = seconds / 60
if (minutes < 60) return "${minutes}m"
return "${minutes / 60}h"
}
/** The session's own name (#125), without the command fallback: `null` for a
* session that was never given one. */
fun customSessionName(session: Session): String? {
val element = session.metadata["name"] ?: return null
val primitive = element as? JsonPrimitive ?: return null
if (!primitive.isString) return null
return primitive.content.takeIf { it.isNotEmpty() }
}
/** Line 1's name: `metadata.name` if present, else the command line. */
fun sessionName(session: Session): String = customSessionName(session) ?: session.command
/** Where the session's name came from (#131), read leniently: anything
* unrecognised — including a session named before the field existed — reads
* as [NameSource.User], so an existing name is never dimmed or overwritten. */
fun sessionNameSource(session: Session): NameSource {
val element = session.metadata["name_source"] as? JsonPrimitive ?: return NameSource.User
return when (element.jsonPrimitive.content) {
"Generated" -> NameSource.Generated
"Haiku" -> NameSource.Haiku
else -> NameSource.User
}
}
/** Whether line 1 renders the name muted (#131): a handle the daemon
* generated at spawn and may still replace with one derived from the first
* prompt. */
fun isProvisionalName(session: Session): Boolean =
customSessionName(session) != null && sessionNameSource(session) == NameSource.Generated
/** One fragment of the compact blocked-work stat (docs/SIDEBAR.md). `kind`
* picks the colour; `text` is what the card prints. */
data class BlockerStat(val kind: String, val text: String)
/** #83: this session's blocked-work breakdown when something actually blocks
* an unforced kill; `null` while clear or not yet probed. */
fun sessionBlockers(session: Session): InTheWay? {
val w = session.blocked_work ?: return null
val blocked = w.uncommitted_files > 0 || w.untracked_files > 0 || w.unpushed_commits > 0
return if (blocked) w else null
}
/** The blocked-work breakdown as diff notation: `+124 -18 ?3 ↑2`. A tree whose
* only tracked change carries no line delta falls back to the file count
* (`~2`) rather than claiming `+0 -0`. */
fun blockerStats(w: InTheWay): List<BlockerStat> {
val stats = mutableListOf<BlockerStat>()
if (w.insertions > 0 || w.deletions > 0) {
if (w.insertions > 0) stats += BlockerStat("add", "+${w.insertions}")
if (w.deletions > 0) stats += BlockerStat("del", "-${w.deletions}")
} else if (w.uncommitted_files > 0) {
stats += BlockerStat("files", "~${w.uncommitted_files}")
}
if (w.untracked_files > 0) stats += BlockerStat("untracked", "?${w.untracked_files}")
if (w.unpushed_commits > 0) stats += BlockerStat("unpushed", "↑${w.unpushed_commits}")
return stats
}
/** The spelled-out phrasing shared by the kill refusal and every client's
* tooltip, so no two describe the same tree differently. */
fun formatInTheWay(w: InTheWay): String {
val parts = mutableListOf<String>()
fun push(n: Int, unit: String) {
if (n <= 0) return
parts += if (n == 1) "1 $unit" else "$n ${unit}s"
}
push(w.uncommitted_files, "uncommitted file")
push(w.untracked_files, "untracked file")
push(w.unpushed_commits, "unpushed commit")
return if (parts.isNotEmpty()) parts.joinToString(", ") else "no unsaved work"
}
private val ENV_ASSIGNMENT = Regex("^[A-Za-z_][A-Za-z0-9_]*=.*$")
private val PLAIN_WRAPPERS = setOf("sudo", "env")
private val DLX_CAPABLE = setOf("pnpm", "yarn")
/** The program a command line runs — never its arguments (docs/SIDEBAR.md).
* Looks through leading `FOO=bar` assignments, `sudo`/`env` wrappers (and
* their own flags), and JS-toolchain launchers (`npx`, `bunx`, `pnpm`/`yarn
* dlx`), then basenames what is left. A command that is nothing but wrappers
* is returned as written rather than as an empty string. */
fun programName(command: String): String {
val trimmed = command.trim()
val words = trimmed.split(Regex("\\s+")).filter { it.isNotEmpty() }
if (words.isEmpty()) return trimmed
var i = 0
while (i < words.size && ENV_ASSIGNMENT.matches(words[i])) i++
while (i < words.size && words[i] in PLAIN_WRAPPERS) {
i++
while (i < words.size && (ENV_ASSIGNMENT.matches(words[i]) || words[i].startsWith("-"))) i++
}
if (i >= words.size) return basename(words.last())
when {
words[i] in DLX_CAPABLE && words.getOrNull(i + 1) == "dlx" -> i += 2
words[i] == "npx" || words[i] == "bunx" -> i += 1
words[i] == "bun" && words.getOrNull(i + 1) == "x" -> i += 2
}
val program = words.getOrNull(i) ?: return basename(words.last())
return basename(program)
}
private fun basename(path: String): String = path.substringAfterLast('/')
/** Sessions are grouped by the repository they belong to (#265): a
* worktree's `base_repo`, or the session's own `cwd` when it owns none. */
fun sessionGroupKey(session: Session): String = session.worktree?.base_repo ?: session.cwd
/** One folder in the sidebar tree: a repo/cwd group and the sessions in it,
* sorted by name. */
data class SessionGroup(val key: String, val label: String, val sessions: List<Session>)
/** Group sessions by [sessionGroupKey], each group sorted by name and the
* groups themselves sorted by label. */
fun groupSessions(sessions: Collection<Session>): List<SessionGroup> =
sessions
.groupBy(::sessionGroupKey)
.map { (key, members) ->
SessionGroup(
key = key,
label = key.trimEnd('/').substringAfterLast('/').ifEmpty { key },
sessions = members.sortedBy { sessionName(it).lowercase() },
)
}
.sortedBy { it.label.lowercase() }
@@ -0,0 +1,190 @@
package pt.naps.maestro.ui.sidebar
import kotlinx.serialization.json.JsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import pt.naps.maestro.data.InTheWay
import pt.naps.maestro.data.Session
import pt.naps.maestro.data.SessionStatus
import pt.naps.maestro.data.SessionWorktree
private fun session(
command: String = "claude",
cwd: String = "/home/user/project",
metadata: Map<String, JsonPrimitive> = emptyMap(),
worktree: SessionWorktree? = null,
blockedWork: InTheWay? = null,
activitySince: String = "1970-01-01T00:00:00Z",
) = Session(
id = "s1",
cwd = cwd,
command = command,
metadata = metadata,
worktree = worktree,
status = SessionStatus.Running,
created_at = "2026-01-01T00:00:00Z",
blocked_work = blockedWork,
activity_since = activitySince,
)
class DurationFormattingTest {
@Test
fun `floors seconds under a minute`() {
assertEquals("30s", activityDurationLabel(30_000))
assertEquals("59s", activityDurationLabel(59_999))
}
@Test
fun `floors minutes under an hour`() {
assertEquals("4m", activityDurationLabel(4 * 60_000 + 59_000))
}
@Test
fun `floors hours with no days unit`() {
assertEquals("2h", activityDurationLabel(2 * 3_600_000 + 59 * 60_000))
}
@Test
fun `held ms is null for missing or implausible since`() {
assertNull(activityHeldMs(null, 1_000))
assertNull(activityHeldMs("", 1_000))
assertNull(activityHeldMs("1970-01-01T00:00:00Z", 1_000))
assertNull(activityHeldMs("not-a-timestamp", 1_000))
}
@Test
fun `held ms is the gap to now for a plausible timestamp`() {
val since = "2026-01-01T00:00:00Z"
val nowMs = java.time.Instant.parse("2026-01-01T00:00:31Z").toEpochMilli()
assertEquals(31_000L, activityHeldMs(since, nowMs))
}
}
class SessionNameTest {
@Test
fun `falls back to the command when no name is set`() {
assertEquals("claude --resume", sessionName(session(command = "claude --resume")))
}
@Test
fun `uses the custom name when set`() {
val s = session(metadata = mapOf("name" to JsonPrimitive("api-work")))
assertEquals("api-work", sessionName(s))
}
@Test
fun `an empty stored name is treated as unset`() {
val s = session(metadata = mapOf("name" to JsonPrimitive("")))
assertNull(customSessionName(s))
}
@Test
fun `generated name is provisional, user and haiku are not`() {
val generated = session(
metadata = mapOf(
"name" to JsonPrimitive("brave-otter"),
"name_source" to JsonPrimitive("Generated"),
),
)
val user = session(
metadata = mapOf(
"name" to JsonPrimitive("api-work"),
"name_source" to JsonPrimitive("User"),
),
)
val unknownSource = session(
metadata = mapOf(
"name" to JsonPrimitive("api-work"),
"name_source" to JsonPrimitive("something-new"),
),
)
assertTrue(isProvisionalName(generated))
assertFalse(isProvisionalName(user))
assertFalse(isProvisionalName(unknownSource))
}
}
class BlockerStatsTest {
@Test
fun `renders insertions and deletions in diff notation`() {
val w = InTheWay(uncommitted_files = 2, untracked_files = 3, unpushed_commits = 2, insertions = 124, deletions = 18)
val stats = blockerStats(w).associate { it.kind to it.text }
assertEquals("+124", stats["add"])
assertEquals("-18", stats["del"])
assertEquals("?3", stats["untracked"])
assertEquals("↑2", stats["unpushed"])
assertNull(stats["files"])
}
@Test
fun `falls back to a file count when a tracked change carries no line delta`() {
val w = InTheWay(uncommitted_files = 2, insertions = 0, deletions = 0)
val stats = blockerStats(w)
assertEquals(listOf(BlockerStat("files", "~2")), stats)
}
@Test
fun `session blockers are null when nothing blocks a kill`() {
assertNull(sessionBlockers(session(blockedWork = null)))
assertNull(sessionBlockers(session(blockedWork = InTheWay())))
}
@Test
fun `format in the way spells out zero as no unsaved work`() {
assertEquals("no unsaved work", formatInTheWay(InTheWay()))
assertEquals("1 uncommitted file, 2 untracked files", formatInTheWay(InTheWay(uncommitted_files = 1, untracked_files = 2)))
}
}
class ProgramNameTest {
@Test
fun `drops arguments and basenames the path`() {
assertEquals("claude", programName("/usr/local/bin/claude -p hi"))
assertEquals("claude", programName("claude --dangerously-skip-permissions"))
}
@Test
fun `looks through leading env assignments`() {
assertEquals("claude", programName("FOO=bar BAZ=qux claude --resume"))
}
@Test
fun `looks through sudo and env wrappers with their own flags`() {
assertEquals("claude", programName("sudo claude"))
assertEquals("claude", programName("env -i FOO=bar claude"))
}
@Test
fun `looks through js-toolchain launchers`() {
assertEquals("claude-code", programName("npx claude-code"))
assertEquals("claude", programName("pnpm dlx claude"))
assertEquals("claude", programName("bunx claude"))
}
@Test
fun `a command that is nothing but wrappers is shown as written`() {
assertEquals("sudo", programName("sudo"))
}
}
class GroupingTest {
@Test
fun `groups by worktree base_repo when present`() {
val a = session(cwd = "/wt/a", worktree = SessionWorktree(path = "/wt/a", base_repo = "/home/user/maestro"))
val b = session(cwd = "/wt/b", worktree = SessionWorktree(path = "/wt/b", base_repo = "/home/user/maestro"))
val groups = groupSessions(listOf(a, b))
assertEquals(1, groups.size)
assertEquals("maestro", groups.single().label)
assertEquals(2, groups.single().sessions.size)
}
@Test
fun `falls back to cwd with no worktree`() {
val a = session(cwd = "/home/user/scratch")
val groups = groupSessions(listOf(a))
assertEquals("scratch", groups.single().label)
}
}