Compare commits

..
2 Commits
Author SHA1 Message Date
Miguel Palhas c67fa2bd4e feat(maestro): force-kill prompt in the web client
ci / web (push) Successful in 35s
ci / rust (push) Successful in 4m1s
ci / flake (push) Successful in 6m55s
The kill button now sends the unforced DELETE immediately instead of
asking first. Only the daemon's guard refusal raises a prompt, and it is
a real dialog rather than confirm(), so it can offer the override the
daemon already implements.

The API error envelope is decoded into ApiRequestError, keeping the code
and the unsaved-work breakdown so the dialog names what is in the way.
2026-09-01 11:51:45 +01:00
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
14 changed files with 472 additions and 98 deletions
+81
View File
@@ -140,6 +140,63 @@ pub fn merge_linear<T>(folder: TreeFolder<T>) -> TreeFolder<T> {
cur 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -318,4 +375,28 @@ mod tests {
}; };
assert_eq!(nested.name, "a/b"); 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(""), "");
}
} }
+29 -4
View File
@@ -8,7 +8,9 @@ use std::collections::{BTreeMap, HashSet};
use std::path::PathBuf; use std::path::PathBuf;
use chrono::Utc; 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 maestro_protocol::{ActivityState, Session, SessionStatus, StatKind};
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
@@ -297,7 +299,8 @@ impl Sidebar {
if dead { if dead {
tail.push(Span::styled(" exited", muted)); 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. // Reserve the triggered status fragments before detail and command.
// If the row is exceptionally narrow, keep one column for the // 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 /// The sidebar is narrow: the command truncates with `…` and never wraps
/// onto a third row (docs/SIDEBAR.md). /// onto a third row (docs/SIDEBAR.md).
#[test] #[test]
fn meta_line_command_truncates_instead_of_wrapping() { fn meta_line_command_truncates_instead_of_wrapping() {
let mut s = session_named(1, "claude"); 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.activity_since = Utc::now() - Duration::seconds(5);
s.blocked_work = Some(InTheWay { s.blocked_work = Some(InTheWay {
uncommitted_files: 120, uncommitted_files: 120,
@@ -1099,7 +1124,7 @@ mod tests {
#[test] #[test]
fn blocked_warning_survives_before_a_truncated_command() { fn blocked_warning_survives_before_a_truncated_command() {
let mut s = session_named(1, "name"); 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 { s.blocked_work = Some(InTheWay {
uncommitted_files: 1, uncommitted_files: 1,
..Default::default() ..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 card's tooltip and the wording of the kill refusal, so no two clients describe
the same tree differently. the same tree differently.
`<command>` is unconditional and muted: the session's command line, shown on `<command>` is unconditional and muted: it names the **program** the session
every card in both clients — no omission in the TUI, no name-vs-command runs — never its arguments. `claude --dangerously-skip-permissions` renders as
comparison gating it. A session named by its command line then shows the same `claude`. Flags say how a session was invoked, which is not what a card is for,
string on both lines; that is accepted, the price of a row that never changes and a long one crowds out every other fragment on the line. It shows on every
height. 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 The program is found by looking through a path, leading `FOO=bar` assignments
icon when it is one we have a mark for — Claude Code, Codex, opencode, Gemini and wrappers (`sudo`, `env`, `npx`, `pnpm dlx`, …), then taking the basename:
CLI, Aider, Cursor, pi, and any shell — and only the remaining words of the `/usr/local/bin/claude -p hi` is `claude`. A line that is nothing but wrappers
command line follow as text. Recognition looks through a path, leading is shown as written rather than as an empty fragment.
`FOO=bar` assignments and wrappers (`sudo`, `env`, `npx`, `pnpm dlx`, …); an
unrecognised program keeps its full text, so the card never shows a mark In the **web client** the program is replaced by that tool's icon when it is
standing for the wrong tool. The full command line stays in the card's tooltip one we have a mark for — Claude Code, Codex, opencode, Gemini CLI, Aider,
either way. The **TUI** keeps the command as text: a terminal cannot be assumed Cursor, pi, and any shell — and nothing follows it as text. An unrecognised
to have a font carrying tool glyphs. 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 ## Colour per activity state
+5
View File
@@ -79,6 +79,11 @@ would have blocked. A tree whose guard is clear never needed the override, so a
forced kill removes it as usual — force changes what happens to blocked work, forced kill removes it as usual — force changes what happens to blocked work,
not ordinary cleanup. not ordinary cleanup.
In the web client the kill button sends the unforced `DELETE` straight away —
there is no pre-confirmation, because the daemon is the one that decides. Only
the refusal raises a prompt, and that prompt names what is in the way and offers
"Force kill", which repeats the request with `force=true`.
Ending a session and destroying its work are two separate acts. Maestro only Ending a session and destroying its work are two separate acts. Maestro only
performs the first one. Removing a tree it left behind is the operator's own performs the first one. Removing a tree it left behind is the operator's own
`git worktree remove`, chosen while looking at exactly what would be lost. `git worktree remove`, chosen while looking at exactly what would be lost.
+32 -7
View File
@@ -1,6 +1,7 @@
import * as routes from "./routes.js"; import * as routes from "./routes.js";
import type { import type {
ApiError, ApiError,
InTheWay,
Branches, Branches,
CreateSessionRequest, CreateSessionRequest,
DaemonInfo, DaemonInfo,
@@ -13,19 +14,38 @@ import type {
Uuid, Uuid,
} from "./types.js"; } from "./types.js";
/** A `/api/*` failure with the daemon's own error envelope kept intact, so a
* caller can branch on `code` — the kill guard's `kill_guarded` (#79) — and
* render `unsavedWork` rather than re-parsing the message. */
export class ApiRequestError extends Error {
readonly status: number;
readonly code: string;
readonly detail: string;
readonly unsavedWork: InTheWay | null;
constructor(res: Response, info?: ApiError["error"], text?: string) {
const detail = info ? `${info.code}: ${info.message}` : (text ?? "");
super(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`);
this.name = "ApiRequestError";
this.status = res.status;
this.code = info?.code ?? "";
this.detail = info?.message ?? text ?? "";
this.unsavedWork = info?.unsaved_work ?? null;
}
}
async function jsonOrError<T>(res: Response): Promise<T> { async function jsonOrError<T>(res: Response): Promise<T> {
if (res.ok) { if (res.ok) {
if (res.status === 204) return undefined as unknown as T; if (res.status === 204) return undefined as unknown as T;
return (await res.json()) as T; return (await res.json()) as T;
} }
let detail = "";
try { try {
const body = (await res.json()) as ApiError; const body = (await res.json()) as ApiError;
detail = `${body.error.code}: ${body.error.message}`; throw new ApiRequestError(res, body.error);
} catch { } catch (e) {
detail = await res.text(); if (e instanceof ApiRequestError) throw e;
throw new ApiRequestError(res, undefined, await res.text().catch(() => ""));
} }
throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`);
} }
export async function listSessions(): Promise<Session[]> { export async function listSessions(): Promise<Session[]> {
@@ -57,9 +77,14 @@ export async function renameSession(id: Uuid, name: string): Promise<Session> {
); );
} }
export async function killSession(id: Uuid): Promise<void> { /** Kill a session. With `force` (#81) the daemon overrides its refusal over a
* worktree holding unsaved work and leaves that worktree on disk. */
export async function killSession(id: Uuid, force = false): Promise<void> {
const url = force
? `${routes.session(id)}?${routes.FORCE_QUERY}=true`
: routes.session(id);
await jsonOrError( await jsonOrError(
await fetch(routes.session(id), { await fetch(url, {
method: "DELETE", method: "DELETE",
credentials: "same-origin", credentials: "same-origin",
}), }),
+5 -4
View File
@@ -56,12 +56,13 @@ export interface AttachInfo {
command: string; command: string;
} }
/** Header echoes the sidebar: the command's leading program becomes its tool /** Header echoes the sidebar: the program becomes its tool icon when we have a
* icon when we have a mark for it, remaining words stay as text. */ * 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 { function commandFragment(command: string): string {
const { tool, rest } = splitCommand(command); const { tool, program } = splitCommand(command);
const icon = tool ? toolIconSvg(tool) : null; 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>`; return `<span class="at-cmd">${icon ?? ""}${text ? escapeHtml(text) : ""}</span>`;
} }
+79 -28
View File
@@ -1,5 +1,5 @@
// @vitest-environment happy-dom // @vitest-environment happy-dom
import { beforeAll, describe, expect, it } from "vitest"; import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { renderSidebar } from "./catalog.js"; import { renderSidebar } from "./catalog.js";
import type { CatalogListener, CatalogStore } from "./events.js"; import type { CatalogListener, CatalogStore } from "./events.js";
@@ -16,6 +16,10 @@ beforeAll(() => {
window.fetch = () => Promise.reject(new Error("offline")); window.fetch = () => Promise.reject(new Error("offline"));
}); });
afterEach(() => {
document.querySelector(".kill-dialog")?.remove();
});
function session(overrides: Partial<Session>): Session { function session(overrides: Partial<Session>): Session {
return { return {
id: "11111111-2222-3333-4444-555555555555", id: "11111111-2222-3333-4444-555555555555",
@@ -46,6 +50,11 @@ function rowFor(el: HTMLElement, id: string): HTMLElement | null {
return el.querySelector<HTMLElement>(`[data-session-id="${id}"]`); return el.querySelector<HTMLElement>(`[data-session-id="${id}"]`);
} }
/** Let the kill chain's awaits (fetch, its json body) settle. */
async function flush(): Promise<void> {
for (let i = 0; i < 8; i++) await Promise.resolve();
}
describe("blocked-work indicator (#83)", () => { describe("blocked-work indicator (#83)", () => {
it("marks the row of a blocked session in diff notation", () => { it("marks the row of a blocked session in diff notation", () => {
const blocked = session({ const blocked = session({
@@ -89,37 +98,77 @@ describe("blocked-work indicator (#83)", () => {
expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull(); expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull();
}); });
it("restates the warning at the kill confirm", async () => { it("kills straight away, without a first confirmation", async () => {
const confirmations: string[] = []; const calls: string[] = [];
window.confirm = (msg?: string) => { window.fetch = ((url: string, init?: RequestInit) => {
confirmations.push(msg ?? ""); calls.push(`${init?.method ?? "GET"} ${url}`);
return false; return Promise.resolve(new Response(null, { status: 204 }));
}; }) as typeof fetch;
const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000005" });
const el = renderSidebar(storeWith([plain])).el;
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush();
expect(calls).toContain(`DELETE /api/sessions/${plain.id}`);
expect(document.querySelector(".kill-dialog")).toBeNull();
});
it("offers the override when the daemon refuses a guarded kill", async () => {
const calls: string[] = [];
window.fetch = ((url: string, init?: RequestInit) => {
calls.push(`${init?.method ?? "GET"} ${url}`);
if (url.includes("force=true")) return Promise.resolve(new Response(null, { status: 204 }));
return Promise.resolve(
new Response(
JSON.stringify({
error: {
code: "kill_guarded",
message: "worktree has unsaved work",
unsaved_work: { untracked_files: 1 },
},
}),
{ status: 409, headers: { "content-type": "application/json" } },
),
);
}) as typeof fetch;
const blocked = session({ const blocked = session({
id: "aaaaaaaa-0000-0000-0000-000000000004", id: "aaaaaaaa-0000-0000-0000-000000000004",
command: "bash", command: "bash",
blocked_work: { untracked_files: 1 }, blocked_work: { untracked_files: 1 },
}); });
const el = renderSidebar(storeWith([blocked])).el; const el = renderSidebar(storeWith([blocked])).el;
const kill = rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill"); rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
kill?.click(); await flush();
await Promise.resolve(); const dialog = document.querySelector(".kill-dialog");
expect(confirmations).toHaveLength(1); expect(dialog?.textContent).toContain("1 untracked file");
expect(confirmations[0]).toContain("1 untracked file"); dialog?.querySelector<HTMLButtonElement>(".kill-dialog-force")?.click();
expect(confirmations[0]).toContain("refused unless forced"); await flush();
expect(calls).toContain(`DELETE /api/sessions/${blocked.id}?force=true`);
expect(document.querySelector(".kill-dialog")).toBeNull();
}); });
it("confirms plainly when nothing blocks", async () => { it("closes the override prompt without killing on cancel", async () => {
const confirmations: string[] = []; const calls: string[] = [];
window.confirm = (msg?: string) => { window.fetch = ((url: string, init?: RequestInit) => {
confirmations.push(msg ?? ""); calls.push(`${init?.method ?? "GET"} ${url}`);
return false; return Promise.resolve(
}; new Response(
const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000005" }); JSON.stringify({
const el = renderSidebar(storeWith([plain])).el; error: { code: "kill_guarded", message: "worktree has unsaved work" },
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click(); }),
await Promise.resolve(); { status: 409, headers: { "content-type": "application/json" } },
expect(confirmations).toEqual(['Kill session "claude"?']); ),
);
}) as typeof fetch;
const blocked = session({
id: "aaaaaaaa-0000-0000-0000-000000000006",
blocked_work: { unpushed_commits: 2 },
});
const el = renderSidebar(storeWith([blocked])).el;
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush();
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
expect(document.querySelector(".kill-dialog")).toBeNull();
expect(calls.filter((c) => c.includes("force=true"))).toHaveLength(0);
}); });
}); });
@@ -209,15 +258,16 @@ describe("session cards (#107)", () => {
expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull(); 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({ const s = session({
id: "aaaaaaaa-0000-0000-0000-00000000001c", id: "aaaaaaaa-0000-0000-0000-00000000001c",
command: "claude --resume", command: "claude --dangerously-skip-permissions",
}); });
const el = renderSidebar(storeWith([s])).el; const el = renderSidebar(storeWith([s])).el;
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd"); 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(".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", () => { it("keeps an unknown command as plain text", () => {
@@ -228,7 +278,8 @@ describe("session cards (#107)", () => {
const el = renderSidebar(storeWith([s])).el; const el = renderSidebar(storeWith([s])).el;
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd"); const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
expect(cmd?.querySelector(".tool-icon")).toBeNull(); 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", () => { it("maps each activity state to its contract slug", () => {
+26 -15
View File
@@ -1,5 +1,6 @@
import { escapeHtml } from "./html.js"; import { escapeHtml } from "./html.js";
import { getDaemonInfo, killSession, renameSession } from "./api.js"; import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js";
import { openForceKillDialog } from "./killDialog.js";
import { openContextMenu } from "./contextMenu.js"; import { openContextMenu } from "./contextMenu.js";
import { isTypingTarget, toggleHelp } from "./help.js"; import { isTypingTarget, toggleHelp } from "./help.js";
import { CatalogStore } from "./events.js"; import { CatalogStore } from "./events.js";
@@ -96,13 +97,14 @@ function blockedFragment(blockers: InTheWay): string {
return `<span class="card-flag leaf-blocked" title="${escapeHtml(title)}">${stats}</span>`; 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 /** docs/SIDEBAR.md: the card names the program only — arguments never reach
* we have a mark for it, and only the remaining words stay as text. The full * the card. The program becomes its tool icon when we have a mark for it, and
* command line stays in the tooltip either way. */ * 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 { function commandFragment(command: string): string {
const { tool, rest } = splitCommand(command); const { tool, program } = splitCommand(command);
const icon = tool ? toolIconSvg(tool) : null; 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>`; return `<span class="card-flag leaf-cmd" title="${escapeHtml(command)}">${icon ?? ""}${text ? `<span class="leaf-cmd-rest">${escapeHtml(text)}</span>` : ""}</span>`;
} }
@@ -179,7 +181,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
killBtn.addEventListener("click", (ev) => { killBtn.addEventListener("click", (ev) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
void confirmAndKill(s, name); void requestKill(s, name);
}); });
row.addEventListener("contextmenu", (ev) => { row.addEventListener("contextmenu", (ev) => {
ev.preventDefault(); ev.preventDefault();
@@ -191,22 +193,31 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
onSelect: () => onSelect: () =>
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s })), document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s })),
}, },
{ label: "Kill session", danger: true, onSelect: () => void confirmAndKill(s, name) }, { label: "Kill session", danger: true, onSelect: () => void requestKill(s, name) },
]); ]);
}); });
return row; return row;
} }
/** #83: the confirm restates the warning at the moment of decision. */ /**
async function confirmAndKill(s: Session, name: string): Promise<void> { * Kill on the first click (#81): the daemon is the one that decides whether a
const blockers = sessionBlockers(s); * session may go, and it refuses over a worktree holding unsaved work. Only
const question = blockers * that refusal is worth a prompt, and it needs one `confirm()` cannot give —
? `Kill session "${name}"? Its worktree still has ${formatInTheWay(blockers)} — the kill will be refused unless forced.` * a forced kill. Anything else surfaces as an alert.
: `Kill session "${name}"?`; */
if (!confirm(question)) return; async function requestKill(s: Session, name: string): Promise<void> {
try { try {
await killSession(s.id); await killSession(s.id);
} catch (e) { } catch (e) {
if (e instanceof ApiRequestError && e.code === "kill_guarded") {
openForceKillDialog({
name,
blockers: e.unsavedWork ?? sessionBlockers(s),
reason: e.detail,
onForce: () => killSession(s.id, true),
});
return;
}
alert(`Failed to kill: ${(e as Error).message}`); alert(`Failed to kill: ${(e as Error).message}`);
} }
} }
+104
View File
@@ -0,0 +1,104 @@
/**
* Forced-kill prompt (#81).
*
* The daemon refuses to kill a session whose worktree still holds uncommitted
* or unpushed work. `confirm()` can only ask the same question twice, so the
* refusal gets its own dialog: it names what is in the way and offers the
* override the daemon's `force=true` already implements. A kill the daemon
* accepts never reaches here — there is nothing to warn about.
*/
import { formatInTheWay, type InTheWay } from "./types.js";
export interface ForceKillPrompt {
/** Session name, as the sidebar shows it. */
name: string;
/** What the daemon reported in the way, when it reported anything. */
blockers: InTheWay | null;
/** Message from the daemon's refusal, shown when there is no breakdown. */
reason: string;
/** Run the forced kill. A rejection is rendered in the dialog, which stays
* open so the override can be retried. */
onForce: () => Promise<void>;
}
let open: { el: HTMLElement; onKeydown: (ev: KeyboardEvent) => void } | null = null;
export function closeForceKillDialog(): void {
if (!open) return;
window.removeEventListener("keydown", open.onKeydown, true);
open.el.remove();
open = null;
}
/** Mount the prompt. Opening while one is up replaces it, so a second refusal
* can never stack two dialogs. */
export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
closeForceKillDialog();
const el = document.createElement("div");
el.className = "kill-dialog";
el.setAttribute("role", "dialog");
el.setAttribute("aria-modal", "true");
el.setAttribute("aria-label", `Kill ${p.name}`);
const panel = document.createElement("div");
panel.className = "kill-dialog-panel";
const title = document.createElement("h2");
title.className = "kill-dialog-title";
title.textContent = `Kill "${p.name}"?`;
const body = document.createElement("p");
body.className = "kill-dialog-body";
body.textContent = p.blockers
? `Its worktree still has ${formatInTheWay(p.blockers)}. Forcing the kill removes the session and leaves the worktree on disk.`
: `${p.reason} Forcing the kill removes the session and leaves the worktree on disk.`;
const error = document.createElement("p");
error.className = "kill-dialog-error";
error.hidden = true;
const actions = document.createElement("div");
actions.className = "kill-dialog-actions";
const cancel = document.createElement("button");
cancel.type = "button";
cancel.className = "btn btn-ghost kill-dialog-cancel";
cancel.textContent = "Cancel";
cancel.addEventListener("click", closeForceKillDialog);
const force = document.createElement("button");
force.type = "button";
force.className = "btn btn-danger kill-dialog-force";
force.textContent = "Force kill";
force.addEventListener("click", () => {
force.disabled = true;
void p
.onForce()
.then(closeForceKillDialog)
.catch((e: unknown) => {
force.disabled = false;
error.hidden = false;
error.textContent = `Failed to kill: ${(e as Error).message}`;
});
});
actions.append(cancel, force);
panel.append(title, body, error, actions);
// A press on the scrim cancels; one inside the panel is left alone.
el.addEventListener("click", (ev) => {
if (!panel.contains(ev.target as Node)) closeForceKillDialog();
});
el.appendChild(panel);
document.body.appendChild(el);
const onKeydown = (ev: KeyboardEvent): void => {
if (ev.key !== "Escape") return;
ev.preventDefault();
ev.stopPropagation();
closeForceKillDialog();
};
window.addEventListener("keydown", onKeydown, true);
open = { el, onKeydown };
cancel.focus();
return el;
}
+4
View File
@@ -18,6 +18,10 @@ export const EVENTS = "/api/events";
/** Name of the query parameter `DIRS` and `GITROOT` both take. */ /** Name of the query parameter `DIRS` and `GITROOT` both take. */
export const PATH_QUERY = "path"; export const PATH_QUERY = "path";
/** Name of the query parameter `DELETE /api/sessions/:id` takes (#81):
* `force=true` overrides the kill refusal over a guarded worktree. */
export const FORCE_QUERY = "force";
/** Name of the query parameter `NAMES` takes. */ /** Name of the query parameter `NAMES` takes. */
export const COUNT_QUERY = "count"; export const COUNT_QUERY = "count";
+58
View File
@@ -1058,6 +1058,64 @@ input.leaf-rename:focus {
border-color: color-mix(in oklab, var(--danger) 40%, transparent); border-color: color-mix(in oklab, var(--danger) 40%, transparent);
} }
/* ---------- forced-kill prompt (#81) ---------- */
.kill-dialog {
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);
}
.kill-dialog-panel {
width: min(400px, 100%);
padding: var(--sp-4);
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--r-3);
box-shadow: var(--shadow-2);
}
.kill-dialog-title {
margin: 0 0 var(--sp-2);
font-family: var(--font-mono);
font-size: var(--fs-15);
color: var(--ink);
}
.kill-dialog-body {
margin: 0;
color: var(--muted);
font-size: var(--fs-13);
line-height: 1.5;
}
.kill-dialog-error {
margin: var(--sp-2) 0 0;
color: var(--danger);
font-size: var(--fs-13);
}
.kill-dialog-actions {
display: flex;
justify-content: flex-end;
gap: var(--sp-2);
margin-top: var(--sp-4);
}
.btn-danger {
color: var(--danger);
border-color: color-mix(in oklab, var(--danger) 40%, transparent);
}
.btn-danger:hover {
background: var(--danger-tint);
border-color: var(--danger);
}
/* ---------- empty / loading ---------- */ /* ---------- empty / loading ---------- */
.empty { .empty {
+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", () => { it("drops everything after the program", () => {
expect(splitCommand("claude --resume -p hi")).toEqual({ expect(splitCommand("claude --dangerously-skip-permissions")).toEqual({
tool: "claude", 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", () => { 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("FOO=1 BAR=2 claude").tool).toBe("claude");
expect(splitCommand("npx opencode").tool).toBe("opencode"); expect(splitCommand("npx opencode").tool).toBe("opencode");
expect(splitCommand("pnpm dlx opencode run").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({ expect(splitCommand("hx src/main.rs")).toEqual({
tool: null, tool: null,
rest: "hx src/main.rs", program: "hx",
}); });
// A wrapper with nothing after it is not a tool either. // A wrapper with nothing after it is not a tool either.
expect(splitCommand("sudo").tool).toBeNull(); 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 * 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 * 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 * as its own mark buys back the whole fragment's width — the arguments are
* spends on the rest of the command line instead. * dropped, so a card says which tool runs, not how it was invoked.
* *
* Recognition is deliberately narrow: an unrecognised program keeps its text, * Recognition is deliberately narrow: an unrecognised program keeps its name
* so the fragment never renders a mark that stands for the wrong tool. * 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. */ /** 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 { export interface CommandParts {
/** Icon id, or `null` when the program is not one we have a mark for. */ /** Icon id, or `null` when the program is not one we have a mark for. */
tool: string | null; tool: string | null;
/** What is left of the command line once the icon stands for the program. /** The program the command runs, basenamed and stripped of its arguments —
* Empty when the icon says everything the command line said. */ * what a card shows when there is no icon to show instead. */
rest: string; 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 * `claude --resume` → `{tool: "claude", program: "claude"}`; an unrecognised
* `hx src/` keeps the whole line as `rest` with no tool. */ * `hx src/` keeps `hx` as the program with no tool. */
export function splitCommand(command: string): CommandParts { export function splitCommand(command: string): CommandParts {
const words = command.trim().split(/\s+/).filter(Boolean); const words = command.trim().split(/\s+/).filter(Boolean);
let i = 0; let i = 0;
@@ -68,23 +68,22 @@ export function splitCommand(command: string): CommandParts {
i += 1; i += 1;
continue; continue;
} }
const program = basename(word); const wrapper = basename(word).toLowerCase();
if (WRAPPERS.has(program)) { if (WRAPPERS.has(wrapper)) {
i += 1; i += 1;
if ((program === "pnpm" || program === "yarn") && words[i] === "dlx") i += 1; if ((wrapper === "pnpm" || wrapper === "yarn") && words[i] === "dlx") i += 1;
continue; continue;
} }
break; break;
} }
if (i >= words.length) return { tool: null, rest: command.trim() }; if (i >= words.length) return { tool: null, program: command.trim() };
const tool = BY_PROGRAM[basename(words[i])] ?? null; const program = basename(words[i]);
if (!tool) return { tool: null, rest: command.trim() }; return { tool: BY_PROGRAM[program.toLowerCase()] ?? null, program };
return { tool, rest: words.slice(i + 1).join(" ") };
} }
function basename(word: string): string { function basename(word: string): string {
const cut = word.lastIndexOf("/"); 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. */ /** Anthropic's burst: tapered rays around a centre, long ones on the axes. */
+6 -1
View File
@@ -253,7 +253,12 @@ export type ClientControl =
| { type: "Error"; code: string; message: string }; | { type: "Error"; code: string; message: string };
export interface ApiError { export interface ApiError {
error: { code: string; message: string }; error: {
code: string;
message: string;
/** Set only by the kill guard (#79): what blocked the unforced kill. */
unsaved_work?: InTheWay;
};
} }
export function sessionName(s: Session): string { export function sessionName(s: Session): string {