Compare commits

..
Author SHA1 Message Date
Miguel PalhasandClaude Opus 5 31158cdb1a fix(maestro): close mobile drawer on spawn
ci / web (push) Successful in 36s
ci / rust (push) Successful in 4m11s
ci / flake (push) Successful in 6m35s
Spawn and Spawn similar open a draft pane behind the open sidebar on
mobile, so the tap read as dead. Extract the drawer into its own module
and close it on both spawn events as well as a session tap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 22:31:05 +01:00
Miguel Palhas 7b04575ed5 Merge remote-tracking branch 'origin/main' into lento-sonata
ci / web (push) Successful in 33s
ci / rust (push) Successful in 3m48s
ci / flake (push) Successful in 6m3s
2026-08-31 22:23:30 +01:00
Miguel PalhasandClaude Opus 5 80af2e6816 feat(maestro): one pane, one tab on mobile
Below 860px the layout no longer grows a second navigation surface. The
pane pill strip is gone, the tab bar stays collapsed however many tabs
exist, and opening or spawning takes over the current pane instead of
splitting beside it or opening a tab. The sidebar drawer is the only way
to move between sessions.

The tree itself is untouched, so a desktop-built layout comes back when
the window widens again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 22:14:42 +01:00
Miguel Palhas 52abfc6eea Merge remote-tracking branch 'origin/main' into animato-trumpet
ci / web (push) Successful in 33s
ci / rust (push) Successful in 3m45s
ci / flake (push) Successful in 5m55s
2026-08-31 22:12:32 +01:00
Miguel Palhas eb9b4b4ae8 fix(maestro): replay pane mouse modes on attach
A full-screen app enables mouse reporting once, at startup. A client
attaching later never saw those sequences, so the web terminal read the
pane as a plain alternate screen with mouseTrackingMode "none" and its
touch and wheel gestures had nowhere to go — scrolling did nothing in a
browser opened after the session started.

Read the pane's mouse flags in the same display-message call and emit
the matching DECSETs ahead of the screen content. tmux expands an
unknown format name to the empty string, so the parser only trusts the
mouse tail when every field is present.

