/** * KDL composition. * * A project ships a *fragment* (pane nodes only); sl0ppi wraps it in the tab * node that carries identity and cwd. Pure string composition, so there is no * KDL parser to keep in step with the fork's dialect, and sl0ppi keeps sole * control of the purpose. * * ALWAYS emit multi-line KDL. On a single line, KDL requires `;` terminators * between nodes: `tab x { pane }` is a parse error, which is a genuinely * confusing way to lose an afternoon. */ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { projectPurpose } from "./purpose.ts"; /** Quote a value for a KDL string property. */ export function kdlString(value: string): string { return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; } /** * Pull the `default_tab_template { ... }` block out of a layout file. * * Without this, a generated session has NO tab bar and NO status bar: our * layout replaces the user's default one, so a root with five projects looks * exactly like a root with one, and the other tabs are invisible. That was * reported as "shouldn't there be a proj_b tab?" -- there was one, with nothing * on screen to say so. * * Brace-matched by hand rather than parsed, but it must respect strings and * comments: a zjstatus template is full of `{index}` and `{name}` inside quoted * format strings, and counting those as braces truncates the block in the * middle and produces a layout that will not parse. */ export function extractDefaultTabTemplate(layoutKdl: string): string | undefined { const found = extractBlocks(layoutKdl, /(^|\n)[ \t]*default_tab_template[ \t]*\{/g); return found[0]; } /** * Find ` { ... }` nodes matching `marker`, with their braces balanced. * * Brace-matched by hand rather than parsed, but it must respect strings and * comments: a zjstatus template is full of `{index}` and `{name}` inside quoted * format strings, and counting those as braces truncates the block in the * middle and produces a layout that will not parse. */ function extractBlocks(layoutKdl: string, marker: RegExp): string[] { const blocks: string[] = []; for (const match of layoutKdl.matchAll(marker)) { const head = match[0].replace(/^\n/, "").trim(); // e.g. `pane_template name="x" {` const start = (match.index ?? 0) + match[0].length - 1; // at the '{' let depth = 0; let inString = false; let inComment = false; for (let i = start; i < layoutKdl.length; i++) { const ch = layoutKdl[i] as string; const next = layoutKdl[i + 1]; if (inComment) { if (ch === "\n") inComment = false; continue; } if (inString) { if (ch === "\\") i++; // skip the escaped character else if (ch === '"') inString = false; continue; } if (ch === "/" && next === "/") { inComment = true; continue; } if (ch === '"') { inString = true; continue; } if (ch === "{") depth++; else if (ch === "}") { depth--; if (depth === 0) { const body = layoutKdl.slice(start, i + 1); blocks.push(`${head.slice(0, -1).trim()} ${body}`); break; } } } // unbalanced: better to drop this block than to emit a broken layout } return blocks; } /** * The `pane_template` definitions from a layout file. * * A generated session layout is a FILE OF ITS OWN: it does not inherit * anything from `layouts/default.kdl`, so a template body that says `sl0pbar * size=3` produces "Invalid tab property: sl0pbar" unless the definition comes * along with it. That is exactly how `sl0ppi up` broke the day the status bar * became a pane_template -- the tab template referenced a name only the * original file knew. */ export function extractPaneTemplates(layoutKdl: string): string[] { return extractBlocks(layoutKdl, /(^|\n)[ \t]*pane_template[ \t][^\n{]*\{/g); } /** The tab template and the pane templates it may reference. */ export interface LayoutTemplates { tabTemplate?: string; paneTemplates: string[]; } /** The templates from the user's own default layout, if they have one. */ export function templatesFrom(configDir: string): LayoutTemplates { try { const kdl = readFileSync(join(configDir, "layouts", "default.kdl"), "utf8"); return { tabTemplate: extractDefaultTabTemplate(kdl), paneTemplates: extractPaneTemplates(kdl), }; } catch { return { paneTemplates: [] }; } } /** The template from the user's own default layout, if they have one. */ export function defaultTabTemplateFrom(configDir: string): string | undefined { return templatesFrom(configDir).tabTemplate; } /** * What a project gets when it has a `.sl0ppi/` marker but no `layout.kdl`. * * One suspended agent: every project wants a main agent (agreed), and * `start_suspended` is what stops `up` on a twelve-project root from launching * twelve of them. The pane exists, focused-and-keypress runs it. */ export const DEFAULT_FRAGMENT = `pane purpose="agent:main" start_suspended=true command="pi"`; export interface TabSpec { /** Tab name, shown in the tab bar. Cosmetic; identity is the purpose. */ name: string; /** Project path relative to the root, which the purpose encodes. */ relPath: string; /** Absolute path used as the tab's cwd. */ cwd: string; /** Pane nodes, verbatim from `.sl0ppi/layout.kdl`. */ fragment?: string; } function indent(text: string, spaces: number): string { const pad = " ".repeat(spaces); return text .split("\n") .map((line) => (line.trim() === "" ? "" : pad + line)) .join("\n"); } /** * Fold a tab template around a project's panes. * * Kept for callers that hand a layout STRING to a running session. The session * layout emits `default_tab_template` instead -- see `sessionLayoutKdl`. */ export function applyTemplate(fragment: string, template: string): string | undefined { const body = template.slice(template.indexOf("{") + 1, template.lastIndexOf("}")); const childrenLine = /^([ \t]*)children[ \t]*$/m.exec(body); if (!childrenLine) return undefined; // nothing to substitute into; skip the template const pad = childrenLine[1] ?? ""; const replaced = fragment .split("\n") .map((line) => (line.trim() === "" ? "" : pad + line)) .join("\n"); return dedent(body.replace(childrenLine[0], replaced)); } /** Strip the common leading indentation, so re-indenting is predictable. */ function dedent(text: string): string { const lines = text.split("\n").filter((line) => line.trim() !== ""); const smallest = Math.min( ...lines.map((line) => (line.match(/^[ \t]*/)?.[0].length ?? 0)), Number.MAX_SAFE_INTEGER, ); return text .split("\n") .map((line) => line.slice(smallest)) .join("\n") .trim(); } /** A single `tab { ... }` node, indented for embedding in a `layout` block. */ export function tabKdl(spec: TabSpec, indentBy = 4, template?: string): string { let fragment = (spec.fragment ?? DEFAULT_FRAGMENT).trimEnd(); if (template) fragment = applyTemplate(fragment, template) ?? fragment; const head = `tab name=${kdlString(spec.name)} ` + `purpose=${kdlString(projectPurpose(spec.relPath))} ` + `cwd=${kdlString(spec.cwd)} {`; return indent(`${head}\n${indent(fragment, 4)}\n}`, indentBy); } /** * Definitions the generated tabs refer to, emitted once at the top of the * layout. Anything a tab body names has to be defined in the same file. */ function preamble(paneTemplates?: string[]): string { if (!paneTemplates || paneTemplates.length === 0) return ""; return `${paneTemplates.map((template) => indent(template, 4)).join("\n")}\n`; } /** * A whole-session layout: what `up` passes to `--layout` when creating. * * The tab template is EMITTED, not inlined into each tab. It used to be * inlined, because a templated tab came back with `purpose=None` and purpose is * identity here -- `sl0ppi ls` finds project tabs by it. The fork now keeps a * tab's own purpose when a template is applied * (`sl0p_a_templated_tab_keeps_its_own_purpose`), which removes that reason and * fixes the thing the workaround cost us: a tab created at RUNTIME -- `new-tab` * from a keybinding, or the bar's `+` button -- had no template to take a * status bar from, and came up bare. */ export function sessionLayoutKdl( specs: TabSpec[], template?: string, paneTemplates?: string[], ): string { const tabs = specs.map((spec) => tabKdl(spec, 4)).join("\n"); return `layout {\n${preamble(paneTemplates)}${templateBlock(template)}${tabs}\n}\n`; } /** The `default_tab_template { ... }` node, indented into the layout block. */ function templateBlock(template?: string): string { if (!template) return ""; return `${indent(template, 4)}\n`; } /** * A one-tab layout: what `open` passes to `new-tab --layout-string`. * * Still inlined: this layout describes ONE tab being added to a session that * already exists, and a `default_tab_template` in it would apply to that tab * anyway -- but the session's own template (if it has one) would apply first, * and the tab would come up with two status bars. */ export function singleTabLayoutKdl( spec: TabSpec, template?: string, paneTemplates?: string[], ): string { return `layout {\n${preamble(paneTemplates)}${tabKdl(spec, 4, template)}\n}\n`; }