Adds a transcript surface for a Structured-view session alongside the existing xterm attach view: a WS /api/structured/:id connection (structured.ts) mounted by ConnectionPool whenever a session's `view` is `"Structured"`, and pure per-row/rail renderers (structuredRender.ts, structuredDiff.ts) covering all eight transcript row kinds. Hierarchy follows the design reference (artifact 88ecd690): the approval is the only lifted, card-like element; a user turn is a tinted block with an accent rail; agent prose is plain text; tool calls are compact bordered rows expanding to output or a coloured, horizontally-scrolling diff computed from ACP's full old/new text. The client renders `reduced_state` and rows exactly as received, with no raw-event folding or turn tracking of its own. Extracted attach.ts's pane-header title building into paneTitle.ts and its connection interface into pane.ts (PaneConnection) so both connection kinds share one contract and one header.
596 lines
23 KiB
TypeScript
596 lines
23 KiB
TypeScript
import "@fontsource/jetbrains-mono/400.css";
|
|
import "@fontsource/jetbrains-mono/500.css";
|
|
import "@fontsource/jetbrains-mono/600.css";
|
|
import "@fontsource/jetbrains-mono/700.css";
|
|
import { renderSidebar } from "./catalog.js";
|
|
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, controlByte, defineCommands, isBarTypingTarget } from "./commands.js";
|
|
import { isCommandBarOpen, openCommandBar, SPAWN_DIRECTORY_EVENT } from "./commandBar.js";
|
|
import { DaemonLink } from "./daemonLink.js";
|
|
import { CatalogStore } from "./events.js";
|
|
import { installRestartOverlay } from "./restartOverlay.js";
|
|
import { shouldRetryClose } from "./reconnect.js";
|
|
import {
|
|
createSession,
|
|
fetchNames,
|
|
getDaemonInfo,
|
|
gitRoot,
|
|
projects as fetchProjects,
|
|
} from "./api.js";
|
|
import {
|
|
buildFastForwardRequest,
|
|
fastForwardSeed,
|
|
resolveFastForward,
|
|
} from "./fastForward.js";
|
|
import { FALLBACK_PRESETS } from "./spawnForm.js";
|
|
import { openSpawnModal } from "./spawnModal.js";
|
|
import { spawnSimilarProjectSeed, spawnSimilarSeed } from "./spawnSimilar.js";
|
|
import { mountHud, type Hud } from "./hud.js";
|
|
import { emptyLayout, type Layout, openSessions } from "./layout.js";
|
|
import { createMultiView, type MultiView } from "./multiview.js";
|
|
import { loadLayout, saveLayout } from "./persistence.js";
|
|
import { createPreviewSchedule } from "./previewSchedule.js";
|
|
import {
|
|
FOCUS_PANE_EVENT,
|
|
OPEN_COMMAND_BAR_EVENT,
|
|
OPEN_SPAWN_EVENT,
|
|
SESSION_SELECT_EVENT,
|
|
SPAWN_SIMILAR_EVENT,
|
|
} from "./catalog.js";
|
|
import { initTheme } from "./theme.js";
|
|
import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js";
|
|
import { installViewportSizing } from "./viewport.js";
|
|
import "./styles.css";
|
|
|
|
initTheme();
|
|
installViewportSizing();
|
|
installHudKeybind();
|
|
installHelpKeybind();
|
|
registerServiceWorker();
|
|
|
|
// Register the PWA service worker so the app is installable and the shell loads
|
|
// offline. Production only — in dev the SW would fight Vite's HMR.
|
|
function registerServiceWorker(): void {
|
|
if (import.meta.env.DEV) return;
|
|
if (!("serviceWorker" in navigator)) return;
|
|
window.addEventListener("load", () => {
|
|
void navigator.serviceWorker.register("/sw.js").catch(() => {
|
|
// Best-effort: a failed registration must never break the app.
|
|
});
|
|
});
|
|
}
|
|
|
|
const rootEl = document.getElementById("app");
|
|
if (!rootEl) {
|
|
throw new Error("missing #app");
|
|
}
|
|
const root: HTMLElement = rootEl;
|
|
|
|
// Dev-only: `?kit` renders the standalone design kit instead of the app shell,
|
|
// so the component system can be reviewed as a whole. Stripped from production
|
|
// builds by Vite (dynamic import behind import.meta.env.DEV).
|
|
if (import.meta.env.DEV && location.search.includes("kit")) {
|
|
void import("./kit.js").then((m) => root.replaceChildren(m.renderKit()));
|
|
} else {
|
|
void bootApp();
|
|
}
|
|
|
|
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;
|
|
// The leader is the operator's, not this module's (#159): a daemon that
|
|
// names one rebinds the chords hanging off it, and the `?` overlay and the
|
|
// command bar re-render from the rebuilt set.
|
|
if (info.leader) useLeader(info.leader);
|
|
})
|
|
.catch(() => {
|
|
/* absolute paths and the default leader are fine fallbacks */
|
|
});
|
|
|
|
// 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.
|
|
const link = new DaemonLink();
|
|
installRestartOverlay(link);
|
|
store.subscribeConnection((up) => (up ? link.noteConnected() : link.noteDisconnected()));
|
|
void link.start();
|
|
|
|
store.start();
|
|
|
|
// Dev-only: `?mock` seeds a representative catalog so the UI can be reviewed
|
|
// without a running daemon. Stripped from production builds by Vite.
|
|
if (import.meta.env.DEV && location.search.includes("mock")) {
|
|
void import("./mock.js").then((m) => m.seedMock(store));
|
|
}
|
|
|
|
// ---- persistent shell: sidebar (list) + content (welcome | attach) ----
|
|
|
|
const shellEl = document.createElement("div");
|
|
shellEl.className = "app-shell";
|
|
shellEl.dataset.route = "catalog";
|
|
|
|
// Forward-declared so the sidebar's drag hook can reach the controller (created
|
|
// after the welcome renderer). The hook only fires on user interaction, by which
|
|
// point `mv` is assigned.
|
|
let mv: MultiView;
|
|
|
|
// Preview timing (docs/PREVIEW.md): the first move shows at once, and a burst
|
|
// — j held down, or the pointer sweeping the whole list — is coalesced so it
|
|
// costs a couple of pane mounts instead of one per row walked.
|
|
const schedulePreview = createPreviewSchedule((sessionId) => {
|
|
ensureMultiView();
|
|
mv?.previewSession(sessionId);
|
|
updateShellRoute();
|
|
});
|
|
|
|
const sidebar = renderSidebar(store, {
|
|
onLeafDrag: (id, e) => mv?.beginSessionDrag(id, e),
|
|
link,
|
|
onPreview: schedulePreview,
|
|
});
|
|
const content = document.createElement("section");
|
|
content.className = "content";
|
|
|
|
// Mobile (issue #35): the sidebar is an off-canvas drawer toggled by the ☰ in
|
|
// the pane header (and the welcome state). `installDrawer` owns the close rules (session tap, Spawn, Spawn
|
|
// similar, backdrop); on desktop the class it toggles is inert.
|
|
const backdrop = document.createElement("div");
|
|
backdrop.className = "mv-backdrop";
|
|
const drawer = installDrawer(shellEl, sidebar.el, backdrop);
|
|
installDrawerSwipe(drawer);
|
|
|
|
shellEl.append(sidebar.el, content, backdrop);
|
|
root.replaceChildren(shellEl);
|
|
|
|
// The pool owns attach connections independently of the route: navigating away
|
|
// parks a session (keeps it streaming); navigating back re-mounts it instantly.
|
|
// It can mount several panes at once — the layout tree decides how many. For now
|
|
// the tree only ever holds a single leaf (splits/tabs arrive in epic #37); this
|
|
// routes the existing single-session view through that model.
|
|
// `mv` is forward-declared above (the sidebar drag hook needs it); the pool's
|
|
// disconnect callback below also references it.
|
|
let hud: Hud | undefined;
|
|
const pool = new ConnectionPool({
|
|
onFocusChange: () => {
|
|
// Sidebar highlighting is driven by the multi-view controller; the pool only
|
|
// nudges the HUD here.
|
|
hud?.refresh();
|
|
},
|
|
// Both the explicit disconnect (⏻) and a session ending on its own close the
|
|
// pane — drop its leaf (collapsing the split) and tear down the socket. A dead
|
|
// pane is never left lying around.
|
|
onRequestClose: (id) => closePane(id),
|
|
onClosed: (id) => closePane(id),
|
|
shouldReconnect: (id, code) => shouldRetryClose(id, code, store.snapshot().sessions),
|
|
store,
|
|
});
|
|
|
|
// Session death used to reach the UI on two uncoordinated channels — the row
|
|
// from the events socket, the pane from `ws.onclose` — so either could happen
|
|
// without the other. The catalog is the authority: a pane for a session that is
|
|
// no longer listed gets closed here.
|
|
store.subscribe((state) => {
|
|
for (const id of mv?.openSessions() ?? []) {
|
|
if (!state.sessions.has(id)) closePane(id);
|
|
}
|
|
});
|
|
|
|
function closePane(id: string): void {
|
|
pool.close(id);
|
|
mv?.closeSession(id);
|
|
updateShellRoute();
|
|
}
|
|
|
|
// Debug HUD (#20): a fixed-corner overlay on the active session's inferred
|
|
// fields, off by default (Ctrl+Shift+D, or `?hud` in dev).
|
|
hud = mountHud(root, store, () => pool.focused());
|
|
|
|
// ---- layout state ----
|
|
// Single source of truth for what's mounted: tabs of split trees. The hash route
|
|
// is just a deep-link into it (`#/attach/:id` focuses/opens a session). Restored
|
|
// from localStorage per device (issue #34); dead sessions self-prune on attach.
|
|
const layout: Layout = loadLayout() ?? emptyLayout();
|
|
|
|
// Debounced persist — the controller calls this after every layout change.
|
|
let saveTimer: number | undefined;
|
|
function scheduleSave(): void {
|
|
clearTimeout(saveTimer);
|
|
saveTimer = window.setTimeout(() => saveLayout(layout), 400);
|
|
}
|
|
|
|
function infoFor(sessionId: string) {
|
|
const s = store.snapshot().sessions.get(sessionId);
|
|
return s ? { name: sessionName(s), command: s.command, view: s.view } : undefined;
|
|
}
|
|
|
|
/** Drive the shell's responsive layout off whether any pane is open. */
|
|
function updateShellRoute(): void {
|
|
// A preview is not an open pane (docs/PREVIEW.md), so the mobile shell does
|
|
// not swap the list out for a terminal on the way past a row.
|
|
const hasPanes = (mv?.openSessions() ?? openSessions(layout)).size > 0;
|
|
shellEl.dataset.route = hasPanes ? "attach" : "catalog";
|
|
}
|
|
|
|
function renderWelcome(): HTMLElement {
|
|
const el = document.createElement("div");
|
|
el.className = "empty content-welcome";
|
|
el.innerHTML = `
|
|
<div class="empty-glyph" aria-hidden="true">⌘</div>
|
|
<div class="empty-title">No session open</div>
|
|
<p class="empty-hint">
|
|
Pick a session from the list, or start a new one. Open sessions keep
|
|
streaming in the background — switching back is instant.
|
|
</p>
|
|
<p class="empty-hint">Press <kbd>?</kbd> for keyboard shortcuts.</p>
|
|
<div class="empty-actions"></div>`;
|
|
const actions = el.querySelector(".empty-actions") as HTMLElement;
|
|
// Mobile-only: with no pane open there is no pane header to carry the ☰, so
|
|
// the drawer would be swipe-only. CSS hides this on desktop.
|
|
const sessions = document.createElement("button");
|
|
sessions.type = "button";
|
|
sessions.className = "btn btn-ghost welcome-sessions";
|
|
sessions.textContent = "☰ Sessions";
|
|
sessions.addEventListener("click", () => requestToggleDrawer());
|
|
actions.appendChild(sessions);
|
|
const spawn = document.createElement("button");
|
|
spawn.type = "button";
|
|
spawn.className = "btn btn-primary";
|
|
spawn.textContent = "+ Spawn session";
|
|
spawn.title = "Draft pane: n — fast-forward spawn: N";
|
|
spawn.addEventListener("click", () => requestOpenSpawn());
|
|
actions.appendChild(spawn);
|
|
return el;
|
|
}
|
|
|
|
function renderNotFound(raw: string): HTMLElement {
|
|
const el = document.createElement("div");
|
|
el.className = "not-found";
|
|
el.innerHTML = `
|
|
<h1>Unknown route</h1>
|
|
<p><code>#${raw.replace(/[<&]/g, "")}</code> doesn't match anything.</p>
|
|
<a class="btn btn-primary" href="#/">Back to catalog</a>`;
|
|
return el;
|
|
}
|
|
|
|
// The multi-view controller renders tabs + splits inside the content pane and
|
|
// mounts a live attach into each session leaf.
|
|
mv = createMultiView({
|
|
pool,
|
|
layout,
|
|
sidebar,
|
|
infoFor,
|
|
onDisconnect: closePane,
|
|
renderWelcome,
|
|
// A spawn fills a pane with its new session; the shell route may flip from
|
|
// catalog (no sessions) to attach.
|
|
onOpenSessionsChange: updateShellRoute,
|
|
onLayoutChange: scheduleSave,
|
|
});
|
|
let contentShowsMv = false;
|
|
/** Put the multi-view in the content pane, without rendering it. A preview
|
|
* renders once, on its own terms (`docs/PREVIEW.md`); a render here would
|
|
* first re-mount the pane it is about to replace, and that mount takes the
|
|
* keyboard away from the list being walked. */
|
|
function ensureMultiView(): void {
|
|
if (!contentShowsMv) {
|
|
content.replaceChildren(mv.el);
|
|
contentShowsMv = true;
|
|
}
|
|
}
|
|
function showMultiView(): void {
|
|
ensureMultiView();
|
|
mv.render();
|
|
updateShellRoute();
|
|
}
|
|
|
|
// Spawn affordances: the sidebar's footer button, its empty state, and
|
|
// `?mock&spawn` all open the spawn modal; "Spawn similar" seeds one from the
|
|
// cloned session (see spawnSimilar.ts).
|
|
function requestOpenSpawn(): void {
|
|
// The session lands in a new tab, not a split of the pane you were reading
|
|
// (#100). "Spawn similar" and the fast-forward key below deliberately keep
|
|
// the old behaviour: both act on the session in front of you, so a split
|
|
// beside it is the point.
|
|
// The session you were last on preselects its project: `n` then Enter spawns
|
|
// another session there without touching the picker.
|
|
const current = focusedSession();
|
|
openSpawnModal({
|
|
seed: current ? spawnSimilarProjectSeed(current) : undefined,
|
|
onSuccess: (session) => mountSpawned(session, { newTab: true }),
|
|
});
|
|
}
|
|
|
|
/** The session the operator was last on, if any. Shared by the plain spawn
|
|
* modal seed and fast-forward's project pick: both should land on "where I
|
|
* currently am". */
|
|
function focusedSession(): Session | undefined {
|
|
const focused = mv?.focusedSessionId() ?? null;
|
|
return focused ? store.snapshot().sessions.get(focused) : undefined;
|
|
}
|
|
|
|
/** The focused session's project — its base repo for a worktree session, its
|
|
* cwd otherwise (same rule as spawnSimilarProjectSeed). */
|
|
function focusedProjectCwd(): string | undefined {
|
|
const s = focusedSession();
|
|
if (!s) return undefined;
|
|
return s.worktree ? s.worktree.base_repo : s.cwd;
|
|
}
|
|
document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
|
|
document.addEventListener(SPAWN_SIMILAR_EVENT, (ev) => {
|
|
const s = (ev as CustomEvent<Session>).detail;
|
|
openSpawnModal({ seed: spawnSimilarSeed(s), onSuccess: (session) => mountSpawned(session) });
|
|
});
|
|
|
|
/** Show a freshly spawned session: bring the multi-view up (the shell may still
|
|
* be on the catalog route) and give the session a pane. */
|
|
function mountSpawned(session: Session, placement?: { newTab?: boolean }): void {
|
|
showMultiView();
|
|
mv.openSessionBeside(session.id, placement);
|
|
updateShellRoute();
|
|
}
|
|
|
|
// Fast-forward spawn (#67): the spawn modal's keystroke neighbour spawns
|
|
// immediately with every default resolved — most recent project, its last
|
|
// command (or the first non-custom preset), a fresh name, worktree on with
|
|
// a new branch named after that name. Anything unresolvable (no projects
|
|
// known yet, no command, no generated name) opens the form pre-filled with
|
|
// what did resolve instead.
|
|
async function spawnPresets(): Promise<SpawnPreset[]> {
|
|
try {
|
|
const info = await getDaemonInfo();
|
|
if (info.presets?.length) return info.presets;
|
|
} catch {
|
|
/* keep fallbacks */
|
|
}
|
|
return FALLBACK_PRESETS;
|
|
}
|
|
|
|
async function requestFastForward(): Promise<void> {
|
|
const [discovered, handle] = await Promise.all([
|
|
fetchProjects().catch(() => [] as Project[]),
|
|
fetchNames(1)
|
|
.then((batch) => batch.names[0])
|
|
.catch(() => undefined),
|
|
]);
|
|
const presets = await spawnPresets();
|
|
const resolved = resolveFastForward(discovered, presets, handle, focusedProjectCwd());
|
|
const req = buildFastForwardRequest(resolved);
|
|
if (req) {
|
|
try {
|
|
const session = await createSession(req);
|
|
// Mount it, the way the TUI's fast path attaches. Without this the
|
|
// keystroke creates a session and leaves the UI untouched, so it reads
|
|
// as a dead key (#67 review).
|
|
mountSpawned(session);
|
|
return;
|
|
} catch {
|
|
// A failed POST degrades to the same pre-filled form as an
|
|
// unresolvable default — never a silent dead keystroke.
|
|
}
|
|
}
|
|
openSpawnModal({
|
|
seed: fastForwardSeed(resolved),
|
|
onSuccess: (session) => mountSpawned(session),
|
|
});
|
|
}
|
|
|
|
// Enter on a `~` row in the command bar (docs/COMMAND-BAR.md): the same
|
|
// fast-forward spawn, aimed at the chosen directory instead of the most recent
|
|
// project. A directory the project scan never saw stands in as its own project;
|
|
// one outside any repo spawns without a worktree, since there is no repo to cut
|
|
// a branch from. Anything still unresolved opens the pre-filled form.
|
|
async function requestSpawnInDirectory(path: string): Promise<void> {
|
|
const [discovered, handle] = await Promise.all([
|
|
fetchProjects().catch(() => [] as Project[]),
|
|
fetchNames(1)
|
|
.then((batch) => batch.names[0])
|
|
.catch(() => undefined),
|
|
]);
|
|
const presets = await spawnPresets();
|
|
const known = discovered.find((p) => p.path === path);
|
|
const project: Project = known ?? { path, name: path.split("/").filter(Boolean).pop() ?? path };
|
|
const resolved = resolveFastForward([project], presets, handle, path);
|
|
const req = buildFastForwardRequest(resolved);
|
|
if (req) {
|
|
const inRepo =
|
|
known !== undefined ||
|
|
(await gitRoot(path)
|
|
.then((r) => !!r.root)
|
|
.catch(() => false));
|
|
if (!inRepo) delete req.worktree;
|
|
try {
|
|
mountSpawned(await createSession(req));
|
|
return;
|
|
} catch {
|
|
// A failed POST degrades to the pre-filled form, never a dead row.
|
|
}
|
|
}
|
|
openSpawnModal({
|
|
seed: fastForwardSeed(resolved),
|
|
onSuccess: (session) => mountSpawned(session),
|
|
});
|
|
}
|
|
|
|
document.addEventListener(SPAWN_DIRECTORY_EVENT, (ev) => {
|
|
void requestSpawnInDirectory((ev as CustomEvent<string>).detail);
|
|
});
|
|
|
|
// The global half of the command registry (#148). Two sets, because they
|
|
// differ in one thing: the help overlay owns the keyboard while it is up, so
|
|
// the plain keys go deaf behind it while the focus chord does not — an
|
|
// attached terminal must never be able to trap the focus keys.
|
|
//
|
|
// `n` opens the spawn modal, `N` fast-forwards a spawn (#67). Both are plain
|
|
// keys, so `isTypingTarget` inside the set keeps them out of form fields and the terminal's
|
|
// hidden textarea.
|
|
const spawnKeys = new CommandSet(
|
|
defineCommands({
|
|
"spawn.open": () => requestOpenSpawn(),
|
|
"spawn.fastForward": () => void requestFastForward(),
|
|
}),
|
|
);
|
|
|
|
// Leader-h moves the keyboard between the session list and the pane, the web
|
|
// counterpart of the TUI's `ctrl-w h`. It has to be a chord behind the
|
|
// leader: an attached terminal owns every plain keystroke, and Ctrl+Shift+H
|
|
// is already claimed by the browser (history), so nothing unmodified — or
|
|
// modified with a common browser chord — could get the focus back out of it.
|
|
//
|
|
// This set also owns the leader (`[tui].leader`, `ctrl-a` by default, served
|
|
// on `/api/info`): it holds the key as a partial chord and takes select-all off
|
|
// it, so the escape hatch docs/COMMAND-BAR.md describes has somewhere to land
|
|
// (#149).
|
|
let currentLeader = DEFAULT_LEADER;
|
|
|
|
function buildFocusKeys(leader: string): CommandSet {
|
|
return new CommandSet(
|
|
defineCommands(
|
|
{
|
|
"focus.toggle": () => {
|
|
if (sidebar.hasListFocus()) {
|
|
pool.focusTerminal();
|
|
} else {
|
|
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
|
|
sidebar.focusList();
|
|
}
|
|
},
|
|
// The escape hatch: binding ctrl-k takes readline's
|
|
// kill-to-end-of-line away from every pane, and leader ctrl-k gives it
|
|
// back as the literal 0x0B byte (docs/COMMAND-BAR.md). It resolves in
|
|
// this set, ahead of the bar's, so the chord never opens the bar.
|
|
"pane.literalCtrlK": () => pool.sendToFocused("\x0b"),
|
|
// The leader itself, pressed twice, tmux-style: sends one literal
|
|
// leader byte and clears the pending chord instead of resolving as a
|
|
// dead chord (docs/COMMAND-BAR.md, #158). Built from the leader, not a
|
|
// hardcoded byte, so a rebound leader sends its own.
|
|
"pane.literalLeader": () => pool.sendToFocused(controlByte(leader)),
|
|
},
|
|
leader,
|
|
),
|
|
{ leader },
|
|
);
|
|
}
|
|
|
|
let focusKeys = buildFocusKeys(DEFAULT_LEADER);
|
|
|
|
/** Rebind the leader-owning set to the daemon's leader. Re-registering the
|
|
* same command ids replaces them, so the help overlay and the command bar —
|
|
* both of which render the published registry when they open — show the
|
|
* configured chord rather than the default. */
|
|
function useLeader(leader: string): void {
|
|
if (leader === currentLeader) return;
|
|
currentLeader = leader;
|
|
focusKeys = buildFocusKeys(leader);
|
|
}
|
|
|
|
// 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 openBar = () => {
|
|
// 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 });
|
|
};
|
|
|
|
const barKeys = new CommandSet(defineCommands({ "bar.open": () => openBar() }));
|
|
|
|
// The sidebar's search field is the touch entry point (#157): no `ctrl-k` on a
|
|
// phone, so the field the operator already reaches for opens the same bar.
|
|
document.addEventListener(OPEN_COMMAND_BAR_EVENT, () => openBar());
|
|
|
|
// 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;
|
|
// The pane's hidden textarea is not a typing target for the bar: ctrl-k is
|
|
// seen ahead of xterm and opens the bar (#152). A real form field — the
|
|
// spawn form's inputs — keeps its own Ctrl+K.
|
|
if (!isBarTypingTarget(e.target) && barKeys.handle(e)) return;
|
|
spawnKeys.handle(e);
|
|
},
|
|
true,
|
|
);
|
|
|
|
|
|
// Tab in the session list hands the keyboard to the pane; leader-h (focus.toggle,
|
|
// above) hands it back the other way.
|
|
document.addEventListener(FOCUS_PANE_EVENT, () => {
|
|
// Handing the keyboard to a previewed pane is choosing it: the preview stops
|
|
// being provisional rather than snapping back the moment the list blurs.
|
|
const previewed = mv.previewedSessionId();
|
|
if (previewed) focusSession(previewed);
|
|
pool.focusTerminal();
|
|
});
|
|
|
|
/** Open/focus a session and reflect it in the shell chrome. Shared by the
|
|
* hash router and the sidebar's click-driven path (#133), which can't rely
|
|
* on `hashchange` alone — a split or drag can move focus without the hash
|
|
* changing, leaving a click back onto that session's row silently ignored. */
|
|
function focusSession(sessionId: string): void {
|
|
schedulePreview.cancel();
|
|
showMultiView();
|
|
mv.openSession(sessionId);
|
|
updateShellRoute();
|
|
}
|
|
|
|
document.addEventListener(SESSION_SELECT_EVENT, (e) => {
|
|
focusSession((e as CustomEvent<string>).detail);
|
|
});
|
|
|
|
type Route =
|
|
| { kind: "catalog" }
|
|
| { kind: "attach"; sessionId: string }
|
|
| { kind: "not-found"; raw: string };
|
|
|
|
function parseRoute(): Route {
|
|
const hash = location.hash.replace(/^#/, "");
|
|
if (hash === "" || hash === "/") return { kind: "catalog" };
|
|
const m = hash.match(/^\/attach\/([^/?#]+)$/);
|
|
if (m) return { kind: "attach", sessionId: decodeURIComponent(m[1]) };
|
|
return { kind: "not-found", raw: hash };
|
|
}
|
|
|
|
function dispatch(): void {
|
|
const route = parseRoute();
|
|
switch (route.kind) {
|
|
case "attach":
|
|
// Deep-link: open/focus the session, then show the layout.
|
|
focusSession(route.sessionId);
|
|
return;
|
|
case "not-found":
|
|
shellEl.dataset.route = "notfound";
|
|
pool.setVisible([]);
|
|
pool.setFocus(null);
|
|
content.replaceChildren(renderNotFound(route.raw));
|
|
contentShowsMv = false;
|
|
return;
|
|
case "catalog":
|
|
// Show the current layout (welcome if empty) — navigating to #/ no longer
|
|
// tears panes down.
|
|
showMultiView();
|
|
return;
|
|
}
|
|
}
|
|
|
|
window.addEventListener("hashchange", dispatch);
|
|
dispatch();
|
|
|
|
} // bootApp
|