aboutsummaryrefslogtreecommitdiffstats
path: root/cli/src/commands
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-12 22:24:55 +0200
committeruser <user@clank>2026-08-12 22:24:55 +0200
commit2cd247828d863cdc779c7178c988152835096ca2 (patch)
tree867b63f58057e81ac8ab4d242e73b733b8372c9e /cli/src/commands
parentdocs(cli): record the fork gap the design turned up (diff)
downloadsl0ppi-2cd247828d863cdc779c7178c988152835096ca2.tar.gz
sl0ppi-2cd247828d863cdc779c7178c988152835096ca2.tar.xz
sl0ppi-2cd247828d863cdc779c7178c988152835096ca2.zip
cli: up, ls, open, close
Slice 2 of docs/cli-design.md. The commands read the world (discovery + list-tabs), compute a plan, then apply it -- never mutating while walking, which is how `up` stays idempotent. sl0ppij.ts process wrapper; every piece of state comes back out of list-tabs/list-panes, so there is nothing to desync layout.ts composes the tab node around a project's fragment root.ts root resolution, session naming, the root registry workspace.ts up / ls / open / close index.ts dispatch + the fail-open wrapper Four things this turned up that reading the code would not have: - `attach --create-background` fails with "There is no active session!" when the caller is itself inside a session -- which sl0ppi almost always is, being run from the user's daily-driver terminal. Session creation now runs with the SL0PPIJ*/ZELLIJ* markers stripped: we are creating a new detached session, not nesting. - falling open by passing our own argv to sl0ppij just produces "unrecognized subcommand: up" and still leaves the user without a terminal. Our verbs and flags are stripped first, so what is handed over is something sl0ppij actually understands. - attaching panics in the client when stdin is not a tty, so `up` from a script or CI step died. It now reports the session is ready and exits. - a start_suspended pane reports `is_held`, not `exited`. Conflating them made `ls` claim every agent was running when none had started. Plugin panes (the status bar, the link handler) also inflated pane counts and are now excluded as chrome. Index conventions were verified against a live session rather than inferred, because guessing closes the wrong tab: list-tabs `position` and `close-tab --tab-id` are 0-based, `go-to-tab` is 1-based, and closing renumbers every tab after it, so batched closes run in descending position order. Verified end to end against a real session (three projects, one shipping a custom fragment): up creates the session with all tabs and their purposes; up again reports "up to date" with tabs unchanged; ls reports panes and agent:suspended correctly; open creates-and-focuses and resolves unique prefixes; close removes a tab; deleting a project auto-closes its tab; and with the root itself missing, nothing is closed at all. 64 tests green.
Diffstat (limited to 'cli/src/commands')
-rw-r--r--cli/src/commands/workspace.ts236
1 files changed, 236 insertions, 0 deletions
diff --git a/cli/src/commands/workspace.ts b/cli/src/commands/workspace.ts
new file mode 100644
index 0000000..beaaf56
--- /dev/null
+++ b/cli/src/commands/workspace.ts
@@ -0,0 +1,236 @@
+/**
+ * up / ls / open / close.
+ *
+ * These are the plumbing on top of the planner. The rule they all obey: read
+ * the world (discovery + list-tabs), compute a plan, then apply it — never
+ * mutate opportunistically while walking, because that is how `up` stops being
+ * idempotent.
+ */
+
+import { existsSync, readFileSync } from "node:fs";
+import { basename, join, resolve } from "node:path";
+import { mkdtempSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { discoverProjects, LAYOUT_FILE, MARKER_DIR } from "../core/discover.ts";
+import { 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";
+import { Sl0ppij, type TabInfo } from "../core/sl0ppij.ts";
+
+export interface Context {
+ resolved: ResolvedRoot;
+ client: Sl0ppij;
+ depth: number;
+ dryRun: boolean;
+ log: (message: string) => void;
+}
+
+function fragmentFor(root: string, relPath: string): string | undefined {
+ const path = join(root, relPath, MARKER_DIR, LAYOUT_FILE);
+ try {
+ const text = readFileSync(path, "utf8").trim();
+ return text.length > 0 ? text : undefined;
+ } catch {
+ return undefined; // no fragment: the default (one suspended agent) applies
+ }
+}
+
+function specFor(root: string, relPath: string): TabSpec {
+ return {
+ name: basename(relPath),
+ relPath,
+ cwd: join(root, relPath),
+ fragment: fragmentFor(root, relPath),
+ };
+}
+
+function computePlan(ctx: Context, tabs: TabInfo[]): Plan {
+ const { root } = ctx.resolved;
+ return planTabs({
+ root,
+ rootExists: rootExists(root),
+ projects: discoverProjects(root, { depth: ctx.depth }),
+ tabs,
+ pathExists: existsSync,
+ });
+}
+
+/**
+ * Create the session, or bring an existing one in line with the filesystem.
+ *
+ * Two paths, one composer: a brand new session is created from a whole-session
+ * layout (which is also the only way tab purposes survived before the fork fix),
+ * and an existing one gets tabs added individually.
+ */
+export function up(ctx: Context): { plan: Plan; created: boolean } {
+ const { root, session } = ctx.resolved;
+ const client = ctx.client.forSession(session);
+
+ if (!rootExists(root)) {
+ throw new Error(`root does not exist: ${root}`);
+ }
+
+ const projects = discoverProjects(root, { depth: ctx.depth });
+
+ if (!client.sessionExists(session)) {
+ const specs = projects.map((relPath) => specFor(root, relPath));
+ if (ctx.dryRun) {
+ ctx.log(`would create session ${session} with ${specs.length} tab(s)`);
+ for (const spec of specs) ctx.log(` + ${spec.relPath}`);
+ return { plan: emptyPlan(), created: true };
+ }
+ // A session must be born with at least one tab; an empty root gets a plain
+ // one rather than an error, so `sl0ppi` still gives you a terminal.
+ const layout =
+ specs.length > 0
+ ? sessionLayoutKdl(specs)
+ : "layout {\n tab name=\"scratch\" {\n pane\n }\n}\n";
+ const dir = mkdtempSync(join(tmpdir(), "sl0ppi-layout-"));
+ const layoutPath = join(dir, "session.kdl");
+ writeFileSync(layoutPath, layout);
+ client.createBackground(session, layoutPath);
+ rememberRoot(session, root);
+ ctx.log(`created session ${session} (${specs.length} project tab(s))`);
+ return { plan: emptyPlan(), created: true };
+ }
+
+ const plan = computePlan(ctx, client.listTabs());
+
+ if (ctx.dryRun) {
+ if (isNoOp(plan)) ctx.log("nothing to do");
+ for (const action of plan.create) ctx.log(` + ${action.relPath}`);
+ for (const tab of plan.close) ctx.log(` - ${tab.name} (stale)`);
+ return { plan, created: false };
+ }
+
+ // Closes first, in DESCENDING position order: closing a tab renumbers every
+ // tab after it, so ascending order would target the wrong ones.
+ for (const tab of [...plan.close].sort((a, b) => b.position - a.position)) {
+ client.closeTab(tab.position);
+ ctx.log(`- ${tab.name} (project gone)`);
+ }
+
+ for (const action of plan.create) {
+ client.newTab(singleTabLayoutKdl(specFor(root, action.relPath)));
+ ctx.log(`+ ${action.relPath}`);
+ }
+
+ rememberRoot(session, root);
+ if (isNoOp(plan)) ctx.log("up to date");
+ return { plan, created: false };
+}
+
+export type AgentState = "running" | "suspended" | "exited" | "none";
+
+export interface ProjectStatus {
+ relPath: string;
+ tab?: TabInfo;
+ /** Terminal panes only; plugin panes (status bar, link handler) are chrome. */
+ panes: number;
+ agent: AgentState;
+}
+
+/**
+ * `is_held` is how a `start_suspended` pane reports itself: the command is
+ * staged but has not run, waiting for a keypress. It is NOT `exited` — that
+ * means the command ran and finished.
+ */
+export function agentStateOf(pane: { is_held?: boolean; exited?: boolean } | undefined): AgentState {
+ if (pane === undefined) return "none";
+ if (pane.is_held) return "suspended";
+ if (pane.exited) return "exited";
+ return "running";
+}
+
+export function ls(ctx: Context): { projects: ProjectStatus[]; stale: TabInfo[] } {
+ const { root, session } = ctx.resolved;
+ const client = ctx.client.forSession(session);
+ const projects = discoverProjects(root, { depth: ctx.depth });
+
+ if (!client.sessionExists(session)) {
+ return {
+ projects: projects.map((relPath) => ({ relPath, panes: 0, agent: "none" })),
+ stale: [],
+ };
+ }
+
+ const tabs = client.listTabs();
+ const panes = client.listPanes();
+ const byPurpose = new Map(tabs.filter((t) => t.purpose).map((t) => [t.purpose as string, t]));
+
+ const statuses: ProjectStatus[] = projects.map((relPath) => {
+ const tab = byPurpose.get(projectPurpose(relPath));
+ if (!tab) return { relPath, panes: 0, agent: "none" };
+ const tabPanes = panes.filter((p) => p.tab_id === tab.position && !p.is_plugin);
+ const agentPane = tabPanes.find((p) => p.purpose?.startsWith("agent:"));
+ return { relPath, tab, panes: tabPanes.length, agent: agentStateOf(agentPane) };
+ });
+
+ const plan = computePlan(ctx, tabs);
+ return { projects: statuses, stale: plan.close };
+}
+
+/** Ensure a tab exists for `name`, then focus it. */
+export function open(ctx: Context, name: string): TabInfo {
+ const { root, session } = ctx.resolved;
+ const client = ctx.client.forSession(session);
+ const relPath = resolveProjectName(ctx, name);
+
+ if (!client.sessionExists(session)) {
+ throw new Error(`no session ${session}; run \`sl0ppi up\` first`);
+ }
+
+ const purpose = projectPurpose(relPath);
+ let tab = client.listTabs().find((t) => t.purpose === purpose);
+ if (!tab) {
+ const position = client.newTab(singleTabLayoutKdl(specFor(root, relPath)));
+ ctx.log(`+ ${relPath}`);
+ tab = client.listTabs().find((t) => t.position === position) ?? {
+ position,
+ name: basename(relPath),
+ purpose,
+ };
+ }
+ client.focusTab(tab.position);
+ return tab;
+}
+
+export function close(ctx: Context, name: string): void {
+ const { session } = ctx.resolved;
+ const client = ctx.client.forSession(session);
+ const purpose = projectPurpose(resolveProjectName(ctx, name));
+ const tab = client.listTabs().find((t) => t.purpose === purpose);
+ if (!tab) throw new Error(`no open tab for ${name}`);
+ client.closeTab(tab.position);
+ ctx.log(`- ${tab.name}`);
+}
+
+/**
+ * Turn what the user typed into a project path.
+ *
+ * Exact match wins, then unique prefix. An ambiguous prefix is an error rather
+ * than a guess — picking one for them is how you end up closing the wrong tab.
+ */
+export function resolveProjectName(ctx: Context, name: string): string {
+ const { root } = ctx.resolved;
+ const projects = discoverProjects(root, { depth: ctx.depth });
+ if (projects.includes(name)) return name;
+
+ const asPath = resolve(ctx.resolved.root, name);
+ if (existsSync(join(asPath, MARKER_DIR))) {
+ const rel = asPath.startsWith(`${root}/`) ? asPath.slice(root.length + 1) : null;
+ if (rel) return rel;
+ }
+
+ const matches = projects.filter((p) => p.startsWith(name) || basename(p) === name);
+ if (matches.length === 1) return matches[0] as string;
+ if (matches.length > 1) {
+ throw new Error(`ambiguous: ${name} matches ${matches.join(", ")}`);
+ }
+ throw new Error(`no project ${name} under ${root}`);
+}
+
+function emptyPlan(): Plan {
+ return { create: [], close: [], keep: [], foreign: [] };
+}