diff --git a/web/src/attach.ts b/web/src/attach.ts
index 57a96d6..22ae5c3 100644
--- a/web/src/attach.ts
+++ b/web/src/attach.ts
@@ -6,6 +6,7 @@ import { attachWsUrl } from "./routes.js";
import { escapeHtml } from "./html.js";
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
import { applyModsToChar, cursorSeq } from "./keys.js";
+import { splitCommand, toolIconSvg } from "./toolIcon.js";
import type { ClientControl, Uuid } from "./types.js";
/**
@@ -54,6 +55,15 @@ export interface AttachInfo {
command: string;
}
+/** Header echoes the sidebar: the command's leading program becomes its tool
+ * icon when we have a mark for it, remaining words stay as text. */
+function commandFragment(command: string): string {
+ const { tool, rest } = splitCommand(command);
+ const icon = tool ? toolIconSvg(tool) : null;
+ const text = icon ? rest : command;
+ return `${icon ?? ""}${text ? escapeHtml(text) : ""}`;
+}
+
export function createAttachConnection(
sessionId: Uuid,
opts: AttachOptions = {},
@@ -65,9 +75,7 @@ export function createAttachConnection(
// tooltip for when you really need it.
const titleHtml = info
? `${escapeHtml(info.name)}` +
- (info.command && info.command !== info.name
- ? `${escapeHtml(info.command)}`
- : "")
+ (info.command && info.command !== info.name ? commandFragment(info.command) : "")
: `session ${escapeHtml(sessionId.slice(0, 8))}`;
const titleAttr = info ? `${info.name} · ${info.command} (${sessionId})` : sessionId;
el.innerHTML = `
diff --git a/web/src/catalog.ts b/web/src/catalog.ts
index 6c88d24..09d5e42 100644
--- a/web/src/catalog.ts
+++ b/web/src/catalog.ts
@@ -300,8 +300,8 @@ function renderEmpty(filtering: boolean, onSpawn: () => void): HTMLElement {
/**
* The global app header (full width, above the sidebar/content split): brand +
- * the app-level actions (spawn, theme toggle). Kept out of the sidebar so these
- * stay put and reachable regardless of which pane is showing.
+ * session tally. App-level actions (help, theme, spawn) live pinned to the
+ * sidebar's foot instead — see {@link renderSidebar}.
*/
export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void): HTMLElement {
const header = document.createElement("header");
@@ -340,23 +340,6 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void):
kit.title = "Open the design kit (DEV)";
actions.appendChild(kit);
}
- const helpBtn = document.createElement("button");
- helpBtn.type = "button";
- helpBtn.className = "btn btn-ghost btn-icon help-btn";
- helpBtn.textContent = "?";
- helpBtn.title = "Keyboard shortcuts (?)";
- helpBtn.setAttribute("aria-label", "Keyboard shortcuts");
- helpBtn.addEventListener("click", () => toggleHelp());
- actions.appendChild(helpBtn);
- actions.appendChild(createThemeToggle());
- const spawnBtn = document.createElement("button");
- spawnBtn.type = "button";
- spawnBtn.className = "btn btn-primary spawn-btn";
- spawnBtn.textContent = "+ Spawn";
- // The keyboard pair sits right here in the tooltip (#67).
- spawnBtn.title = "Draft pane: n — fast-forward spawn: N";
- spawnBtn.addEventListener("click", () => requestOpenSpawn());
- actions.appendChild(spawnBtn);
return header;
}
@@ -425,6 +408,30 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
treeHost.className = "tree";
el.appendChild(treeHost);
+ // App-level actions (help, theme, spawn) live pinned to the sidebar's foot
+ // rather than the sidebar's own scrolling content, so they stay reachable
+ // regardless of tree scroll position.
+ const footer = document.createElement("div");
+ footer.className = "sidebar-footer";
+ const helpBtn = document.createElement("button");
+ helpBtn.type = "button";
+ helpBtn.className = "btn btn-ghost btn-icon help-btn";
+ helpBtn.textContent = "?";
+ helpBtn.title = "Keyboard shortcuts (?)";
+ helpBtn.setAttribute("aria-label", "Keyboard shortcuts");
+ helpBtn.addEventListener("click", () => toggleHelp());
+ footer.appendChild(helpBtn);
+ footer.appendChild(createThemeToggle());
+ const spawnBtn = document.createElement("button");
+ spawnBtn.type = "button";
+ spawnBtn.className = "btn btn-primary spawn-btn";
+ spawnBtn.textContent = "+ Spawn";
+ // The keyboard pair sits right here in the tooltip (#67).
+ spawnBtn.title = "Draft pane: n — fast-forward spawn: N";
+ spawnBtn.addEventListener("click", () => requestOpenSpawn());
+ footer.appendChild(spawnBtn);
+ el.appendChild(footer);
+
// Drag a session row into the canvas (issue #32). Delegated so it survives
// tree re-renders; the controller handles drag-vs-tap (tap still navigates).
treeHost.addEventListener("pointerdown", (e) => {
diff --git a/web/src/styles.css b/web/src/styles.css
index f6ebced..9663809 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -484,14 +484,27 @@ textarea {
}
}
-/* Sidebar's own slim header: just the session search (app-level actions live
- in the global top bar). */
+/* Sidebar's own slim header: just the session search. */
/* The search input IS the header — it spans edge to edge, flush, with just a
bottom rule for separation. No wrapper padding, no boxed border/radius. */
.sidebar-search {
flex: 0 0 auto;
}
+/* App-level actions (help, theme, spawn), pinned to the sidebar's foot. */
+.sidebar-footer {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-2);
+ flex: 0 0 auto;
+ padding: var(--sp-2) var(--sp-3);
+ border-top: 1px solid var(--border);
+}
+
+.sidebar-footer .spawn-btn {
+ flex: 1 1 auto;
+}
+
.sidebar-search .search {
width: 100%;
max-width: none;
@@ -1279,6 +1292,9 @@ input.leaf-rename:focus {
.attach-title .at-cmd {
color: var(--muted);
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
}
.attach-title .at-cmd::before {