Compare commits

...
Author SHA1 Message Date
Miguel Palhas 88b6bba9f2 feat(maestro): show only the program on session cards
ci / web (push) Successful in 34s
ci / rust (push) Successful in 3m56s
ci / flake (push) Successful in 7m8s
The command fragment named the full command line, so a session spawned
with `claude --dangerously-skip-permissions` spent the whole line on a
flag. Cards now name the program only — its icon in the web client, its
bare name in the TUI — and keep the full command line in the tooltip.

Adds `maestro_protocol::display::command_program`, the Rust counterpart
of the web's `splitCommand`.
2026-09-01 11:11:39 +01:00
Miguel PalhasandClaude Opus 5 1c349e5858 ci(maestro): cancel superseded runs
ci / web (push) Successful in 1m30s
ci / rust (push) Successful in 3m42s
ci / flake (push) Successful in 5m50s
Pushes queued a new run without stopping the old one. Group by ref and
cancel in-progress, except on main where runs finish.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 10:35:31 +01:00
Miguel Palhas 9fc21b4306 Merge remote-tracking branch 'origin/main' into moderato-motif
ci / flake (push) Has been cancelled
ci / web (push) Has been cancelled
ci / rust (push) Has been cancelled
2026-09-01 10:00:32 +01:00
naps62 19162f9952 Merge pull request 'feat(maestro): hide tab bar when a single tab' (#129) from marcato-lute into main
ci / web (push) Has been cancelled
ci / flake (push) Has been cancelled
ci / rust (push) Has been cancelled
Reviewed-on: #129
2026-09-01 09:54:58 +01:00
Miguel PalhasandClaude Opus 5 2fcfc06c3c feat(maestro): drop the global app header
The header carried only the brand and a session tally; app actions had
already moved to the sidebar footer. The mobile ☰ it hosted moves to the
pane header (replacing the redundant back arrow) and to the welcome
state, so the session drawer stays reachable without a swipe. The DEV
`?kit` link moves to the sidebar footer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 09:51:22 +01:00
Miguel Palhas f82ab45766 Merge remote-tracking branch 'origin/main' into marcato-lute
ci / web (pull_request) Successful in 1m16s
ci / rust (pull_request) Successful in 7m39s
ci / flake (pull_request) Has been cancelled
2026-09-01 09:49:07 +01:00
Miguel Palhas d304171d7c feat(maestro): hide tab bar when a single tab
The bar has nothing to switch between with one tab, so it is pure chrome. It reappears as soon as a second tab exists; spawn already opens its own tab, so the "+" button stays reachable.
2026-09-01 09:48:15 +01:00
Miguel Palhas 95b459bd65 Merge remote-tracking branch 'origin/main' into largo-piano
ci / web (push) Successful in 40m34s
ci / rust (push) Successful in 44m56s
ci / flake (push) Has been cancelled
2026-08-31 22:40:53 +01:00
Miguel PalhasandClaude Opus 5 c9cfdb5668 feat(maestro): one-word handles, quieter project boxes
Session handles are one word from a single 218-entry list — instruments,
forms, notation and theory, tempo and dynamics — replacing the
adjective-noun pair. A batch is shuffled, so it repeats only once the
wordlist runs out.

Project boxes show the parent directory instead of the full path: the
last segment is already the name line above it. The last command is gone
from the box, and picking a project no longer pre-selects it — the
directory and what runs in it are independent choices. Both changes land
in the TUI picker too, which mirrors the web form's rules.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 22:40:49 +01:00
24 changed files with 588 additions and 323 deletions
+4
View File
@@ -7,6 +7,10 @@ on:
- 'linear/**'
pull_request:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
rust:
runs-on: ubuntu-latest
+4 -7
View File
@@ -232,17 +232,14 @@ async fn projects_returns_empty_for_a_disabled_scanner() {
assert!(projects.is_empty());
}
/// #53: every returned handle splits into a known adjective and noun.
/// #53: every returned handle is one word from the shared list.
fn assert_valid_names(batch: &maestro_protocol::NameBatch) {
for name in &batch.names {
let (adj, noun) = name.split_once('-').unwrap_or_else(|| {
panic!("handle {name:?} is not adjective-noun");
});
assert!(!name.contains('-'), "handle {name:?} is not one word");
assert!(
maestro_names::ADJECTIVES.contains(&adj),
"{adj} not in ADJECTIVES"
maestro_names::WORDS.contains(&name.as_str()),
"{name} not in WORDS"
);
assert!(maestro_names::NOUNS.contains(&noun), "{noun} not in NOUNS");
}
}
+233 -29
View File
@@ -1,10 +1,195 @@
//! The one home of the session-handle wordlists (#53).
//! The one home of the session-handle wordlist (#53).
//!
//! Previously duplicated in `web/src/names.ts`; every client now gets handles
//! from here, served by `GET /api/names`.
/// Musical adjectives — tempo, dynamics, and articulation markings.
pub const ADJECTIVES: &[&str] = &[
/// Every word a handle can be: instruments, forms, and the vocabulary of
/// tempo, dynamics, notation and theory. One word per handle — the list is
/// wide enough that a single word still reads as distinct.
pub const WORDS: &[&str] = &[
// Instruments
"cello",
"violin",
"viola",
"oboe",
"flute",
"piccolo",
"clarinet",
"bassoon",
"trumpet",
"trombone",
"tuba",
"horn",
"cornet",
"bugle",
"flugelhorn",
"euphonium",
"saxophone",
"recorder",
"timpani",
"harp",
"piano",
"organ",
"lyre",
"lute",
"mandolin",
"banjo",
"ukulele",
"guitar",
"sitar",
"koto",
"shamisen",
"balalaika",
"zither",
"dulcimer",
"kantele",
"erhu",
"guzheng",
"pipa",
"duduk",
"ocarina",
"kalimba",
"harmonica",
"accordion",
"bandoneon",
"bagpipe",
"harpsichord",
"clavichord",
"celesta",
"theremin",
"marimba",
"xylophone",
"vibraphone",
"glockenspiel",
"snare",
"cymbal",
"tambourine",
"castanet",
"djembe",
"tabla",
"bodhran",
"gong",
"chimes",
"contrabass",
"shawm",
"crumhorn",
"sackbut",
"didgeridoo",
// Forms and pieces
"sonata",
"fugue",
"canon",
"prelude",
"overture",
"rondo",
"nocturne",
"scherzo",
"etude",
"ballade",
"barcarolle",
"bagatelle",
"caprice",
"chaconne",
"passacaglia",
"toccata",
"intermezzo",
"interlude",
"minuet",
"gavotte",
"sarabande",
"gigue",
"allemande",
"courante",
"mazurka",
"polonaise",
"waltz",
"bolero",
"habanera",
"tarantella",
"fandango",
"serenade",
"aubade",
"requiem",
"cantata",
"oratorio",
"madrigal",
"motet",
"chorale",
"hymn",
"anthem",
"lullaby",
"elegy",
"rhapsody",
"symphony",
"concerto",
"fantasia",
"divertimento",
"quartet",
"quintet",
"sextet",
"septet",
"octet",
"trio",
// Structure, notation and theory
"octave",
"chord",
"cadence",
"cadenza",
"coda",
"motif",
"refrain",
"verse",
"chorus",
"bridge",
"finale",
"arpeggio",
"glissando",
"ostinato",
"counterpoint",
"descant",
"harmony",
"melody",
"rhythm",
"tempo",
"timbre",
"treble",
"clef",
"staff",
"quaver",
"crotchet",
"semibreve",
"fermata",
"trill",
"mordent",
"appoggiatura",
"portamento",
"vibrato",
"triplet",
"tuplet",
"downbeat",
"upbeat",
"anacrusis",
"syncopation",
"modulation",
"transposition",
"unison",
"semitone",
"tritone",
"diapason",
"tonic",
"dominant",
"mediant",
"ionian",
"dorian",
"phrygian",
"lydian",
"mixolydian",
"aeolian",
"locrian",
"pentatonic",
"chromatic",
"diatonic",
// Tempo, dynamics and articulation
"allegro",
"andante",
"adagio",
@@ -15,6 +200,7 @@ pub const ADJECTIVES: &[&str] = &[
"lento",
"moderato",
"crescendo",
"diminuendo",
"staccato",
"legato",
"forte",
@@ -35,60 +221,78 @@ pub const ADJECTIVES: &[&str] = &[
"sotto",
"vivo",
"feroce",
];
/// Musical nouns — instruments and forms from the concert hall.
pub const NOUNS: &[&str] = &[
"cello", "violin", "viola", "oboe", "flute", "clarinet", "bassoon", "trumpet", "trombone",
"tuba", "horn", "timpani", "harp", "piano", "organ", "lyre", "lute", "fugue", "sonata",
"octave", "chord", "cadence", "prelude", "rondo", "nocturne", "scherzo", "refrain", "coda",
"motif", "overture",
"giocoso",
"affettuoso",
"risoluto",
"leggiero",
"pesante",
"morendo",
"accelerando",
"ritardando",
];
/// Upper bound for a `GET /api/names?count=N` batch.
pub const MAX_BATCH: usize = 100;
/// A fresh `adjective-noun` handle, e.g. `allegro-cello`.
/// A fresh one-word handle, e.g. `nocturne`.
pub fn generate() -> String {
use rand::seq::SliceRandom;
let mut rng = rand::thread_rng();
format!(
"{}-{}",
ADJECTIVES.choose(&mut rng).unwrap(),
NOUNS.choose(&mut rng).unwrap()
)
(*WORDS.choose(&mut rng).unwrap()).to_string()
}
/// `count` fresh handles (the caller clamps; this only trusts its input).
/// `count` fresh handles, distinct while the wordlist lasts (the caller
/// clamps; this only trusts its input).
pub fn generate_batch(count: usize) -> Vec<String> {
(0..count).map(|_| generate()).collect()
use rand::seq::SliceRandom;
let mut rng = rand::thread_rng();
let mut pool: Vec<&str> = WORDS.to_vec();
pool.shuffle(&mut rng);
(0..count)
.map(|i| match pool.get(i) {
// Past the wordlist a batch has to repeat itself.
Some(word) => (*word).to_string(),
None => generate(),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
#[test]
fn generated_name_is_adjective_dash_noun() {
fn generated_name_is_a_single_known_word() {
let name = generate();
let (adj, noun) = name.split_once('-').expect("name contains a dash");
assert!(ADJECTIVES.contains(&adj), "{adj} not in ADJECTIVES");
assert!(NOUNS.contains(&noun), "{noun} not in NOUNS");
assert!(!name.contains('-'), "{name} is not one word");
assert!(WORDS.contains(&name.as_str()), "{name} not in WORDS");
}
#[test]
fn batch_returns_the_requested_count() {
fn the_wordlist_has_no_duplicates_and_covers_a_full_batch() {
let unique: HashSet<&&str> = WORDS.iter().collect();
assert_eq!(unique.len(), WORDS.len(), "WORDS has duplicates");
assert!(
WORDS.len() >= MAX_BATCH,
"a full batch would have to repeat"
);
}
#[test]
fn batch_returns_the_requested_count_without_repeats() {
assert_eq!(generate_batch(1).len(), 1);
assert_eq!(generate_batch(50).len(), 50);
for name in generate_batch(10) {
let (adj, noun) = name.split_once('-').unwrap();
assert!(ADJECTIVES.contains(&adj) && NOUNS.contains(&noun));
let batch = generate_batch(MAX_BATCH);
assert_eq!(batch.len(), MAX_BATCH);
for name in &batch {
assert!(WORDS.contains(&name.as_str()), "{name} not in WORDS");
}
let unique: HashSet<&String> = batch.iter().collect();
assert_eq!(unique.len(), batch.len(), "batch repeated a handle");
}
#[test]
fn rerolls_can_differ() {
// 30x30=900 combinations; 20 draws all matching has probability ~1e-40.
let first = generate();
let differs = (0..20).any(|_| generate() != first);
assert!(differs);
+81
View File
@@ -140,6 +140,63 @@ pub fn merge_linear<T>(folder: TreeFolder<T>) -> TreeFolder<T> {
cur
}
/// Command wrappers that run another program: the program is what follows.
const WRAPPERS: [&str; 8] = [
"sudo", "env", "npx", "bunx", "uvx", "pnpm", "yarn", "command",
];
/// The program a command line runs, basenamed and stripped of its arguments —
/// what a session card shows per `docs/SIDEBAR.md`, which names the program
/// and never its flags. Leading `FOO=bar` assignments and wrappers (`sudo`,
/// `npx`, `pnpm dlx`, …) are looked through. Mirrors the web's `splitCommand`.
pub fn command_program(command: &str) -> String {
let words: Vec<&str> = command.split_whitespace().collect();
let mut i = 0;
while i < words.len() {
let word = words[i];
if is_env_assignment(word) {
i += 1;
continue;
}
let wrapper = basename(word).to_lowercase();
if WRAPPERS.contains(&wrapper.as_str()) {
i += 1;
if (wrapper == "pnpm" || wrapper == "yarn") && words.get(i) == Some(&"dlx") {
i += 1;
}
continue;
}
break;
}
match words.get(i) {
Some(word) => basename(word).to_string(),
// Nothing but wrappers and assignments: say what was there rather than
// rendering an empty fragment.
None => command.trim().to_string(),
}
}
/// `FOO=bar`, the shell's own prefix for one command's environment.
fn is_env_assignment(word: &str) -> bool {
let mut chars = word.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
let rest: String = chars.collect();
match rest.split_once('=') {
Some((name, _)) => name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
None => false,
}
}
fn basename(word: &str) -> &str {
match word.rsplit_once('/') {
Some((_, tail)) => tail,
None => word,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -318,4 +375,28 @@ mod tests {
};
assert_eq!(nested.name, "a/b");
}
#[test]
fn command_program_drops_arguments() {
assert_eq!(
command_program("claude --dangerously-skip-permissions"),
"claude"
);
assert_eq!(command_program(" zsh -l "), "zsh");
assert_eq!(command_program("hx src/main.rs"), "hx");
}
#[test]
fn command_program_looks_through_paths_env_and_wrappers() {
assert_eq!(command_program("/usr/local/bin/claude -p hi"), "claude");
assert_eq!(command_program("FOO=1 BAR=2 claude"), "claude");
assert_eq!(command_program("npx opencode"), "opencode");
assert_eq!(command_program("pnpm dlx opencode run"), "opencode");
}
#[test]
fn command_program_keeps_a_line_that_is_only_wrappers() {
assert_eq!(command_program("sudo"), "sudo");
assert_eq!(command_program(""), "");
}
}
+1 -1
View File
@@ -506,7 +506,7 @@ pub struct DaemonInfo {
}
/// Payload of `GET /api/names?count=N` (#53) — a batch of fresh
/// `adjective-noun` session handles generated by the `maestro-names` crate.
/// one-word session handles generated by the `maestro-names` crate.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NameBatch {
pub names: Vec<String>,
+28 -18
View File
@@ -18,8 +18,9 @@
//!
//! The picker itself is two steps (#96), verbatim from the rules in
//! `web/src/spawnForm.ts`'s "working-directory picker (#95)" comment:
//! 1. Step one is one box per repository (name, then full path). A worktree
//! is a box only when its main checkout is missing from the scan.
//! 1. Step one is one box per repository (name, then its parent directory).
//! A worktree is a box only when its main checkout is missing from the
//! scan.
//! 2. The filter keeps a repository when the query matches it or any of its
//! worktrees, so a worktree name still finds its box.
//! 3. Picking a box reveals step two: "+ new", then that repository's
@@ -32,6 +33,8 @@
//! these are separate acts.
//! 6. Neither step probes `/api/gitroot`; the scan already reports
//! repository membership. Only the free-text hatch pays for a probe.
//! 7. Neither step touches the command: the directory and what runs in it
//! are independent choices.
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
@@ -39,6 +42,7 @@ use std::time::{Duration, Instant};
use chrono::{DateTime, Utc};
use maestro_config::{builtin_presets, custom_preset};
use maestro_protocol::display::abbreviate_dir;
use maestro_protocol::{BranchInfo, CreateSessionRequest, Project, SpawnPreset, WorktreeSpec};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
@@ -128,6 +132,15 @@ pub struct DraftWorktreeRow {
pub stale: bool,
}
/// A box's second column: the directory holding the project, `~`-abbreviated.
/// Only the stem — the last segment is the name column beside it.
fn parent_dir_label(path: &Path, home: &str) -> String {
match path.parent() {
Some(parent) => abbreviate_dir(parent, home),
None => String::new(),
}
}
/// The repository key a [`DraftGroup`] was formed under — same key
/// `projects::group_projects` groups by, kept separately because the main
/// box's own path only equals it when the main checkout itself was found.
@@ -893,13 +906,6 @@ impl DraftPane {
self.worktree_choice = choice.clone();
self.picked_cwd = Some(target.path.clone());
self.cwd_raw_visible = false;
if let Some(cmd) = target.last_command.as_deref() {
if !cmd.is_empty() {
if let Some(idx) = self.presets.iter().position(|p| p.command == cmd) {
self.selected_preset = idx;
}
}
}
match choice {
WorktreeChoice::New => {
self.worktree_enabled = true;
@@ -1200,9 +1206,9 @@ impl DraftPane {
cursor = Some((col, filter_row.y));
}
// Step one (#96 rule 1): one box per repository, name then the FULL
// path — not the abbreviated form other lists use — so two projects
// sharing a basename stay distinguishable.
// Step one (#96 rule 1): one box per repository, name then the parent
// directory it sits in. The last segment is the name column already;
// the stem is what keeps two projects sharing a basename apart.
let visible = self.visible_groups();
let sel = self.selection();
let count = visible.len() + 1;
@@ -1241,7 +1247,10 @@ impl DraftPane {
Span::styled(if active { ">" } else { " " }, base),
Span::raw(" "),
Span::styled(format!("{:<name_width$}", group.main.name), base),
Span::styled(format!(" {}", group.main.path.display()), base),
Span::styled(
format!(" {}", parent_dir_label(&group.main.path, &self.home_dir)),
base,
),
])
} else {
let mut style = Style::default();
@@ -1741,8 +1750,9 @@ mod tests {
let stale_pos = rendered.find("stale-repo").unwrap();
assert!(fresh_pos < stale_pos, "fresh before stale:\n{rendered}");
assert!(
rendered.contains("/home/u/new/repo"),
"the full path, not an abbreviation (#96 rule 1):\n{rendered}"
rendered.contains("~/new") && !rendered.contains("~/new/repo"),
"the parent directory only — the name column is the last segment \
(#96 rule 1):\n{rendered}"
);
// Staleness dims the row's cells.
@@ -1787,7 +1797,7 @@ mod tests {
assert_eq!(
draft.command(),
"claude",
"pick pre-selects the last command"
"the preset is the operator's own choice — picking a project leaves it alone"
);
}
@@ -2640,8 +2650,8 @@ mod tests {
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
assert!(
rendered.contains("/home/u/repos/main"),
"step one shows the full repository path:\n{rendered}"
rendered.contains("~/repos"),
"step one shows the directory holding the repository:\n{rendered}"
);
assert!(
rendered.contains("+ new"),
+29 -4
View File
@@ -8,7 +8,9 @@ use std::collections::{BTreeMap, HashSet};
use std::path::PathBuf;
use chrono::Utc;
use maestro_protocol::display::{abbreviate_dir, abbreviate_folder_name, session_tree_path};
use maestro_protocol::display::{
abbreviate_dir, abbreviate_folder_name, command_program, session_tree_path,
};
use maestro_protocol::{ActivityState, Session, SessionStatus, StatKind};
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
@@ -297,7 +299,8 @@ impl Sidebar {
if dead {
tail.push(Span::styled(" exited", muted));
}
let command = Span::styled(format!(" {}", s.command), muted);
// docs/SIDEBAR.md: the card names the program, never its arguments.
let command = Span::styled(format!(" {}", command_program(&s.command)), muted);
// Reserve the triggered status fragments before detail and command.
// If the row is exceptionally narrow, keep one column for the
@@ -874,12 +877,34 @@ mod tests {
);
}
/// docs/SIDEBAR.md: line 2 names the program, never its arguments.
#[test]
fn meta_line_drops_the_commands_arguments() {
let mut s = session_named(1, "handle");
s.command = "claude --dangerously-skip-permissions".into();
let sb = Sidebar {
sessions: vec![s],
highlight: None,
attached: Default::default(),
};
let all = rows(&draw(&sb, 60, 6));
let meta = content(&all[3]);
assert!(
meta.contains("claude"),
"line 2 must name the program: {meta:?}"
);
assert!(
!meta.contains("dangerously"),
"line 2 must not carry arguments: {meta:?}"
);
}
/// The sidebar is narrow: the command truncates with `…` and never wraps
/// onto a third row (docs/SIDEBAR.md).
#[test]
fn meta_line_command_truncates_instead_of_wrapping() {
let mut s = session_named(1, "claude");
s.command = "a command that cannot fit".into();
s.command = "a-program-name-that-cannot-fit".into();
s.activity_since = Utc::now() - Duration::seconds(5);
s.blocked_work = Some(InTheWay {
uncommitted_files: 120,
@@ -1099,7 +1124,7 @@ mod tests {
#[test]
fn blocked_warning_survives_before_a_truncated_command() {
let mut s = session_named(1, "name");
s.command = "a command that cannot fit".into();
s.command = "a-program-name-that-cannot-fit".into();
s.blocked_work = Some(InTheWay {
uncommitted_files: 1,
..Default::default()
+19 -14
View File
@@ -88,21 +88,26 @@ 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.
`<command>` is unconditional and muted: it names the **program** the session
runs — never its arguments. `claude --dangerously-skip-permissions` renders as
`claude`. Flags say how a session was invoked, which is not what a card is for,
and a long one crowds out every other fragment on the line. It shows on every
card in both clients — no omission in the TUI, no name-vs-command comparison
gating it. A session named by its program 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.
The program is found by looking through a path, leading `FOO=bar` assignments
and wrappers (`sudo`, `env`, `npx`, `pnpm dlx`, …), then taking the basename:
`/usr/local/bin/claude -p hi` is `claude`. A line that is nothing but wrappers
is shown as written rather than as an empty fragment.
In the **web client** the 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 nothing follows it as text. An unrecognised
program keeps its name as text, so the card never shows a mark standing for the
wrong tool. The full command line, arguments and all, stays in the card's
tooltip either way. The **TUI** keeps the program as text: a terminal cannot be
assumed to have a font carrying tool glyphs.
## Colour per activity state
+13 -5
View File
@@ -6,6 +6,7 @@ import { attachWsUrl } from "./routes.js";
import { escapeHtml } from "./html.js";
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
import { applyModsToChar, cursorSeq } from "./keys.js";
import { requestToggleDrawer } from "./drawer.js";
import { splitCommand, toolIconSvg } from "./toolIcon.js";
import type { ClientControl, Uuid } from "./types.js";
@@ -55,12 +56,13 @@ export interface AttachInfo {
command: string;
}
/** Header echoes the sidebar: the command's leading program becomes its tool
* icon when we have a mark for it, remaining words stay as text. */
/** Header echoes the sidebar: the program becomes its tool icon when we have a
* mark for it and stays as its bare name when we do not. Arguments are
* dropped, as on the card. */
function commandFragment(command: string): string {
const { tool, rest } = splitCommand(command);
const { tool, program } = splitCommand(command);
const icon = tool ? toolIconSvg(tool) : null;
const text = icon ? rest : command;
const text = icon ? "" : program;
return `<span class="at-cmd">${icon ?? ""}${text ? escapeHtml(text) : ""}</span>`;
}
@@ -80,7 +82,7 @@ export function createAttachConnection(
const titleAttr = info ? `${info.name} · ${info.command} (${sessionId})` : sessionId;
el.innerHTML = `
<header class="attach-header">
<a class="btn btn-ghost back" href="#/" aria-label="Back to catalog" title="Back to catalog">←</a>
<button type="button" class="btn btn-ghost btn-icon sessions-btn" aria-label="Show sessions" title="Show sessions">☰</button>
<span class="attach-title" title="${escapeHtml(titleAttr)}">${titleHtml}</span>
<span class="attach-state" data-state="connecting">connecting…</span>
<button class="btn btn-ghost btn-icon disconnect" type="button" aria-label="Disconnect" title="Disconnect this session">⏻</button>
@@ -100,6 +102,12 @@ export function createAttachConnection(
stateEl.dataset.state = kind;
};
// Mobile-only (CSS hides it on desktop, where the sidebar is always in view):
// the session list is an off-canvas drawer and this is what opens it.
(el.querySelector(".sessions-btn") as HTMLElement).addEventListener("click", () =>
requestToggleDrawer(),
);
disconnectBtn.addEventListener("click", () => {
opts.onRequestClose?.(sessionId);
});
+6 -4
View File
@@ -209,15 +209,16 @@ describe("session cards (#107)", () => {
expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull();
});
it("keeps the command's arguments as text beside the icon", () => {
it("drops the command's arguments, keeping them in the tooltip", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-00000000001c",
command: "claude --resume",
command: "claude --dangerously-skip-permissions",
});
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");
expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull();
expect(cmd?.getAttribute("title")).toBe("claude --dangerously-skip-permissions");
});
it("keeps an unknown command as plain text", () => {
@@ -228,7 +229,8 @@ describe("session cards (#107)", () => {
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");
expect(cmd?.querySelector(".leaf-cmd-rest")?.textContent).toBe("hx");
expect(cmd?.getAttribute("title")).toBe("hx src/main.rs");
});
it("maps each activity state to its contract slug", () => {
+16 -62
View File
@@ -96,13 +96,14 @@ function blockedFragment(blockers: InTheWay): string {
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. */
/** docs/SIDEBAR.md: the card names the program only — arguments never reach
* the card. The program becomes its tool icon when we have a mark for it, and
* stays as its bare name when we do not. The full command line, arguments and
* all, stays in the tooltip either way. */
function commandFragment(command: string): string {
const { tool, rest } = splitCommand(command);
const { tool, program } = splitCommand(command);
const icon = tool ? toolIconSvg(tool) : null;
const text = icon ? rest : command;
const text = icon ? "" : program;
return `<span class="card-flag leaf-cmd" title="${escapeHtml(command)}">${icon ?? ""}${text ? `<span class="leaf-cmd-rest">${escapeHtml(text)}</span>` : ""}</span>`;
}
@@ -266,18 +267,6 @@ function beginRename(row: HTMLElement, s: Session): void {
input.select();
}
function renderStatusbar(sessions: Session[]): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "statusbar";
const live = sessions.filter((s) => s.status === "Running").length;
const tally = document.createElement("span");
tally.className = "session-tally";
tally.textContent = `${sessions.length} session${sessions.length === 1 ? "" : "s"} · ${live} live`;
wrap.appendChild(tally);
return wrap;
}
function renderEmpty(filtering: boolean, onSpawn: () => void): HTMLElement {
const el = document.createElement("div");
el.className = "empty";
@@ -302,51 +291,6 @@ function renderEmpty(filtering: boolean, onSpawn: () => void): HTMLElement {
return el;
}
/**
* The global app header (full width, above the sidebar/content split): brand +
* 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");
header.className = "app-header";
header.innerHTML = `
<button type="button" class="app-menu" title="Sessions" aria-label="Toggle session list">☰</button>
<a class="brand" href="#/" aria-label="maestro — clear selection">
<span class="brand-dot" aria-hidden="true"></span>
<h1>maestro<span class="blink" aria-hidden="true">_</span></h1>
</a>
<div class="app-status"></div>
<div class="app-actions"></div>
`;
// Mobile-only ☰ (CSS hides it on desktop) — opens the off-canvas session drawer.
(header.querySelector(".app-menu") as HTMLElement).addEventListener("click", () =>
onToggleSidebar?.(),
);
// Session tally lives centered in the header (not the sidebar).
const statusHost = header.querySelector(".app-status") as HTMLElement;
const renderStatus = () => {
const state = store.snapshot();
statusHost.replaceChildren(renderStatusbar([...state.sessions.values()]));
};
store.subscribe(renderStatus);
renderStatus();
const actions = header.querySelector(".app-actions") as HTMLElement;
// DEV-only shortcut to the standalone design kit (?kit). Lets you reach it by
// clicking inside a proxied preview (e.g. crit live) that has no address bar.
if (import.meta.env.DEV) {
const kit = document.createElement("a");
kit.className = "btn btn-ghost";
kit.href = "?kit";
kit.textContent = "kit";
kit.title = "Open the design kit (DEV)";
actions.appendChild(kit);
}
return header;
}
/** Two-tier sidebar highlight state (issue #33): which sessions are open in some
* pane (quiet), and which one is focused (loud). */
export interface SidebarHighlights {
@@ -426,6 +370,16 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
helpBtn.addEventListener("click", () => toggleHelp());
footer.appendChild(helpBtn);
footer.appendChild(createThemeToggle());
// DEV-only shortcut to the standalone design kit (?kit). Lets you reach it by
// clicking inside a proxied preview (e.g. crit live) that has no address bar.
if (import.meta.env.DEV) {
const kit = document.createElement("a");
kit.className = "btn btn-ghost";
kit.href = "?kit";
kit.textContent = "kit";
kit.title = "Open the design kit (DEV)";
footer.appendChild(kit);
}
const spawnBtn = document.createElement("button");
spawnBtn.type = "button";
spawnBtn.className = "btn btn-primary spawn-btn";
+9
View File
@@ -8,6 +8,14 @@
import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "./catalog.js";
/** Toggling the drawer from outside the shell (the pane header's ☰ on mobile),
* without threading the controller through every view. */
export const TOGGLE_DRAWER_EVENT = "maestro-toggle-drawer";
export function requestToggleDrawer(): void {
document.dispatchEvent(new CustomEvent(TOGGLE_DRAWER_EVENT));
}
export interface Drawer {
open(): void;
close(): void;
@@ -31,6 +39,7 @@ export function installDrawer(
sidebarEl.addEventListener("click", (e) => {
if ((e.target as HTMLElement).closest(".tree-leaf")) drawer.close();
});
document.addEventListener(TOGGLE_DRAWER_EVENT, () => drawer.toggle());
document.addEventListener(OPEN_SPAWN_EVENT, () => drawer.close());
document.addEventListener(SPAWN_SIMILAR_EVENT, () => drawer.close());
-1
View File
@@ -205,7 +205,6 @@ export function renderKit(): HTMLElement {
);
activeLine.setAttribute("data-activity", "active");
body.append(
specimen("session-tally", h("span", "session-tally", "6 sessions · 5 live")),
specimen("folder-count", h("span", "folder-count", "4")),
specimen("card-line2 · active", activeLine),
specimen("attach-state · open", h("span", "attach-state", "live")),
+15 -7
View File
@@ -2,9 +2,9 @@ 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 { renderSidebar } from "./catalog.js";
import { ConnectionPool } from "./connections.js";
import { installDrawer, installDrawerSwipe } from "./drawer.js";
import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js";
import { installHudKeybind } from "./debug.js";
import { installHelpKeybind, isHelpOpen, isTypingTarget } from "./help.js";
import { CatalogStore } from "./events.js";
@@ -93,16 +93,15 @@ const content = document.createElement("section");
content.className = "content";
// Mobile (issue #35): the sidebar is an off-canvas drawer toggled by the ☰ in
// the header. `installDrawer` owns the close rules (session tap, Spawn, Spawn
// the pane header (and the welcome state). `installDrawer` owns the close rules (session tap, Spawn, Spawn
// similar, backdrop); on desktop the class it toggles is inert.
const backdrop = document.createElement("div");
backdrop.className = "mv-backdrop";
const drawer = installDrawer(shellEl, sidebar.el, backdrop);
installDrawerSwipe(drawer);
const toggleDrawer = () => drawer.toggle();
shellEl.append(sidebar.el, content, backdrop);
root.replaceChildren(renderTopbar(store, toggleDrawer), shellEl);
root.replaceChildren(shellEl);
// The pool owns attach connections independently of the route: navigating away
// parks a session (keeps it streaming); navigating back re-mounts it instantly.
@@ -182,13 +181,22 @@ function renderWelcome(): HTMLElement {
</p>
<p class="empty-hint">Press <kbd>?</kbd> for keyboard shortcuts.</p>
<div class="empty-actions"></div>`;
const actions = el.querySelector(".empty-actions") as HTMLElement;
// Mobile-only: with no pane open there is no pane header to carry the ☰, so
// the drawer would be swipe-only. CSS hides this on desktop.
const sessions = document.createElement("button");
sessions.type = "button";
sessions.className = "btn btn-ghost welcome-sessions";
sessions.textContent = "☰ Sessions";
sessions.addEventListener("click", () => requestToggleDrawer());
actions.appendChild(sessions);
const spawn = document.createElement("button");
spawn.type = "button";
spawn.className = "btn btn-primary";
spawn.textContent = "+ Spawn session";
spawn.title = "Draft pane: n — fast-forward spawn: N";
spawn.addEventListener("click", () => requestOpenSpawn());
(el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn);
actions.appendChild(spawn);
return el;
}
@@ -226,7 +234,7 @@ function showMultiView(): void {
updateShellRoute();
}
// Spawn affordances (#62): the header button, the sidebar's empty state, and
// Spawn affordances (#62): the sidebar's footer button, its empty state, and
// `?mock&spawn` all open a draft pane; "Spawn similar" seeds one from the
// cloned session (see spawnSimilar.ts).
function requestOpenSpawn(): void {
+36 -1
View File
@@ -17,7 +17,7 @@ function stubFetch(): void {
: url.includes("/api/projects")
? []
: url.includes("/api/names")
? { names: ["allegro-cello"] }
? { names: ["cello"] }
: undefined;
return new Response(JSON.stringify(body), { status: 200 });
}),
@@ -523,3 +523,38 @@ describe("multiview mobile single pane", () => {
expect(mv.el.classList.contains("mv-mobile-solo")).toBe(true);
});
});
describe("multiview solo tab bar", () => {
beforeEach(() => {
stubFetch();
document.body.replaceChildren();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("hides the tab bar while only one tab exists", () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true);
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
expect(mv.el.classList.contains("mv-solo-tab")).toBe(true);
});
it("shows the tab bar again once a second tab appears", () => {
const layout = emptyLayout();
const first = layout.tabs[0];
first.focusedLeafId = splitLeaf(first, "", "s1", "row", true);
const second = addTab(layout);
second.focusedLeafId = splitLeaf(second, "", "s2", "row", true);
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
expect(mv.el.classList.contains("mv-solo-tab")).toBe(false);
});
});
+5 -1
View File
@@ -470,8 +470,12 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
canvas.replaceChildren();
const tab = activeTab(layout);
// Mobile: the tab bar is a second navigation surface we don't want, so CSS
// collapses it; the header ☰ keeps the sidebar drawer reachable.
// collapses it; the pane header's ☰ keeps the sidebar drawer reachable.
el.classList.toggle("mv-mobile-solo", isMobile);
// A lone tab has nothing to switch between, so the bar is only a strip of
// chrome; CSS hides it until a second tab exists. Spawning still opens its
// own tab (#100), which brings the bar back with the "+" button on it.
el.classList.toggle("mv-solo-tab", layout.tabs.length === 1);
if (!tab.root) {
pruneForms(tab);
+17 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { longestCommonPrefix, splitPath } from "./paths.js";
import { longestCommonPrefix, parentDir, splitPath } from "./paths.js";
describe("splitPath", () => {
it("splits on the last slash, keeping it with the directory", () => {
@@ -29,3 +29,19 @@ describe("longestCommonPrefix", () => {
expect(longestCommonPrefix(["Alpha", "alpha"])).toBe("");
});
});
describe("parentDir", () => {
it("drops the last segment", () => {
expect(parentDir("/home/user/tea/maestro")).toBe("/home/user/tea");
expect(parentDir("/home/user/tea/maestro/")).toBe("/home/user/tea");
});
it("keeps the root for a top-level path", () => {
expect(parentDir("/maestro")).toBe("/");
expect(parentDir("/")).toBe("/");
});
it("is empty for a bare name", () => {
expect(parentDir("maestro")).toBe("");
});
});
+9
View File
@@ -19,3 +19,12 @@ export function longestCommonPrefix(items: string[]): string {
}
return lcp;
}
/** Everything above the last segment of an absolute path: `/a/b/c` → `/a/b`.
* A top-level path keeps `/`, and a trailing slash is ignored. */
export function parentDir(path: string): string {
const trimmed = path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
const slash = trimmed.lastIndexOf("/");
if (slash <= 0) return slash === 0 ? "/" : "";
return trimmed.slice(0, slash);
}
+17 -7
View File
@@ -27,7 +27,7 @@ function stubFetch(info: unknown, projects: Project[]): void {
: url.includes("/api/projects")
? projects
: url.includes("/api/names")
? { names: ["allegro-cello", "presto-fugue", "adagio-lyre"] }
? { names: ["cello", "fugue", "lyre"] }
: undefined;
return new Response(JSON.stringify(body), { status: 200 });
}),
@@ -79,14 +79,22 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () =>
expect(boxes[0].classList.contains("is-stale")).toBe(false);
});
it("shows the name as the primary line and the full path unabbreviated", async () => {
it("shows the name as the primary line and its parent directory below", async () => {
const p = project({ name: "nixos-config", path: "/home/user/tea/nixos-config" });
const form = (await mount([p])).el;
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
expect(box.querySelector(".project-name")!.textContent).toBe("nixos-config");
// Not `~/t/nixos-config`: the abbreviation hid what distinguishes two
// projects sharing a basename (#95).
expect(box.querySelector(".project-path")!.textContent).toBe("/home/user/tea/nixos-config");
// Only the stem: repeating the last segment would just echo the name. Not
// `~/t`, since the stem is what distinguishes two projects sharing a
// basename (#95).
expect(box.querySelector(".project-path")!.textContent).toBe("~/tea");
});
it("keeps the last command out of the box", async () => {
const p = project({ name: "maestro", path: "/home/user/maestro", last_command: "claude" });
const form = (await mount([p])).el;
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
expect(box.textContent).not.toContain("claude");
});
it("lists only main checkouts as boxes — worktrees belong to step two", async () => {
@@ -126,11 +134,11 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () =>
expect(form.querySelector(".picker-empty")).toBeTruthy();
});
it("picking a project selects its cwd and pre-selects its last command", async () => {
it("picking a project selects its cwd and leaves the command alone", async () => {
const p = project({
name: "maestro",
path: "/home/user/maestro",
last_command: "claude",
last_command: "aider",
});
const onChange = vi.fn();
stubFetch(
@@ -157,6 +165,8 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () =>
const values = form.querySelector<HTMLInputElement>("input[name=name]")!.value;
const last = onChange.mock.lastCall![0];
expect(last.cwd).toBe("/home/user/maestro");
// The directory and the command are independent choices: the preset stays
// where it was, whatever the project was last run with.
expect(last.command).toBe("claude");
expect(values).toBeTruthy();
// The raw input stays hidden — the picked box is the source of truth.
+13 -11
View File
@@ -8,8 +8,10 @@ import {
gitRoot,
} from "./api.js";
import { attachDirAutocomplete } from "./dirAutocomplete.js";
import { parentDir } from "./paths.js";
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
import type { ProjectGroup } from "./projectList.js";
import { abbreviateCwd } from "./types.js";
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
/** Where a worktree's branch comes from: freshly created (default) or picked
@@ -440,8 +442,9 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
// ---- working-directory picker (#95) ----------------------------------
// Two steps, and the TUI mirror (#96) must match these rules verbatim:
// 1. Step one is one box per repository (name, then full path). A worktree
// is a box only when its main checkout is missing from the scan.
// 1. Step one is one box per repository (name, then its parent directory).
// A worktree is a box only when its main checkout is missing from the
// scan.
// 2. The filter keeps a repository when the query matches it or any of its
// worktrees, so a worktree name still finds its box.
// 3. Picking a box reveals step two: "+ new", then that repository's
@@ -454,6 +457,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
// these are separate acts — #91's rule, now an explicit choice.
// 6. Neither step probes /api/gitroot; the scan already reports repository
// membership. Only the free-text hatch pays for a probe.
// 7. Neither step touches the command: the directory and what runs in it
// are independent choices.
/** "new" (create a worktree) or the path of an existing worktree to run in. */
type WorktreeChoice = "new" | string;
@@ -544,9 +549,6 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
wtHint.hidden = true;
}
emit();
// The chosen directory pre-selects the command it was last used with.
const match = presets.find((p) => p.command === target.last_command && p.command !== "");
if (match) selectPreset(match.id);
}
/** Groups with at least one member (itself or a worktree) matching `query`. */
@@ -634,14 +636,14 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
box.className = `project-box${isStale(project, new Date()) ? " is-stale" : ""}`;
box.dataset.cwd = project.path;
const trees = worktreesOf(group).length;
// The full path, not the sidebar's `~/t/n/maestro` abbreviation: it is what
// tells two projects sharing a basename apart (#95).
const meta = [trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : "", project.last_command ?? ""]
.filter(Boolean)
.join(" · ");
// The parent directory only — the last segment is the name line above it.
// Unabbreviated beyond `~`, since the stem is what tells two projects
// sharing a basename apart (#95).
const parent = abbreviateCwd(parentDir(project.path), homeDir);
const meta = trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : "";
box.innerHTML = `
<span class="project-name">${escapeHtml(project.name)}</span>
<span class="project-path">${escapeHtml(project.path)}</span>
<span class="project-path">${escapeHtml(parent)}</span>
<span class="project-meta">${escapeHtml(meta)}</span>`;
box.addEventListener("click", () => selectProject(group));
return box;
+2 -2
View File
@@ -3,8 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
const BATCH_A = ["allegro-cello", "presto-fugue", "adagio-lyre"];
const BATCH_B = ["forte-oboe", "largo-tuba", "vivace-harp"];
const BATCH_A = ["cello", "fugue", "lyre"];
const BATCH_B = ["oboe", "tuba", "harp"];
/** Stub the three fetches the form's initial load makes. Records every
* /api/names URL so tests can assert how many batches were fetched. */
+7 -123
View File
@@ -365,73 +365,6 @@ textarea {
/* ---------- app shell: sidebar + content ---------- */
/* Global app header: brand + app-level actions (spawn, theme toggle), spanning
the full width above the split. */
.app-header {
display: flex;
align-items: center;
gap: var(--sp-3);
flex: 0 0 auto;
padding: var(--sp-1) var(--sp-3);
border-bottom: 1px solid var(--border);
background: color-mix(in oklab, var(--surface) 82%, transparent);
backdrop-filter: blur(8px);
}
/* Hamburger to the left of the brand — toggles the off-canvas session drawer.
Mobile only (desktop keeps the sidebar permanently in view). */
.app-menu {
display: none;
place-items: center;
flex: none;
width: 32px;
height: 32px;
border: none;
border-radius: var(--r-1);
background: transparent;
color: var(--ink);
font-size: var(--fs-17);
cursor: pointer;
}
.app-menu:hover {
background: var(--surface-2);
}
/* Three-part header: brand (left), status (centered), actions (right). Equal
flex on the outer two keeps the status group truly centered. */
.app-header .brand {
flex: 1 1 0;
min-width: 0;
margin-right: 0;
}
.app-status {
flex: 0 1 auto;
display: flex;
justify-content: center;
min-width: 0;
overflow: hidden;
}
.app-status .statusbar {
border-bottom: 0;
background: transparent;
padding: 0;
flex-wrap: nowrap;
}
.app-status .statusbar .spacer {
display: none;
}
.app-actions {
display: flex;
align-items: center;
gap: var(--sp-2);
flex: 1 1 0;
justify-content: flex-end;
}
.app-shell {
flex: 1 1 auto;
min-height: 0;
@@ -479,9 +412,6 @@ textarea {
.sidebar-resize {
display: none;
}
.app-status {
display: none;
}
}
/* Sidebar's own slim header: just the session search. */
@@ -638,42 +568,6 @@ textarea {
box-shadow: 0 0 0 3px var(--accent-tint);
}
/* ---------- status bar ---------- */
.statusbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-2) var(--sp-3);
padding: var(--sp-2) var(--sp-3);
border-bottom: 1px solid var(--border);
background: var(--surface);
font-size: var(--fs-12);
color: var(--muted);
}
.statusbar .spacer {
/* zero basis: claim leftover space without inflating the bar's min width */
flex: 1 1 0;
min-width: var(--sp-2);
}
.session-tally {
display: inline-flex;
align-items: center;
gap: 7px;
font-variant-numeric: tabular-nums;
}
.session-tally::before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--live);
box-shadow: 0 0 5px var(--live-glow);
}
/* ---------- session tree ---------- */
.tree {
@@ -1247,16 +1141,17 @@ input.leaf-rename:focus {
width: 26px;
}
.attach-header .back {
/* Opens the off-canvas session drawer. Desktop keeps the sidebar permanently in
view, so it is mobile-only — same rule as the welcome state's ☰. */
.attach-header .sessions-btn {
font-size: var(--fs-15);
width: 28px;
height: 26px;
}
/* Desktop keeps the session list permanently in view, so the in-header "back"
arrow is redundant — the sidebar is the nav. Mobile (single-pane) keeps it. */
@media (min-width: 860px) {
.attach-header .back {
.attach-header .sessions-btn,
.welcome-sessions {
display: none;
}
}
@@ -2087,9 +1982,6 @@ input.leaf-rename:focus {
max-width: none;
order: -1;
}
.statusbar .spacer {
display: none;
}
.tree-leaf .leaf-actions {
opacity: 1;
}
@@ -2204,7 +2096,8 @@ input.leaf-rename:focus {
}
/* Mobile: the tab bar is a second navigation surface — collapse it (a floating
☰ inside the pane keeps the sidebar drawer reachable). */
.mv-mobile-solo > .mv-tabs {
.mv-mobile-solo > .mv-tabs,
.mv-solo-tab > .mv-tabs {
display: none;
}
@@ -2383,11 +2276,6 @@ input.leaf-rename:focus {
.mv-pane:not(.is-focused) .attach-header {
opacity: 0.7;
}
/* per-pane back button is meaningless in multi-view (use Close pane instead) */
.mv-pane .attach-header .back {
display: none;
}
/* Draft pane (#57): an empty pane renders the shared spawn form inline.
The host is already a flex column; the form fills and scrolls within it. */
.mv-pane-host > .mv-draft {
@@ -2509,10 +2397,6 @@ input.leaf-rename:focus {
}
@media (max-width: 859px) {
.app-menu {
display: inline-grid;
}
/* sidebar → off-canvas drawer (override the route-based show/hide) */
.app-shell > .sidebar {
display: flex !important;
+7 -7
View File
@@ -20,12 +20,12 @@ describe("tool icons on the command fragment (docs/SIDEBAR.md)", () => {
}
});
it("keeps everything after the program as text", () => {
expect(splitCommand("claude --resume -p hi")).toEqual({
it("drops everything after the program", () => {
expect(splitCommand("claude --dangerously-skip-permissions")).toEqual({
tool: "claude",
rest: "--resume -p hi",
program: "claude",
});
expect(splitCommand(" zsh -l ")).toEqual({ tool: "shell", rest: "-l" });
expect(splitCommand(" zsh -l ")).toEqual({ tool: "shell", program: "zsh" });
});
it("looks through paths, env assignments and wrappers", () => {
@@ -33,13 +33,13 @@ describe("tool icons on the command fragment (docs/SIDEBAR.md)", () => {
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");
expect(splitCommand("pnpm dlx opencode run").program).toBe("opencode");
});
it("leaves an unrecognised program as text, mark-less", () => {
it("leaves an unrecognised program as its bare name, mark-less", () => {
expect(splitCommand("hx src/main.rs")).toEqual({
tool: null,
rest: "hx src/main.rs",
program: "hx",
});
// A wrapper with nothing after it is not a tool either.
expect(splitCommand("sudo").tool).toBeNull();
+17 -18
View File
@@ -2,11 +2,11 @@
*
* 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.
* as its own mark buys back the whole fragment's width — the arguments are
* dropped, so a card says which tool runs, not how it was invoked.
*
* Recognition is deliberately narrow: an unrecognised program keeps its text,
* so the fragment never renders a mark that stands for the wrong tool.
* Recognition is deliberately narrow: an unrecognised program keeps its name
* as 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. */
@@ -48,15 +48,15 @@ export const TOOL_LABELS: Record<string, string> = {
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;
/** The program the command runs, basenamed and stripped of its arguments —
* what a card shows when there is no icon to show instead. */
program: string;
}
/** Split a command line into the tool its icon stands for and the remainder.
/** Split a command line into the tool its icon stands for and the program name.
*
* `claude --resume` → `{tool: "claude", rest: "--resume"}`; an unrecognised
* `hx src/` keeps the whole line as `rest` with no tool. */
* `claude --resume` → `{tool: "claude", program: "claude"}`; an unrecognised
* `hx src/` keeps `hx` as the program with no tool. */
export function splitCommand(command: string): CommandParts {
const words = command.trim().split(/\s+/).filter(Boolean);
let i = 0;
@@ -68,23 +68,22 @@ export function splitCommand(command: string): CommandParts {
i += 1;
continue;
}
const program = basename(word);
if (WRAPPERS.has(program)) {
const wrapper = basename(word).toLowerCase();
if (WRAPPERS.has(wrapper)) {
i += 1;
if ((program === "pnpm" || program === "yarn") && words[i] === "dlx") i += 1;
if ((wrapper === "pnpm" || wrapper === "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(" ") };
if (i >= words.length) return { tool: null, program: command.trim() };
const program = basename(words[i]);
return { tool: BY_PROGRAM[program.toLowerCase()] ?? null, program };
}
function basename(word: string): string {
const cut = word.lastIndexOf("/");
return (cut >= 0 ? word.slice(cut + 1) : word).toLowerCase();
return cut >= 0 ? word.slice(cut + 1) : word;
}
/** Anthropic's burst: tapered rays around a centre, long ones on the axes. */