diff options
| author | user <user@clank> | 2026-08-13 13:06:44 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-13 13:06:44 +0200 |
| commit | 6d9e46274c0d7613441338e10fea6972e4d335dd (patch) | |
| tree | 02a22eeb155f1a9e3182eb9a21caf034a0bb8d40 /cli/src | |
| parent | cli: create sessions by attaching, to dodge the phantom-client bug (diff) | |
| download | sl0ppi-6d9e46274c0d7613441338e10fea6972e4d335dd.tar.gz sl0ppi-6d9e46274c0d7613441338e10fea6972e4d335dd.tar.xz sl0ppi-6d9e46274c0d7613441338e10fea6972e4d335dd.zip | |
cli: keep the status bar, so other projects are visible
Reported: only one tab for proj_a, where was proj_b? Both tabs existed --
list-tabs showed proj_b at position 1 the whole time -- but there was nothing on
screen to say so, because a generated session had NO tab bar and NO status bar.
sl0ppi passes its own `--layout` when creating a session, which replaces the
user's default layout, and the shipped config gets its chrome from
`layouts/default.kdl`'s `default_tab_template` (the zjstatus pane, which is what
draws the tab names). So a root with five projects looked exactly like a root
with one.
The obvious fix is to emit that `default_tab_template` into the generated
layout. It does not work: a templated tab comes back with `purpose=None`,
because the fork hangs a tab's purpose on the tab's root layout and templating
replaces it. Identity is load-bearing here -- with no purposes `sl0ppi ls` sees
no project tabs and `up` would happily create a second set of them. Caught by
checking list-tabs after the change rather than trusting the tab bar appearing.
So the template is INLINED instead: its body is taken, the project's panes are
substituted for its `children` node, and the result becomes the tab's contents.
The `tab` node keeps its own name/purpose/cwd, and the status bar comes along.
A template with no `children` node is skipped rather than mangled.
The extractor brace-matches by hand but respects strings and comments: a real
zjstatus template is full of `{index}` and `{name}` inside quoted format
strings, and counting those would truncate the block and emit a layout that does
not parse. It reads the live config, so a user's own default.kdl is honoured.
Verified on the test box: tab bar reads "1 proj_a 2 proj_b", tab purposes are
project:proj_a / project:proj_b, `sl0ppi ls` sees both, `up` still says "up to
date", and no phantom-client marker.
Diffstat (limited to 'cli/src')
| -rw-r--r-- | cli/src/commands/workspace.ts | 34 | ||||
| -rw-r--r-- | cli/src/core/layout.ts | 122 |
2 files changed, 145 insertions, 11 deletions
diff --git a/cli/src/commands/workspace.ts b/cli/src/commands/workspace.ts index 4eec946..f688ce4 100644 --- a/cli/src/commands/workspace.ts +++ b/cli/src/commands/workspace.ts @@ -14,7 +14,12 @@ import { tmpdir } from "node:os"; import { checkFragment } from "./doctor.ts"; import { discoverProjects, LAYOUT_FILE, MARKER_DIR } from "../core/discover.ts"; import { UserError } from "../core/errors.ts"; -import { sessionLayoutKdl, singleTabLayoutKdl, type TabSpec } from "../core/layout.ts"; +import { + defaultTabTemplateFrom, + sessionLayoutKdl, + singleTabLayoutKdl, + type TabSpec, +} from "../core/layout.ts"; import { isNoOp, planTabs, type Plan } from "../core/plan.ts"; import { projectPurpose } from "../core/purpose.ts"; import { rememberRoot, rootExists, type ResolvedRoot } from "../core/root.ts"; @@ -32,9 +37,28 @@ export interface Context { * it BY ATTACHING to it. See `UpResult.createWith`. */ deferCreate?: boolean; + /** Cached tab template; "" means "looked, found none". */ + tabTemplate?: string; log: (message: string) => void; } +/** + * The tab template every generated tab is wrapped in, so sessions keep the + * status/tab bar the shipped config provides. Without it a multi-project + * session shows no tab bar at all and the other projects are invisible. + */ +function tabTemplate(ctx: Context): string | undefined { + if (ctx.tabTemplate === undefined) { + ctx.tabTemplate = defaultTabTemplateFrom(sl0ppijConfigDir()) ?? ""; + } + return ctx.tabTemplate === "" ? undefined : ctx.tabTemplate; +} + +export function sl0ppijConfigDir(env: NodeJS.ProcessEnv = process.env): string { + if (env.SL0PPIJ_CONFIG_DIR) return env.SL0PPIJ_CONFIG_DIR; + return join(env.HOME ?? "", ".config", "sl0ppij"); +} + function fragmentFor(root: string, relPath: string): string | undefined { const path = join(root, relPath, MARKER_DIR, LAYOUT_FILE); try { @@ -165,7 +189,7 @@ export function up(ctx: Context): UpResult { // one rather than an error, so `sl0ppi` still gives you a terminal. const layout = specs.length > 0 - ? sessionLayoutKdl(specs) + ? sessionLayoutKdl(specs, tabTemplate(ctx)) : "layout {\n tab name=\"scratch\" {\n pane\n }\n}\n"; const dir = mkdtempSync(join(tmpdir(), "sl0ppi-layout-")); const layoutPath = join(dir, "session.kdl"); @@ -217,7 +241,7 @@ export function up(ctx: Context): UpResult { for (const spec of specs) { try { - client.newTab(singleTabLayoutKdl(spec)); + client.newTab(singleTabLayoutKdl(spec, tabTemplate(ctx))); ctx.log(`+ ${spec.relPath}`); } catch (error) { const message = error instanceof Error ? error.message.split("\n")[0] : String(error); @@ -299,7 +323,7 @@ export function open(ctx: Context, name: string): TabInfo { const purpose = projectPurpose(relPath); let tab = client.listTabs().find((t) => t.purpose === purpose); if (!tab) { - const position = client.newTab(singleTabLayoutKdl(specFor(root, relPath))); + const position = client.newTab(singleTabLayoutKdl(specFor(root, relPath), tabTemplate(ctx))); ctx.log(`+ ${relPath}`); tab = client.listTabs().find((t) => t.position === position) ?? { position, @@ -353,5 +377,5 @@ function emptyPlan(): Plan { /** The layout sl0ppi would use for a project — for eyeballing and debugging. */ export function print(ctx: Context, name: string): string { const relPath = resolveProjectName(ctx, name); - return singleTabLayoutKdl(specFor(ctx.resolved.root, relPath)); + return singleTabLayoutKdl(specFor(ctx.resolved.root, relPath), tabTemplate(ctx)); } diff --git a/cli/src/core/layout.ts b/cli/src/core/layout.ts index a1bf0bb..ec5f0da 100644 --- a/cli/src/core/layout.ts +++ b/cli/src/core/layout.ts @@ -11,6 +11,8 @@ * 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. */ @@ -19,6 +21,72 @@ export function kdlString(value: string): string { } /** + * 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 marker = /(^|\n)\s*default_tab_template\s*\{/.exec(layoutKdl); + if (!marker) return undefined; + + const start = marker.index + marker[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) { + return `default_tab_template ${layoutKdl.slice(start, i + 1)}`; + } + } + } + return undefined; // unbalanced: better no template than a broken layout +} + +/** The template from the user's own default layout, if they have one. */ +export function defaultTabTemplateFrom(configDir: string): string | undefined { + try { + return extractDefaultTabTemplate( + readFileSync(join(configDir, "layouts", "default.kdl"), "utf8"), + ); + } catch { + return undefined; + } +} + +/** * 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 @@ -46,9 +114,51 @@ function indent(text: string, spaces: number): string { .join("\n"); } +/** + * Fold a tab template around a project's panes. + * + * We do NOT emit `default_tab_template` into the generated layout, even though + * that is what it is for: a templated tab loses its `purpose=`, because the + * fork hangs a tab's purpose on the tab's root layout and templating replaces + * it. Verified -- with a template every tab came back `purpose=None`, which + * breaks identity outright: `sl0ppi ls` sees no project tabs and `up` would + * cheerfully create a second set. + * + * So we inline instead: take the template body and substitute the project's + * panes for its `children` node. The tab node keeps its own name/purpose/cwd, + * and the status bar comes along. + */ +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): string { - const fragment = (spec.fragment ?? DEFAULT_FRAGMENT).trimEnd(); +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))} ` + @@ -57,12 +167,12 @@ export function tabKdl(spec: TabSpec, indentBy = 4): string { } /** A whole-session layout: what `up` passes to `--layout` when creating. */ -export function sessionLayoutKdl(specs: TabSpec[]): string { - const tabs = specs.map((spec) => tabKdl(spec)).join("\n"); +export function sessionLayoutKdl(specs: TabSpec[], template?: string): string { + const tabs = specs.map((spec) => tabKdl(spec, 4, template)).join("\n"); return `layout {\n${tabs}\n}\n`; } /** A one-tab layout: what `up` passes to `new-tab --layout-string`. */ -export function singleTabLayoutKdl(spec: TabSpec): string { - return `layout {\n${tabKdl(spec)}\n}\n`; +export function singleTabLayoutKdl(spec: TabSpec, template?: string): string { + return `layout {\n${tabKdl(spec, 4, template)}\n}\n`; } |
