Compare commits

...
Author SHA1 Message Date
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
Miguel Palhas 5b2add06fe Merge remote-tracking branch 'origin/main' into refresh
ci / web (push) Successful in 55s
ci / rust (push) Successful in 3m27s
ci / flake (push) Successful in 6m42s
2026-08-31 09:58:18 +01:00
Miguel PalhasandClaude Opus 5 0388f06404 fix(maestro): keep pane background in the attach snapshot
capture-pane trims trailing spaces, so a line's background stopped at its
last glyph and the client's own background showed through the rest as
stripes. -N keeps the padding, and with it the colour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 09:43:21 +01:00
Miguel PalhasandClaude Opus 5 c98c11b5c3 fix(maestro): replay tmux's screen to fresh attaches
A new attach replayed the tail of the raw byte buffer. That tail starts
mid-stream, long after a full-screen program set the modes, colours and
cursor position that give those bytes meaning, so a browser refresh
painted garbage over every agent TUI until something forced a repaint —
which is why opening the same session in the TUI (a resize) fixed it.

Ask tmux for the pane's current screen instead, and fall back to the byte
buffer when there is no pane left to capture, as for a dead session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 09:37:13 +01:00
33 changed files with 2088 additions and 204 deletions
+23
View File
@@ -39,6 +39,10 @@ pub trait Panes: std::fmt::Debug + Send + Sync + 'static {
fn pipe_pane(&self, id: Uuid, sink_command: &str) -> Result<(), TmuxError>;
fn resize_window(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), TmuxError>;
/// Escape-sequence snapshot of the pane's current screen, or `None` when
/// there is no pane to capture. See [`Tmux::capture_pane`].
fn capture_pane(&self, id: Uuid) -> Result<Option<Vec<u8>>, TmuxError>;
}
impl Panes for Tmux {
@@ -76,6 +80,10 @@ impl Panes for Tmux {
fn resize_window(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), TmuxError> {
Tmux::resize_window(self, id, cols, rows)
}
fn capture_pane(&self, id: Uuid) -> Result<Option<Vec<u8>>, TmuxError> {
Tmux::capture_pane(self, id)
}
}
// ---------------------------------------------------------------------------
@@ -92,6 +100,7 @@ pub enum Op {
SendKeys,
PipePane,
ResizeWindow,
CapturePane,
}
/// One recorded call, in the order the manager made it. Rollback ordering is a
@@ -116,6 +125,7 @@ pub enum Call {
cols: u16,
rows: u16,
},
CapturePane(Uuid),
}
/// In-memory [`Panes`] for tests.
@@ -133,6 +143,7 @@ struct FakeState {
live: HashSet<Uuid>,
panes: HashMap<Uuid, PaneInfo>,
calls: Vec<Call>,
captures: HashMap<Uuid, Vec<u8>>,
fail: HashMap<Op, String>,
}
@@ -162,6 +173,12 @@ impl FakePanes {
self.lock().live.contains(&id)
}
/// Screen snapshot `capture_pane` hands back for `id`. Unset means "no
/// pane", which is what a dead session looks like.
pub fn set_capture(&self, id: Uuid, bytes: impl Into<Vec<u8>>) {
self.lock().captures.insert(id, bytes.into());
}
pub fn set_pane_info(&self, id: Uuid, info: PaneInfo) {
self.lock().panes.insert(id, info);
}
@@ -242,6 +259,12 @@ impl Panes for FakePanes {
fn resize_window(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), TmuxError> {
self.record(Call::ResizeWindow { id, cols, rows }, Op::ResizeWindow)
}
fn capture_pane(&self, id: Uuid) -> Result<Option<Vec<u8>>, TmuxError> {
self.record(Call::CapturePane(id), Op::CapturePane)?;
let st = self.lock();
Ok(st.captures.get(&id).cloned())
}
}
#[cfg(test)]
+14 -1
View File
@@ -141,8 +141,21 @@ impl PtyBridge {
/// `subscribe()` keeps the reader task from publishing in between, so
/// the seam has no gap and no duplicates.
pub fn subscribe_with_replay(&self) -> (Vec<u8>, broadcast::Receiver<Vec<u8>>) {
self.subscribe_with_snapshot(|| None)
}
/// As [`Self::subscribe_with_replay`], but lets the caller supply the
/// replay payload instead of the raw byte buffer — used to hand a fresh
/// client tmux's own view of the screen, which the byte tail cannot
/// reconstruct for a full-screen program. `capture` runs while the reader
/// is held off, so the payload and the live stream still meet with no gap
/// and no duplicate. Returning `None` falls back to the byte buffer.
pub fn subscribe_with_snapshot<F>(&self, capture: F) -> (Vec<u8>, broadcast::Receiver<Vec<u8>>)
where
F: FnOnce() -> Option<Vec<u8>>,
{
let guard = self.replay.lock_for_snapshot();
let snapshot = guard.snapshot();
let snapshot = capture().unwrap_or_else(|| guard.snapshot());
let rx = match &self.sender {
Some(tx) => tx.subscribe(),
// Sealed: hand back the scrollback plus a receiver that is already
+22 -1
View File
@@ -260,8 +260,29 @@ impl SessionManager {
&self,
id: Uuid,
) -> Option<(Vec<u8>, broadcast::Receiver<Vec<u8>>)> {
let tmux = self.inner.tmux.clone();
let sessions = self.inner.sessions.lock().await;
sessions.get(&id).map(|r| r.bridge.subscribe_with_replay())
let rec = sessions.get(&id)?;
// For a live session, tmux's own screen beats the byte tail: the tail
// starts mid-stream, so a full-screen program (an agent TUI) replayed
// from it renders as garbage until something forces a repaint. A dead
// session has no pane left to capture, so it keeps the byte tail.
let alive = rec.session.status == SessionStatus::Running;
// The capture shells out to tmux while the replay lock is held, which
// parks the reader for the length of one tmux round-trip. That only
// happens on attach, and the alternative is a seam in the stream.
Some(rec.bridge.subscribe_with_snapshot(|| {
if !alive {
return None;
}
match tmux.capture_pane(id) {
Ok(snapshot) => snapshot,
Err(e) => {
tracing::warn!(error = ?e, %id, "capture-pane failed; replaying raw bytes");
None
}
}
}))
}
pub async fn has(&self, id: Uuid) -> bool {
+261
View File
@@ -246,6 +246,70 @@ impl Tmux {
check(out)
}
/// Escape-sequence snapshot of a pane's current screen (#…), for clients
/// that just attached.
///
/// The raw byte replay buffer cannot reconstruct a full-screen program:
/// its tail starts mid-stream, long after the app set up the modes,
/// colours and cursor position that give those bytes meaning. A browser
/// refresh therefore painted garbage until something forced the app to
/// repaint. tmux already holds the authoritative screen, so ask it.
///
/// Returns `Ok(None)` when the session or the tmux server is gone — the
/// caller falls back to the byte replay, which is all a dead session has.
pub fn capture_pane(&self, id: Uuid) -> Result<Option<Vec<u8>>, TmuxError> {
let name = session_name(id);
let start = format!("-{CAPTURE_HISTORY_LINES}");
// One invocation, so tmux runs both commands in a single pass of its
// command queue: the cursor position cannot belong to an older screen
// than the captured content. Two invocations raced — a program that
// switched to the alternate screen in between produced a snapshot with
// the new screen and the old cursor.
let out = self
.cmd()
.args([
"display-message",
"-p",
"-t",
&name,
"-F",
"#{cursor_x} #{cursor_y} #{pane_height} #{alternate_on} #{cursor_flag}",
";",
"capture-pane",
"-p",
"-e",
// Keep trailing spaces, so a line's background colour reaches
// the edge instead of stopping at the last glyph and letting
// the client's own background show through.
"-N",
"-t",
&name,
"-S",
&start,
])
.output()?;
let Some(stdout) = soft_output(out)? else {
return Ok(None);
};
let Some((head, content)) = stdout.split_once('\n') else {
return Ok(None);
};
let Some(geom) = parse_screen_geometry(head) else {
return Ok(None);
};
let mut lines: Vec<&str> = content
.strip_suffix('\n')
.unwrap_or(content)
.split('\n')
.collect();
// Scrollback belongs to the normal screen. Replaying it under an
// alternate-screen program would paste the shell's history behind it.
if geom.alternate {
lines.drain(..lines.len().saturating_sub(geom.pane_height));
}
Ok(Some(render_screen_snapshot(&lines, geom)))
}
/// `tmux resize-window -t maestro-<id> -x <cols> -y <rows>`.
pub fn resize_window(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), TmuxError> {
let name = session_name(id);
@@ -446,6 +510,124 @@ fn new_session_args(
args
}
/// How much scrollback a fresh attach inherits from tmux's history, in lines.
/// Enough to land in context after a browser refresh without making the first
/// frame huge.
const CAPTURE_HISTORY_LINES: usize = 300;
/// Cursor position and screen geometry of a pane, from `display-message`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ScreenGeometry {
cursor_x: usize,
cursor_y: usize,
pane_height: usize,
alternate: bool,
cursor_visible: bool,
}
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";
// Older tmux without `cursor_flag` just means "assume visible".
let cursor_visible = it.next().map(|f| f == "1").unwrap_or(true);
if pane_height == 0 {
return None;
}
Some(ScreenGeometry {
cursor_x,
cursor_y,
pane_height,
alternate,
cursor_visible,
})
}
/// Turn captured pane lines into bytes a fresh terminal can replay: clear,
/// print the lines, then put the cursor back where the pane has it.
///
/// The cursor is restored with *relative* moves so the snapshot renders the
/// same whatever height the client's terminal has — it may not match the
/// pane's yet, since the client's first `Resize` arrives after this.
fn render_screen_snapshot(lines: &[&str], geom: ScreenGeometry) -> Vec<u8> {
// Screen rows are the last `pane_height` captured lines; anything before
// them is scrollback.
let screen_top = lines.len().saturating_sub(geom.pane_height);
let cursor_row = (screen_top + geom.cursor_y).min(lines.len().saturating_sub(1));
// Trailing blank rows would scroll the real content off a short client
// terminal, so drop them — but never the cursor's own row or anything
// above it, which is where a program that draws below its cursor lives.
let last = lines
.iter()
.rposition(|l| !strip_sgr(l).trim().is_empty())
.unwrap_or(0)
.max(cursor_row);
let mut out = Vec::new();
// Put the client on the same buffer the pane is using, so a program's
// repaints stay out of the client's scrollback while it runs.
if geom.alternate {
out.extend_from_slice(b"\x1b[?1049h");
}
out.extend_from_slice(b"\x1b[H\x1b[2J\x1b[m");
for (i, line) in lines[..=last].iter().enumerate() {
if i > 0 {
out.extend_from_slice(b"\r\n");
}
out.extend_from_slice(line.as_bytes());
}
out.extend_from_slice(b"\x1b[m\r");
if last > cursor_row {
out.extend_from_slice(format!("\x1b[{}A", last - cursor_row).as_bytes());
}
if geom.cursor_x > 0 {
out.extend_from_slice(format!("\x1b[{}C", geom.cursor_x).as_bytes());
}
if !geom.cursor_visible {
out.extend_from_slice(b"\x1b[?25l");
}
out
}
/// Text of a line with SGR escapes removed, for emptiness checks.
fn strip_sgr(line: &str) -> String {
let mut out = String::with_capacity(line.len());
let mut chars = line.chars();
while let Some(c) = chars.next() {
if c != '\x1b' {
out.push(c);
continue;
}
// Skip CSI ... final-byte. Anything else escape-ish is not produced by
// `capture-pane -e`, so treating it the same way is harmless.
for c in chars.by_ref() {
if c.is_ascii_alphabetic() {
break;
}
}
}
out
}
/// `Ok(None)` when tmux failed because the server or the target is gone,
/// `Ok(Some(stdout))` on success, `Err` for anything else.
fn soft_output(out: Output) -> Result<Option<String>, TmuxError> {
if out.status.success() {
return Ok(Some(String::from_utf8_lossy(&out.stdout).into_owned()));
}
let stderr = String::from_utf8_lossy(&out.stderr);
if is_no_server_error(&stderr) || stderr.contains("can't find") {
return Ok(None);
}
Err(TmuxError::NonZero {
code: out.status.code().unwrap_or(-1),
stderr: stderr.into_owned(),
})
}
fn check(out: Output) -> Result<(), TmuxError> {
if out.status.success() {
Ok(())
@@ -461,6 +643,85 @@ fn check(out: Output) -> Result<(), TmuxError> {
mod tests {
use super::*;
#[test]
fn parse_screen_geometry_reads_cursor_and_alternate_flag() {
let g = parse_screen_geometry("12 3 40 1 0\n").unwrap();
assert_eq!(
g,
ScreenGeometry {
cursor_x: 12,
cursor_y: 3,
pane_height: 40,
alternate: true,
cursor_visible: false,
}
);
let g = parse_screen_geometry("0 0 24 0").unwrap();
assert!(!g.alternate);
assert!(g.cursor_visible, "missing cursor_flag means visible");
assert!(parse_screen_geometry("0 0 0 0").is_none());
assert!(parse_screen_geometry("garbage").is_none());
}
#[test]
fn snapshot_clears_prints_lines_and_parks_the_cursor() {
// Two scrollback lines, then a 3-row screen with the cursor on row 1.
let lines = ["old", "older", "prompt> hi", "", ""];
let out = render_screen_snapshot(
&lines,
ScreenGeometry {
cursor_x: 10,
cursor_y: 0,
pane_height: 3,
alternate: false,
cursor_visible: true,
},
);
let out = String::from_utf8(out).unwrap();
assert_eq!(
out,
"\x1b[H\x1b[2J\x1b[mold\r\nolder\r\nprompt> hi\x1b[m\r\x1b[10C"
);
}
#[test]
fn snapshot_keeps_rows_below_the_cursor_and_moves_back_up() {
let lines = ["prompt> hi", "hint line", ""];
let out = render_screen_snapshot(
&lines,
ScreenGeometry {
cursor_x: 0,
cursor_y: 0,
pane_height: 3,
alternate: true,
cursor_visible: false,
},
);
let out = String::from_utf8(out).unwrap();
assert!(out.starts_with("\x1b[?1049h\x1b[H\x1b[2J"), "{out:?}");
assert!(
out.ends_with("prompt> hi\r\nhint line\x1b[m\r\x1b[1A\x1b[?25l"),
"{out:?}"
);
}
#[test]
fn snapshot_ignores_colour_only_rows_when_trimming() {
let lines = ["text", "\x1b[38;5;4m\x1b[m", ""];
let out = String::from_utf8(render_screen_snapshot(
&lines,
ScreenGeometry {
cursor_x: 4,
cursor_y: 0,
pane_height: 3,
alternate: false,
cursor_visible: true,
},
))
.unwrap();
assert_eq!(out, "\x1b[H\x1b[2J\x1b[mtext\x1b[m\r\x1b[4C");
}
/// 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();
@@ -188,6 +188,64 @@ async fn create_captures_initial_output_in_replay_buffer() {
fx.manager.kill(s.id, false).await.unwrap();
}
/// A fresh attach must land on the pane's *current* screen. Replaying the raw
/// byte tail instead reproduced a browser refresh painting garbage over an
/// agent TUI: the tail starts mid-stream, so the clears, modes and cursor
/// moves that gave those bytes meaning are long gone.
#[tokio::test]
async fn attach_replay_is_the_current_screen_not_the_byte_tail() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let s = fx
.manager
.create(CreateSessionRequest {
cwd: cwd.path().to_path_buf(),
// Print a line, then switch to the alternate screen the way a
// full-screen program does on startup. Only the second marker is
// on the pane; the first belongs to the screen behind it.
command: "sh -c 'printf STALE_MARKER_LINE\\\\n; sleep 0.5; printf \"\\\\033[?1049h\\\\033[2J\\\\033[HLIVE_MARKER\"; exec sleep 30'"
.into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("create");
let want = b"LIVE_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!(
replay.starts_with(b"\x1b[?1049h\x1b[H\x1b[2J"),
"replay did not start with an alternate-screen snapshot: {text:?}"
);
assert!(
text.contains("LIVE_MARKER"),
"replay lost the live screen: {text:?}"
);
assert!(
!text.contains("STALE_MARKER_LINE"),
"replay carried bytes from behind the alternate screen: {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
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':
+6 -1
View File
@@ -27,6 +27,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;
}
@@ -97,7 +99,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 +384,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();
});
+158 -25
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,18 @@ 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";
/** 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 +75,33 @@ function metaLine(s: Session, now: number): string {
s.activity_detail
? `<span class="activity-detail">${escapeHtml(s.activity_detail)}</span>`
: "",
blockers
? `<span class="card-flag leaf-blocked" title="Kill will be refused unless forced">⚠ ${escapeHtml(formatInTheWay(blockers))}</span>`
: "",
blockers ? blockedFragment(blockers) : "",
dead ? `<span class="card-flag">exited</span>` : "",
`<span class="card-flag leaf-cmd" title="${escapeHtml(s.command)}">${escapeHtml(s.command)}</span>`,
commandFragment(s.command),
];
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
}
/** docs/SIDEBAR.md: blocked work renders as diff notation — `+124 -18 ?3 ↑2`
* — with the spelled-out breakdown kept in the tooltip. */
function blockedFragment(blockers: InTheWay): string {
const stats = blockerStats(blockers)
.map((st) => `<span class="blocked-stat" data-stat="${st.kind}">${escapeHtml(st.text)}</span>`)
.join("");
const title = `Kill will be refused unless forced — ${formatInTheWay(blockers)}`;
return `<span class="card-flag leaf-blocked" title="${escapeHtml(title)}">${stats}</span>`;
}
/** docs/SIDEBAR.md: the command's leading program becomes its tool icon when
* we have a mark for it, and only the remaining words stay as text. The full
* command line stays in the tooltip either way. */
function commandFragment(command: string): string {
const { tool, rest } = splitCommand(command);
const icon = tool ? toolIconSvg(tool) : null;
const text = icon ? rest : command;
return `<span class="card-flag leaf-cmd" title="${escapeHtml(command)}">${icon ?? ""}${text ? `<span class="leaf-cmd-rest">${escapeHtml(text)}</span>` : ""}</span>`;
}
function renderNode(node: TreeNode, depth: number): HTMLElement {
if (node.kind === "folder") {
const wrap = document.createElement("div");
@@ -81,6 +114,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 +144,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);
@@ -265,8 +300,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 +340,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 +357,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 +408,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 +494,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 +503,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 +594,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 +627,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 +634,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);
+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();
});
}
+34 -14
View File
@@ -1,6 +1,11 @@
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 { installHudKeybind } from "./debug.js";
import { installHelpKeybind, isHelpOpen, isTypingTarget } from "./help.js";
import { CatalogStore } from "./events.js";
import { shouldRetryClose } from "./reconnect.js";
import {
@@ -15,11 +20,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 } 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 +34,7 @@ import "./styles.css";
initTheme();
installViewportSizing();
installHudKeybind();
installHelpKeybind();
registerServiceWorker();
// Register the PWA service worker so the app is installable and the shell loads
@@ -219,6 +226,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 +273,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
@@ -279,7 +287,7 @@ document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
document.addEventListener("catalog-spawn-similar", (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 +335,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 +347,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 {
openDrawer(); // 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"),
+4
View File
@@ -36,6 +36,10 @@ function fakeSidebar(): SidebarHandle {
el: document.createElement("aside"),
setActive() {},
setHighlights() {},
focusList() {},
hasListFocus() {
return false;
},
dispose() {},
};
}
+3 -15
View File
@@ -22,7 +22,6 @@ import {
removeLeaf,
splitLeaf,
} from "./layout.js";
import { sessionColor } from "./sessioncolor.js";
import type { AttachInfo } from "./attach.js";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
@@ -97,12 +96,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
// ---- highlight helpers ----
function colorMap(): Map<string, string> {
const m = new Map<string, string>();
for (const id of openSessions(layout)) m.set(id, sessionColor(id));
return m;
}
function focusedLeafSession(): string | null {
const tab = activeTab(layout);
const fl = tab.focusedLeafId ? findLeaf(tab.root, tab.focusedLeafId) : null;
@@ -120,7 +113,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
sidebar.setHighlights({
open: openSessions(layout),
focused: focusedLeafSession(),
colors: colorMap(),
});
}
@@ -142,10 +134,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
const pane = document.createElement("div");
pane.className = "mv-pane";
pane.dataset.leafId = leaf.id;
if (leaf.sessionId) {
pane.dataset.sessionId = leaf.sessionId;
pane.style.setProperty("--mv-color", sessionColor(leaf.sessionId));
}
if (leaf.sessionId) pane.dataset.sessionId = leaf.sessionId;
const host = document.createElement("div");
host.className = "mv-pane-host";
@@ -482,7 +471,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
pool.setVisible([]);
pool.setFocus(null);
canvas.appendChild(renderWelcome());
sidebar.setHighlights({ open: openSessions(layout), focused: null, colors: colorMap() });
sidebar.setHighlights({ open: openSessions(layout), focused: null });
persist();
return;
}
@@ -523,7 +512,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
pill.type = "button";
pill.className = "mv-pill" + (l.id === focused.id ? " is-active" : "");
pill.textContent = l.sessionId ? (infoFor(l.sessionId)?.name ?? l.sessionId.slice(0, 8)) : "empty";
if (l.sessionId) pill.style.setProperty("--mv-color", sessionColor(l.sessionId));
pill.addEventListener("click", () => {
tab.focusedLeafId = l.id;
render();
@@ -543,7 +531,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
focused.sessionId ? [{ id: focused.sessionId, pane: pane.host, info: infoFor(focused.sessionId) }] : [],
);
pool.setFocus(focused.sessionId ?? null);
sidebar.setHighlights({ open: openSessions(layout), focused: focused.sessionId ?? null, colors: colorMap() });
sidebar.setHighlights({ open: openSessions(layout), focused: focused.sessionId ?? null });
}
function closeSession(sessionId: string): void {
-23
View File
@@ -1,23 +0,0 @@
// Stable per-session accent colour, used to map a sidebar row to the pane
// showing that session (issue #33). Hash the session id into a fixed OKLCH
// palette tuned to the maestro tokens; collisions are tolerated when there are
// more open sessions than hues — the colour is a hint, not an identity.
import type { Uuid } from "./types.js";
const PALETTE = [
"oklch(0.80 0.13 152)", // green (the house accent)
"oklch(0.80 0.12 90)", // amber
"oklch(0.76 0.13 30)", // coral
"oklch(0.78 0.11 210)", // teal
"oklch(0.74 0.13 265)", // blue
"oklch(0.76 0.14 320)", // magenta
"oklch(0.80 0.11 60)", // gold
"oklch(0.78 0.12 185)", // cyan
];
export function sessionColor(id: Uuid): string {
let h = 0;
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;
return PALETTE[h % PALETTE.length];
}
+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,
};
}
+183 -50
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
@@ -2106,7 +2278,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 +2366,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,31 +2471,12 @@ input.leaf-rename:focus {
border-radius: var(--r-1);
}
/* sidebar two-tier highlight + colour dot (issue #33).
/* sidebar two-tier highlight (issue #33).
.is-active = focused pane (loud, from existing rule); .is-open = open
elsewhere (quiet). The dot uses the session's colour so a row maps to a pane. */
elsewhere (quiet). */
.tree-leaf.is-open {
background: color-mix(in oklab, var(--accent) 6%, transparent);
}
.tree-leaf.is-open::after,
.tree-leaf.is-active::after {
content: "";
position: absolute;
right: 9px;
top: 50%;
width: 7px;
height: 7px;
margin-top: -3.5px;
border-radius: 50%;
background: var(--mv-color, var(--accent));
pointer-events: none;
}
.tree-leaf.is-open::after {
opacity: 0.7;
}
.tree-leaf.is-active::after {
box-shadow: 0 0 8px var(--mv-color, var(--accent-ring));
}
/* =========================================================================
multi-view mobile (#35): below 860px the split tree projects to a pill strip
@@ -2346,7 +2488,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;
@@ -2380,14 +2521,6 @@ input.leaf-rename:focus {
cursor: pointer;
border: 1px solid transparent;
}
.mv-pill::before {
content: "";
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--mv-color, var(--faint));
flex: none;
}
.mv-pill.is-active {
background: var(--accent-tint);
color: var(--accent-strong);
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { splitCommand, toolIconSvg } from "./toolIcon.js";
describe("tool icons on the command fragment (docs/SIDEBAR.md)", () => {
it("recognises the agents and shells sessions are spawned with", () => {
const cases: Array<[string, string]> = [
["claude", "claude"],
["codex", "codex"],
["opencode", "opencode"],
["gemini", "gemini"],
["aider", "aider"],
["cursor-agent", "cursor"],
["pi", "pi"],
["zsh", "shell"],
["bash", "shell"],
["fish", "shell"],
];
for (const [command, tool] of cases) {
expect(splitCommand(command).tool, command).toBe(tool);
}
});
it("keeps everything after the program as text", () => {
expect(splitCommand("claude --resume -p hi")).toEqual({
tool: "claude",
rest: "--resume -p hi",
});
expect(splitCommand(" zsh -l ")).toEqual({ tool: "shell", rest: "-l" });
});
it("looks through paths, env assignments and wrappers", () => {
expect(splitCommand("/usr/local/bin/claude").tool).toBe("claude");
expect(splitCommand("FOO=1 BAR=2 claude").tool).toBe("claude");
expect(splitCommand("npx opencode").tool).toBe("opencode");
expect(splitCommand("pnpm dlx opencode run").tool).toBe("opencode");
expect(splitCommand("pnpm dlx opencode run").rest).toBe("run");
});
it("leaves an unrecognised program as text, mark-less", () => {
expect(splitCommand("hx src/main.rs")).toEqual({
tool: null,
rest: "hx src/main.rs",
});
// A wrapper with nothing after it is not a tool either.
expect(splitCommand("sudo").tool).toBeNull();
expect(splitCommand("").tool).toBeNull();
});
it("gives every recognised tool a labelled mark", () => {
for (const tool of ["claude", "codex", "opencode", "gemini", "aider", "cursor", "pi", "shell"]) {
const svg = toolIconSvg(tool);
expect(svg, tool).toContain("<svg");
expect(svg, tool).toContain(`data-tool="${tool}"`);
expect(svg, tool).toContain("aria-label=");
}
expect(toolIconSvg("nonesuch")).toBeNull();
});
});
+141
View File
@@ -0,0 +1,141 @@
/** Tool icons for the sidebar's command fragment (docs/SIDEBAR.md).
*
* A session's command line starts with the program that runs it, and that
* program is nearly always one of a handful of agents or a shell. Rendering it
* as its own mark buys back most of the fragment's width, which the sidebar
* spends on the rest of the command line instead.
*
* Recognition is deliberately narrow: an unrecognised program keeps its text,
* so the fragment never renders a mark that stands for the wrong tool.
*/
/** Command wrappers that run another program: the tool is what follows. */
const WRAPPERS = new Set(["sudo", "env", "npx", "bunx", "uvx", "pnpm", "yarn", "command"]);
/** Program name (already basenamed and lowercased) → icon id. */
const BY_PROGRAM: Record<string, string> = {
claude: "claude",
"claude-code": "claude",
codex: "codex",
opencode: "opencode",
gemini: "gemini",
aider: "aider",
cursor: "cursor",
"cursor-agent": "cursor",
pi: "pi",
bash: "shell",
zsh: "shell",
sh: "shell",
fish: "shell",
dash: "shell",
ksh: "shell",
nu: "shell",
xonsh: "shell",
};
/** Human name behind an icon id, for the tooltip and the aria label. */
export const TOOL_LABELS: Record<string, string> = {
claude: "Claude Code",
codex: "Codex",
opencode: "opencode",
gemini: "Gemini CLI",
aider: "Aider",
cursor: "Cursor",
pi: "pi",
shell: "Shell",
};
export interface CommandParts {
/** Icon id, or `null` when the program is not one we have a mark for. */
tool: string | null;
/** What is left of the command line once the icon stands for the program.
* Empty when the icon says everything the command line said. */
rest: string;
}
/** Split a command line into the tool its icon stands for and the remainder.
*
* `claude --resume` → `{tool: "claude", rest: "--resume"}`; an unrecognised
* `hx src/` keeps the whole line as `rest` with no tool. */
export function splitCommand(command: string): CommandParts {
const words = command.trim().split(/\s+/).filter(Boolean);
let i = 0;
// Leading `FOO=bar` assignments and wrappers stand in front of the real
// program; `pnpm dlx x` / `yarn dlx x` need one more word skipped.
while (i < words.length) {
const word = words[i];
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
i += 1;
continue;
}
const program = basename(word);
if (WRAPPERS.has(program)) {
i += 1;
if ((program === "pnpm" || program === "yarn") && words[i] === "dlx") i += 1;
continue;
}
break;
}
if (i >= words.length) return { tool: null, rest: command.trim() };
const tool = BY_PROGRAM[basename(words[i])] ?? null;
if (!tool) return { tool: null, rest: command.trim() };
return { tool, rest: words.slice(i + 1).join(" ") };
}
function basename(word: string): string {
const cut = word.lastIndexOf("/");
return (cut >= 0 ? word.slice(cut + 1) : word).toLowerCase();
}
/** Anthropic's burst: tapered rays around a centre, long ones on the axes. */
function burst(): string {
const rays = Array.from({ length: 8 }, (_, k) => {
const long = k % 2 === 0;
const tip = long ? 1.1 : 3.4;
const waist = long ? 6.3 : 6.6;
const half = long ? 1.05 : 0.85;
return `<path d="M8 ${tip} L${8 + half} ${waist} L8 8 L${8 - half} ${waist} Z" transform="rotate(${k * 45} 8 8)"/>`;
}).join("");
return `<g fill="#d97757">${rays}</g>`;
}
const SVGS: Record<string, string> = {
claude: burst(),
// A hexafoil knot in the shape of OpenAI's mark. The petals stay thin and
// wide apart: three fatter loops merge into a blob at 13px.
codex: `<g fill="none" stroke="currentColor" stroke-width="1.05">
<ellipse cx="8" cy="8" rx="2.5" ry="6.7"/>
<ellipse cx="8" cy="8" rx="2.5" ry="6.7" transform="rotate(60 8 8)"/>
<ellipse cx="8" cy="8" rx="2.5" ry="6.7" transform="rotate(120 8 8)"/>
</g>`,
gemini: `<path fill="#4285f4" d="M8 0.8C8.6 4.6 11.4 7.4 15.2 8C11.4 8.6 8.6 11.4 8 15.2C7.4 11.4 4.6 8.6 0.8 8C4.6 7.4 7.4 4.6 8 0.8Z"/>`,
// An isometric cube, the shape Cursor's mark is built from.
cursor: `<g fill="currentColor">
<path d="M8 1 L14.5 4.75 L8 8.5 L1.5 4.75 Z" opacity="0.95"/>
<path d="M14.5 4.75 L14.5 12.25 L8 16 L8 8.5 Z" opacity="0.5"/>
<path d="M1.5 4.75 L8 8.5 L8 16 L1.5 12.25 Z" opacity="0.75"/>
</g>`,
shell: `<g fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M2.6 4 L6.4 8 L2.6 12"/>
<path d="M8.4 12.6 H13.4"/>
</g>`,
opencode: monogram("oc"),
aider: monogram("ai"),
pi: monogram("π"),
};
/** Tools whose own mark is a wordmark get their initials instead — inventing
* a logo for them would be worse than saying which letters they start with.
* No chip around the letters: a border this small eats the height the letters
* need to stay apart. */
function monogram(text: string): string {
return `<text x="8" y="8.6" fill="currentColor" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="11" font-weight="700" letter-spacing="-0.6" text-anchor="middle" dominant-baseline="central">${text}</text>`;
}
/** The `<svg>` for an icon id, or `null` when the id has no mark. */
export function toolIconSvg(tool: string): string | null {
const body = SVGS[tool];
if (!body) return null;
const label = TOOL_LABELS[tool] ?? tool;
return `<svg class="tool-icon" data-tool="${tool}" viewBox="0 0 16 16" width="13" height="13" role="img" aria-label="${label}"><title>${label}</title>${body}</svg>`;
}
+36
View File
@@ -164,6 +164,42 @@ export interface InTheWay {
uncommitted_files?: number;
untracked_files?: number;
unpushed_commits?: number;
/** Lines added across the tracked changes, staged and unstaged together. */
insertions?: number;
/** Lines removed across the same tracked changes. */
deletions?: number;
}
/** One fragment of the sidebar's compact blocked-work stat (docs/SIDEBAR.md).
* `kind` picks the colour; `text` is what the client prints. */
export interface BlockerStat {
kind: "add" | "del" | "files" | "untracked" | "unpushed";
text: string;
}
/** The blocked-work breakdown as diff notation: `+124 -18 ?3 ↑2`.
*
* The line counts cover tracked changes only, so a tree whose only tracked
* change carries no line delta — a mode change, a rename — falls back to the
* file count (`~2`) rather than claiming `+0 -0`. Both clients render exactly
* these fragments; the spelled-out {@link formatInTheWay} survives in the
* tooltip and in the kill refusal. */
export function blockerStats(w: InTheWay): BlockerStat[] {
const stats: BlockerStat[] = [];
const added = w.insertions ?? 0;
const removed = w.deletions ?? 0;
const changed = w.uncommitted_files ?? 0;
if (added > 0 || removed > 0) {
if (added > 0) stats.push({ kind: "add", text: `+${added}` });
if (removed > 0) stats.push({ kind: "del", text: `-${removed}` });
} else if (changed > 0) {
stats.push({ kind: "files", text: `~${changed}` });
}
if ((w.untracked_files ?? 0) > 0)
stats.push({ kind: "untracked", text: `?${w.untracked_files}` });
if ((w.unpushed_commits ?? 0) > 0)
stats.push({ kind: "unpushed", text: `\u2191${w.unpushed_commits}` });
return stats;
}
/** The phrasing `maestro_protocol::InTheWay`'s Display impl uses — shared by
+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");
});
});