Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88b6bba9f2 | ||
|
|
1c349e5858 | ||
|
|
9fc21b4306 | ||
|
|
19162f9952 | ||
|
|
2fcfc06c3c | ||
|
|
f82ab45766 | ||
|
|
d304171d7c |
@@ -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
|
||||
|
||||
@@ -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(""), "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
+7
-123
@@ -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;
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user