Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dde63a2ab | ||
|
|
1fe2ae6575 | ||
|
|
5df23b8714 | ||
|
|
46a000aa68 | ||
|
|
b46ba9dd26 | ||
|
|
cd37ac5d6d | ||
|
|
7a3287f231 |
@@ -32,6 +32,33 @@ The throwaway tab never counts as an open session: the tab bar does not appear
|
||||
because of it, the mobile shell does not switch away from the session list for
|
||||
it, and the sidebar does not mark the session as open.
|
||||
|
||||
## How a preview reads
|
||||
|
||||
A preview is provisional, so it never wears the accent — the accent is what
|
||||
says "this is the one you picked". It has a hue of its own, and every surface
|
||||
involved in the preview carries that same hue, so the pane and the row it came
|
||||
from read as one thing rather than two unrelated highlights.
|
||||
|
||||
Web client (`--preview` and friends in `web/src/styles.css`):
|
||||
|
||||
| Surface | While previewing |
|
||||
|---|---|
|
||||
| The previewed pane's titlebar | Tinted in the preview hue, at full strength, in place of the focused pane's accent tint. |
|
||||
| The canvas | A `preview` chip, top right, and a dashed inset ring. |
|
||||
| A throwaway tab | Dashed border, italic label. |
|
||||
| The sidebar row being previewed | Filled with the preview hue, over whatever open/focused state the row already had. |
|
||||
| A hovered sidebar row, and the keyboard cursor on a session | A lighter wash of the same hue, because both are what previews a row. |
|
||||
|
||||
Rows say it with fill, not with an edge: the only ring left in the session list
|
||||
is `.is-active`'s, which means "open in a pane". The dashed edges belong to the
|
||||
pane — the canvas ring and the throwaway tab.
|
||||
|
||||
The hue is a palette token, not a fixed colour: a palette whose accent already
|
||||
sits on it moves it (`--preview-h`), so "previewing" and "picked" never collide.
|
||||
|
||||
The TUI marks a preview in text only — the title bar reads `<name> · preview`
|
||||
— and has no preview colour role yet.
|
||||
|
||||
## What ends a preview
|
||||
|
||||
- The highlight moves to another session — that session is previewed instead.
|
||||
|
||||
@@ -388,6 +388,9 @@ export interface SidebarHandle {
|
||||
setActive(id: string | null): void;
|
||||
/** Apply the full open/focused highlight state. */
|
||||
setHighlights(h: SidebarHighlights): void;
|
||||
/** Mark the row the content pane is previewing (docs/PREVIEW.md), or clear it
|
||||
* with `null`. A preview is a view, not state, so this only paints. */
|
||||
setPreview(sessionId: string | null): void;
|
||||
/** Hand the keyboard to the session list, highlighting a row to walk from. */
|
||||
focusList(): void;
|
||||
/** Whether the session list currently holds DOM focus. */
|
||||
@@ -547,6 +550,10 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
});
|
||||
|
||||
let highlights: SidebarHighlights = { open: new Set(), focused: null };
|
||||
// The previewed row is painted alongside the open/focused tiers so a
|
||||
// re-render can't drop it, but it is never part of `highlights`: a preview
|
||||
// is a view, and nothing about it is durable (docs/PREVIEW.md).
|
||||
let previewing: string | null = null;
|
||||
const applyHighlights = () => {
|
||||
for (const n of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
|
||||
const id = n.dataset.sessionId;
|
||||
@@ -555,6 +562,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
const open = highlights.open.has(id);
|
||||
n.classList.toggle("is-active", focused);
|
||||
n.classList.toggle("is-open", open && !focused);
|
||||
n.classList.toggle("is-previewing", previewing === id);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -762,6 +770,10 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
highlights = h;
|
||||
applyHighlights();
|
||||
},
|
||||
setPreview(sessionId: string | null) {
|
||||
previewing = sessionId;
|
||||
applyHighlights();
|
||||
},
|
||||
focusList() {
|
||||
// Start from the session on screen — that is where the user's attention
|
||||
// already is — falling back to the standing highlight, then the top row.
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SESSION_SELECT_EVENT } from "./catalog.js";
|
||||
import {
|
||||
barGroups,
|
||||
barRows,
|
||||
closeCommandBar,
|
||||
isCommandBarOpen,
|
||||
openCommandBar,
|
||||
orderSessionsForBar,
|
||||
sessionBarCandidates,
|
||||
sessionCandidate,
|
||||
} from "./commandBar.js";
|
||||
import { COMMAND_SPECS } from "./commands.js";
|
||||
import type { Session } from "./types.js";
|
||||
|
||||
function session(id: string, over: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id,
|
||||
cwd: "/home/me/dev/api",
|
||||
command: "bash",
|
||||
env: {},
|
||||
metadata: { name: id },
|
||||
status: "Running",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
closeCommandBar();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
function press(key: string, init: KeyboardEventInit = {}): void {
|
||||
const input = document.querySelector(".cmdbar-input") as HTMLInputElement;
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, ...init }));
|
||||
}
|
||||
|
||||
function type(value: string): void {
|
||||
const input = document.querySelector(".cmdbar-input") as HTMLInputElement;
|
||||
input.value = value;
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
function rowNames(): string[] {
|
||||
return [...document.querySelectorAll(".cmdbar-row-name")].map((r) => r.textContent ?? "");
|
||||
}
|
||||
|
||||
function activeName(): string | undefined {
|
||||
return document.querySelector(".cmdbar-row.is-active .cmdbar-row-name")?.textContent ?? undefined;
|
||||
}
|
||||
|
||||
describe("empty-query ordering", () => {
|
||||
it("sorts most recently focused first", () => {
|
||||
const older = session("older", { last_focused_at: "2026-01-01T10:00:00Z" });
|
||||
const newer = session("newer", { last_focused_at: "2026-01-01T11:00:00Z" });
|
||||
expect(orderSessionsForBar([older, newer]).map((s) => s.id)).toEqual(["newer", "older"]);
|
||||
});
|
||||
|
||||
it("sorts a never-focused session after every focused one, newest created first", () => {
|
||||
const focused = session("focused", {
|
||||
last_focused_at: "2020-01-01T00:00:00Z",
|
||||
created_at: "2020-01-01T00:00:00Z",
|
||||
});
|
||||
const oldFresh = session("old-fresh", { created_at: "2026-01-01T00:00:00Z" });
|
||||
const newFresh = session("new-fresh", { created_at: "2026-06-01T00:00:00Z" });
|
||||
expect(orderSessionsForBar([oldFresh, newFresh, focused]).map((s) => s.id)).toEqual([
|
||||
"focused",
|
||||
"new-fresh",
|
||||
"old-fresh",
|
||||
]);
|
||||
});
|
||||
|
||||
it("sorts dead sessions after live ones regardless of recency", () => {
|
||||
const deadRecent = session("dead", {
|
||||
status: "Dead",
|
||||
last_focused_at: "2026-06-01T00:00:00Z",
|
||||
});
|
||||
const liveStale = session("live", { last_focused_at: "2020-01-01T00:00:00Z" });
|
||||
const liveNever = session("never");
|
||||
expect(orderSessionsForBar([deadRecent, liveNever, liveStale]).map((s) => s.id)).toEqual([
|
||||
"live",
|
||||
"never",
|
||||
"dead",
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats a null last_focused_at as never focused", () => {
|
||||
const nulled = session("nulled", { last_focused_at: null });
|
||||
const stamped = session("stamped", { last_focused_at: "2020-01-01T00:00:00Z" });
|
||||
expect(orderSessionsForBar([nulled, stamped]).map((s) => s.id)).toEqual(["stamped", "nulled"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session candidates", () => {
|
||||
it("carries the candidate table's fields, with the directory abbreviated", () => {
|
||||
const s = session("api", {
|
||||
metadata: { name: "api" },
|
||||
command: "claude --resume",
|
||||
current_cwd: "/home/me/dev/api",
|
||||
});
|
||||
expect(sessionCandidate(s, "/home/me")).toEqual({
|
||||
kind: "session",
|
||||
session: s,
|
||||
fields: {
|
||||
name: "api",
|
||||
directory: "~/dev/api",
|
||||
command: "claude --resume",
|
||||
tool: "claude",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the tool out when the program has no mark, and groups a worktree by its repo", () => {
|
||||
const s = session("edit", {
|
||||
metadata: {},
|
||||
command: "hx src/",
|
||||
worktree: { path: "/home/me/dev/api/wt/x", base_repo: "/home/me/dev/api" },
|
||||
});
|
||||
const candidate = sessionCandidate(s, "/home/me");
|
||||
expect(candidate.fields.tool).toBeUndefined();
|
||||
expect(candidate.fields.directory).toBe("~/dev/api");
|
||||
// No `metadata.name`: the sidebar's fallback is the command line.
|
||||
expect(candidate.fields.name).toBe("hx src/");
|
||||
});
|
||||
|
||||
it("builds candidates in the empty-query order", () => {
|
||||
const a = session("a", { last_focused_at: "2026-01-01T10:00:00Z" });
|
||||
const b = session("b", { last_focused_at: "2026-01-01T11:00:00Z" });
|
||||
expect(sessionBarCandidates([a, b]).map((c) => c.session.id)).toEqual(["b", "a"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rows and grouping", () => {
|
||||
it("an empty query keeps every session in input order", () => {
|
||||
const candidates = sessionBarCandidates([session("a"), session("b")]);
|
||||
expect(barRows("", candidates).map((r) => r.candidate.session.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("groups by kind without reordering the ranking", () => {
|
||||
const candidates = sessionBarCandidates([
|
||||
session("worker", { metadata: { name: "worker" }, current_cwd: "/home/me/dev/api" }),
|
||||
session("api", { metadata: { name: "api" }, current_cwd: "/home/me/dev/web" }),
|
||||
]);
|
||||
const rows = barRows("api", candidates);
|
||||
const groups = barGroups(rows);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].title).toBe("Sessions");
|
||||
expect(groups[0].results.map((r) => r.candidate.session.id)).toEqual(
|
||||
rows.map((r) => r.candidate.session.id),
|
||||
);
|
||||
// F6: the name beats the directory, and the directory match highlights
|
||||
// inside the directory.
|
||||
expect(groups[0].results[0].candidate.session.id).toBe("api");
|
||||
expect(groups[0].results[1].field).toBe("directory");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the overlay", () => {
|
||||
const sessions = [
|
||||
session("api", {
|
||||
metadata: { name: "api" },
|
||||
current_cwd: "/home/me/dev/api",
|
||||
last_focused_at: "2026-01-01T11:00:00Z",
|
||||
}),
|
||||
session("worker", {
|
||||
metadata: { name: "worker" },
|
||||
current_cwd: "/home/me/dev/worker",
|
||||
last_focused_at: "2026-01-01T10:00:00Z",
|
||||
}),
|
||||
session("notes", { metadata: { name: "notes" }, current_cwd: "/home/me/notes" }),
|
||||
];
|
||||
|
||||
function open(over: Parameters<typeof openCommandBar>[0] | null = null) {
|
||||
return openCommandBar(over ?? { sessions, homeDir: "/home/me", onOpenSession: () => {} });
|
||||
}
|
||||
|
||||
it("is a registry command, so the help overlay lists it", () => {
|
||||
const spec = COMMAND_SPECS.find((s) => s.id === "bar.open");
|
||||
expect(spec?.keys).toEqual(["ctrl-k"]);
|
||||
expect(spec?.scope).toBe("global");
|
||||
});
|
||||
|
||||
it("lists sessions by recency under a kind header on an empty query", () => {
|
||||
open();
|
||||
expect([...document.querySelectorAll(".cmdbar-group-title")].map((h) => h.textContent)).toEqual([
|
||||
"Sessions",
|
||||
]);
|
||||
expect(rowNames()).toEqual(["api", "worker", "notes"]);
|
||||
expect(activeName()).toBe("api");
|
||||
});
|
||||
|
||||
it("opening twice leaves one overlay", () => {
|
||||
open();
|
||||
open();
|
||||
expect(document.querySelectorAll(".cmdbar-overlay")).toHaveLength(1);
|
||||
expect(isCommandBarOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it("ranks a typed query through the matcher and highlights the matched range", () => {
|
||||
open();
|
||||
type("wor");
|
||||
expect(rowNames()).toEqual(["worker"]);
|
||||
const marks = [...document.querySelectorAll(".cmdbar-row-name mark")].map((m) => m.textContent);
|
||||
expect(marks).toEqual(["wor"]);
|
||||
});
|
||||
|
||||
it("highlights inside the directory when that is the field that matched", () => {
|
||||
open();
|
||||
type("notes");
|
||||
const row = document.querySelector(".cmdbar-row") as HTMLElement;
|
||||
expect(row.dataset.sessionId).toBe("notes");
|
||||
type("dev/wor");
|
||||
expect(rowNames()).toEqual(["worker"]);
|
||||
expect(
|
||||
[...document.querySelectorAll(".cmdbar-row-meta mark")].map((m) => m.textContent).join(""),
|
||||
).toBe("dev/wor");
|
||||
});
|
||||
|
||||
it("renders a no-results state rather than an empty list", () => {
|
||||
open();
|
||||
type("zzz");
|
||||
expect(rowNames()).toEqual([]);
|
||||
expect(document.querySelector(".cmdbar-empty")?.textContent).toBe("No matches");
|
||||
});
|
||||
|
||||
it("moves the highlight with the arrows and ctrl-p/ctrl-n, without wrapping", () => {
|
||||
open();
|
||||
press("ArrowUp");
|
||||
expect(activeName()).toBe("api");
|
||||
press("ArrowDown");
|
||||
expect(activeName()).toBe("worker");
|
||||
press("n", { ctrlKey: true });
|
||||
expect(activeName()).toBe("notes");
|
||||
press("ArrowDown");
|
||||
expect(activeName()).toBe("notes");
|
||||
press("p", { ctrlKey: true });
|
||||
expect(activeName()).toBe("worker");
|
||||
});
|
||||
|
||||
it("puts the highlight back on the best row whenever the query changes", () => {
|
||||
open();
|
||||
press("ArrowDown");
|
||||
expect(activeName()).toBe("worker");
|
||||
type("o");
|
||||
expect(activeName()).toBe(document.querySelector(".cmdbar-row-name")?.textContent);
|
||||
});
|
||||
|
||||
it("opens the highlighted session through the shared open path on Enter", () => {
|
||||
const seen: string[] = [];
|
||||
const listener = (e: Event) => seen.push((e as CustomEvent<string>).detail);
|
||||
document.addEventListener(SESSION_SELECT_EVENT, listener);
|
||||
openCommandBar({ sessions, homeDir: "/home/me" });
|
||||
press("ArrowDown");
|
||||
press("Enter");
|
||||
document.removeEventListener(SESSION_SELECT_EVENT, listener);
|
||||
expect(seen).toEqual(["worker"]);
|
||||
expect(isCommandBarOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it("Enter on an empty result list does nothing", () => {
|
||||
const onOpenSession = vi.fn();
|
||||
open({ sessions, homeDir: "/home/me", onOpenSession });
|
||||
type("zzz");
|
||||
press("Enter");
|
||||
expect(onOpenSession).not.toHaveBeenCalled();
|
||||
expect(isCommandBarOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it("closes on Escape and on a press outside the panel", () => {
|
||||
open();
|
||||
press("Escape");
|
||||
expect(isCommandBarOpen()).toBe(false);
|
||||
|
||||
open();
|
||||
(document.querySelector(".cmdbar-overlay") as HTMLElement).dispatchEvent(
|
||||
new MouseEvent("mousedown", { bubbles: true }),
|
||||
);
|
||||
expect(isCommandBarOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it("stays open for a press inside the panel", () => {
|
||||
open();
|
||||
(document.querySelector(".cmdbar-panel") as HTMLElement).dispatchEvent(
|
||||
new MouseEvent("mousedown", { bubbles: true }),
|
||||
);
|
||||
expect(isCommandBarOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns the keyboard to whatever held it when it opened", () => {
|
||||
const before = document.createElement("button");
|
||||
document.body.appendChild(before);
|
||||
before.focus();
|
||||
open();
|
||||
expect(document.activeElement).toBe(document.querySelector(".cmdbar-input"));
|
||||
press("Escape");
|
||||
expect(document.activeElement).toBe(before);
|
||||
});
|
||||
|
||||
it("clicking a row opens that session", () => {
|
||||
const onOpenSession = vi.fn();
|
||||
open({ sessions, homeDir: "/home/me", onOpenSession });
|
||||
(document.querySelectorAll(".cmdbar-row")[1] as HTMLElement).click();
|
||||
expect(onOpenSession).toHaveBeenCalledWith("worker");
|
||||
expect(isCommandBarOpen()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,381 @@
|
||||
// The `ctrl-k` command bar (docs/COMMAND-BAR.md): a query line over a result
|
||||
// list, opened from the sidebar, closed with Escape.
|
||||
//
|
||||
// Sessions are the only kind here (#149); the sigils, the action page and the
|
||||
// terminal-focus binding land in #150-#152.
|
||||
|
||||
import { SESSION_SELECT_EVENT } from "./catalog.js";
|
||||
import {
|
||||
matchAndRank,
|
||||
type CandidateKind,
|
||||
type MatchRange,
|
||||
type MatchResult,
|
||||
type SessionCandidate,
|
||||
} from "./matcher.js";
|
||||
import { splitCommand } from "./toolIcon.js";
|
||||
import { abbreviateCwd, sessionName, sessionTreePath, type Session } from "./types.js";
|
||||
|
||||
/** A matcher candidate that remembers which session it came from, so a result
|
||||
* can be acted on without a second lookup. The extra property is invisible to
|
||||
* the matcher, which only ever reads `kind` and `fields`. */
|
||||
export type SessionBarCandidate = SessionCandidate & { session: Session };
|
||||
|
||||
export type BarCandidate = SessionBarCandidate;
|
||||
export type BarResult = MatchResult<BarCandidate>;
|
||||
|
||||
/** One kind's slice of the ranked list, in rank order. */
|
||||
export interface BarGroup {
|
||||
kind: CandidateKind;
|
||||
title: string;
|
||||
results: BarResult[];
|
||||
}
|
||||
|
||||
const KIND_TITLES: Record<CandidateKind, string> = {
|
||||
session: "Sessions",
|
||||
command: "Commands",
|
||||
directory: "Directories",
|
||||
};
|
||||
|
||||
// ---- candidates ----
|
||||
|
||||
function focusedAt(s: Session): number | null {
|
||||
const stamp = s.last_focused_at;
|
||||
if (typeof stamp !== "string" || stamp === "") return null;
|
||||
const ms = Date.parse(stamp);
|
||||
return Number.isNaN(ms) ? null : ms;
|
||||
}
|
||||
|
||||
function createdAt(s: Session): number {
|
||||
const ms = Date.parse(s.created_at ?? "");
|
||||
return Number.isNaN(ms) ? 0 : ms;
|
||||
}
|
||||
|
||||
/** The empty-query order (docs/COMMAND-BAR.md, "Empty query"): most recently
|
||||
* focused first; a session that has never been focused sorts after every
|
||||
* session that has, most recently created first among those; and a dead
|
||||
* session sorts after every live one regardless of recency, because a session
|
||||
* you cannot attach to is not where you just were.
|
||||
*
|
||||
* This is also the input order for a non-empty query, which is what the
|
||||
* matcher's last tie-break preserves. */
|
||||
export function orderSessionsForBar(sessions: readonly Session[]): Session[] {
|
||||
return [...sessions].sort((a, b) => {
|
||||
const deadA = a.status === "Dead" ? 1 : 0;
|
||||
const deadB = b.status === "Dead" ? 1 : 0;
|
||||
if (deadA !== deadB) return deadA - deadB;
|
||||
|
||||
const focusA = focusedAt(a);
|
||||
const focusB = focusedAt(b);
|
||||
if ((focusA === null) !== (focusB === null)) return focusA === null ? 1 : -1;
|
||||
if (focusA !== null && focusB !== null && focusA !== focusB) return focusB - focusA;
|
||||
|
||||
return createdAt(b) - createdAt(a);
|
||||
});
|
||||
}
|
||||
|
||||
/** Build one session's candidate: the fields docs/COMMAND-BAR.md's candidate
|
||||
* table names, with the directory abbreviated the way the sidebar shows it. */
|
||||
export function sessionCandidate(s: Session, homeDir?: string): SessionBarCandidate {
|
||||
const treePath = sessionTreePath(s);
|
||||
const directory = treePath ? abbreviateCwd(treePath, homeDir) : undefined;
|
||||
const tool = splitCommand(s.command).tool ?? undefined;
|
||||
return {
|
||||
kind: "session",
|
||||
session: s,
|
||||
fields: {
|
||||
name: sessionName(s),
|
||||
...(directory ? { directory } : {}),
|
||||
...(s.command ? { command: s.command } : {}),
|
||||
...(tool ? { tool } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Every session as a candidate, in the empty-query order. */
|
||||
export function sessionBarCandidates(
|
||||
sessions: readonly Session[],
|
||||
homeDir?: string,
|
||||
): SessionBarCandidate[] {
|
||||
return orderSessionsForBar(sessions).map((s) => sessionCandidate(s, homeDir));
|
||||
}
|
||||
|
||||
// ---- grouping ----
|
||||
|
||||
/** Bucket a ranked list by kind for rendering. Grouping is a rendering of the
|
||||
* ranking, never a reordering of it: a group holds its matches in rank order,
|
||||
* and groups appear in the order their best-scoring member does — so the first
|
||||
* row under the first header is the globally best result. */
|
||||
export function barGroups(results: readonly BarResult[]): BarGroup[] {
|
||||
const groups: BarGroup[] = [];
|
||||
for (const result of results) {
|
||||
const kind = result.candidate.kind;
|
||||
let group = groups.find((g) => g.kind === kind);
|
||||
if (!group) {
|
||||
group = { kind, title: KIND_TITLES[kind], results: [] };
|
||||
groups.push(group);
|
||||
}
|
||||
group.results.push(result);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** The rows a query produces, flat and in rank order — what the highlight walks
|
||||
* and what Enter acts on. Headers are labels, not rows, so they are never in
|
||||
* this list. */
|
||||
export function barRows(query: string, candidates: readonly BarCandidate[]): BarResult[] {
|
||||
return matchAndRank(query, candidates);
|
||||
}
|
||||
|
||||
// ---- rendering ----
|
||||
|
||||
/** Text with its matched ranges wrapped in `<mark>`. Ranges come from the
|
||||
* matcher as `[start, end)` character pairs over the field that won. */
|
||||
export function renderHighlighted(text: string, ranges: readonly MatchRange[]): DocumentFragment {
|
||||
const frag = document.createDocumentFragment();
|
||||
let cursor = 0;
|
||||
for (const [start, end] of ranges) {
|
||||
if (start > cursor) frag.append(text.slice(cursor, start));
|
||||
const mark = document.createElement("mark");
|
||||
mark.className = "cmdbar-hit";
|
||||
mark.textContent = text.slice(start, end);
|
||||
frag.append(mark);
|
||||
cursor = end;
|
||||
}
|
||||
if (cursor < text.length) frag.append(text.slice(cursor));
|
||||
return frag;
|
||||
}
|
||||
|
||||
/** The secondary line under a row's name: the field the match was found in when
|
||||
* that was not the name, so a session found through its directory says so. */
|
||||
function metaText(result: BarResult): string | undefined {
|
||||
const fields = result.candidate.fields;
|
||||
switch (result.field) {
|
||||
case "directory":
|
||||
return fields.directory;
|
||||
case "command":
|
||||
return fields.command;
|
||||
case "tool":
|
||||
return fields.tool;
|
||||
default:
|
||||
return fields.directory;
|
||||
}
|
||||
}
|
||||
|
||||
function renderRow(result: BarResult, index: number): HTMLElement {
|
||||
const row = document.createElement("div");
|
||||
row.className = "cmdbar-row";
|
||||
row.setAttribute("role", "option");
|
||||
row.id = `cmdbar-row-${index}`;
|
||||
row.dataset.index = String(index);
|
||||
row.dataset.sessionId = result.candidate.session.id;
|
||||
if (result.candidate.session.status === "Dead") row.classList.add("is-dead");
|
||||
|
||||
const name = document.createElement("span");
|
||||
name.className = "cmdbar-row-name";
|
||||
const nameText = result.candidate.fields.name;
|
||||
name.append(
|
||||
result.field === "name" ? renderHighlighted(nameText, result.ranges) : nameText,
|
||||
);
|
||||
row.appendChild(name);
|
||||
|
||||
const meta = metaText(result);
|
||||
if (meta) {
|
||||
const el = document.createElement("span");
|
||||
el.className = "cmdbar-row-meta";
|
||||
const highlighted = result.field !== undefined && result.field !== "name";
|
||||
el.append(highlighted ? renderHighlighted(meta, result.ranges) : meta);
|
||||
row.appendChild(el);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
// ---- the overlay ----
|
||||
|
||||
export interface CommandBarOptions {
|
||||
/** Sessions to build candidates from, as the store has them right now. */
|
||||
sessions: readonly Session[];
|
||||
/** The daemon's home dir, for the directory field's `~/…` abbreviation. */
|
||||
homeDir?: string;
|
||||
/** Open the highlighted session. Defaults to the shared open path — the
|
||||
* sidebar's click event — which is what the contract requires; tests pass
|
||||
* their own. */
|
||||
onOpenSession?: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
interface OpenBar {
|
||||
el: HTMLElement;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
let current: OpenBar | null = null;
|
||||
|
||||
export function isCommandBarOpen(): boolean {
|
||||
return current !== null;
|
||||
}
|
||||
|
||||
export function closeCommandBar(): void {
|
||||
current?.dispose();
|
||||
}
|
||||
|
||||
/** Mount the bar. Opening while one is up replaces it, so a stray second call
|
||||
* can never stack two overlays. */
|
||||
export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
||||
closeCommandBar();
|
||||
|
||||
// Closing returns the keyboard to whatever held it when the bar opened: a
|
||||
// round trip through the bar never silently moves focus.
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
const openSession =
|
||||
opts.onOpenSession ??
|
||||
((id: string) => document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: id })));
|
||||
|
||||
const candidates: BarCandidate[] = sessionBarCandidates(opts.sessions, opts.homeDir);
|
||||
|
||||
const el = document.createElement("div");
|
||||
el.className = "cmdbar-overlay";
|
||||
el.setAttribute("role", "dialog");
|
||||
el.setAttribute("aria-modal", "true");
|
||||
el.setAttribute("aria-label", "Command bar");
|
||||
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "cmdbar-panel";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "cmdbar-input";
|
||||
input.setAttribute("role", "combobox");
|
||||
input.setAttribute("aria-expanded", "true");
|
||||
input.setAttribute("aria-controls", "cmdbar-results");
|
||||
input.setAttribute("aria-label", "Search sessions");
|
||||
input.setAttribute("autocomplete", "off");
|
||||
input.spellcheck = false;
|
||||
input.placeholder = "Go to a session…";
|
||||
|
||||
const results = document.createElement("div");
|
||||
results.className = "cmdbar-results";
|
||||
results.id = "cmdbar-results";
|
||||
results.setAttribute("role", "listbox");
|
||||
|
||||
panel.append(input, results);
|
||||
el.appendChild(panel);
|
||||
document.body.appendChild(el);
|
||||
|
||||
let rows: BarResult[] = [];
|
||||
let highlighted = 0;
|
||||
|
||||
function paintHighlight(): void {
|
||||
for (const row of results.querySelectorAll<HTMLElement>(".cmdbar-row")) {
|
||||
const active = Number(row.dataset.index) === highlighted;
|
||||
row.classList.toggle("is-active", active);
|
||||
row.setAttribute("aria-selected", active ? "true" : "false");
|
||||
if (active) {
|
||||
row.scrollIntoView({ block: "nearest" });
|
||||
input.setAttribute("aria-activedescendant", row.id);
|
||||
}
|
||||
}
|
||||
if (rows.length === 0) input.removeAttribute("aria-activedescendant");
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
rows = barRows(input.value, candidates);
|
||||
// The highlight starts on the globally best row every time the list
|
||||
// changes: an old index would point at a different result.
|
||||
highlighted = 0;
|
||||
results.replaceChildren();
|
||||
|
||||
if (rows.length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "cmdbar-empty";
|
||||
empty.textContent = "No matches";
|
||||
results.appendChild(empty);
|
||||
paintHighlight();
|
||||
return;
|
||||
}
|
||||
|
||||
let index = 0;
|
||||
for (const group of barGroups(rows)) {
|
||||
const header = document.createElement("div");
|
||||
header.className = "cmdbar-group-title";
|
||||
header.setAttribute("role", "presentation");
|
||||
header.textContent = group.title;
|
||||
results.appendChild(header);
|
||||
for (const result of group.results) {
|
||||
const at = index;
|
||||
const row = renderRow(result, at);
|
||||
row.addEventListener("click", () => activate(at));
|
||||
results.appendChild(row);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
paintHighlight();
|
||||
}
|
||||
|
||||
/** The highlight does not wrap: it stops at the first and last row. */
|
||||
function move(delta: number): void {
|
||||
if (rows.length === 0) return;
|
||||
highlighted = Math.min(rows.length - 1, Math.max(0, highlighted + delta));
|
||||
paintHighlight();
|
||||
}
|
||||
|
||||
function activate(index: number): void {
|
||||
const result = rows[index];
|
||||
if (!result) return;
|
||||
const sessionId = result.candidate.session.id;
|
||||
close();
|
||||
openSession(sessionId);
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent): void {
|
||||
switch (e.key) {
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
close();
|
||||
return;
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
activate(highlighted);
|
||||
return;
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
move(1);
|
||||
return;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
move(-1);
|
||||
return;
|
||||
case "n":
|
||||
if (e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
move(1);
|
||||
}
|
||||
return;
|
||||
case "p":
|
||||
if (e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
move(-1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
if (current?.el !== el) return;
|
||||
current = null;
|
||||
el.remove();
|
||||
// Restoring focus is what makes the bar a view over the app rather than a
|
||||
// place in it; a detached node cannot take it.
|
||||
if (previouslyFocused?.isConnected) previouslyFocused.focus();
|
||||
}
|
||||
|
||||
input.addEventListener("keydown", onKeydown);
|
||||
input.addEventListener("input", render);
|
||||
// A press on the scrim closes; one inside the panel is left alone.
|
||||
el.addEventListener("mousedown", (ev) => {
|
||||
if (!panel.contains(ev.target as Node)) close();
|
||||
});
|
||||
|
||||
current = { el, dispose: close };
|
||||
render();
|
||||
input.focus();
|
||||
return el;
|
||||
}
|
||||
@@ -49,6 +49,13 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [
|
||||
keys: ["N"],
|
||||
scope: "global",
|
||||
},
|
||||
{
|
||||
id: "bar.open",
|
||||
label: "Open the command bar",
|
||||
group: "Sessions",
|
||||
keys: ["ctrl-k"],
|
||||
scope: "global",
|
||||
},
|
||||
{
|
||||
id: "focus.toggle",
|
||||
label: "Move the keyboard between the session list and the pane",
|
||||
|
||||
@@ -129,6 +129,7 @@ export function renderKit(): HTMLElement {
|
||||
"--muted",
|
||||
"--faint",
|
||||
"--accent",
|
||||
"--preview",
|
||||
"--danger",
|
||||
"--warn",
|
||||
];
|
||||
|
||||
+43
-7
@@ -7,7 +7,8 @@ import { ConnectionPool } from "./connections.js";
|
||||
import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js";
|
||||
import { installHudKeybind } from "./debug.js";
|
||||
import { installHelpKeybind, isHelpOpen } from "./help.js";
|
||||
import { CommandSet, DEFAULT_LEADER, defineCommands } from "./commands.js";
|
||||
import { CommandSet, DEFAULT_LEADER, defineCommands, isTypingTarget } from "./commands.js";
|
||||
import { isCommandBarOpen, openCommandBar } from "./commandBar.js";
|
||||
import { DaemonLink } from "./daemonLink.js";
|
||||
import { CatalogStore } from "./events.js";
|
||||
import { installRestartOverlay } from "./restartOverlay.js";
|
||||
@@ -80,6 +81,15 @@ async function bootApp(): Promise<void> {
|
||||
|
||||
const store = new CatalogStore();
|
||||
|
||||
// The daemon's home dir, for the command bar's `~/…` directory abbreviation.
|
||||
// Absolute paths are a fine fallback until it lands.
|
||||
let homeDir: string | undefined;
|
||||
void getDaemonInfo()
|
||||
.then((info) => (homeDir = info.home_dir))
|
||||
.catch(() => {
|
||||
/* absolute paths are a fine fallback */
|
||||
});
|
||||
|
||||
// The daemon's identity and liveness: names the release in the sidebar's status
|
||||
// dot, and blocks the UI behind the restart overlay while the daemon is away
|
||||
// (docs/RESTART.md). The events socket is the trigger — it dies first.
|
||||
@@ -399,12 +409,38 @@ const focusKeys = new CommandSet(
|
||||
{ leader: DEFAULT_LEADER },
|
||||
);
|
||||
|
||||
window.addEventListener("keydown", (e) => {
|
||||
if (focusKeys.handle(e)) return;
|
||||
// The help overlay owns the keyboard while it is up.
|
||||
if (isHelpOpen()) return;
|
||||
spawnKeys.handle(e);
|
||||
});
|
||||
// Ctrl+K opens the command bar (docs/COMMAND-BAR.md). It is a registry command
|
||||
// like any other, so the `?` overlay lists it without a second list.
|
||||
const barKeys = new CommandSet(
|
||||
defineCommands({
|
||||
"bar.open": () => {
|
||||
// Opening while a session is previewed discards the preview first, like
|
||||
// any other real change (docs/PREVIEW.md): the bar is not previewed into.
|
||||
schedulePreview.cancel();
|
||||
mv?.previewSession(null);
|
||||
openCommandBar({ sessions: [...store.snapshot().sessions.values()], homeDir });
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Capture phase: an attached xterm swallows every keystroke it is given, so the
|
||||
// bar's binding has to be seen ahead of the terminal (docs/COMMAND-BAR.md).
|
||||
window.addEventListener(
|
||||
"keydown",
|
||||
(e) => {
|
||||
// The bar owns the keyboard while it is up; its own input handles the keys.
|
||||
if (isCommandBarOpen()) return;
|
||||
if (focusKeys.handle(e)) return;
|
||||
// The help overlay owns the keyboard while it is up.
|
||||
if (isHelpOpen()) return;
|
||||
// Opening the bar over a focused terminal is #152; until then the pane's
|
||||
// hidden textarea keeps its Ctrl+K, like any other typing target.
|
||||
if (!isTypingTarget(e.target) && barKeys.handle(e)) return;
|
||||
spawnKeys.handle(e);
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
|
||||
// Tab in the session list hands the keyboard to the pane; Escape in the pane
|
||||
// hands it back — the pair the TUI gets from `tab` and `esc`.
|
||||
|
||||
@@ -36,6 +36,7 @@ function fakeSidebar(): SidebarHandle {
|
||||
el: document.createElement("aside"),
|
||||
setActive() {},
|
||||
setHighlights() {},
|
||||
setPreview() {},
|
||||
focusList() {},
|
||||
hasListFocus() {
|
||||
return false;
|
||||
|
||||
@@ -221,6 +221,12 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
const tab = activeTab(layout);
|
||||
for (const pane of canvas.querySelectorAll<HTMLElement>(".mv-pane")) {
|
||||
pane.classList.toggle("is-focused", pane.dataset.leafId === tab.focusedLeafId);
|
||||
// The previewed pane's titlebar wears the preview colour instead of the
|
||||
// accent tint a focused pane gets (docs/PREVIEW.md).
|
||||
pane.classList.toggle(
|
||||
"is-preview",
|
||||
preview !== null && pane.dataset.sessionId === preview.sessionId,
|
||||
);
|
||||
}
|
||||
// A preview is not a focused pane: reporting it would clear the session's
|
||||
// `AwaitingInput` glow just because the operator scrolled past it.
|
||||
@@ -229,6 +235,9 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
open: realOpenSessions(),
|
||||
focused: focusedLeafSession(),
|
||||
});
|
||||
// The row the preview came from wears the same colour, so the sidebar and
|
||||
// the pane read as one thing rather than two unrelated highlights.
|
||||
sidebar.setPreview(preview?.sessionId ?? null);
|
||||
}
|
||||
|
||||
function focusLeaf(leafId: string): void {
|
||||
@@ -549,6 +558,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
if (!preview) pool.setFocus(null);
|
||||
canvas.appendChild(renderWelcome());
|
||||
sidebar.setHighlights({ open: realOpenSessions(), focused: null });
|
||||
sidebar.setPreview(preview?.sessionId ?? null);
|
||||
persist();
|
||||
return;
|
||||
}
|
||||
@@ -588,6 +598,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
|
||||
const pane = buildPane(focused);
|
||||
pane.el.classList.add("is-focused"); // the only pane — never dimmed
|
||||
if (preview && focused.sessionId === preview.sessionId) pane.el.classList.add("is-preview");
|
||||
wrap.appendChild(pane.el);
|
||||
canvas.appendChild(wrap);
|
||||
|
||||
@@ -598,6 +609,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
);
|
||||
if (!preview) pool.setFocus(focused.sessionId ?? null);
|
||||
sidebar.setHighlights({ open: realOpenSessions(), focused: focused.sessionId ?? null });
|
||||
sidebar.setPreview(preview?.sessionId ?? null);
|
||||
}
|
||||
|
||||
function closeSession(sessionId: string): void {
|
||||
|
||||
@@ -45,11 +45,17 @@ function fakePool(): Spy {
|
||||
return { pool, visible: () => visible, focusCalls, mountedWithFocus };
|
||||
}
|
||||
|
||||
/** Last value the controller pushed to `SidebarHandle.setPreview`. */
|
||||
const previewRow: { id: string | null } = { id: null };
|
||||
|
||||
function fakeSidebar(): SidebarHandle {
|
||||
return {
|
||||
el: document.createElement("aside"),
|
||||
setActive() {},
|
||||
setHighlights() {},
|
||||
setPreview(id: string | null) {
|
||||
previewRow.id = id;
|
||||
},
|
||||
focusList() {},
|
||||
hasListFocus() {
|
||||
return false;
|
||||
@@ -88,6 +94,7 @@ describe("sidebar preview", () => {
|
||||
beforeEach(() => {
|
||||
stubFetch();
|
||||
document.body.replaceChildren();
|
||||
previewRow.id = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -110,6 +117,23 @@ describe("sidebar preview", () => {
|
||||
expect(serializeLayout(layout)).toEqual(before);
|
||||
});
|
||||
|
||||
it("marks the previewed pane and its sidebar row, and unmarks them after", () => {
|
||||
const layout = twoTabs();
|
||||
const spy = fakePool();
|
||||
const mv = makeMv(layout, spy.pool);
|
||||
|
||||
mv.previewSession("s1");
|
||||
expect(previewRow.id).toBe("s1");
|
||||
expect(mv.el.querySelectorAll(".mv-pane.is-preview")).toHaveLength(1);
|
||||
expect(
|
||||
(mv.el.querySelector(".mv-pane.is-preview") as HTMLElement).dataset.sessionId,
|
||||
).toBe("s1");
|
||||
|
||||
mv.previewSession(null);
|
||||
expect(previewRow.id).toBeNull();
|
||||
expect(mv.el.querySelectorAll(".mv-pane.is-preview")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("shows a session no pane holds in a throwaway tab that leaves no trace", () => {
|
||||
const layout = twoTabs();
|
||||
const before = serializeLayout(layout);
|
||||
|
||||
+197
-15
@@ -87,6 +87,28 @@
|
||||
--accent-tint: oklch(0.92 0.005 256 / 0.1);
|
||||
--accent-ring: oklch(0.92 0.005 256 / 0.5);
|
||||
|
||||
/* preview (docs/PREVIEW.md): a provisional view needs a colour that is not
|
||||
the accent, because the accent is what says "this is the one you picked".
|
||||
Defined once, off a single hue variable, so a palette whose accent already
|
||||
sits on that hue can move it (see periwinkle/orchid below); `light-dark()`
|
||||
keeps the light pair here instead of in all eighteen theme blocks. */
|
||||
--preview-h: 305;
|
||||
--preview: light-dark(
|
||||
oklch(0.55 0.16 var(--preview-h)),
|
||||
oklch(0.78 0.14 var(--preview-h))
|
||||
);
|
||||
--preview-ink: light-dark(
|
||||
oklch(0.99 0.005 var(--preview-h)),
|
||||
oklch(0.19 0.045 var(--preview-h))
|
||||
);
|
||||
/* Opaque, not a wash: the previewed row reads as a fill, and a fill has to
|
||||
land on the same colour whatever tier the row was already painted. */
|
||||
--preview-tint: color-mix(in oklab, var(--preview) 30%, var(--surface-2));
|
||||
--preview-ring: light-dark(
|
||||
oklch(0.55 0.16 var(--preview-h) / 0.5),
|
||||
oklch(0.78 0.14 var(--preview-h) / 0.6)
|
||||
);
|
||||
|
||||
--live: oklch(0.84 0.17 152);
|
||||
--live-glow: oklch(0.84 0.17 152 / 0.55);
|
||||
|
||||
@@ -256,6 +278,9 @@
|
||||
--accent-ink: oklch(0.18 0.04 278);
|
||||
--accent-tint: oklch(0.75 0.125 275 / 0.14);
|
||||
--accent-ring: oklch(0.75 0.125 275 / 0.55);
|
||||
/* This accent is already violet, so preview moves to cyan to stay legible
|
||||
as "not the accent". Set once here: the light blocks below don't touch it. */
|
||||
--preview-h: 195;
|
||||
--term-bg: oklch(0.155 0.018 278);
|
||||
}
|
||||
:root[data-palette="periwinkle"][data-theme="light"],
|
||||
@@ -437,6 +462,8 @@
|
||||
--accent-ink: oklch(0.22 0.05 310);
|
||||
--accent-tint: oklch(0.78 0.13 310 / 0.14);
|
||||
--accent-ring: oklch(0.78 0.13 310 / 0.55);
|
||||
/* Same collision as periwinkle: an orchid accent needs a non-purple preview. */
|
||||
--preview-h: 195;
|
||||
--term-bg: oklch(0.155 0.014 305);
|
||||
}
|
||||
:root[data-palette="orchid"][data-theme="light"],
|
||||
@@ -1114,10 +1141,10 @@ textarea {
|
||||
border-radius: var(--r-1);
|
||||
color: var(--ink);
|
||||
position: relative;
|
||||
/* No transition on `background`: it is what hover paints, and easing it puts
|
||||
a visible delay between the pointer landing on a row and the row admitting
|
||||
it. The ring (`.is-highlighted`, `.is-active`) still eases — that one
|
||||
follows a keyboard walk, not the pointer. */
|
||||
/* No transition on `background`: it is what hover, the keyboard cursor and
|
||||
the preview all paint, and easing it puts a visible delay between the input
|
||||
landing on a row and the row admitting it. `.is-active`'s ring still eases
|
||||
— being open in a pane is a state, not a response to the pointer. */
|
||||
transition: box-shadow var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
@@ -1126,12 +1153,14 @@ textarea {
|
||||
background: color-mix(in oklab, var(--accent) 6%, transparent);
|
||||
}
|
||||
|
||||
/* Hovering a row is what previews it (docs/PREVIEW.md), so hover reads in the
|
||||
preview hue — never the accent, which belongs to the row actually open. */
|
||||
.tree-leaf:hover {
|
||||
background: var(--surface-2);
|
||||
background: color-mix(in oklab, var(--preview) 12%, var(--surface-2));
|
||||
}
|
||||
|
||||
.tree-leaf[data-status="running"]:hover {
|
||||
background: color-mix(in oklab, var(--accent) 13%, var(--surface-2));
|
||||
background: color-mix(in oklab, var(--preview) 18%, var(--surface-2));
|
||||
}
|
||||
|
||||
.tree-leaf:focus-visible {
|
||||
@@ -1139,10 +1168,18 @@ textarea {
|
||||
background: color-mix(in oklab, var(--accent) 16%, var(--surface-2));
|
||||
}
|
||||
|
||||
/* Where the keyboard walk (j/k) is standing. Distinct from `.is-active`: this
|
||||
is a cursor, not a statement about what is mounted, so it reads as a ring
|
||||
rather than a fill and the two can sit on the same row. */
|
||||
.tree-leaf.is-highlighted,
|
||||
/* Where the keyboard walk (j/k) is standing. On a session row the cursor is
|
||||
also what previews it, so it reads exactly like a hover — the preview hue,
|
||||
deepening to the full fill once the preview lands. A folder row previews
|
||||
nothing, so it keeps the plain cursor ring. */
|
||||
.tree-leaf.is-highlighted {
|
||||
background: color-mix(in oklab, var(--preview) 12%, var(--surface-2));
|
||||
}
|
||||
|
||||
.tree-leaf.is-highlighted[data-status="running"] {
|
||||
background: color-mix(in oklab, var(--preview) 18%, var(--surface-2));
|
||||
}
|
||||
|
||||
.tree-folder-header.is-highlighted {
|
||||
box-shadow: inset 0 0 0 2px var(--accent-ring);
|
||||
}
|
||||
@@ -1162,6 +1199,16 @@ textarea {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* The row the content pane is previewing right now — after `.is-active` so a
|
||||
preview of an already-open session still reads as provisional. */
|
||||
.tree-leaf.is-previewing,
|
||||
.tree-leaf.is-previewing[data-status="running"],
|
||||
.tree-leaf.is-previewing.is-active,
|
||||
.tree-leaf.is-previewing.is-highlighted {
|
||||
background: var(--preview-tint);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Exited sessions recede: dimmed name + an "exited" badge carry status, so it's
|
||||
never the running tint's absence alone doing the work. */
|
||||
.tree-leaf[data-status="dead"] {
|
||||
@@ -1378,6 +1425,128 @@ input.leaf-rename:focus {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* ---------- command bar (Ctrl+K) — docs/COMMAND-BAR.md ---------- */
|
||||
/* Same family of surface as the spawn modal and the help overlay: the overlay
|
||||
tokens below are theirs, not a third set. */
|
||||
.cmdbar-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-modal);
|
||||
display: grid;
|
||||
grid-template-rows: 12vh auto 1fr;
|
||||
justify-items: center;
|
||||
padding: var(--sp-4);
|
||||
background: oklch(0 0 0 / 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.cmdbar-panel {
|
||||
grid-row: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(560px, 100%);
|
||||
max-height: min(60vh, 520px);
|
||||
overflow: hidden;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--r-3);
|
||||
box-shadow: var(--shadow-2);
|
||||
}
|
||||
|
||||
.cmdbar-input {
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
color: var(--ink);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-15);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
}
|
||||
|
||||
.cmdbar-input:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cmdbar-results {
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-2) 0;
|
||||
}
|
||||
|
||||
.cmdbar-group-title {
|
||||
padding: var(--sp-2) var(--sp-4) var(--sp-1);
|
||||
font-size: var(--fs-11);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.cmdbar-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-1) var(--sp-4);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cmdbar-row.is-active {
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
.cmdbar-row-name {
|
||||
font-size: var(--fs-13);
|
||||
color: var(--ink);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* A dead session still lists — it just is not where you just were. */
|
||||
.cmdbar-row.is-dead .cmdbar-row-name {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.cmdbar-row-meta {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: var(--fs-11);
|
||||
color: var(--faint);
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cmdbar-hit {
|
||||
background: none;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cmdbar-empty {
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
font-size: var(--fs-13);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Phone: the bottom-sheet treatment the spawn modal already uses. */
|
||||
@media (max-width: 600px) {
|
||||
.cmdbar-overlay {
|
||||
grid-template-rows: 1fr auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.cmdbar-panel {
|
||||
grid-row: 2;
|
||||
width: 100%;
|
||||
max-height: 70vh;
|
||||
border-radius: var(--r-3) var(--r-3) 0 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Line 2 — dot + duration in the state's colour, then muted fragments. The
|
||||
state word itself is never spelled out here; the colour carries it. */
|
||||
.card-line2 {
|
||||
@@ -2885,10 +3054,14 @@ input.leaf-rename:focus {
|
||||
}
|
||||
|
||||
/* Preview (docs/PREVIEW.md): the content pane is showing a session the
|
||||
operator only highlighted, so it reads as provisional — a dashed tab and a
|
||||
dashed inset ring on the canvas — and reverts when the highlight moves. */
|
||||
operator only highlighted, so it reads as provisional — the preview hue on
|
||||
the pane's titlebar, a dashed tab, a dashed inset ring on the canvas — and
|
||||
reverts when the highlight moves. The hue is deliberately not the accent:
|
||||
the accent means "this is what you picked", which a preview is not. */
|
||||
.mv-tab.is-preview {
|
||||
border-style: dashed;
|
||||
border-color: var(--preview-ring);
|
||||
color: var(--preview);
|
||||
font-style: italic;
|
||||
}
|
||||
.mv.mv-previewing .mv-canvas::before {
|
||||
@@ -2901,8 +3074,8 @@ input.leaf-rename:focus {
|
||||
border-radius: var(--r-1);
|
||||
font: 500 var(--fs-11) / 1.5 var(--font-mono);
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--accent-ink);
|
||||
background: var(--accent);
|
||||
color: var(--preview-ink);
|
||||
background: var(--preview);
|
||||
opacity: 0.85;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -2911,7 +3084,7 @@ input.leaf-rename:focus {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
border: 1px dashed var(--accent-ring);
|
||||
border: 1px dashed var(--preview-ring);
|
||||
border-radius: var(--r-1);
|
||||
z-index: var(--z-sticky);
|
||||
}
|
||||
@@ -3029,6 +3202,15 @@ input.leaf-rename:focus {
|
||||
.mv-pane:not(.is-focused) .attach-header {
|
||||
opacity: 0.7;
|
||||
}
|
||||
/* The previewed pane's titlebar carries the preview hue, so the chip is not the
|
||||
only thing saying the pane is provisional — and so it is never mistaken for
|
||||
the accent tint of the pane the operator actually focused. It keeps full
|
||||
strength even though a preview never takes focus. */
|
||||
.mv-pane.is-preview .attach-header,
|
||||
.mv-pane.is-preview.is-focused .attach-header {
|
||||
background: color-mix(in oklab, var(--preview) 34%, var(--surface));
|
||||
opacity: 1;
|
||||
}
|
||||
/* A pane with no session yet: a quiet hint rather than blank terminal black. */
|
||||
.mv-pane-empty {
|
||||
flex: 1;
|
||||
|
||||
@@ -50,6 +50,12 @@ export interface Session {
|
||||
* create one. `base_repo` is the repository's main checkout, which is what
|
||||
* the session tree groups by (#99). */
|
||||
worktree?: SessionWorktree | null;
|
||||
/** Wall-clock of when this session last became the pane a client was showing
|
||||
* (#145) — stamped by both `POST /api/sessions/:id/focus` and opening
|
||||
* `/api/attach/:id`. Absent until the session has been focused once, which is
|
||||
* how the command bar's empty query sorts a never-focused session after every
|
||||
* session that has one (`docs/COMMAND-BAR.md`). */
|
||||
last_focused_at?: IsoTimestamp | null;
|
||||
}
|
||||
|
||||
/** A session's daemon-created worktree (`SessionWorktree` on the wire). */
|
||||
|
||||
Reference in New Issue
Block a user