Merge remote-tracking branch 'origin/main' into sostenuto-bassoon
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user