Closes #128
2026-08-31 22:07:22 +01:00
Miguel PalhasandClaude Opus 5 1f543d16d1 fix(web): bump pnpmDeps hash for JetBrains Mono
ci / web (push) Successful in 39s
ci / rust (push) Successful in 4m6s
ci / flake (push) Successful in 6m13s
1257ec6 added @fontsource/jetbrains-mono to web/pnpm-lock.yaml without
updating the hash, so pnpm install failed offline in configurePhase.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 22:03:16 +01:00
Miguel Palhas f6cfcbb2c4 Merge remote-tracking branch 'origin/main' into maestoso-lyre
ci / web (push) Successful in 32s
ci / flake (push) Failing after 2m37s
ci / rust (push) Successful in 3m17s
2026-08-31 17:53:45 +01:00
Miguel Palhas 5920bbf2ec fix(maestro): fold the sole root sidebar folder into its own children
~ never distinguishes anything from a sibling — nothing else sits
beside it — so it showed as its own header even when it immediately
branched into multiple real projects. Now it folds into each child
instead, at the top only; a real divergence further down still gets
its own header.
2026-08-31 17:53:38 +01:00
Miguel Palhas eb5d28d3a6 Merge branch 'dolce-sonata'
ci / web (push) Successful in 1m7s
ci / flake (push) Failing after 2m45s
ci / rust (push) Successful in 3m48s
2026-08-31 15:32:45 +01:00
Miguel PalhasandClaude Sonnet 5 511ee3be4e fix(maestro): show tool icon in attach header instead of raw command
Session header used to print the raw command line ("claude"); mirror
the sidebar's icon-for-program treatment so both agree.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 15:31:00 +01:00
Miguel Palhas a92202302a Merge remote-tracking branch 'origin/main' into largo-lute
ci / web (push) Successful in 53s
ci / flake (push) Failing after 3m19s
ci / rust (push) Successful in 4m1s
2026-08-31 15:30:51 +01:00
Miguel Palhas 44c67fcee7 feat(maestro): move header actions to the sidebar footer
Help, theme toggle, and Spawn now live pinned to the bottom of the
sidebar instead of the top-right app header, keeping them next to the
session list they act on.
2026-08-31 15:30:19 +01:00
Miguel Palhas d8d26d181a Merge remote-tracking branch 'origin/feroce-violin'
ci / web (push) Successful in 1m50s
ci / flake (push) Failing after 2m7s
ci / rust (push) Successful in 4m1s
2026-08-31 15:25:50 +01:00
Miguel PalhasandClaude Sonnet 5 aec7f4cae6 fix(maestro): swap focus rings for background tints on web
ci / web (push) Successful in 41s
ci / flake (push) Failing after 3m8s
ci / rust (push) Failing after 3m10s
Focused sidebar rows and multiview panes drew a green border/ring.
Swap to a background tint on the row and the pane titlebar instead,
so focus stays borderless.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 15:24:56 +01:00
Miguel Palhas 7f3d450f63 Merge remote-tracking branch 'origin/main' into feroce-violin 2026-08-31 15:24:32 +01:00
Miguel PalhasandClaude Sonnet 5 0d088c2581 fix(maestro): remove horizontal margin on sidebar session rows
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 15:24:29 +01:00
Miguel Palhas 1257ec6eba feat(maestro): self-host JetBrains Mono for web terminal
ci / web (push) Successful in 34s
ci / flake (push) Failing after 1m37s
ci / rust (push) Successful in 3m30s
Sets it as the primary --font-mono/xterm font, system stack kept as
fallback.
2026-08-31 15:19:51 +01:00
Miguel Palhas 2dac56b43d fix(maestro): drop the removed colors field from the nav test
ci / web (push) Successful in 1m22s
ci / rust (push) Successful in 4m58s
ci / flake (push) Successful in 7m32s
2026-08-31 15:06:04 +01:00
Miguel Palhas 39e8239a7d Merge remote-tracking branch 'origin/main' into sostenuto-bassoon 2026-08-31 15:04:28 +01:00
Miguel Palhas 1afe079ca0 feat(maestro): keyboard navigation for the web sidebar
Ctrl+Shift+H moves the keyboard between the session list and the pane;
j/k walk the cards, Enter opens, Escape hands the pane back. The walk
skips expanded folder headers and stops on collapsed ones, matching the
TUI.
2026-08-31 15:04:02 +01:00
naps62 5c391f12a6 Merge pull request 'feat(maestro): sidebar cards show diff stats and tool icons' (#127) from tranquillo-nocturne into main
ci / web (push) Successful in 57s
ci / rust (push) Successful in 4m45s
ci / flake (push) Successful in 9m38s
Reviewed-on: #127
2026-08-31 15:02:57 +01:00
Miguel Palhas 8cf9bd0586 Merge remote-tracking branch 'origin/main' into tranquillo-nocturne
ci / web (pull_request) Successful in 46s
ci / rust (pull_request) Successful in 4m16s
ci / flake (pull_request) Successful in 8m45s
2026-08-31 14:58:51 +01:00
Miguel Palhas 0421a153c2 fix(maestro): sidebar j/k skips expanded group headers
Group rows are not something to focus; the cards under them are one row
away. A collapsed group still stops the walk — its header is the only row
it has, so skipping it would leave the group unreachable.
2026-08-31 14:58:07 +01:00
Miguel Palhas 1589d58ca0 feat(maestro): sidebar cards show diff stats and tool icons
Line 2 of a session card spelled out its blocked work ("1 uncommitted
file") and its command ("claude"). Both now render as marks: the
breakdown as diff notation, and the command's leading program as its
tool's icon in the web client.

The probe carries the line delta of the tracked changes — staged and
unstaged together, against HEAD — so `git add` never makes the counter
drop. Untracked files stay a file count; counting their lines means
reading every one off disk. A tracked change with no line delta falls
back to `~N` rather than claiming `+0 -0`.

The TUI keeps its command as text: a terminal cannot be assumed to have
a font carrying tool glyphs.
2026-08-31 14:54:54 +01:00
Miguel Palhas a503ec7c2b refactor(maestro): drop per-session colour and pane glow
ci / web (push) Successful in 49s
ci / rust (push) Successful in 4m47s
ci / flake (push) Successful in 7m31s
The sidebar row dot, the pane top strip and the mobile pill dot all
painted a hashed per-session colour; the focused pane added an outer
accent glow on top of its ring. Removes the colour concept entirely
along with the glow, leaving the flat accent ring as the only focus mark.
2026-08-31 14:51:24 +01:00
Miguel Palhas 904a665432 Merge branch 'crescendo-rondo'
ci / web (push) Successful in 53s
ci / rust (push) Successful in 4m36s
ci / flake (push) Successful in 8m47s
2026-08-31 14:43:44 +01:00
Miguel Palhas 8e1f26ac8d fix(maestro): spawn similar preselects the cloned project
Spawn similar dropped the session's cwd into the free-text directory hatch,
so the project picker showed nothing selected. For a worktree session that
cwd was the daemon-made checkout, and the worktree toggle defaulted on, so
the clone asked for a worktree of a worktree.

It now seeds the picker: the cloned project's box selected, the same
worktree decision, base repo plus '+ new' when the source had a worktree.
A seeded path that matches no project falls back to the raw input showing
that path instead of an empty picker.
2026-08-31 14:39:29 +01:00
Miguel Palhas f0851a62ed feat(maestro): keyboard shortcut overlay on web
ci / web (push) Successful in 37s
ci / rust (push) Successful in 4m13s
ci / flake (push) Successful in 8m48s
`?` toggles a dialog listing every web binding, mirroring the TUI's help
overlay. A `?` button in the header covers pointer-only clients, and the
welcome screen points at the key.

One registry (SHORTCUTS) drives the rendering, so a binding added without
an entry reads as missing. main.ts and the overlay now share one
isTypingTarget guard instead of two copies.
2026-08-31 14:37:53 +01:00
35 changed files with 2180 additions and 326 deletions
+135 -8
View File
@@ -273,7 +273,7 @@ impl Tmux {
"-t",
&name,
"-F",
"#{cursor_x} #{cursor_y} #{pane_height} #{alternate_on} #{cursor_flag}",
SCREEN_GEOMETRY_FORMAT,
";",
"capture-pane",
"-p",
@@ -515,6 +515,52 @@ fn new_session_args(
/// frame huge.
const CAPTURE_HISTORY_LINES: usize = 300;
/// `display-message -F` request behind [`parse_screen_geometry`]. tmux expands
/// a name it does not know to the empty string rather than failing, so a field
/// missing on an older tmux shifts everything after it — hence the parser keys
/// the optional tail off the field count.
const SCREEN_GEOMETRY_FORMAT: &str = concat!(
"#{cursor_x} #{cursor_y} #{pane_height} #{alternate_on} #{cursor_flag} ",
"#{mouse_standard_flag} #{mouse_button_flag} #{mouse_all_flag} ",
"#{mouse_utf8_flag} #{mouse_sgr_flag}",
);
/// Field count of [`SCREEN_GEOMETRY_FORMAT`]; fewer means an older tmux did not
/// know one of the names, and the mouse tail cannot be trusted by position.
const SCREEN_GEOMETRY_FIELDS: usize = 10;
/// Mouse-reporting modes a pane has turned on. A full-screen program sets these
/// once, at startup, so a client attaching later never sees the sequences —
/// [`render_screen_snapshot`] replays them.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
struct MouseModes {
/// `?1000` — report button press and release.
standard: bool,
/// `?1002` — also report motion while a button is held.
button: bool,
/// `?1003` — report all motion.
all: bool,
/// `?1005` — UTF-8 coordinates (superseded by SGR, still replayed if on).
utf8: bool,
/// `?1006` — SGR-encoded coordinates.
sgr: bool,
}
impl MouseModes {
/// DECSET parameters to re-enable these modes, private-mode order.
fn decset_params(self) -> impl Iterator<Item = u16> {
[
(self.standard, 1000),
(self.button, 1002),
(self.all, 1003),
(self.utf8, 1005),
(self.sgr, 1006),
]
.into_iter()
.filter_map(|(on, param)| on.then_some(param))
}
}
/// Cursor position and screen geometry of a pane, from `display-message`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ScreenGeometry {
@@ -523,17 +569,31 @@ struct ScreenGeometry {
pane_height: usize,
alternate: bool,
cursor_visible: bool,
mouse: MouseModes,
}
fn parse_screen_geometry(s: &str) -> Option<ScreenGeometry> {
let line = s.lines().next()?;
let mut it = line.split_whitespace();
let cursor_x = it.next()?.parse().ok()?;
let cursor_y = it.next()?.parse().ok()?;
let pane_height: usize = it.next()?.parse().ok()?;
let alternate = it.next()? == "1";
let fields: Vec<&str> = line.split_whitespace().collect();
let cursor_x = fields.first()?.parse().ok()?;
let cursor_y = fields.get(1)?.parse().ok()?;
let pane_height: usize = fields.get(2)?.parse().ok()?;
let alternate = *fields.get(3)? == "1";
// Older tmux without `cursor_flag` just means "assume visible".
let cursor_visible = it.next().map(|f| f == "1").unwrap_or(true);
let cursor_visible = fields.get(4).map(|f| *f == "1").unwrap_or(true);
// Only trust the mouse tail when every field expanded; a short line means
// some name is unknown here and the remaining ones sit at other offsets.
let mouse = if fields.len() >= SCREEN_GEOMETRY_FIELDS {
MouseModes {
standard: fields[5] == "1",
button: fields[6] == "1",
all: fields[7] == "1",
utf8: fields[8] == "1",
sgr: fields[9] == "1",
}
} else {
MouseModes::default()
};
if pane_height == 0 {
return None;
}
@@ -543,6 +603,7 @@ fn parse_screen_geometry(s: &str) -> Option<ScreenGeometry> {
pane_height,
alternate,
cursor_visible,
mouse,
})
}
@@ -572,6 +633,12 @@ fn render_screen_snapshot(lines: &[&str], geom: ScreenGeometry) -> Vec<u8> {
if geom.alternate {
out.extend_from_slice(b"\x1b[?1049h");
}
// Mouse reporting is set up once, before this client existed. Without it
// the client cannot tell a mouse-driven app from a plain alternate screen,
// and its wheel and touch gestures have nowhere to go.
for param in geom.mouse.decset_params() {
out.extend_from_slice(format!("\x1b[?{param}h").as_bytes());
}
out.extend_from_slice(b"\x1b[H\x1b[2J\x1b[m");
for (i, line) in lines[..=last].iter().enumerate() {
if i > 0 {
@@ -645,7 +712,7 @@ mod tests {
#[test]
fn parse_screen_geometry_reads_cursor_and_alternate_flag() {
let g = parse_screen_geometry("12 3 40 1 0\n").unwrap();
let g = parse_screen_geometry("12 3 40 1 0 0 1 0 0 1\n").unwrap();
assert_eq!(
g,
ScreenGeometry {
@@ -654,6 +721,11 @@ mod tests {
pane_height: 40,
alternate: true,
cursor_visible: false,
mouse: MouseModes {
button: true,
sgr: true,
..MouseModes::default()
},
}
);
let g = parse_screen_geometry("0 0 24 0").unwrap();
@@ -663,6 +735,31 @@ mod tests {
assert!(parse_screen_geometry("garbage").is_none());
}
/// A tmux that does not know one of the mouse names expands it to nothing,
/// so the tail is one field short and every flag in it has slid. Reading it
/// by position would claim modes the pane never set.
#[test]
fn parse_screen_geometry_drops_a_short_mouse_tail() {
let g = parse_screen_geometry("0 0 24 1 1 0 0 1 1").unwrap();
assert_eq!(g.mouse, MouseModes::default());
}
#[test]
fn mouse_modes_map_to_private_mode_parameters() {
let all = MouseModes {
standard: true,
button: true,
all: true,
utf8: true,
sgr: true,
};
assert_eq!(
all.decset_params().collect::<Vec<_>>(),
vec![1000, 1002, 1003, 1005, 1006]
);
assert_eq!(MouseModes::default().decset_params().count(), 0);
}
#[test]
fn snapshot_clears_prints_lines_and_parks_the_cursor() {
// Two scrollback lines, then a 3-row screen with the cursor on row 1.
@@ -675,6 +772,7 @@ mod tests {
pane_height: 3,
alternate: false,
cursor_visible: true,
mouse: MouseModes::default(),
},
);
let out = String::from_utf8(out).unwrap();
@@ -695,6 +793,7 @@ mod tests {
pane_height: 3,
alternate: true,
cursor_visible: false,
mouse: MouseModes::default(),
},
);
let out = String::from_utf8(out).unwrap();
@@ -716,12 +815,40 @@ mod tests {
pane_height: 3,
alternate: false,
cursor_visible: true,
mouse: MouseModes::default(),
},
))
.unwrap();
assert_eq!(out, "\x1b[H\x1b[2J\x1b[mtext\x1b[m\r\x1b[4C");
}
/// A client attaching to a running full-screen app never saw it turn mouse
/// reporting on, so the snapshot has to. Without this the client reads the
/// pane as a plain alternate screen and its scroll gestures go nowhere.
#[test]
fn snapshot_replays_the_panes_mouse_modes() {
let out = String::from_utf8(render_screen_snapshot(
&["app"],
ScreenGeometry {
cursor_x: 0,
cursor_y: 0,
pane_height: 1,
alternate: true,
cursor_visible: true,
mouse: MouseModes {
button: true,
sgr: true,
..MouseModes::default()
},
},
))
.unwrap();
assert!(
out.starts_with("\x1b[?1049h\x1b[?1002h\x1b[?1006h\x1b[H\x1b[2J"),
"{out:?}"
);
}
/// Every phrasing must be recognised: an unmatched one makes
/// `list_sessions` fail, and a failed pass reaps nothing.
#[test]
@@ -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();
@@ -246,6 +246,59 @@ async fn attach_replay_is_the_current_screen_not_the_byte_tail() {
fx.manager.kill(s.id, false).await.unwrap();
}
/// A full-screen app turns mouse reporting on once, at startup. A client that
/// attaches later never sees those sequences, so it reads the pane as a plain
/// alternate screen and its wheel and touch gestures have nowhere to go. The
/// snapshot must carry the pane's mouse modes with it.
#[tokio::test]
async fn attach_replay_carries_the_panes_mouse_modes() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let s = fx
.manager
.create(CreateSessionRequest {
cwd: cwd.path().to_path_buf(),
// Alternate screen, then button-event tracking (?1002) with SGR
// coordinates (?1006) — what tmux and agent TUIs actually request.
command: "sh -c 'printf \"\\\\033[?1049h\\\\033[?1002h\\\\033[?1006h\\\\033[2J\\\\033[HMOUSE_MARKER\"; exec sleep 30'"
.into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("create");
let want = b"MOUSE_MARKER";
let deadline = std::time::Instant::now() + Duration::from_secs(5);
let mut replay = Vec::new();
while std::time::Instant::now() < deadline {
let (bytes, _rx) = fx
.manager
.subscribe_pty_with_replay(s.id)
.await
.expect("subscribe");
replay = bytes;
if replay.windows(want.len()).any(|w| w == want) {
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
let text = String::from_utf8_lossy(&replay).into_owned();
assert!(
text.contains("MOUSE_MARKER"),
"replay never reached the live screen: {text:?}"
);
assert!(
replay.starts_with(b"\x1b[?1049h\x1b[?1002h\x1b[?1006h\x1b[H\x1b[2J"),
"replay dropped the pane's mouse modes: {text:?}"
);
fx.manager.kill(s.id, false).await.unwrap();
}
/// Regression for NAP-228: after `recover`, PTY output must reach new
/// subscribers. Before the fix tmux's stale `pipe-pane` (with `-o`) silently
/// kept feeding the old FIFO and the revived manager's subscribers saw
+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>,
+72 -21
View File
@@ -353,22 +353,8 @@ impl App {
Action::ScrollPanePageDown => {
self.scroll_active_pane(-(self.pane_page_rows() as isize))
}
Action::SidebarNext => {
let order = visible_sidebar_order(
&self.catalog.snapshot(),
&self.state.collapsed_sidebar_groups,
);
self.state.move_sidebar_highlight(&order, 1);
self.state.focus = Focus::Sidebar;
}
Action::SidebarPrev => {
let order = visible_sidebar_order(
&self.catalog.snapshot(),
&self.state.collapsed_sidebar_groups,
);
self.state.move_sidebar_highlight(&order, -1);
self.state.focus = Focus::Sidebar;
}
Action::SidebarNext => self.step_sidebar_highlight(1),
Action::SidebarPrev => self.step_sidebar_highlight(-1),
Action::ToggleSidebarGroup => self.toggle_highlighted_sidebar_group(),
Action::ToggleSidebar => self.state.toggle_sidebar(),
Action::ShowHelp => {
@@ -459,6 +445,21 @@ impl App {
snap.sessions.first().map(|s| s.id)
}
/// `j`/`k`: step the sidebar highlight one row. Group headers are skipped
/// — a directory is not something to focus, and the cards under it are one
/// row away anyway. A *collapsed* group is the exception: its header is the
/// only row it has, so skipping it would make the group unreachable.
fn step_sidebar_highlight(&mut self, delta: i32) {
let collapsed = self.state.collapsed_sidebar_groups.clone();
let order = visible_sidebar_order(&self.catalog.snapshot(), &collapsed);
self.state
.move_sidebar_highlight(&order, delta, |item| match item {
SidebarItem::Group(key) => collapsed.contains(key),
SidebarItem::Session(_) => true,
});
self.state.focus = Focus::Sidebar;
}
fn toggle_highlighted_sidebar_group(&mut self) {
let key = match self.state.sidebar_highlight.clone() {
Some(SidebarItem::Group(key)) => Some(key),
@@ -1338,11 +1339,9 @@ mod tests {
app.catalog.apply_event(CatalogEvent::Snapshot {
sessions: vec![test_session(sid, "/repo")],
});
app.dispatch(Action::SidebarNext);
assert_eq!(
app.state.sidebar_highlight,
Some(SidebarItem::Group(PathBuf::from("/repo")))
);
// `j` never lands on an expanded group, so reach it the way a click
// would.
app.state.sidebar_highlight = Some(SidebarItem::Group(PathBuf::from("/repo")));
assert!(app.dispatch(Action::AttachHighlighted).is_empty());
assert!(app
.state
@@ -1357,6 +1356,58 @@ mod tests {
assert!(app.state.overlay().is_none());
}
#[test]
fn sidebar_navigation_skips_expanded_group_headers() {
use maestro_protocol::CatalogEvent;
let a = Uuid::from_u128(20);
let b = Uuid::from_u128(21);
let mut app = make_app();
app.catalog.apply_event(CatalogEvent::Snapshot {
sessions: vec![test_session(a, "/one"), test_session(b, "/two")],
});
// Two groups, one card each: `j` visits the cards and nothing else.
app.dispatch(Action::SidebarNext);
assert_eq!(app.state.sidebar_highlight, Some(SidebarItem::Session(a)));
app.dispatch(Action::SidebarNext);
assert_eq!(app.state.sidebar_highlight, Some(SidebarItem::Session(b)));
app.dispatch(Action::SidebarNext);
assert_eq!(app.state.sidebar_highlight, Some(SidebarItem::Session(a)));
app.dispatch(Action::SidebarPrev);
assert_eq!(app.state.sidebar_highlight, Some(SidebarItem::Session(b)));
}
/// A collapsed group hides its cards, so its header is the only row left
/// to land on — skipping it would strand the group.
#[test]
fn sidebar_navigation_stops_on_a_collapsed_group() {
use maestro_protocol::CatalogEvent;
let a = Uuid::from_u128(22);
let b = Uuid::from_u128(23);
let mut app = make_app();
app.catalog.apply_event(CatalogEvent::Snapshot {
sessions: vec![test_session(a, "/one"), test_session(b, "/two")],
});
app.dispatch(Action::SidebarNext);
assert_eq!(app.state.sidebar_highlight, Some(SidebarItem::Session(a)));
// `c` on a card collapses the group that holds it.
app.dispatch(Action::ToggleSidebarGroup);
assert_eq!(
app.state.sidebar_highlight,
Some(SidebarItem::Group(PathBuf::from("/one")))
);
app.dispatch(Action::SidebarNext);
assert_eq!(app.state.sidebar_highlight, Some(SidebarItem::Session(b)));
app.dispatch(Action::SidebarNext);
assert_eq!(
app.state.sidebar_highlight,
Some(SidebarItem::Group(PathBuf::from("/one")))
);
}
#[test]
fn collapsing_the_group_containing_a_card_highlight_keeps_a_visible_group_highlight() {
let sid = Uuid::from_u128(15);
+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: {:?}",
+93 -14
View File
@@ -165,21 +165,42 @@ impl AppState {
self.active_tab().session()
}
/// Move the sidebar highlight by `delta` (positive = down) within the
/// rendered item sequence. Wraps. No-op on empty.
pub fn move_sidebar_highlight(&mut self, order: &[SidebarItem], delta: i32) {
if order.is_empty() {
/// Move the sidebar highlight one step through the rendered item
/// sequence, in the direction of `delta` (positive = down), landing only
/// on items `is_stop` accepts. Only the sign of `delta` matters. Wraps.
/// No-op when `order` holds no stop.
///
/// Anchoring walks the *full* rendered order, not just the stops, so a
/// highlight parked on a skipped row — an expanded group header, which a
/// mouse click can still select — steps to its neighbour instead of
/// jumping to the far end of the list.
pub fn move_sidebar_highlight(
&mut self,
order: &[SidebarItem],
delta: i32,
is_stop: impl Fn(&SidebarItem) -> bool,
) {
if order.is_empty() || delta == 0 {
return;
}
let len = order.len() as i32;
let step = delta.signum();
let cur = self
.sidebar_highlight
.as_ref()
.and_then(|id| order.iter().position(|x| x == id))
.map(|i| i as i32)
.unwrap_or(-1);
let next = (cur + delta).rem_euclid(len) as usize;
self.sidebar_highlight = Some(order[next].clone());
// Nothing highlighted: start one off the end we walk from, so the
// first step lands on the first (or last) item.
.unwrap_or(if step > 0 { -1 } else { 0 });
let mut idx = cur;
for _ in 0..len {
idx = (idx + step).rem_euclid(len);
if is_stop(&order[idx as usize]) {
self.sidebar_highlight = Some(order[idx as usize].clone());
return;
}
}
}
/// Toggle a group. When collapsing a group containing the current card,
@@ -232,28 +253,86 @@ mod tests {
];
// No prior highlight + delta +1 lands on first item.
state.move_sidebar_highlight(&order, 1);
state.move_sidebar_highlight(&order, 1, |_| true);
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(a)));
state.move_sidebar_highlight(&order, 1);
state.move_sidebar_highlight(&order, 1, |_| true);
assert_eq!(
state.sidebar_highlight,
Some(SidebarItem::Group("repo".into()))
);
state.move_sidebar_highlight(&order, 1);
state.move_sidebar_highlight(&order, 1, |_| true);
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(b)));
// Wraps forward.
state.move_sidebar_highlight(&order, 1);
state.move_sidebar_highlight(&order, 1, |_| true);
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(c)));
// Wraps forward.
state.move_sidebar_highlight(&order, 1, |_| true);
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(a)));
// Wraps backward.
state.move_sidebar_highlight(&order, -1);
state.move_sidebar_highlight(&order, -1, |_| true);
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(c)));
}
#[test]
fn move_sidebar_highlight_skips_rows_the_filter_rejects() {
let mut state = AppState::new();
let a = Uuid::from_u128(1);
let b = Uuid::from_u128(2);
let order = vec![
SidebarItem::Group("repo".into()),
SidebarItem::Session(a),
SidebarItem::Group("other".into()),
SidebarItem::Session(b),
];
let sessions_only = |item: &SidebarItem| matches!(item, SidebarItem::Session(_));
state.move_sidebar_highlight(&order, 1, sessions_only);
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(a)));
state.move_sidebar_highlight(&order, 1, sessions_only);
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(b)));
state.move_sidebar_highlight(&order, -1, sessions_only);
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(a)));
// Backwards from nothing lands on the last stop, not the last row.
state.sidebar_highlight = None;
state.move_sidebar_highlight(&order, -1, sessions_only);
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(b)));
}
/// A highlight sitting on a skipped row still moves to its neighbour.
#[test]
fn move_sidebar_highlight_anchors_on_a_skipped_row() {
let mut state = AppState::new();
let a = Uuid::from_u128(1);
let b = Uuid::from_u128(2);
let order = vec![
SidebarItem::Session(a),
SidebarItem::Group("repo".into()),
SidebarItem::Session(b),
];
let sessions_only = |item: &SidebarItem| matches!(item, SidebarItem::Session(_));
state.sidebar_highlight = Some(SidebarItem::Group("repo".into()));
state.move_sidebar_highlight(&order, 1, sessions_only);
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(b)));
state.sidebar_highlight = Some(SidebarItem::Group("repo".into()));
state.move_sidebar_highlight(&order, -1, sessions_only);
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(a)));
}
#[test]
fn move_sidebar_highlight_is_noop_when_nothing_is_a_stop() {
let mut state = AppState::new();
let order = vec![SidebarItem::Group("repo".into())];
state.move_sidebar_highlight(&order, 1, |_| false);
assert_eq!(state.sidebar_highlight, None);
}
#[test]
fn move_sidebar_highlight_is_noop_on_empty_order() {
let mut state = AppState::new();
state.move_sidebar_highlight(&[], 1);
state.move_sidebar_highlight(&[], 1, |_| true);
assert_eq!(state.sidebar_highlight, None);
}
+58 -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
@@ -146,3 +181,22 @@ dropping the whole session would be worse than dimming one card. A report
arriving at `POST /api/sessions/:id/activity` is parsed strictly and an unknown
state is a 4xx: an agent posting a state the daemon does not know is a bug in
the agent, and answering 204 would hide it.
## Keyboard walk
`j`/`k` (and the arrow keys) move a highlight through the sidebar. Both clients
follow one rule for what the walk stops on:
- **Session cards, always.**
- **Folder headers, only while collapsed.** An expanded folder's cards are one
row away, so its header is nothing to land on; a collapsed folder's header is
the only row it has, and skipping it would leave the folder unreachable.
Enter opens the highlighted session, or expands the highlighted folder and
lands on the first row it was hiding. A highlight parked on a skipped row —
reachable by clicking a folder header — still steps to its neighbour rather
than jumping to the end of the list.
The web client needs a chord to reach the list at all (`Ctrl+Shift+H`, and the
same chord back): an attached terminal owns every plain keystroke. The TUI uses
`ctrl-w h`.
+1 -1
View File
@@ -125,7 +125,7 @@
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
inherit (finalAttrs) pname version src;
fetcherVersion = 3;
hash = "sha256-D9JdgI92vzAY8en2zGEEPd4hwOLVi3sr1joT4b17Be4=";
hash = "sha256-AvIrpXipbcp7hRujh/L4UAc6tAJOxe67P/iji6L/oL0=";
};
buildPhase = ''
+1
View File
@@ -13,6 +13,7 @@
"test:watch": "vitest"
},
"dependencies": {
"@fontsource/jetbrains-mono": "^5.3.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0"
},
+8
View File
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@fontsource/jetbrains-mono':
specifier: ^5.3.0
version: 5.3.0
'@xterm/addon-fit':
specifier: ^0.10.0
version: 0.10.0(@xterm/xterm@5.5.0)
@@ -168,6 +171,9 @@ packages:
cpu: [x64]
os: [win32]
'@fontsource/jetbrains-mono@5.3.0':
resolution: {integrity: sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
@@ -631,6 +637,8 @@ snapshots:
'@esbuild/win32-x64@0.21.5':
optional: true
'@fontsource/jetbrains-mono@5.3.0': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@rollup/rollup-android-arm-eabi@4.60.4':
+17 -4
View File
@@ -6,6 +6,7 @@ import { attachWsUrl } from "./routes.js";
import { escapeHtml } from "./html.js";
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
import { applyModsToChar, cursorSeq } from "./keys.js";
import { splitCommand, toolIconSvg } from "./toolIcon.js";
import type { ClientControl, Uuid } from "./types.js";
/**
@@ -27,6 +28,8 @@ export interface AttachConnection {
onMount(): void;
/** Parked into the cache: keep streaming, just stop driving focus/resize. */
onUnmount(): void;
/** Put the keyboard back into this terminal — what leaving the sidebar does. */
focusTerminal(): void;
/** Fully disconnect: close the WS, dispose the terminal, remove the element. */
dispose(): void;
}
@@ -52,6 +55,15 @@ export interface AttachInfo {
command: string;
}
/** Header echoes the sidebar: the command's leading program becomes its tool
* icon when we have a mark for it, remaining words stay as text. */
function commandFragment(command: string): string {
const { tool, rest } = splitCommand(command);
const icon = tool ? toolIconSvg(tool) : null;
const text = icon ? rest : command;
return `<span class="at-cmd">${icon ?? ""}${text ? escapeHtml(text) : ""}</span>`;
}
export function createAttachConnection(
sessionId: Uuid,
opts: AttachOptions = {},
@@ -63,9 +75,7 @@ export function createAttachConnection(
// tooltip for when you really need it.
const titleHtml = info
? `<span class="at-name">${escapeHtml(info.name)}</span>` +
(info.command && info.command !== info.name
? `<span class="at-cmd">${escapeHtml(info.command)}</span>`
: "")
(info.command && info.command !== info.name ? commandFragment(info.command) : "")
: `session <span class="sid">${escapeHtml(sessionId.slice(0, 8))}</span>`;
const titleAttr = info ? `${info.name} · ${info.command} (${sessionId})` : sessionId;
el.innerHTML = `
@@ -97,7 +107,7 @@ export function createAttachConnection(
const term = new Terminal({
cursorBlink: true,
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
'"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
fontSize: 13,
convertEol: false,
theme: {
@@ -382,6 +392,9 @@ export function createAttachConnection(
onUnmount() {
mounted = false;
},
focusTerminal() {
term.focus();
},
dispose() {
if (disposed) return;
disposed = true;
+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();
});
+164 -27
View File
@@ -1,10 +1,12 @@
import { escapeHtml } from "./html.js";
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,
@@ -12,6 +14,7 @@ import {
sessionBlockers,
sessionName,
} from "./types.js";
import { splitCommand, toolIconSvg } from "./toolIcon.js";
import {
activityColorSlug,
activityDurationLabel,
@@ -29,6 +32,22 @@ export function requestOpenSpawn(): void {
document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT));
}
/** Ask the shell to hand the keyboard back to the focused pane's terminal —
* what Escape in the sidebar list does. Handled in main.ts, which owns the
* connection pool. */
export const FOCUS_PANE_EVENT = "catalog-focus-pane";
/** Ask the shell to open a draft pane seeded from an existing session
* ("Spawn similar"). Detail is the session being cloned. */
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
/** The rows a keyboard walk stops on, in visual (document) order. Expanded
* folder headers are skipped: a directory is not something to focus, and the
* cards under it are one row away anyway. A *collapsed* folder still counts —
* its header is the only row it has, so skipping it would leave the folder
* unreachable. Same rule as the TUI sidebar. */
const NAV_SELECTOR = '.tree-leaf, .tree-folder-header[aria-expanded="false"]';
const collapsed = new Set<string>();
/** Session whose card is currently being renamed inline (#125). The tree
@@ -60,15 +79,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");
@@ -81,6 +118,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
header.style.paddingLeft = `${depth * 16}px`;
const isCollapsed = collapsed.has(node.path);
header.setAttribute("aria-expanded", String(!isCollapsed));
header.dataset.navKey = `folder:${node.path}`;
// Single down-chevron; CSS rotates it -90° (→ right) when collapsed, so the
// glyph and the rotation never fight each other.
// A deep path (`~/git.naps.pt/naps62/maestro`) collapses its leading
@@ -110,6 +148,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
row.href = `#/attach/${encodeURIComponent(s.id)}`;
row.style.paddingLeft = `${depth * 16 + 8}px`;
row.dataset.sessionId = s.id;
row.dataset.navKey = `session:${s.id}`;
const running = s.status === "Running";
row.dataset.status = running ? "running" : "dead";
const name = sessionName(s);
@@ -134,7 +173,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
similarBtn.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
document.dispatchEvent(new CustomEvent("catalog-spawn-similar", { detail: s }));
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s }));
});
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
killBtn.addEventListener("click", (ev) => {
@@ -150,7 +189,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
{
label: "Spawn similar",
onSelect: () =>
document.dispatchEvent(new CustomEvent("catalog-spawn-similar", { detail: s })),
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s })),
},
{ label: "Kill session", danger: true, onSelect: () => void confirmAndKill(s, name) },
]);
@@ -265,8 +304,8 @@ function renderEmpty(filtering: boolean, onSpawn: () => void): HTMLElement {
/**
* The global app header (full width, above the sidebar/content split): brand +
* the app-level actions (spawn, theme toggle). Kept out of the sidebar so these
* stay put and reachable regardless of which pane is showing.
* session tally. App-level actions (help, theme, spawn) live pinned to the
* sidebar's foot instead — see {@link renderSidebar}.
*/
export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void): HTMLElement {
const header = document.createElement("header");
@@ -305,25 +344,14 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void):
kit.title = "Open the design kit (DEV)";
actions.appendChild(kit);
}
actions.appendChild(createThemeToggle());
const spawnBtn = document.createElement("button");
spawnBtn.type = "button";
spawnBtn.className = "btn btn-primary spawn-btn";
spawnBtn.textContent = "+ Spawn";
// The keyboard pair sits right here in the tooltip (#67).
spawnBtn.title = "Draft pane: n — fast-forward spawn: N";
spawnBtn.addEventListener("click", () => requestOpenSpawn());
actions.appendChild(spawnBtn);
return header;
}
/** 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). */
@@ -333,8 +361,12 @@ 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;
/** Whether the session list currently holds DOM focus. */
hasListFocus(): boolean;
/** Tear down the store subscription + listeners. */
dispose(): void;
}
@@ -380,6 +412,30 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
treeHost.className = "tree";
el.appendChild(treeHost);
// App-level actions (help, theme, spawn) live pinned to the sidebar's foot
// rather than the sidebar's own scrolling content, so they stay reachable
// regardless of tree scroll position.
const footer = document.createElement("div");
footer.className = "sidebar-footer";
const helpBtn = document.createElement("button");
helpBtn.type = "button";
helpBtn.className = "btn btn-ghost btn-icon help-btn";
helpBtn.textContent = "?";
helpBtn.title = "Keyboard shortcuts (?)";
helpBtn.setAttribute("aria-label", "Keyboard shortcuts");
helpBtn.addEventListener("click", () => toggleHelp());
footer.appendChild(helpBtn);
footer.appendChild(createThemeToggle());
const spawnBtn = document.createElement("button");
spawnBtn.type = "button";
spawnBtn.className = "btn btn-primary spawn-btn";
spawnBtn.textContent = "+ Spawn";
// The keyboard pair sits right here in the tooltip (#67).
spawnBtn.title = "Draft pane: n — fast-forward spawn: N";
spawnBtn.addEventListener("click", () => requestOpenSpawn());
footer.appendChild(spawnBtn);
el.appendChild(footer);
// Drag a session row into the canvas (issue #32). Delegated so it survives
// tree re-renders; the controller handles drag-vs-tap (tap still navigates).
treeHost.addEventListener("pointerdown", (e) => {
@@ -442,7 +498,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;
@@ -451,12 +507,80 @@ 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");
}
};
// ---- keyboard navigation ----
// The walk reads the rendered DOM rather than a parallel model, so it can
// never disagree with what is on screen. Rows carry a `data-nav-key`
// (`session:<id>` / `folder:<path>`) that survives a re-render.
let navKey: string | null = null;
const navRows = (): HTMLElement[] => [
...treeHost.querySelectorAll<HTMLElement>(NAV_SELECTOR),
];
const rowFor = (key: string | null): HTMLElement | undefined =>
key === null ? undefined : navRows().find((r) => r.dataset.navKey === key);
const applyNav = () => {
const rows = navRows();
// A row that went away (session killed, folder collapsed) drops the
// highlight rather than leaving it pointing at nothing.
if (navKey !== null && !rows.some((r) => r.dataset.navKey === navKey)) navKey = null;
for (const r of rows) r.classList.toggle("is-highlighted", r.dataset.navKey === navKey);
};
const moveNav = (delta: number) => {
const rows = navRows();
if (rows.length === 0) return;
const cur = rows.findIndex((r) => r.dataset.navKey === navKey);
// Nothing highlighted: start one off the end we are walking from, so the
// first step lands on the first (or last) row.
const from = cur === -1 ? (delta > 0 ? -1 : 0) : cur;
const next = rows[(((from + delta) % rows.length) + rows.length) % rows.length];
navKey = next.dataset.navKey ?? null;
applyNav();
next.scrollIntoView({ block: "nearest" });
};
/** Enter: open the highlighted session, or expand the highlighted folder. */
const activateNav = () => {
const row = rowFor(navKey);
if (!row) return;
if (row.classList.contains("tree-leaf")) {
row.click();
return;
}
// Expanding replaces the folder's header in the walk with the rows it was
// hiding, so land on the first of them instead of losing the highlight.
const key = navKey;
row.click();
const header = [...treeHost.querySelectorAll<HTMLElement>(".tree-folder-header")].find(
(h) => h.dataset.navKey === key,
);
const revealed = header?.parentElement
? [...header.parentElement.querySelectorAll<HTMLElement>(NAV_SELECTOR)][0]
: undefined;
navKey = revealed?.dataset.navKey ?? null;
applyNav();
};
// Focusable as a unit: the walk moves a highlight, not DOM focus, so the
// keyboard stays here while j/k runs.
treeHost.tabIndex = -1;
treeHost.addEventListener("keydown", (e) => {
if (e.ctrlKey || e.altKey || e.metaKey) return;
// The inline rename field lives inside a row and owns its own keys.
if (isTypingTarget(e.target)) return;
if (e.key === "j" || e.key === "ArrowDown") moveNav(1);
else if (e.key === "k" || e.key === "ArrowUp") moveNav(-1);
else if (e.key === "Enter") activateNav();
else if (e.key === "Escape") document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT));
else return;
e.preventDefault();
});
const refresh = () => {
// Rebuilding the tree now would blow away the field being typed into.
if (renamingId !== null) return;
@@ -474,6 +598,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
for (const child of tree.children) treeHost.appendChild(renderNode(child, 0));
}
applyHighlights();
applyNav();
};
void getDaemonInfo()
@@ -506,7 +631,6 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
highlights = {
open: id ? new Set([id]) : new Set(),
focused: id,
colors: new Map(),
};
applyHighlights();
},
@@ -514,6 +638,19 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
highlights = h;
applyHighlights();
},
focusList() {
// Start from the session on screen — that is where the user's attention
// already is — falling back to the standing highlight, then the top row.
const preferred = highlights.focused ? `session:${highlights.focused}` : null;
const start = rowFor(preferred) ?? rowFor(navKey) ?? navRows()[0];
navKey = start?.dataset.navKey ?? null;
applyNav();
treeHost.focus();
start?.scrollIntoView({ block: "nearest" });
},
hasListFocus() {
return treeHost.contains(document.activeElement);
},
dispose() {
clearInterval(clock);
// An unfinished rename would otherwise leave every later sidebar
+7
View File
@@ -140,6 +140,13 @@ export class ConnectionPool {
this.setFocus(null);
}
/** Put the keyboard back into the focused session's terminal. No-op when
* nothing is focused (welcome pane) or its connection is gone. */
focusTerminal(): void {
if (this.focusedId === null) return;
this.cache.get(this.focusedId)?.focusTerminal();
}
/** Fully disconnect + forget a session. */
close(id: Uuid): void {
const conn = this.cache.get(id);
+47
View File
@@ -0,0 +1,47 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from "vitest";
import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "./catalog.js";
import { installDrawer, type Drawer } from "./drawer.js";
function mount(): { shell: HTMLElement; sidebar: HTMLElement; drawer: Drawer } {
const shell = document.createElement("div");
const sidebar = document.createElement("aside");
const backdrop = document.createElement("div");
const row = document.createElement("div");
row.className = "tree-leaf";
sidebar.appendChild(row);
shell.append(sidebar, backdrop);
document.body.replaceChildren(shell);
const drawer = installDrawer(shell, sidebar, backdrop);
drawer.open();
return { shell, sidebar, drawer };
}
describe("mobile drawer", () => {
beforeEach(() => document.body.replaceChildren());
it("closes when a session row is tapped", () => {
const { shell, sidebar } = mount();
(sidebar.querySelector(".tree-leaf") as HTMLElement).click();
expect(shell.classList.contains("drawer-open")).toBe(false);
});
it("closes when a spawn is requested", () => {
const { shell } = mount();
document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT));
expect(shell.classList.contains("drawer-open")).toBe(false);
});
it("closes when spawn similar is requested", () => {
const { shell } = mount();
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: {} }));
expect(shell.classList.contains("drawer-open")).toBe(false);
});
it("leaves the backdrop click closing it", () => {
const { shell } = mount();
(shell.lastElementChild as HTMLElement).click();
expect(shell.classList.contains("drawer-open")).toBe(false);
});
});
+74
View File
@@ -0,0 +1,74 @@
// Mobile off-canvas sidebar (issue #35); on desktop the `drawer-open` class is
// inert (CSS gates the drawer styling).
//
// Anything that puts a new view in the content area must close the drawer, or
// it renders behind the sidebar and the tap reads as a dead button. "Spawn
// similar" sits inside a session row but stops propagation, so it never reaches
// the row's own close — both spawn paths arrive here via document events.
import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "./catalog.js";
export interface Drawer {
open(): void;
close(): void;
toggle(): void;
isOpen(): boolean;
}
export function installDrawer(
shellEl: HTMLElement,
sidebarEl: HTMLElement,
backdropEl: HTMLElement,
): Drawer {
const drawer: Drawer = {
open: () => shellEl.classList.add("drawer-open"),
close: () => shellEl.classList.remove("drawer-open"),
toggle: () => shellEl.classList.toggle("drawer-open"),
isOpen: () => shellEl.classList.contains("drawer-open"),
};
backdropEl.addEventListener("click", () => drawer.close());
sidebarEl.addEventListener("click", (e) => {
if ((e.target as HTMLElement).closest(".tree-leaf")) drawer.close();
});
document.addEventListener(OPEN_SPAWN_EVENT, () => drawer.close());
document.addEventListener(SPAWN_SIMILAR_EVENT, () => drawer.close());
return drawer;
}
/** Horizontal swipes drive the drawer (right opens, left closes) rather than
* the browser's back/forward navigation. Only a clearly horizontal gesture past
* a threshold counts, so vertical terminal scrolling (which owns its own touch
* handler) is untouched. */
export function installDrawerSwipe(drawer: Drawer): void {
const mobileMq = window.matchMedia("(max-width: 859px)");
let sx = 0;
let sy = 0;
let tracking = false;
document.addEventListener(
"touchstart",
(e) => {
tracking = mobileMq.matches && e.touches.length === 1;
if (tracking) {
sx = e.touches[0].clientX;
sy = e.touches[0].clientY;
}
},
{ passive: true },
);
document.addEventListener(
"touchend",
(e) => {
if (!tracking) return;
tracking = false;
const t = e.changedTouches[0];
const dx = t.clientX - sx;
const dy = t.clientY - sy;
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
if (dx > 0 && !drawer.isOpen()) drawer.open();
else if (dx < 0 && drawer.isOpen()) drawer.close();
},
{ passive: true },
);
}
+112
View File
@@ -0,0 +1,112 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
SHORTCUTS,
closeHelp,
installHelpKeybind,
isHelpOpen,
isTypingTarget,
openHelp,
} from "./help.js";
beforeEach(() => {
installHelpKeybind();
});
afterEach(() => {
closeHelp();
document.body.replaceChildren();
});
function press(key: string, init: KeyboardEventInit = {}): void {
window.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, ...init }));
}
function rows(): string[] {
return [...document.querySelectorAll(".help-row")].map((r) =>
[...r.querySelectorAll("kbd")].map((k) => k.textContent).join(" "),
);
}
describe("help overlay", () => {
it("renders every registered shortcut, grouped", () => {
openHelp();
const groups = [...document.querySelectorAll(".help-group-title")].map((h) => h.textContent);
expect(groups).toEqual(SHORTCUTS.map((g) => g.title));
expect(rows()).toEqual(SHORTCUTS.flatMap((g) => g.items.map((s) => s.keys.join(" "))));
expect(document.querySelectorAll(".help-desc")).toHaveLength(
SHORTCUTS.reduce((n, g) => n + g.items.length, 0),
);
});
it("opens on ? and closes on a second ?", () => {
press("?", { shiftKey: true });
expect(isHelpOpen()).toBe(true);
press("?", { shiftKey: true });
expect(isHelpOpen()).toBe(false);
});
it("closes on Escape, on the close button, and on a click outside the panel", () => {
openHelp();
press("Escape");
expect(isHelpOpen()).toBe(false);
openHelp();
(document.querySelector(".help-close") as HTMLButtonElement).click();
expect(isHelpOpen()).toBe(false);
openHelp();
(document.querySelector(".help-overlay") as HTMLElement).dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
expect(isHelpOpen()).toBe(false);
});
it("stays open for a click inside the panel", () => {
openHelp();
(document.querySelector(".help-panel") as HTMLElement).dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
expect(isHelpOpen()).toBe(true);
});
it("opening twice leaves one overlay", () => {
openHelp();
openHelp();
expect(document.querySelectorAll(".help-overlay")).toHaveLength(1);
});
it("ignores ? while typing and when a non-shift modifier is held", () => {
const input = document.createElement("input");
document.body.appendChild(input);
input.dispatchEvent(new KeyboardEvent("keydown", { key: "?", bubbles: true, shiftKey: true }));
expect(isHelpOpen()).toBe(false);
press("?", { ctrlKey: true });
expect(isHelpOpen()).toBe(false);
});
it("installing the keybind twice still toggles once", () => {
installHelpKeybind();
press("?", { shiftKey: true });
expect(isHelpOpen()).toBe(true);
expect(document.querySelectorAll(".help-overlay")).toHaveLength(1);
});
});
describe("isTypingTarget", () => {
it("is true for form fields and editable nodes, false otherwise", () => {
const el = (html: string) => {
const d = document.createElement("div");
d.innerHTML = html;
return d.firstElementChild as HTMLElement;
};
expect(isTypingTarget(el("<input>"))).toBe(true);
expect(isTypingTarget(el("<textarea></textarea>"))).toBe(true);
expect(isTypingTarget(el("<select></select>"))).toBe(true);
expect(isTypingTarget(el('<div contenteditable="true"></div>'))).toBe(true);
expect(isTypingTarget(el("<button></button>"))).toBe(false);
expect(isTypingTarget(null)).toBe(false);
});
});
+172
View File
@@ -0,0 +1,172 @@
/** Keyboard-shortcut overlay (`?`), the web counterpart of the TUI's help
* overlay. One registry drives both the rendering and what we claim is bound,
* so a binding added to `main.ts` without an entry here reads as missing. */
export interface Shortcut {
/** Keycaps, rendered left to right as separate <kbd>. */
keys: string[];
description: string;
}
export interface ShortcutGroup {
title: string;
items: Shortcut[];
}
export const SHORTCUTS: ShortcutGroup[] = [
{
title: "Sessions",
items: [
{ keys: ["n"], description: "Open a spawn draft pane" },
{ keys: ["N"], description: "Spawn a session with every default resolved" },
],
},
{
title: "Session list",
items: [
{
keys: ["Ctrl", "Shift", "H"],
description: "Move the keyboard between the session list and the pane",
},
{ keys: ["j"], description: "Highlight the next session" },
{ keys: ["k"], description: "Highlight the previous session" },
{ keys: ["Enter"], description: "Open the highlighted session, or expand a folder" },
{ keys: ["Esc"], description: "Hand the keyboard back to the pane" },
],
},
{
title: "Help",
items: [
{ keys: ["?"], description: "Show or hide this help" },
{ keys: ["Esc"], description: "Close the help overlay or an open menu" },
],
},
{
title: "Debug",
items: [{ keys: ["Ctrl", "Shift", "D"], description: "Toggle the debug HUD" }],
},
];
/** True when the event target swallows plain keystrokes — a form field, or the
* hidden textarea xterm types into. Shared with `main.ts` so every global
* single-key binding is deaf in the same places. */
export function isTypingTarget(target: EventTarget | null): boolean {
const el = target as HTMLElement | null;
if (!el || typeof el.tagName !== "string") return false;
return (
el.tagName === "INPUT" ||
el.tagName === "TEXTAREA" ||
el.tagName === "SELECT" ||
el.isContentEditable
);
}
let open: { el: HTMLElement; dispose: () => void } | null = null;
export function isHelpOpen(): boolean {
return open !== null;
}
export function closeHelp(): void {
open?.dispose();
}
export function toggleHelp(): void {
if (open) closeHelp();
else openHelp();
}
/** Mount the overlay. Opening while already open is a no-op re-render, so a
* stray second call can never stack two dialogs. */
export function openHelp(): HTMLElement {
closeHelp();
const el = document.createElement("div");
el.className = "help-overlay";
el.setAttribute("role", "dialog");
el.setAttribute("aria-modal", "true");
el.setAttribute("aria-label", "Keyboard shortcuts");
const panel = document.createElement("div");
panel.className = "help-panel";
const head = document.createElement("header");
head.className = "help-head";
const title = document.createElement("h2");
title.className = "help-title";
title.textContent = "Keyboard shortcuts";
const close = document.createElement("button");
close.type = "button";
close.className = "help-close";
close.setAttribute("aria-label", "Close");
close.textContent = "✕";
close.addEventListener("click", closeHelp);
head.append(title, close);
panel.appendChild(head);
for (const group of SHORTCUTS) {
const section = document.createElement("section");
section.className = "help-group";
const heading = document.createElement("h3");
heading.className = "help-group-title";
heading.textContent = group.title;
const list = document.createElement("dl");
list.className = "help-list";
for (const item of group.items) {
const row = document.createElement("div");
row.className = "help-row";
const keys = document.createElement("dt");
keys.className = "help-keys";
for (const key of item.keys) {
const cap = document.createElement("kbd");
cap.textContent = key;
keys.appendChild(cap);
}
const desc = document.createElement("dd");
desc.className = "help-desc";
desc.textContent = item.description;
row.append(keys, desc);
list.appendChild(row);
}
section.append(heading, list);
panel.appendChild(section);
}
// A press on the scrim closes; one inside the panel is left alone.
el.addEventListener("click", (ev) => {
if (!panel.contains(ev.target as Node)) closeHelp();
});
el.appendChild(panel);
document.body.appendChild(el);
const dispose = () => {
if (open?.el !== el) return;
open = null;
el.remove();
};
open = { el, dispose };
close.focus();
return el;
}
let installed = false;
/** Global keybind: `?` toggles the overlay, Escape closes it. Idempotent, so a
* second call during a hot reload doesn't double-toggle. */
export function installHelpKeybind(): void {
if (installed) return;
installed = true;
window.addEventListener("keydown", (e) => {
if (e.key === "Escape" && open) {
e.preventDefault();
closeHelp();
return;
}
// Shift is how `?` is typed on most layouts; the others would be a
// different binding entirely.
if (e.key !== "?" || e.ctrlKey || e.altKey || e.metaKey) return;
if (isTypingTarget(e.target)) return;
e.preventDefault();
toggleHelp();
});
}
+41 -67
View File
@@ -1,6 +1,12 @@
import "@fontsource/jetbrains-mono/400.css";
import "@fontsource/jetbrains-mono/500.css";
import "@fontsource/jetbrains-mono/600.css";
import "@fontsource/jetbrains-mono/700.css";
import { renderSidebar, renderTopbar } from "./catalog.js";
import { ConnectionPool } from "./connections.js";
import { installDrawer, installDrawerSwipe } from "./drawer.js";
import { installHudKeybind } from "./debug.js";
import { installHelpKeybind, isHelpOpen, isTypingTarget } from "./help.js";
import { CatalogStore } from "./events.js";
import { shouldRetryClose } from "./reconnect.js";
import {
@@ -15,11 +21,12 @@ import {
resolveFastForward,
} from "./fastForward.js";
import { FALLBACK_PRESETS } from "./spawnForm.js";
import { spawnSimilarSeed } from "./spawnSimilar.js";
import { mountHud, type Hud } from "./hud.js";
import { emptyLayout, type Layout, openSessions } from "./layout.js";
import { createMultiView, type MultiView } from "./multiview.js";
import { loadLayout, saveLayout } from "./persistence.js";
import { OPEN_SPAWN_EVENT } from "./catalog.js";
import { FOCUS_PANE_EVENT, OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "./catalog.js";
import { initTheme } from "./theme.js";
import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js";
import { installViewportSizing } from "./viewport.js";
@@ -28,6 +35,7 @@ import "./styles.css";
initTheme();
installViewportSizing();
installHudKeybind();
installHelpKeybind();
registerServiceWorker();
// Register the PWA service worker so the app is installable and the shell loads
@@ -85,60 +93,13 @@ const content = document.createElement("section");
content.className = "content";
// Mobile (issue #35): the sidebar is an off-canvas drawer toggled by the ☰ in
// the header; a backdrop covers the content while it's open, and tapping a
// session closes it. On desktop these are inert (CSS gates the drawer styling).
// the header. `installDrawer` owns the close rules (session tap, Spawn, Spawn
// similar, backdrop); on desktop the class it toggles is inert.
const backdrop = document.createElement("div");
backdrop.className = "mv-backdrop";
function closeDrawer(): void {
shellEl.classList.remove("drawer-open");
}
function openDrawer(): void {
shellEl.classList.add("drawer-open");
}
function toggleDrawer(): void {
shellEl.classList.toggle("drawer-open");
}
backdrop.addEventListener("click", closeDrawer);
sidebar.el.addEventListener("click", (e) => {
if ((e.target as HTMLElement).closest(".tree-leaf")) closeDrawer();
});
// Mobile: horizontal swipes drive the drawer (right opens, left closes) rather
// than the browser's back/forward navigation. We only act on a clearly
// horizontal gesture past a threshold, so vertical terminal scrolling (which
// owns its own touch handler) is untouched.
{
const mobileMq = window.matchMedia("(max-width: 859px)");
let sx = 0;
let sy = 0;
let tracking = false;
document.addEventListener(
"touchstart",
(e) => {
tracking = mobileMq.matches && e.touches.length === 1;
if (tracking) {
sx = e.touches[0].clientX;
sy = e.touches[0].clientY;
}
},
{ passive: true },
);
document.addEventListener(
"touchend",
(e) => {
if (!tracking) return;
tracking = false;
const t = e.changedTouches[0];
const dx = t.clientX - sx;
const dy = t.clientY - sy;
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
const open = shellEl.classList.contains("drawer-open");
if (dx > 0 && !open) openDrawer();
else if (dx < 0 && open) closeDrawer();
},
{ passive: true },
);
}
const drawer = installDrawer(shellEl, sidebar.el, backdrop);
installDrawerSwipe(drawer);
const toggleDrawer = () => drawer.toggle();
shellEl.append(sidebar.el, content, backdrop);
root.replaceChildren(renderTopbar(store, toggleDrawer), shellEl);
@@ -219,6 +180,7 @@ function renderWelcome(): HTMLElement {
Pick a session from the list, or start a new one. Open sessions keep
streaming in the background — switching back is instant.
</p>
<p class="empty-hint">Press <kbd>?</kbd> for keyboard shortcuts.</p>
<div class="empty-actions"></div>`;
const spawn = document.createElement("button");
spawn.type = "button";
@@ -265,8 +227,8 @@ function showMultiView(): void {
}
// Spawn affordances (#62): the header button, the sidebar's empty state, and
// `?mock&spawn` all open a draft pane; "Spawn similar" seeds one with the
// cloned session's dir + command.
// `?mock&spawn` all open a draft pane; "Spawn similar" seeds one from the
// cloned session (see spawnSimilar.ts).
function requestOpenSpawn(): void {
showMultiView();
// A new tab, not a split of the pane you were reading (#100). "Spawn
@@ -276,10 +238,10 @@ function requestOpenSpawn(): void {
mv.openDraft(undefined, { newTab: true });
}
document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
document.addEventListener("catalog-spawn-similar", (ev) => {
document.addEventListener(SPAWN_SIMILAR_EVENT, (ev) => {
const s = (ev as CustomEvent<Session>).detail;
showMultiView();
mv.openDraft({ cwd: s.cwd, command: s.command });
mv.openDraft(spawnSimilarSeed(s));
});
// Fast-forward spawn (#67): the draft pane's keystroke neighbour spawns
@@ -327,16 +289,9 @@ async function requestFastForward(): Promise<void> {
// textarea, search) so an attached session's keys are never hijacked.
document.addEventListener("keydown", (e) => {
if (e.ctrlKey || e.altKey || e.metaKey) return;
const target = e.target as HTMLElement | null;
if (
target &&
(target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.isContentEditable)
) {
return;
}
if (isTypingTarget(e.target)) return;
// The help overlay owns the keyboard while it is up.
if (isHelpOpen()) return;
if (e.key === "n") {
e.preventDefault();
requestOpenSpawn();
@@ -346,6 +301,25 @@ document.addEventListener("keydown", (e) => {
}
});
// Ctrl+Shift+H moves the keyboard between the session list and the pane, the
// web counterpart of the TUI's `ctrl-w h`. It has to be a chord: an attached
// terminal owns every plain keystroke, so nothing unmodified could ever get
// the focus back out of it.
window.addEventListener("keydown", (e) => {
if (!e.ctrlKey || !e.shiftKey || e.altKey || e.metaKey) return;
if (e.key !== "H" && e.key !== "h") return;
e.preventDefault();
if (sidebar.hasListFocus()) {
pool.focusTerminal();
} else {
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
sidebar.focusList();
}
});
// Escape in the session list hands the keyboard back to the pane.
document.addEventListener(FOCUS_PANE_EVENT, () => pool.focusTerminal());
type Route =
| { kind: "catalog" }
| { kind: "attach"; sessionId: string }
+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"),
+95
View File
@@ -36,6 +36,10 @@ function fakeSidebar(): SidebarHandle {
el: document.createElement("aside"),
setActive() {},
setHighlights() {},
focusList() {},
hasListFocus() {
return false;
},
dispose() {},
};
}
@@ -428,3 +432,94 @@ describe("multiview closeSession tab cleanup (#137)", () => {
expect(layout.activeTabId).toBe(tab.id);
});
});
describe("multiview mobile single pane", () => {
/** Force the mobile media query on, whatever happy-dom's viewport says. */
function forceMobile(): void {
vi.spyOn(window, "matchMedia").mockImplementation(
(query: string) =>
({
matches: query.includes("859"),
media: query,
onchange: null,
addEventListener() {},
removeEventListener() {},
addListener() {},
removeListener() {},
dispatchEvent: () => false,
}) as unknown as MediaQueryList,
);
}
beforeEach(() => {
stubFetch();
forceMobile();
document.body.replaceChildren();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("replaces the focused pane instead of splitting beside it", () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true);
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
mv.openSessionBeside("s2");
const ls = leaves(tab.root);
expect(ls).toHaveLength(1);
expect(ls[0].sessionId).toBe("s2");
expect(layout.tabs).toHaveLength(1);
});
it("keeps a spawn draft in the current pane rather than a new tab", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true);
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
mv.openDraft(undefined, { newTab: true });
await flush();
expect(layout.tabs).toHaveLength(1);
const ls = leaves(tab.root);
expect(ls).toHaveLength(1);
expect(ls[0].sessionId).toBeNull();
expect(ls[0].draft).not.toBeNull();
});
it("renders one pane and no pane pills for a multi-leaf tab", () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
const first = splitLeaf(tab, "", "s1", "row", true);
tab.focusedLeafId = splitLeaf(tab, first, "s2", "row", true);
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
expect(mv.el.querySelectorAll(".mv-pane")).toHaveLength(1);
expect(mv.el.querySelector(".mv-panebar")).toBeNull();
expect(mv.el.querySelector(".mv-pill")).toBeNull();
});
it("collapses the tab bar even with several tabs", () => {
const layout = emptyLayout();
const first = layout.tabs[0];
first.focusedLeafId = splitLeaf(first, "", "s1", "row", true);
const second = addTab(layout);
second.focusedLeafId = splitLeaf(second, "", "s2", "row", true);
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
expect(mv.el.classList.contains("mv-mobile-solo")).toBe(true);
});
});
+22 -42
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";
@@ -85,9 +84,11 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
canvas.className = "mv-canvas";
el.append(tabBar, canvas);
// Mobile (issue #35): below 860px the tree projects to a one-pane-at-a-time
// strip of pills — splits/drag/resize are desktop-only. The tree is untouched,
// so widening restores the splits.
// Mobile (issue #35): below 860px the layout collapses to exactly one visible
// pane and no tab bar — splits/drag/resize are desktop-only, and opening or
// spawning replaces the pane rather than adding a second thing to navigate.
// The sidebar drawer is the only navigation. The tree is untouched, so
// widening restores whatever splits and tabs a desktop session built.
const mq = window.matchMedia("(max-width: 859px)");
let isMobile = mq.matches;
mq.addEventListener("change", (e) => {
@@ -97,12 +98,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 +115,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
sidebar.setHighlights({
open: openSessions(layout),
focused: focusedLeafSession(),
colors: colorMap(),
});
}
@@ -142,10 +136,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";
@@ -402,7 +393,9 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
return;
}
const target = findLeaf(tab.root, tab.focusedLeafId)!;
if (!target.sessionId) {
// Mobile shows one pane, so there is no "beside": take over the pane. The
// evicted session keeps running and stays a tap away in the sidebar.
if (!target.sessionId || isMobile) {
target.sessionId = sessionId;
target.draft = null;
} else {
@@ -414,7 +407,9 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
function openDraft(seed?: Partial<Draft>, opts?: { newTab?: boolean }): void {
const draft = (): Draft => ({ command: "", cwd: "", name: "", ...seed });
if (opts?.newTab) {
// Mobile keeps a single tab and a single pane: the spawn form takes over the
// current pane instead of opening a tab or a split.
if (opts?.newTab && !isMobile) {
// Spawn opens its own tab rather than halving whatever you were reading
// (#100). The empty-pane reuse below still applies within a tab.
const fresh = addTab(layout);
@@ -432,9 +427,10 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
return;
}
const target = findLeaf(tab.root, tab.focusedLeafId)!;
if (!target.sessionId) {
if (!target.sessionId || isMobile) {
// Empty = no session, not `!draft` (#72): the mounted form auto-syncs
// picked defaults into `draft` once ready, so that's never reliably null.
target.sessionId = null;
target.draft = draft();
const stale = draftForms.get(target.id);
if (stale) {
@@ -473,16 +469,16 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
renderTabs();
canvas.replaceChildren();
const tab = activeTab(layout);
// Mobile single-tab: the tab bar carries no information (one tab, one pane),
// so CSS collapses it and we float a ☰ instead (rendered in renderMobile).
el.classList.toggle("mv-mobile-solo", isMobile && layout.tabs.length === 1);
// Mobile: the tab bar is a second navigation surface we don't want, so CSS
// collapses it; the header ☰ keeps the sidebar drawer reachable.
el.classList.toggle("mv-mobile-solo", isMobile);
if (!tab.root) {
pruneForms(tab);
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;
}
@@ -507,7 +503,9 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
persist();
}
// Mobile: a pill strip of the active tab's leaves + one full-screen pane.
// Mobile: the focused leaf, full screen, alone. Panes a desktop session left
// behind stay in the tree but are reached only by tapping the session in the
// sidebar drawer — mobile shows no pane switcher of its own.
function renderMobile(tab: { root: Node | null; focusedLeafId: string | null }): void {
const ls = leaves(tab.root);
const focused = (tab.focusedLeafId && findLeaf(tab.root, tab.focusedLeafId)) || ls[0];
@@ -515,24 +513,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
const wrap = document.createElement("div");
wrap.className = "mv-mobile";
if (ls.length > 1) {
const bar = document.createElement("div");
bar.className = "mv-panebar";
for (const l of ls) {
const pill = document.createElement("button");
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();
});
bar.appendChild(pill);
}
wrap.appendChild(bar);
}
const pane = buildPane(focused);
pane.el.classList.add("is-focused"); // the only pane — never dimmed
wrap.appendChild(pane.el);
@@ -543,7 +523,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];
}
+128
View File
@@ -0,0 +1,128 @@
// @vitest-environment happy-dom
import { beforeAll, describe, expect, it } from "vitest";
import { FOCUS_PANE_EVENT, renderSidebar } from "./catalog.js";
import type { CatalogListener, CatalogStore } from "./events.js";
import type { Session } from "./types.js";
beforeAll(() => {
Object.defineProperty(window, "localStorage", {
value: { getItem: () => null, setItem: () => {} },
});
window.fetch = () => Promise.reject(new Error("offline"));
});
function session(id: string, cwd: string): Session {
return {
id,
cwd,
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-08-24T10:00:00Z",
} as Session;
}
function storeWith(sessions: Session[]): CatalogStore {
const state = { sessions: new Map(sessions.map((s) => [s.id, s])) };
return {
snapshot: () => state,
subscribe: (fn: CatalogListener) => {
fn(state);
return () => {};
},
} as unknown as CatalogStore;
}
const A = "aaaaaaaa-0000-0000-0000-00000000000a";
const B = "aaaaaaaa-0000-0000-0000-00000000000b";
/** Two sessions in sibling directories: two folder headers, two cards. */
function twoFolders(): { el: HTMLElement; handle: ReturnType<typeof renderSidebar> } {
const handle = renderSidebar(storeWith([session(A, "/one"), session(B, "/two")]));
// Mounted: `focusList` moves real DOM focus, which needs a live document.
document.body.replaceChildren(handle.el);
return { el: handle.el, handle };
}
function press(el: HTMLElement, key: string, init: KeyboardEventInit = {}): void {
el.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, ...init }));
}
function highlighted(el: HTMLElement): string | null {
return el.querySelector<HTMLElement>(".is-highlighted")?.dataset.navKey ?? null;
}
function tree(el: HTMLElement): HTMLElement {
return el.querySelector<HTMLElement>(".tree")!;
}
describe("sidebar keyboard navigation", () => {
it("walks the cards and never stops on an expanded folder", () => {
const { el } = twoFolders();
// The folders are there — the walk just refuses to land on them.
expect(el.querySelectorAll(".tree-folder-header")).toHaveLength(2);
press(tree(el), "j");
expect(highlighted(el)).toBe(`session:${A}`);
press(tree(el), "j");
expect(highlighted(el)).toBe(`session:${B}`);
// Wraps.
press(tree(el), "j");
expect(highlighted(el)).toBe(`session:${A}`);
press(tree(el), "k");
expect(highlighted(el)).toBe(`session:${B}`);
});
it("accepts the arrow keys as well as j/k", () => {
const { el } = twoFolders();
press(tree(el), "ArrowDown");
expect(highlighted(el)).toBe(`session:${A}`);
press(tree(el), "ArrowUp");
// Backwards from the first card wraps to the last.
expect(highlighted(el)).toBe(`session:${B}`);
});
it("stops on a collapsed folder, since its cards are gone", () => {
const { el } = twoFolders();
const header = el.querySelectorAll<HTMLElement>(".tree-folder-header")[0];
header.click(); // collapse /one
press(tree(el), "j");
expect(highlighted(el)).toBe("folder:one");
press(tree(el), "j");
expect(highlighted(el)).toBe(`session:${B}`);
// Enter expands it again and lands on the card it was hiding.
press(tree(el), "j");
expect(highlighted(el)).toBe("folder:one");
press(tree(el), "Enter");
expect(highlighted(el)).toBe(`session:${A}`);
});
it("focusList starts from the session on screen", () => {
const { el, handle } = twoFolders();
handle.setHighlights({ open: new Set([B]), focused: B });
handle.focusList();
expect(highlighted(el)).toBe(`session:${B}`);
expect(handle.hasListFocus()).toBe(true);
});
it("Escape asks the shell for the pane back", () => {
const { el } = twoFolders();
let asked = 0;
document.addEventListener(FOCUS_PANE_EVENT, () => (asked += 1));
press(tree(el), "Escape");
expect(asked).toBe(1);
});
it("leaves the keyboard alone while a row is being renamed", () => {
const { el } = twoFolders();
press(tree(el), "j");
const input = document.createElement("input");
tree(el).appendChild(input);
input.dispatchEvent(new KeyboardEvent("keydown", { key: "j", bubbles: true }));
expect(highlighted(el)).toBe(`session:${A}`);
});
});
+4 -1
View File
@@ -589,7 +589,10 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
cwd = seedCwd;
cwdRaw.value = seedCwd;
// Restoring must not touch the worktree toggle — the seed carries it.
if (!restoreSelection(seedCwd)) clearProjectSelection();
// A path no box matches (the project is gone, or the scan never saw
// it) falls back to the raw input showing that path: an empty picker
// with an invisible cwd reads as a form that lost the directory.
if (!restoreSelection(seedCwd)) showRawCwd(seedCwd, false);
} else {
showRawCwd(seedCwd, false);
}
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { spawnSimilarSeed } from "./spawnSimilar.js";
import type { Session } from "./types.js";
function session(overrides: Partial<Session> = {}): Session {
return {
id: "s1",
cwd: "/repos/app",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-01-01T00:00:00Z",
...overrides,
} as Session;
}
describe("spawnSimilarSeed (#28)", () => {
it("seeds the project picker, not the free-text hatch", () => {
const seed = spawnSimilarSeed(session());
expect(seed.cwd).toBe("/repos/app");
expect(seed.command).toBe("claude");
expect(seed.cwdRawVisible).toBe(false);
});
it("keeps the worktree toggle off for a session that has no worktree", () => {
expect(spawnSimilarSeed(session()).worktreeEnabled).toBe(false);
});
it("clones a worktree session as a new worktree of its base repo", () => {
const seed = spawnSimilarSeed(
session({
cwd: "/repos/app-worktrees/feature",
worktree: { path: "/repos/app-worktrees/feature", base_repo: "/repos/app" },
}),
);
expect(seed.cwd).toBe("/repos/app");
expect(seed.worktreeEnabled).toBe(true);
});
it("never carries the cloned session's name", () => {
const seed = spawnSimilarSeed(session({ metadata: { name: "amber-falcon" } }));
expect(seed.name).toBeUndefined();
});
});
+30
View File
@@ -0,0 +1,30 @@
// "Spawn similar" (#28): the seed a cloned session hands to a draft pane.
// Pure — the caller owns opening the pane.
import type { Draft } from "./layout.js";
import type { Session } from "./types.js";
/** Clone a session into draft values: same project, same command, same
* worktree decision, fresh name.
*
* A worktree session seeds its *base repository*, not the checkout the daemon
* made for it: "similar" is another session of that project, and the daemon
* never adopts an existing worktree (docs/WORKTREES.md), so seeding the
* checkout would ask it to build a worktree out of a worktree. The picker
* then lands on that repository's box with "+ new" active.
*
* `cwdRawVisible: false` keeps the two-step project picker on screen with the
* cloned project selected, instead of dropping the path into the free-text
* escape hatch.
*
* The name is left out on purpose: a clone is a new session and takes its own
* generated handle. */
export function spawnSimilarSeed(s: Session): Partial<Draft> {
const wt = s.worktree ?? null;
return {
command: s.command,
cwd: wt ? wt.base_repo : s.cwd,
worktreeEnabled: wt != null,
cwdRawVisible: false,
};
}
+190 -80
View File
@@ -8,7 +8,7 @@
:root {
/* ---- type ---- */
--font-mono:
ui-monospace, "SF Mono", "SFMono-Regular", "Cascadia Code", "JetBrains Mono",
"JetBrains Mono", ui-monospace, "SF Mono", "SFMono-Regular", "Cascadia Code",
"Roboto Mono", Menlo, Consolas, "Liberation Mono", monospace;
--font-sans:
system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
@@ -484,14 +484,27 @@ textarea {
}
}
/* Sidebar's own slim header: just the session search (app-level actions live
in the global top bar). */
/* Sidebar's own slim header: just the session search. */
/* The search input IS the header — it spans edge to edge, flush, with just a
bottom rule for separation. No wrapper padding, no boxed border/radius. */
.sidebar-search {
flex: 0 0 auto;
}
/* App-level actions (help, theme, spawn), pinned to the sidebar's foot. */
.sidebar-footer {
display: flex;
align-items: center;
gap: var(--sp-2);
flex: 0 0 auto;
padding: var(--sp-2) var(--sp-3);
border-top: 1px solid var(--border);
}
.sidebar-footer .spawn-btn {
flex: 1 1 auto;
}
.sidebar-search .search {
width: 100%;
max-width: none;
@@ -736,7 +749,7 @@ textarea {
display: flex;
align-items: center;
gap: var(--sp-2);
margin: 1px var(--sp-2);
margin: 1px 0;
padding: 4px var(--sp-3);
border-radius: var(--r-1);
color: var(--ink);
@@ -761,7 +774,19 @@ textarea {
.tree-leaf:focus-visible {
outline: none;
box-shadow: inset 0 0 0 1px var(--accent-ring);
background: color-mix(in oklab, var(--accent) 16%, var(--surface-2));
}
/* Where the keyboard walk (j/k) is standing. Distinct from `.is-active`: this
is a cursor, not a statement about what is mounted, so it reads as a ring
rather than a fill and the two can sit on the same row. */
.tree-leaf.is-highlighted,
.tree-folder-header.is-highlighted {
box-shadow: inset 0 0 0 2px var(--accent-ring);
}
.tree {
outline: none;
}
/* The session currently mounted in the content pane. */
@@ -876,6 +901,113 @@ input.leaf-rename:focus {
background: var(--danger-tint);
}
/* ---------- help overlay (`?`) ---------- */
.help-overlay {
position: fixed;
inset: 0;
z-index: var(--z-modal);
display: grid;
place-items: center;
padding: var(--sp-4);
background: oklch(0 0 0 / 0.5);
backdrop-filter: blur(4px);
}
.help-panel {
width: min(440px, 100%);
max-height: min(80vh, 640px);
overflow-y: auto;
padding: var(--sp-4);
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--r-3);
box-shadow: var(--shadow-2);
}
.help-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
margin-bottom: var(--sp-3);
}
.help-title {
margin: 0;
font-family: var(--font-mono);
font-size: var(--fs-15);
color: var(--ink);
}
.help-close {
appearance: none;
background: none;
border: 0;
border-radius: var(--r-2);
color: var(--muted);
font: inherit;
line-height: 1;
padding: var(--sp-1) var(--sp-2);
cursor: pointer;
}
.help-close:hover,
.help-close:focus-visible {
background: var(--surface-3);
color: var(--ink);
outline: none;
}
.help-group + .help-group {
margin-top: var(--sp-4);
}
.help-group-title {
margin: 0 0 var(--sp-2);
font-size: var(--fs-11);
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--faint);
}
.help-list {
margin: 0;
}
/* Keycaps in a fixed column so the descriptions line up down the list. */
.help-row {
display: grid;
grid-template-columns: 14ch 1fr;
align-items: baseline;
gap: var(--sp-3);
padding: var(--sp-1) 0;
}
.help-keys {
display: flex;
flex-wrap: nowrap;
gap: var(--sp-1);
margin: 0;
}
.help-keys kbd {
font-family: var(--font-mono);
font-size: var(--fs-11);
background: var(--surface-2);
border: 1px solid var(--border);
border-bottom-width: 2px;
border-radius: var(--r-1);
padding: 1px 6px;
color: var(--ink);
}
.help-desc {
margin: 0;
font-size: var(--fs-13);
color: var(--muted);
}
/* Line 2 — dot + duration in the state's colour, then muted fragments. The
state word itself is never spelled out here; the colour carries it. */
.card-line2 {
@@ -940,6 +1072,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
@@ -1120,6 +1292,9 @@ input.leaf-rename:focus {
.attach-title .at-cmd {
color: var(--muted);
display: inline-flex;
align-items: center;
gap: 4px;
}
.attach-title .at-cmd::before {
@@ -2027,8 +2202,8 @@ input.leaf-rename:focus {
display: flex;
flex-direction: column;
}
/* Mobile, single tab: the tab bar is pure chrome — collapse it (a floating ☰
inside the pane keeps the sidebar drawer reachable). */
/* Mobile: the tab bar is a second navigation surface — collapse it (a floating
☰ inside the pane keeps the sidebar drawer reachable). */
.mv-mobile-solo > .mv-tabs {
display: none;
}
@@ -2106,7 +2281,7 @@ input.leaf-rename:focus {
.mv-canvas > .mv-split,
.mv-canvas > .mv-pane {
position: absolute;
inset: var(--sp-2);
inset: 0;
}
.mv-canvas > .content-welcome {
position: absolute;
@@ -2194,20 +2369,9 @@ 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 */
.mv-pane.is-focused {
border-color: var(--accent);
box-shadow: inset 0 0 0 1px var(--accent), 0 0 22px -8px var(--accent-ring);
/* focused pane: titlebar tints instead of a ring, full strength */
.mv-pane.is-focused .attach-header {
background: color-mix(in oklab, var(--accent) 14%, var(--surface));
}
/* unfocused panes recede so the eye finds the live one */
.mv-pane:not(.is-focused) {
@@ -2310,35 +2474,16 @@ 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
with one pane visible; the sidebar becomes an off-canvas drawer (☰).
multi-view mobile (#35): below 860px only the focused pane is shown — no tab
bar, no pane switcher; the sidebar drawer (☰) is the navigation.
========================================================================= */
.mv-backdrop {
display: none;
@@ -2346,7 +2491,6 @@ input.leaf-rename:focus {
.mv-mobile {
position: absolute;
/* Flush to the edges — a single full-screen pane needs no canvas inset. */
inset: 0;
display: flex;
flex-direction: column;
@@ -2359,40 +2503,6 @@ input.leaf-rename:focus {
border-radius: 0;
box-shadow: none;
}
.mv-panebar {
display: flex;
gap: 4px;
padding: var(--sp-1) var(--sp-2);
overflow-x: auto;
scrollbar-width: none;
flex: none;
}
.mv-pill {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
padding: 7px var(--sp-3);
border-radius: var(--r-1);
background: var(--surface-2);
color: var(--muted);
font: 600 var(--fs-12) / 1 var(--font-mono);
white-space: nowrap;
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);
border-color: var(--accent-ring);
}
.mv-mobile > .mv-pane {
flex: 1;
min-height: 0;
+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>`;
}
+10
View File
@@ -39,6 +39,16 @@ describe("buildTree", () => {
expect(names(root)).toEqual(["~/git/naps62/maestro"]);
});
it("folds the sole root folder into each of its own children when it branches", () => {
const root = buildTree(
[session("a", "/home/n/subvisual/bullish"), session("b", "/home/n/tea/maestro")],
"/home/n",
"",
) as TreeFolder;
expect(names(root).sort()).toEqual(["~/subvisual/bullish", "~/tea/maestro"]);
});
it("stops collapsing where a directory branches", () => {
const root = buildTree(
[session("a", "/home/n/git/one"), session("b", "/home/n/git/two")],
+9
View File
@@ -59,6 +59,15 @@ export function buildTree(sessions: Session[], home: string | undefined, filter:
for (const c of n.children) if (c.kind === "folder") sortRec(c);
};
sortRec(root);
// The root's sole child (usually `~`) has no sibling to distinguish it
// from, so it folds into every one of its own children, not just a lone
// one — unlike the chain merge below, and only here at the very top.
if (root.children.length === 1 && root.children[0].kind === "folder") {
const only = root.children[0];
root.children = only.children.map((c) =>
c.kind === "folder" ? { ...c, name: `${only.name}/${c.name}` } : c,
);
}
// Collapse linear directory chains so `~/git.naps.pt/naps62/maestro` reads as
// one compact folder rather than four nested ones.
root.children = root.children.map((c) => (c.kind === "folder" ? mergeLinear(c) : c));
+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
+31
View File
@@ -240,4 +240,35 @@ describe("spawnForm worktree step (#95)", () => {
expect(box(form, MAESTRO.path).classList.contains("is-active")).toBe(true);
expect(toggle(form).checked).toBe(false);
});
it("lands a 'spawn similar' seed on the cloned project with '+ new' active", async () => {
urls = stubFetch([MAESTRO, WT_A, WT_B]);
handle = createSpawnForm({
onSuccess() {},
seed: { cwd: MAESTRO.path, cwdRawVisible: false, worktreeEnabled: true },
});
document.body.appendChild(handle.el);
await drain();
const form = handle.el;
expect(box(form, MAESTRO.path).classList.contains("is-active")).toBe(true);
expect(activeChoice(form)!.dataset.kind).toBe("new");
expect(toggle(form).checked).toBe(true);
expect(form.querySelector<HTMLElement>(".cwd-raw-wrap")!.hidden).toBe(true);
});
it("falls back to the raw input when the seeded directory matches no project", async () => {
urls = stubFetch([MAESTRO, WT_A]);
handle = createSpawnForm({
onSuccess() {},
seed: { cwd: "/home/user/gone", cwdRawVisible: false, worktreeEnabled: false },
});
document.body.appendChild(handle.el);
await drain();
const form = handle.el;
const raw = form.querySelector<HTMLInputElement>(".cwd-raw")!;
expect(form.querySelector<HTMLElement>(".cwd-raw-wrap")!.hidden).toBe(false);
expect(raw.value).toBe("/home/user/gone");
});
});