1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
|
/**
* 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 `<head> { ... }` 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`;
}
|