Merge remote-tracking branch 'origin/main' into sostenuto-bassoon

This commit is contained in:
Miguel Palhas
2026-08-31 15:04:28 +01:00
13 changed files with 675 additions and 131 deletions
@@ -45,6 +45,25 @@ pub fn in_the_way(path: &Path) -> Result<InTheWay, ProbeError> {
breakdown.uncommitted_files += 1;
}
}
// The line delta behind the sidebar's `+N −M` (docs/SIDEBAR.md). Diffing
// the working tree against HEAD covers staged and unstaged changes in one
// pass, so a `git add` never makes the counter drop.
let numstat = run_git(path, ["diff", "--numstat", "HEAD"]);
if let Ok(numstat) = numstat {
for line in String::from_utf8_lossy(&numstat.stdout).lines() {
let mut cols = line.split('\t');
// A binary file reports `-` for both columns; it has no lines to
// add to either total.
let added: usize = cols.next().and_then(|c| c.parse().ok()).unwrap_or(0);
let removed: usize = cols.next().and_then(|c| c.parse().ok()).unwrap_or(0);
breakdown.insertions += added;
breakdown.deletions += removed;
}
}
// A repo with no commits yet has no HEAD to diff against, and the counts
// stay zero rather than failing the whole probe — the file counts above
// already reported the work.
// Resolved inside the worktree so a detached HEAD still has a starting
// point; asking the base repo for `refs/heads/<branch>` fails there.
let head = run_git(path, ["rev-parse", "--abbrev-ref", "HEAD"])?;
@@ -210,6 +229,67 @@ mod tests {
assert_eq!(dirty.untracked_files, 1);
}
/// docs/SIDEBAR.md: the sidebar renders `+N −M`, so the probe carries the
/// line delta of the tracked changes — staged and unstaged together,
/// against HEAD.
#[test]
fn staged_and_unstaged_lines_both_count() {
let r = repo_with_worktree();
std::fs::write(r.wt().join("staged.txt"), b"a\nb\n").unwrap();
git(&r.wt(), &["add", "staged.txt"]);
// README loses its one line and gains three, unstaged.
std::fs::write(r.wt().join("README"), b"x\ny\nz\n").unwrap();
let breakdown = in_the_way(&r.wt()).expect("probe");
assert_eq!(breakdown.insertions, 5, "2 staged + 3 unstaged");
assert_eq!(breakdown.deletions, 1);
}
/// Untracked files are counted as files and never as lines — reading each
/// one off disk is the cost the sidebar contract declines to pay.
#[test]
fn untracked_files_contribute_no_lines() {
let r = repo_with_worktree();
std::fs::write(r.wt().join("scratch.txt"), b"a\nb\nc\n").unwrap();
let breakdown = in_the_way(&r.wt()).expect("probe");
assert_eq!(breakdown.untracked_files, 1);
assert_eq!(breakdown.insertions, 0);
assert_eq!(breakdown.deletions, 0);
}
/// A binary file's numstat is `-\t-`. It must not poison the totals, and
/// it must not abort the probe either.
#[test]
fn binary_changes_add_no_lines() {
let r = repo_with_worktree();
std::fs::write(r.wt().join("blob.bin"), [0u8, 159, 0, 12]).unwrap();
git(&r.wt(), &["add", "blob.bin"]);
git(&r.wt(), &["commit", "-qm", "blob"]);
std::fs::write(r.wt().join("blob.bin"), [0u8, 1, 2, 3, 4]).unwrap();
let breakdown = in_the_way(&r.wt()).expect("probe");
assert_eq!(breakdown.uncommitted_files, 1);
assert_eq!(breakdown.insertions, 0);
assert_eq!(breakdown.deletions, 0);
}
/// A line delta alone never decides `is_clear` — the file counts do — so a
/// mode change with no line delta still blocks a kill.
#[test]
fn a_line_less_change_still_blocks() {
let r = repo_with_worktree();
let script = r.wt().join("run.sh");
std::fs::write(&script, b"echo hi\n").unwrap();
git(&r.wt(), &["add", "run.sh"]);
git(&r.wt(), &["commit", "-qm", "script"]);
git(&r.wt(), &["update-index", "--chmod=+x", "run.sh"]);
let breakdown = in_the_way(&r.wt()).expect("probe");
assert_eq!(breakdown.insertions, 0);
assert_eq!(breakdown.deletions, 0);
assert!(!breakdown.is_clear());
}
#[test]
fn ignored_files_never_count() {
let r = repo_with_worktree();
+104 -1
View File
@@ -236,12 +236,71 @@ pub struct InTheWay {
/// Commits on the branch reachable from no remote-tracking ref.
#[serde(default)]
pub unpushed_commits: usize,
/// Lines added across the tracked changes — staged and unstaged alike,
/// measured against `HEAD`. Untracked files contribute nothing: counting
/// their lines means reading every one off disk, and they already have
/// their own count above (docs/SIDEBAR.md).
#[serde(default)]
pub insertions: usize,
/// Lines removed across the same tracked changes.
#[serde(default)]
pub deletions: usize,
}
impl InTheWay {
/// Nothing blocks any more — the work has since been committed and pushed.
///
/// The line counts are not consulted: they describe the same tracked
/// changes `uncommitted_files` counts, and a change with no line delta —
/// a mode change, a rename — still blocks.
pub fn is_clear(&self) -> bool {
*self == Self::default()
self.uncommitted_files == 0 && self.untracked_files == 0 && self.unpushed_commits == 0
}
}
/// Which fragment of the compact blocked-work stat a value is, and so which
/// colour the client paints it (docs/SIDEBAR.md).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatKind {
/// Lines added.
Added,
/// Lines removed.
Removed,
/// Changed tracked files, when the change carries no line delta.
Files,
/// Untracked files.
Untracked,
/// Unpushed commits.
Unpushed,
}
impl InTheWay {
/// The breakdown as diff notation: `+124`, `-18`, `?3`, `↑2`.
///
/// The line counts cover tracked changes only, so a tree whose only
/// tracked change carries no line delta — a mode change, a rename —
/// reports the file count (`~2`) rather than claiming `+0 -0`. Both
/// sidebars render exactly these fragments; [`Display`](std::fmt::Display)
/// remains the spelled-out phrasing for the kill refusal and the tooltip.
pub fn stats(&self) -> Vec<(StatKind, String)> {
let mut out = Vec::new();
if self.insertions > 0 || self.deletions > 0 {
if self.insertions > 0 {
out.push((StatKind::Added, format!("+{}", self.insertions)));
}
if self.deletions > 0 {
out.push((StatKind::Removed, format!("-{}", self.deletions)));
}
} else if self.uncommitted_files > 0 {
out.push((StatKind::Files, format!("~{}", self.uncommitted_files)));
}
if self.untracked_files > 0 {
out.push((StatKind::Untracked, format!("?{}", self.untracked_files)));
}
if self.unpushed_commits > 0 {
out.push((StatKind::Unpushed, format!("↑{}", self.unpushed_commits)));
}
out
}
}
@@ -544,6 +603,50 @@ mod tests {
assert!(is_compatible("unknown"));
}
/// docs/SIDEBAR.md: the sidebars render the breakdown as diff notation.
#[test]
fn stats_read_as_a_diff() {
let w = InTheWay {
uncommitted_files: 3,
untracked_files: 2,
unpushed_commits: 1,
insertions: 124,
deletions: 18,
};
let text: Vec<String> = w.stats().into_iter().map(|(_, t)| t).collect();
assert_eq!(text, ["+124", "-18", "?2", "↑1"]);
let kinds: Vec<StatKind> = w.stats().into_iter().map(|(k, _)| k).collect();
assert_eq!(
kinds,
[
StatKind::Added,
StatKind::Removed,
StatKind::Untracked,
StatKind::Unpushed
]
);
}
/// A change with no line delta — a mode change — still has to say
/// something, and `+0 -0` would be a lie about the tree.
#[test]
fn stats_fall_back_to_the_file_count_without_a_line_delta() {
let w = InTheWay {
uncommitted_files: 2,
..InTheWay::default()
};
let text: Vec<String> = w.stats().into_iter().map(|(_, t)| t).collect();
assert_eq!(text, ["~2"]);
assert!(!w.is_clear());
}
/// A clear tree says nothing at all — the fragment is omitted, not empty.
#[test]
fn a_clear_tree_has_no_stats() {
assert!(InTheWay::default().stats().is_empty());
assert!(InTheWay::default().is_clear());
}
fn round_trip<T>(value: &T) -> T
where
T: Serialize + for<'de> Deserialize<'de>,
+67 -19
View File
@@ -9,7 +9,7 @@ use std::path::PathBuf;
use chrono::Utc;
use maestro_protocol::display::{abbreviate_dir, abbreviate_folder_name, session_tree_path};
use maestro_protocol::{ActivityState, Session, SessionStatus};
use maestro_protocol::{ActivityState, Session, SessionStatus, StatKind};
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
@@ -283,7 +283,16 @@ impl Sidebar {
}
let mut tail: Vec<Span<'static>> = Vec::new();
if let Some(b) = blocked {
tail.push(Span::styled(format!(" ⚠ {b}"), muted));
let palette = palette::active();
let surface = palette.surface(highlighted && focused);
for (kind, text) in b.stats() {
let style = match kind {
StatKind::Added => muted.fg(surface.active),
StatKind::Removed => muted.fg(surface.error),
_ => muted,
};
tail.push(Span::styled(format!(" {text}"), style));
}
}
if dead {
tail.push(Span::styled(" exited", muted));
@@ -627,14 +636,18 @@ mod tests {
}
/// #83 via docs/SIDEBAR.md: blocked work lives on the meta line now, not
/// on the name row; a clear or unprobed worktree never warns.
/// on the name row, and reads as diff notation; a clear or unprobed
/// worktree says nothing.
#[test]
fn blocked_session_warns_on_its_meta_line() {
fn blocked_session_states_its_diff_on_the_meta_line() {
let mut blocked = session_named(1, "claude");
blocked.activity_since = Utc::now() - Duration::seconds(5);
blocked.blocked_work = Some(InTheWay {
uncommitted_files: 2,
untracked_files: 3,
unpushed_commits: 4,
..InTheWay::default()
insertions: 124,
deletions: 18,
});
let mut clear = session_named(2, "bash");
clear.blocked_work = Some(InTheWay::default());
@@ -644,31 +657,66 @@ mod tests {
highlight: None,
attached: Default::default(),
};
let all = rows(&draw(&sb, 40, 10));
let all = rows(&draw(&sb, 44, 10));
let meta = all
.iter()
.find(|l| l.contains("⚠"))
.expect("blocked card must warn somewhere");
assert!(
meta.contains("4 unpushed commits"),
"warning must carry InTheWay's summary: {meta:?}"
);
.find(|l| l.contains("+124"))
.expect("blocked card must state its diff somewhere");
for fragment in ["-18", "?3", "↑4"] {
assert!(
meta.contains(fragment),
"meta line must carry {fragment}: {meta:?}"
);
}
let name_row = row_containing(&all, "claude");
assert!(
!name_row.contains('⚠'),
"warning moved off the name row: {name_row:?}"
!name_row.contains("+124"),
"the diff stays off the name row: {name_row:?}"
);
for name in ["bash", "vim"] {
let idx = all.iter().position(|l| l.contains(name)).unwrap();
assert!(
!all[idx + 1].contains('⚠'),
"{name} must not warn: {:?}",
!all[idx + 1].contains('+') && !all[idx + 1].contains('?'),
"{name} must not state a diff: {:?}",
all[idx + 1]
);
}
}
/// The additions and deletions are the only coloured fragments: they are
/// what a reader scans for, and colouring the rest would make the line
/// compete with the activity dot.
#[test]
fn the_diff_colours_only_its_additions_and_deletions() {
let mut s = session_named(1, "claude");
s.blocked_work = Some(InTheWay {
uncommitted_files: 1,
untracked_files: 0,
unpushed_commits: 2,
insertions: 7,
deletions: 3,
});
let sb = Sidebar {
sessions: vec![s],
highlight: None,
attached: Default::default(),
};
let buf = draw(&sb, 44, 8);
let palette = palette::active();
let surface = palette.surface(false);
let colour_of = |needle: char| {
(0..buf.area.width)
.flat_map(|x| (0..buf.area.height).map(move |y| (x, y)))
.find(|&(x, y)| buf[(x, y)].symbol() == needle.to_string())
.map(|(x, y)| buf[(x, y)].fg)
.expect("fragment must be drawn")
};
assert_eq!(colour_of('+'), surface.active);
assert_eq!(colour_of('-'), surface.error);
assert_eq!(colour_of('↑'), surface.muted);
}
/// A fresh session still gets its unconditional second line, but no
/// duration until it has been held in state for 30 seconds.
#[test]
@@ -929,7 +977,7 @@ mod tests {
let meta = content(&all[idx + 1]);
assert!(meta.contains('…'), "truncated detail ends with …: {meta:?}");
assert!(
meta.contains("2 unpushed commits"),
meta.contains("↑2"),
"blocked work stays on the line: {meta:?}"
);
assert!(
@@ -1062,7 +1110,7 @@ mod tests {
attached: Default::default(),
};
let all = rows(&draw(&sb, 29, 6));
assert!(all[3].contains("⚠ 1 uncommitted file"), "{:?}", all[3]);
assert!(all[3].contains("~1"), "{:?}", all[3]);
let meta = content(&all[3]);
assert!(meta.ends_with('…'), "command must truncate: {meta:?}");
}
@@ -1081,7 +1129,7 @@ mod tests {
attached: Default::default(),
};
let all = rows(&draw(&sb, 40, 6));
assert!(all[3].contains("⚠ 1 uncommitted file"), "{:?}", all[3]);
assert!(all[3].contains("~1"), "{:?}", all[3]);
assert!(
all[3].contains("bash"),
"command must survive: {:?}",
+39 -4
View File
@@ -37,7 +37,7 @@ itself is not.
Line 2 reads left to right:
```
<dot> [<duration>] [<detail>] [<blocked work> | exited] <command>
<dot> [<duration>] [<detail>] [<diff stat> | exited] <command>
```
Its minimum content is therefore the dot and the command.
@@ -64,12 +64,46 @@ truncates with `…` ahead of the trailing fragments — a long detail never
pushes `<blocked work>`, `exited`, or `<command>` off the line, and neither
client ever lets it wrap.
`<diff stat>` is the blocked-work breakdown (#83) in diff notation, replacing
the spelled-out `⚠ 3 uncommitted files, 2 unpushed commits`. It renders only
while something would block an unforced kill, and reads:
```
+<insertions> -<deletions> ?<untracked files> ↑<unpushed commits>
```
Each fragment is omitted when its count is zero. The line counts cover
**tracked** changes only — staged and unstaged together, measured against
`HEAD` — so a `git add` never makes the counter drop. Untracked files are
counted as files, not lines: counting their lines means reading every one off
disk, and `?3` already says what is there. A tree whose only tracked change
carries no line delta — a mode change, a rename — shows `~<changed files>`
instead of the additions and deletions, since `+0 -0` would describe it
wrongly.
Only `+` and `-` take colour (`--live` / `--danger` in the web client, the
surface's `active` / `error` roles in the TUI); `~`, `?` and `↑` stay muted, so
the fragment reads as a diff rather than as an alarm. The spelled-out phrasing
of `maestro_protocol::InTheWay`'s `Display` does not disappear: it is the web
card's tooltip and the wording of the kill refusal, so no two clients describe
the same tree differently.
`<command>` is unconditional and muted: the session's command line, shown on
every card in both clients — no omission in the TUI, no name-vs-command
comparison gating it. A session named by its command line then shows the same
string on both lines; that is accepted, the price of a row that never changes
height.
In the **web client** the command's leading program is replaced by that tool's
icon when it is one we have a mark for — Claude Code, Codex, opencode, Gemini
CLI, Aider, Cursor, pi, and any shell — and only the remaining words of the
command line follow as text. Recognition looks through a path, leading
`FOO=bar` assignments and wrappers (`sudo`, `env`, `npx`, `pnpm dlx`, …); an
unrecognised program keeps its full text, so the card never shows a mark
standing for the wrong tool. The full command line stays in the card's tooltip
either way. The **TUI** keeps the command as text: a terminal cannot be assumed
to have a font carrying tool glyphs.
## Colour per activity state
The state word is never spelled out — the colour carries it.
@@ -95,9 +129,10 @@ surface; a colour chosen for the card background is never painted onto the
selection background. Contrast is a property of the palette (values are RGB)
and is asserted in the palette's tests, not left to the terminal theme.
The duration text inherits its state's colour; the blocked-work, exited and
command fragments use the surface's `muted` role, since the ⚠ glyph already
signals severity.
The duration text inherits its state's colour; the exited and command
fragments use the surface's `muted` role, as do the diff stat's `~`, `?` and
`↑` fragments. Only `+` and `-` step outside the surface's muted role, and they
take the `active` and `error` roles of the same surface.
## Duration format
+60 -11
View File
@@ -47,15 +47,33 @@ function rowFor(el: HTMLElement, id: string): HTMLElement | null {
}
describe("blocked-work indicator (#83)", () => {
it("marks the row of a blocked session with the shared phrasing", () => {
it("marks the row of a blocked session in diff notation", () => {
const blocked = session({
id: "aaaaaaaa-0000-0000-0000-000000000001",
blocked_work: { uncommitted_files: 3, unpushed_commits: 2 },
blocked_work: {
uncommitted_files: 3,
unpushed_commits: 2,
insertions: 124,
deletions: 18,
},
});
const el = renderSidebar(storeWith([blocked])).el;
const badge = rowFor(el, blocked.id)?.querySelector(".leaf-blocked");
expect(badge?.textContent).toContain("⚠");
expect(badge?.textContent).toContain("3 uncommitted files, 2 unpushed commits");
expect(badge?.textContent).toBe("+124-18↑2");
// The spelled-out breakdown survives in the tooltip.
expect(badge?.getAttribute("title")).toContain(
"3 uncommitted files, 2 unpushed commits",
);
});
it("falls back to the file count when tracked changes carry no lines", () => {
const blocked = session({
id: "aaaaaaaa-0000-0000-0000-00000000001a",
blocked_work: { uncommitted_files: 2, untracked_files: 1 },
});
const el = renderSidebar(storeWith([blocked])).el;
const badge = rowFor(el, blocked.id)?.querySelector(".leaf-blocked");
expect(badge?.textContent).toBe("~2?1");
});
it("does not mark clear or unprobed sessions", () => {
@@ -161,7 +179,7 @@ describe("session cards (#107)", () => {
expect(line2).not.toBeNull();
expect(line2?.querySelector(".activity-dot")).not.toBeNull();
expect(line2?.querySelector(".activity-duration")).toBeNull();
expect(line2?.querySelector(".leaf-cmd")?.textContent).toBe("claude");
expect(line2?.querySelector(".leaf-cmd")).not.toBeNull();
});
it("shows dot + floored duration once the state has been held past 30s", () => {
@@ -177,14 +195,40 @@ describe("session cards (#107)", () => {
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("4m");
});
it("renders the command when the session name equals it", () => {
it("renders the command as its tool icon when the tool is known", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-00000000001b",
command: "claude",
metadata: { name: "claude" },
});
const el = renderSidebar(storeWith([s])).el;
expect(rowFor(el, s.id)?.querySelector(".card-line2 .leaf-cmd")?.textContent).toBe("claude");
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
expect(cmd?.querySelector(".tool-icon")?.getAttribute("data-tool")).toBe("claude");
expect(cmd?.getAttribute("title")).toBe("claude");
// The icon said it all: no text left over.
expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull();
});
it("keeps the command's arguments as text beside the icon", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-00000000001c",
command: "claude --resume",
});
const el = renderSidebar(storeWith([s])).el;
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
expect(cmd?.querySelector(".tool-icon")?.getAttribute("data-tool")).toBe("claude");
expect(cmd?.querySelector(".leaf-cmd-rest")?.textContent).toBe("--resume");
});
it("keeps an unknown command as plain text", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-00000000001d",
command: "hx src/main.rs",
});
const el = renderSidebar(storeWith([s])).el;
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
expect(cmd?.querySelector(".tool-icon")).toBeNull();
expect(cmd?.querySelector(".leaf-cmd-rest")?.textContent).toBe("hx src/main.rs");
});
it("maps each activity state to its contract slug", () => {
@@ -222,15 +266,20 @@ describe("session cards (#107)", () => {
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("2m");
});
it("moves blocked work onto line 2 in the shared phrasing", () => {
it("moves blocked work onto line 2 as diff notation", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000017",
blocked_work: { uncommitted_files: 3, unpushed_commits: 2 },
blocked_work: {
uncommitted_files: 3,
unpushed_commits: 2,
insertions: 124,
deletions: 18,
},
});
const el = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
expect(line2?.textContent).toContain("⚠");
expect(line2?.textContent).toContain("3 uncommitted files, 2 unpushed commits");
expect(line2?.textContent).toContain("+124");
expect(line2?.textContent).toContain("-18");
// Line 1 carries no badges.
expect(rowFor(el, s.id)?.querySelector(".leaf-badge")).toBeNull();
});
+28 -14
View File
@@ -3,9 +3,10 @@ import { getDaemonInfo, killSession, renameSession } from "./api.js";
import { openContextMenu } from "./contextMenu.js";
import { isTypingTarget, toggleHelp } from "./help.js";
import { CatalogStore } from "./events.js";
import type { Session } from "./types.js";
import type { InTheWay, Session } from "./types.js";
import {
abbreviateFolderName,
blockerStats,
customSessionName,
formatInTheWay,
MAX_SESSION_NAME_LEN,
@@ -13,6 +14,7 @@ import {
sessionBlockers,
sessionName,
} from "./types.js";
import { splitCommand, toolIconSvg } from "./toolIcon.js";
import {
activityColorSlug,
activityDurationLabel,
@@ -73,15 +75,33 @@ function metaLine(s: Session, now: number): string {
s.activity_detail
? `<span class="activity-detail">${escapeHtml(s.activity_detail)}</span>`
: "",
blockers
? `<span class="card-flag leaf-blocked" title="Kill will be refused unless forced">⚠ ${escapeHtml(formatInTheWay(blockers))}</span>`
: "",
blockers ? blockedFragment(blockers) : "",
dead ? `<span class="card-flag">exited</span>` : "",
`<span class="card-flag leaf-cmd" title="${escapeHtml(s.command)}">${escapeHtml(s.command)}</span>`,
commandFragment(s.command),
];
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
}
/** docs/SIDEBAR.md: blocked work renders as diff notation — `+124 -18 ?3 ↑2`
* — with the spelled-out breakdown kept in the tooltip. */
function blockedFragment(blockers: InTheWay): string {
const stats = blockerStats(blockers)
.map((st) => `<span class="blocked-stat" data-stat="${st.kind}">${escapeHtml(st.text)}</span>`)
.join("");
const title = `Kill will be refused unless forced — ${formatInTheWay(blockers)}`;
return `<span class="card-flag leaf-blocked" title="${escapeHtml(title)}">${stats}</span>`;
}
/** docs/SIDEBAR.md: the command's leading program becomes its tool icon when
* we have a mark for it, and only the remaining words stay as text. The full
* command line stays in the tooltip either way. */
function commandFragment(command: string): string {
const { tool, rest } = splitCommand(command);
const icon = tool ? toolIconSvg(tool) : null;
const text = icon ? rest : command;
return `<span class="card-flag leaf-cmd" title="${escapeHtml(command)}">${icon ?? ""}${text ? `<span class="leaf-cmd-rest">${escapeHtml(text)}</span>` : ""}</span>`;
}
function renderNode(node: TreeNode, depth: number): HTMLElement {
if (node.kind === "folder") {
const wrap = document.createElement("div");
@@ -341,12 +361,10 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void):
}
/** Two-tier sidebar highlight state (issue #33): which sessions are open in some
* pane (quiet), which one is focused (loud), and a stable per-session colour so
* a row maps to its pane. */
* pane (quiet), and which one is focused (loud). */
export interface SidebarHighlights {
open: Set<string>;
focused: string | null;
colors: Map<string, string>;
}
/** Live handle to the persistent sidebar (built once, survives navigation). */
@@ -356,7 +374,7 @@ export interface SidebarHandle {
/** Highlight the row for `id` (or clear it with `null`) as the focused session.
* Back-compat shim over {@link setHighlights}. */
setActive(id: string | null): void;
/** Apply the full open/focused/colour highlight state. */
/** Apply the full open/focused highlight state. */
setHighlights(h: SidebarHighlights): void;
/** Hand the keyboard to the session list, highlighting a row to walk from. */
focusList(): void;
@@ -469,7 +487,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
refresh();
});
let highlights: SidebarHighlights = { open: new Set(), focused: null, colors: new Map() };
let highlights: SidebarHighlights = { open: new Set(), focused: null };
const applyHighlights = () => {
for (const n of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
const id = n.dataset.sessionId;
@@ -478,9 +496,6 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
const open = highlights.open.has(id);
n.classList.toggle("is-active", focused);
n.classList.toggle("is-open", open && !focused);
const color = highlights.colors.get(id);
if (color && (open || focused)) n.style.setProperty("--mv-color", color);
else n.style.removeProperty("--mv-color");
}
};
@@ -605,7 +620,6 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
highlights = {
open: id ? new Set([id]) : new Set(),
focused: id,
colors: new Map(),
};
applyHighlights();
},
+15 -3
View File
@@ -14,6 +14,7 @@ function sess(
status: "Running" | "Dead",
cwd: string,
name?: string,
blocked_work?: Session["blocked_work"],
): Session {
const seed = parseInt(id.slice(-1), 16) || 0;
return {
@@ -30,6 +31,7 @@ function sess(
last_activity_at: new Date(Date.now() - seed * 11_000).toISOString(),
// The session tree nests by current_cwd; default it to the spawn dir.
current_cwd: cwd,
blocked_work,
};
}
@@ -109,16 +111,26 @@ function stubSpawnApi(): void {
export function seedMock(store: CatalogStore): void {
const sessions: Session[] = [
// ~/git/maestro/web/* — deep dir chain; "~/git/maestro" collapses to one node.
sess("a1b2c3d4-0000-0000-0000-000000000001", "claude", "Running", "/home/dev/git/maestro/web", "redesign"),
sess("a1b2c3d4-0000-0000-0000-000000000001", "claude", "Running", "/home/dev/git/maestro/web", "redesign", {
uncommitted_files: 4,
untracked_files: 2,
unpushed_commits: 1,
insertions: 312,
deletions: 47,
}),
sess("a1b2c3d4-0000-0000-0000-000000000002", "vite dev", "Running", "/home/dev/git/maestro/web", "dev-server"),
sess("a1b2c3d4-0000-0000-0000-000000000003", "bash", "Dead", "/home/dev/git/maestro/web", "scratch"),
sess("a1b2c3d4-0000-0000-0000-000000000004", "claude", "Running", "/home/dev/git/maestro/web/tests", "e2e-fixes"),
sess("a1b2c3d4-0000-0000-0000-000000000004", "codex --resume", "Running", "/home/dev/git/maestro/web/tests", "e2e-fixes", {
uncommitted_files: 1,
}),
// ~/git/maestro/crates/* — sibling subtree under the same repo.
sess("a1b2c3d4-0000-0000-0000-000000000005", "claude", "Running", "/home/dev/git/maestro/crates/daemon", "daemon-work"),
sess("a1b2c3d4-0000-0000-0000-000000000006", "cargo watch -x test", "Running", "/home/dev/git/maestro/crates/daemon", "test-loop"),
sess("a1b2c3d4-0000-0000-0000-000000000007", "cargo run -- daemon", "Running", "/home/dev/git/maestro/crates/daemon", "daemon"),
// ~/work/acme/* — a separate top-level dir on the same host.
sess("a1b2c3d4-0000-0000-0000-000000000008", "claude", "Running", "/home/dev/work/acme/api", "billing-api"),
sess("a1b2c3d4-0000-0000-0000-000000000008", "gemini", "Running", "/home/dev/work/acme/api", "billing-api", {
untracked_files: 3,
}),
sess("a1b2c3d4-0000-0000-0000-000000000009", "pnpm dev", "Dead", "/home/dev/work/acme/web", "dashboard"),
// ~/git/dotfiles — single session, fully collapsed chain.
sess("a1b2c3d4-0000-0000-0000-000000000010", "opencode", "Running", "/home/dev/git/dotfiles", "dotfiles"),
+3 -15
View File
@@ -22,7 +22,6 @@ import {
removeLeaf,
splitLeaf,
} from "./layout.js";
import { sessionColor } from "./sessioncolor.js";
import type { AttachInfo } from "./attach.js";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
@@ -97,12 +96,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
// ---- highlight helpers ----
function colorMap(): Map<string, string> {
const m = new Map<string, string>();
for (const id of openSessions(layout)) m.set(id, sessionColor(id));
return m;
}
function focusedLeafSession(): string | null {
const tab = activeTab(layout);
const fl = tab.focusedLeafId ? findLeaf(tab.root, tab.focusedLeafId) : null;
@@ -120,7 +113,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
sidebar.setHighlights({
open: openSessions(layout),
focused: focusedLeafSession(),
colors: colorMap(),
});
}
@@ -142,10 +134,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
const pane = document.createElement("div");
pane.className = "mv-pane";
pane.dataset.leafId = leaf.id;
if (leaf.sessionId) {
pane.dataset.sessionId = leaf.sessionId;
pane.style.setProperty("--mv-color", sessionColor(leaf.sessionId));
}
if (leaf.sessionId) pane.dataset.sessionId = leaf.sessionId;
const host = document.createElement("div");
host.className = "mv-pane-host";
@@ -482,7 +471,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
pool.setVisible([]);
pool.setFocus(null);
canvas.appendChild(renderWelcome());
sidebar.setHighlights({ open: openSessions(layout), focused: null, colors: colorMap() });
sidebar.setHighlights({ open: openSessions(layout), focused: null });
persist();
return;
}
@@ -523,7 +512,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
pill.type = "button";
pill.className = "mv-pill" + (l.id === focused.id ? " is-active" : "");
pill.textContent = l.sessionId ? (infoFor(l.sessionId)?.name ?? l.sessionId.slice(0, 8)) : "empty";
if (l.sessionId) pill.style.setProperty("--mv-color", sessionColor(l.sessionId));
pill.addEventListener("click", () => {
tab.focusedLeafId = l.id;
render();
@@ -543,7 +531,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
focused.sessionId ? [{ id: focused.sessionId, pane: pane.host, info: infoFor(focused.sessionId) }] : [],
);
pool.setFocus(focused.sessionId ?? null);
sidebar.setHighlights({ open: openSessions(layout), focused: focused.sessionId ?? null, colors: colorMap() });
sidebar.setHighlights({ open: openSessions(layout), focused: focused.sessionId ?? null });
}
function closeSession(sessionId: string): void {
-23
View File
@@ -1,23 +0,0 @@
// Stable per-session accent colour, used to map a sidebar row to the pane
// showing that session (issue #33). Hash the session id into a fixed OKLCH
// palette tuned to the maestro tokens; collisions are tolerated when there are
// more open sessions than hues — the colour is a hint, not an identity.
import type { Uuid } from "./types.js";
const PALETTE = [
"oklch(0.80 0.13 152)", // green (the house accent)
"oklch(0.80 0.12 90)", // amber
"oklch(0.76 0.13 30)", // coral
"oklch(0.78 0.11 210)", // teal
"oklch(0.74 0.13 265)", // blue
"oklch(0.76 0.14 320)", // magenta
"oklch(0.80 0.11 60)", // gold
"oklch(0.78 0.12 185)", // cyan
];
export function sessionColor(id: Uuid): string {
let h = 0;
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;
return PALETTE[h % PALETTE.length];
}
+44 -41
View File
@@ -1059,6 +1059,46 @@ input.leaf-rename:focus {
truncation target when even the complete trailing set cannot fit. */
.leaf-cmd {
flex: 0 1 auto;
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
}
/* The tool's mark stands in for the program the command line starts with
(docs/SIDEBAR.md). It keeps its own colours where the tool has brand
colours, and inherits the muted fragment colour where it does not. */
.tool-icon {
flex: 0 0 auto;
opacity: 0.9;
}
.leaf-cmd-rest {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Blocked work as diff notation: `+124 -18 ?3 ↑2` (docs/SIDEBAR.md). The
spelled-out breakdown is the tooltip; only the additions and deletions take
colour, so the ⚠-less fragment still reads as a diff and not as an alarm. */
.leaf-blocked {
display: inline-flex;
align-items: center;
gap: 4px;
font-variant-numeric: tabular-nums;
}
.blocked-stat[data-stat="add"] {
color: var(--live);
}
.blocked-stat[data-stat="del"] {
color: var(--danger);
}
.blocked-stat[data-stat="files"],
.blocked-stat[data-stat="untracked"],
.blocked-stat[data-stat="unpushed"] {
color: var(--faint);
}
/* Actions live in line 1's flow at its right edge — never overlaid on the
@@ -2313,20 +2353,10 @@ input.leaf-rename:focus {
min-width: 0;
min-height: 0;
}
/* per-session colour strip — maps a pane to its sidebar row */
.mv-pane::before {
content: "";
position: absolute;
inset: 0 0 auto 0;
height: 2px;
background: var(--mv-color, transparent);
z-index: 3;
pointer-events: none;
}
/* focused pane: accent ring + glow, full strength */
/* focused pane: accent ring, full strength */
.mv-pane.is-focused {
border-color: var(--accent);
box-shadow: inset 0 0 0 1px var(--accent), 0 0 22px -8px var(--accent-ring);
box-shadow: inset 0 0 0 1px var(--accent);
}
/* unfocused panes recede so the eye finds the live one */
.mv-pane:not(.is-focused) {
@@ -2429,31 +2459,12 @@ input.leaf-rename:focus {
border-radius: var(--r-1);
}
/* sidebar two-tier highlight + colour dot (issue #33).
/* sidebar two-tier highlight (issue #33).
.is-active = focused pane (loud, from existing rule); .is-open = open
elsewhere (quiet). The dot uses the session's colour so a row maps to a pane. */
elsewhere (quiet). */
.tree-leaf.is-open {
background: color-mix(in oklab, var(--accent) 6%, transparent);
}
.tree-leaf.is-open::after,
.tree-leaf.is-active::after {
content: "";
position: absolute;
right: 9px;
top: 50%;
width: 7px;
height: 7px;
margin-top: -3.5px;
border-radius: 50%;
background: var(--mv-color, var(--accent));
pointer-events: none;
}
.tree-leaf.is-open::after {
opacity: 0.7;
}
.tree-leaf.is-active::after {
box-shadow: 0 0 8px var(--mv-color, var(--accent-ring));
}
/* =========================================================================
multi-view mobile (#35): below 860px the split tree projects to a pill strip
@@ -2498,14 +2509,6 @@ input.leaf-rename:focus {
cursor: pointer;
border: 1px solid transparent;
}
.mv-pill::before {
content: "";
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--mv-color, var(--faint));
flex: none;
}
.mv-pill.is-active {
background: var(--accent-tint);
color: var(--accent-strong);
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { splitCommand, toolIconSvg } from "./toolIcon.js";
describe("tool icons on the command fragment (docs/SIDEBAR.md)", () => {
it("recognises the agents and shells sessions are spawned with", () => {
const cases: Array<[string, string]> = [
["claude", "claude"],
["codex", "codex"],
["opencode", "opencode"],
["gemini", "gemini"],
["aider", "aider"],
["cursor-agent", "cursor"],
["pi", "pi"],
["zsh", "shell"],
["bash", "shell"],
["fish", "shell"],
];
for (const [command, tool] of cases) {
expect(splitCommand(command).tool, command).toBe(tool);
}
});
it("keeps everything after the program as text", () => {
expect(splitCommand("claude --resume -p hi")).toEqual({
tool: "claude",
rest: "--resume -p hi",
});
expect(splitCommand(" zsh -l ")).toEqual({ tool: "shell", rest: "-l" });
});
it("looks through paths, env assignments and wrappers", () => {
expect(splitCommand("/usr/local/bin/claude").tool).toBe("claude");
expect(splitCommand("FOO=1 BAR=2 claude").tool).toBe("claude");
expect(splitCommand("npx opencode").tool).toBe("opencode");
expect(splitCommand("pnpm dlx opencode run").tool).toBe("opencode");
expect(splitCommand("pnpm dlx opencode run").rest).toBe("run");
});
it("leaves an unrecognised program as text, mark-less", () => {
expect(splitCommand("hx src/main.rs")).toEqual({
tool: null,
rest: "hx src/main.rs",
});
// A wrapper with nothing after it is not a tool either.
expect(splitCommand("sudo").tool).toBeNull();
expect(splitCommand("").tool).toBeNull();
});
it("gives every recognised tool a labelled mark", () => {
for (const tool of ["claude", "codex", "opencode", "gemini", "aider", "cursor", "pi", "shell"]) {
const svg = toolIconSvg(tool);
expect(svg, tool).toContain("<svg");
expect(svg, tool).toContain(`data-tool="${tool}"`);
expect(svg, tool).toContain("aria-label=");
}
expect(toolIconSvg("nonesuch")).toBeNull();
});
});
+141
View File
@@ -0,0 +1,141 @@
/** Tool icons for the sidebar's command fragment (docs/SIDEBAR.md).
*
* A session's command line starts with the program that runs it, and that
* program is nearly always one of a handful of agents or a shell. Rendering it
* as its own mark buys back most of the fragment's width, which the sidebar
* spends on the rest of the command line instead.
*
* Recognition is deliberately narrow: an unrecognised program keeps its text,
* so the fragment never renders a mark that stands for the wrong tool.
*/
/** Command wrappers that run another program: the tool is what follows. */
const WRAPPERS = new Set(["sudo", "env", "npx", "bunx", "uvx", "pnpm", "yarn", "command"]);
/** Program name (already basenamed and lowercased) → icon id. */
const BY_PROGRAM: Record<string, string> = {
claude: "claude",
"claude-code": "claude",
codex: "codex",
opencode: "opencode",
gemini: "gemini",
aider: "aider",
cursor: "cursor",
"cursor-agent": "cursor",
pi: "pi",
bash: "shell",
zsh: "shell",
sh: "shell",
fish: "shell",
dash: "shell",
ksh: "shell",
nu: "shell",
xonsh: "shell",
};
/** Human name behind an icon id, for the tooltip and the aria label. */
export const TOOL_LABELS: Record<string, string> = {
claude: "Claude Code",
codex: "Codex",
opencode: "opencode",
gemini: "Gemini CLI",
aider: "Aider",
cursor: "Cursor",
pi: "pi",
shell: "Shell",
};
export interface CommandParts {
/** Icon id, or `null` when the program is not one we have a mark for. */
tool: string | null;
/** What is left of the command line once the icon stands for the program.
* Empty when the icon says everything the command line said. */
rest: string;
}
/** Split a command line into the tool its icon stands for and the remainder.
*
* `claude --resume` → `{tool: "claude", rest: "--resume"}`; an unrecognised
* `hx src/` keeps the whole line as `rest` with no tool. */
export function splitCommand(command: string): CommandParts {
const words = command.trim().split(/\s+/).filter(Boolean);
let i = 0;
// Leading `FOO=bar` assignments and wrappers stand in front of the real
// program; `pnpm dlx x` / `yarn dlx x` need one more word skipped.
while (i < words.length) {
const word = words[i];
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
i += 1;
continue;
}
const program = basename(word);
if (WRAPPERS.has(program)) {
i += 1;
if ((program === "pnpm" || program === "yarn") && words[i] === "dlx") i += 1;
continue;
}
break;
}
if (i >= words.length) return { tool: null, rest: command.trim() };
const tool = BY_PROGRAM[basename(words[i])] ?? null;
if (!tool) return { tool: null, rest: command.trim() };
return { tool, rest: words.slice(i + 1).join(" ") };
}
function basename(word: string): string {
const cut = word.lastIndexOf("/");
return (cut >= 0 ? word.slice(cut + 1) : word).toLowerCase();
}
/** Anthropic's burst: tapered rays around a centre, long ones on the axes. */
function burst(): string {
const rays = Array.from({ length: 8 }, (_, k) => {
const long = k % 2 === 0;
const tip = long ? 1.1 : 3.4;
const waist = long ? 6.3 : 6.6;
const half = long ? 1.05 : 0.85;
return `<path d="M8 ${tip} L${8 + half} ${waist} L8 8 L${8 - half} ${waist} Z" transform="rotate(${k * 45} 8 8)"/>`;
}).join("");
return `<g fill="#d97757">${rays}</g>`;
}
const SVGS: Record<string, string> = {
claude: burst(),
// A hexafoil knot in the shape of OpenAI's mark. The petals stay thin and
// wide apart: three fatter loops merge into a blob at 13px.
codex: `<g fill="none" stroke="currentColor" stroke-width="1.05">
<ellipse cx="8" cy="8" rx="2.5" ry="6.7"/>
<ellipse cx="8" cy="8" rx="2.5" ry="6.7" transform="rotate(60 8 8)"/>
<ellipse cx="8" cy="8" rx="2.5" ry="6.7" transform="rotate(120 8 8)"/>
</g>`,
gemini: `<path fill="#4285f4" d="M8 0.8C8.6 4.6 11.4 7.4 15.2 8C11.4 8.6 8.6 11.4 8 15.2C7.4 11.4 4.6 8.6 0.8 8C4.6 7.4 7.4 4.6 8 0.8Z"/>`,
// An isometric cube, the shape Cursor's mark is built from.
cursor: `<g fill="currentColor">
<path d="M8 1 L14.5 4.75 L8 8.5 L1.5 4.75 Z" opacity="0.95"/>
<path d="M14.5 4.75 L14.5 12.25 L8 16 L8 8.5 Z" opacity="0.5"/>
<path d="M1.5 4.75 L8 8.5 L8 16 L1.5 12.25 Z" opacity="0.75"/>
</g>`,
shell: `<g fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M2.6 4 L6.4 8 L2.6 12"/>
<path d="M8.4 12.6 H13.4"/>
</g>`,
opencode: monogram("oc"),
aider: monogram("ai"),
pi: monogram("π"),
};
/** Tools whose own mark is a wordmark get their initials instead — inventing
* a logo for them would be worse than saying which letters they start with.
* No chip around the letters: a border this small eats the height the letters
* need to stay apart. */
function monogram(text: string): string {
return `<text x="8" y="8.6" fill="currentColor" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="11" font-weight="700" letter-spacing="-0.6" text-anchor="middle" dominant-baseline="central">${text}</text>`;
}
/** The `<svg>` for an icon id, or `null` when the id has no mark. */
export function toolIconSvg(tool: string): string | null {
const body = SVGS[tool];
if (!body) return null;
const label = TOOL_LABELS[tool] ?? tool;
return `<svg class="tool-icon" data-tool="${tool}" viewBox="0 0 16 16" width="13" height="13" role="img" aria-label="${label}"><title>${label}</title>${body}</svg>`;
}
+36
View File
@@ -164,6 +164,42 @@ export interface InTheWay {
uncommitted_files?: number;
untracked_files?: number;
unpushed_commits?: number;
/** Lines added across the tracked changes, staged and unstaged together. */
insertions?: number;
/** Lines removed across the same tracked changes. */
deletions?: number;
}
/** One fragment of the sidebar's compact blocked-work stat (docs/SIDEBAR.md).
* `kind` picks the colour; `text` is what the client prints. */
export interface BlockerStat {
kind: "add" | "del" | "files" | "untracked" | "unpushed";
text: string;
}
/** The blocked-work breakdown as diff notation: `+124 -18 ?3 ↑2`.
*
* The line counts cover tracked changes only, so a tree whose only tracked
* change carries no line delta — a mode change, a rename — falls back to the
* file count (`~2`) rather than claiming `+0 -0`. Both clients render exactly
* these fragments; the spelled-out {@link formatInTheWay} survives in the
* tooltip and in the kill refusal. */
export function blockerStats(w: InTheWay): BlockerStat[] {
const stats: BlockerStat[] = [];
const added = w.insertions ?? 0;
const removed = w.deletions ?? 0;
const changed = w.uncommitted_files ?? 0;
if (added > 0 || removed > 0) {
if (added > 0) stats.push({ kind: "add", text: `+${added}` });
if (removed > 0) stats.push({ kind: "del", text: `-${removed}` });
} else if (changed > 0) {
stats.push({ kind: "files", text: `~${changed}` });
}
if ((w.untracked_files ?? 0) > 0)
stats.push({ kind: "untracked", text: `?${w.untracked_files}` });
if ((w.unpushed_commits ?? 0) > 0)
stats.push({ kind: "unpushed", text: `\u2191${w.unpushed_commits}` });
return stats;
}
/** The phrasing `maestro_protocol::InTheWay`'s Display impl uses — shared by