From 3ef1decbddfe42987715e124f8083693899e3031 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 13 Sep 2026 00:39:09 +0100 Subject: [PATCH] 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. --- .../maestro/ui/sidebar/SessionFormatting.kt | 161 +++++++++++++++ .../ui/sidebar/SessionFormattingTest.kt | 190 ++++++++++++++++++ 2 files changed, 351 insertions(+) create mode 100644 android/app/src/main/java/pt/naps/maestro/ui/sidebar/SessionFormatting.kt create mode 100644 android/app/src/test/java/pt/naps/maestro/ui/sidebar/SessionFormattingTest.kt diff --git a/android/app/src/main/java/pt/naps/maestro/ui/sidebar/SessionFormatting.kt b/android/app/src/main/java/pt/naps/maestro/ui/sidebar/SessionFormatting.kt new file mode 100644 index 0000000..f806920 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/ui/sidebar/SessionFormatting.kt @@ -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 { + val stats = mutableListOf() + 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() + 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) + +/** Group sessions by [sessionGroupKey], each group sorted by name and the + * groups themselves sorted by label. */ +fun groupSessions(sessions: Collection): List = + 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() } diff --git a/android/app/src/test/java/pt/naps/maestro/ui/sidebar/SessionFormattingTest.kt b/android/app/src/test/java/pt/naps/maestro/ui/sidebar/SessionFormattingTest.kt new file mode 100644 index 0000000..590fd6e --- /dev/null +++ b/android/app/src/test/java/pt/naps/maestro/ui/sidebar/SessionFormattingTest.kt @@ -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 = 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) + } +}