aboutsummaryrefslogtreecommitdiffstats
path: root/cli/src
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-13 13:06:44 +0200
committeruser <user@clank>2026-08-13 13:06:44 +0200
commit6d9e46274c0d7613441338e10fea6972e4d335dd (patch)
tree02a22eeb155f1a9e3182eb9a21caf034a0bb8d40 /cli/src
parentcli: create sessions by attaching, to dodge the phantom-client bug (diff)
downloadsl0ppi-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.ts34
-rw-r--r--cli/src/core/layout.ts122
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`;
}