tmux-ide 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/init.js ADDED
@@ -0,0 +1,81 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { resolve, basename } from "node:path";
3
+ import { writeFileSync } from "node:fs";
4
+ import { detectStack, suggestConfig } from "./detect.js";
5
+ import { printLayout } from "./lib/output.js";
6
+
7
+ export async function init({ template, json } = {}) {
8
+ const dir = process.cwd();
9
+ const configPath = resolve(dir, "ide.yml");
10
+
11
+ if (existsSync(configPath)) {
12
+ if (json) {
13
+ console.error(JSON.stringify({ error: "ide.yml already exists", code: "EXISTS" }));
14
+ } else {
15
+ console.error("ide.yml already exists in this directory");
16
+ }
17
+ process.exit(1);
18
+ }
19
+
20
+ // If a specific template is requested, use it
21
+ if (template) {
22
+ const templatePath = resolve(import.meta.dirname, "..", "templates", `${template}.yml`);
23
+ if (!existsSync(templatePath)) {
24
+ if (json) {
25
+ console.error(JSON.stringify({ error: `Template "${template}" not found`, code: "NOT_FOUND" }));
26
+ } else {
27
+ console.error(`Template "${template}" not found. Available: default, nextjs, convex, vite, python, go, agent-team, agent-team-nextjs, agent-team-monorepo`);
28
+ }
29
+ process.exit(1);
30
+ }
31
+
32
+ let content = readFileSync(templatePath, "utf-8");
33
+ const name = basename(dir);
34
+ content = content.replace(/^name: .+/m, `name: ${name}`);
35
+ writeFileSync(configPath, content);
36
+
37
+ if (json) {
38
+ console.log(JSON.stringify({ created: true, template, name }));
39
+ } else {
40
+ console.log(`Created ide.yml from "${template}" template for "${name}"`);
41
+ const yaml = (await import("js-yaml")).default;
42
+ printLayout(yaml.load(content));
43
+ }
44
+ return;
45
+ }
46
+
47
+ // Smart detection
48
+ const detected = detectStack(dir);
49
+ const name = basename(dir);
50
+
51
+ if (detected.frameworks.length > 0) {
52
+ // Use detected stack to generate config
53
+ const config = suggestConfig(dir, detected);
54
+ const yaml = (await import("js-yaml")).default;
55
+ writeFileSync(configPath, yaml.dump(config, { lineWidth: -1, noRefs: true, quotingType: '"' }));
56
+
57
+ const desc = detected.frameworks.join(" + ");
58
+ if (json) {
59
+ console.log(JSON.stringify({ created: true, detected: detected.frameworks, name }));
60
+ } else {
61
+ console.log(`Detected ${desc}. Created ide.yml for "${name}".`);
62
+ printLayout(config);
63
+ console.log("Edit it to customize, then run: tmux-ide");
64
+ }
65
+ } else {
66
+ // Fallback to default template
67
+ const templatePath = resolve(import.meta.dirname, "..", "templates", "default.yml");
68
+ let content = readFileSync(templatePath, "utf-8");
69
+ content = content.replace(/^name: .+/m, `name: ${name}`);
70
+ writeFileSync(configPath, content);
71
+
72
+ if (json) {
73
+ console.log(JSON.stringify({ created: true, template: "default", name }));
74
+ } else {
75
+ console.log(`Created ide.yml for "${name}"`);
76
+ const yaml = (await import("js-yaml")).default;
77
+ printLayout(yaml.load(content));
78
+ console.log("Edit it to configure your workspace, then run: tmux-ide");
79
+ }
80
+ }
81
+ }
@@ -0,0 +1,94 @@
1
+ import { describe, it, before, after } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { execSync, execFileSync } from "node:child_process";
4
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { tmpdir } from "node:os";
7
+
8
+ // Skip entire suite if tmux is not available
9
+ let tmuxAvailable = false;
10
+ try {
11
+ execSync("tmux -V", { stdio: "ignore" });
12
+ tmuxAvailable = true;
13
+ } catch {}
14
+
15
+ describe("integration", { skip: !tmuxAvailable && "tmux not available" }, () => {
16
+ let tmpDir;
17
+ const session = "tmux-ide-test-integration";
18
+ const cli = join(import.meta.dirname, "..", "bin", "cli.js");
19
+
20
+ function run(args) {
21
+ return execFileSync("node", [cli, ...args], { cwd: tmpDir, encoding: "utf-8" });
22
+ }
23
+
24
+ function runJSON(args) {
25
+ return JSON.parse(run([...args, "--json"]));
26
+ }
27
+
28
+ function killSession() {
29
+ try {
30
+ execSync(`tmux kill-session -t "${session}"`, { stdio: "ignore" });
31
+ } catch {}
32
+ }
33
+
34
+ function createSession() {
35
+ execSync(`tmux new-session -d -s "${session}" -x 80 -y 24`, { stdio: "ignore" });
36
+ }
37
+
38
+ before(() => {
39
+ tmpDir = mkdtempSync(join(tmpdir(), "tmux-ide-test-"));
40
+ writeFileSync(
41
+ join(tmpDir, "ide.yml"),
42
+ `name: ${session}\nrows:\n - panes:\n - title: Shell\n`
43
+ );
44
+ });
45
+
46
+ after(() => {
47
+ killSession();
48
+ rmSync(tmpDir, { recursive: true, force: true });
49
+ });
50
+
51
+ it("status --json reports not running when no session exists", () => {
52
+ killSession();
53
+ const result = runJSON(["status"]);
54
+ assert.strictEqual(result.running, false);
55
+ });
56
+
57
+ it("status --json reports running after session is created", () => {
58
+ killSession();
59
+ createSession();
60
+ const result = runJSON(["status"]);
61
+ assert.strictEqual(result.running, true);
62
+ killSession();
63
+ });
64
+
65
+ it("stop --json kills a running session", () => {
66
+ createSession();
67
+ run(["stop"]);
68
+ // Verify it's gone
69
+ const result = runJSON(["status"]);
70
+ assert.strictEqual(result.running, false);
71
+ });
72
+
73
+ it("validate --json reports valid for our test config", () => {
74
+ const result = runJSON(["validate"]);
75
+ assert.strictEqual(result.valid, true);
76
+ assert.deepStrictEqual(result.errors, []);
77
+ });
78
+
79
+ it("doctor --json passes checks", () => {
80
+ const result = runJSON(["doctor"]);
81
+ assert.strictEqual(result.ok, true);
82
+ });
83
+
84
+ it("config --json dumps config", () => {
85
+ const result = runJSON(["config"]);
86
+ assert.strictEqual(result.name, session);
87
+ assert.ok(Array.isArray(result.rows));
88
+ });
89
+
90
+ it("ls --json returns sessions list", () => {
91
+ const result = runJSON(["ls"]);
92
+ assert.ok(Array.isArray(result.sessions));
93
+ });
94
+ });
package/src/launch.js ADDED
@@ -0,0 +1,238 @@
1
+ import { resolve } from "node:path";
2
+ import { execSync } from "node:child_process";
3
+ import { readConfig, getSessionName } from "./lib/yaml-io.js";
4
+ import { computeSizes, toSplitPercents } from "./lib/sizes.js";
5
+
6
+ function tmux(cmd) {
7
+ execSync(`tmux ${cmd}`, { stdio: "inherit" });
8
+ }
9
+
10
+ function tmuxQuiet(cmd) {
11
+ try {
12
+ execSync(`tmux ${cmd}`, { stdio: "ignore" });
13
+ } catch {}
14
+ }
15
+
16
+ export async function launch(targetDir) {
17
+ const dir = resolve(targetDir ?? ".");
18
+
19
+ let config;
20
+ try {
21
+ ({ config } = readConfig(dir));
22
+ } catch {
23
+ console.error(`No ide.yml found in ${dir}. Run "tmux-ide init" to create one.`);
24
+ process.exit(1);
25
+ }
26
+
27
+ // Validate config structure before building tmux session
28
+ if (!Array.isArray(config.rows) || config.rows.length === 0) {
29
+ console.error("Invalid ide.yml: 'rows' must be a non-empty array");
30
+ console.error("Run: tmux-ide validate");
31
+ process.exit(1);
32
+ }
33
+ for (const row of config.rows) {
34
+ if (!Array.isArray(row.panes) || row.panes.length === 0) {
35
+ console.error("Invalid ide.yml: each row must have a non-empty 'panes' array");
36
+ console.error("Run: tmux-ide validate");
37
+ process.exit(1);
38
+ }
39
+ }
40
+
41
+ const session = config.name ?? getSessionName(dir);
42
+ const rows = config.rows;
43
+ const theme = config.theme ?? {};
44
+ const team = config.team ?? null;
45
+
46
+ // Run before hook
47
+ if (config.before) {
48
+ console.log(`Running: ${config.before}`);
49
+ try {
50
+ execSync(config.before, { cwd: dir, stdio: "inherit" });
51
+ } catch (e) {
52
+ console.error(`"before" hook failed: ${config.before}`);
53
+ process.exit(1);
54
+ }
55
+ }
56
+
57
+ // If session already exists, just attach to it
58
+ try {
59
+ execSync(`tmux has-session -t "${session}"`, { stdio: "ignore" });
60
+ console.log(`Session "${session}" is already running. Attaching...`);
61
+ tmux(`attach -t "${session}"`);
62
+ return;
63
+ } catch {
64
+ // Session doesn't exist, continue with creation
65
+ }
66
+
67
+ // Get terminal dimensions
68
+ const cols = process.stdout.columns ?? 200;
69
+ const lines = process.stdout.rows ?? 50;
70
+
71
+ // Create session with first pane
72
+ tmux(`new-session -d -s "${session}" -c "${dir}" -x ${cols} -y ${lines}`);
73
+
74
+ // Set agent teams env var if team config is present
75
+ if (team) {
76
+ tmux(`set-environment -t "${session}" CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS 1`);
77
+ }
78
+
79
+ // Compute row sizes and convert to tmux split percentages
80
+ const rowSizes = computeSizes(rows);
81
+ const rowSplitPercents = toSplitPercents(rowSizes);
82
+
83
+ // Phase 1: Create all rows (vertical splits) BEFORE any horizontal splits.
84
+ // This ensures each row spans the full window width.
85
+ const rowPaneIndices = [0]; // rowPaneIndices[i] = tmux pane index for row i
86
+ let nextPaneIndex = 1;
87
+
88
+ for (let rowIdx = 1; rowIdx < rows.length; rowIdx++) {
89
+ const splitFrom = rowPaneIndices[rowIdx - 1];
90
+ tmux(
91
+ `split-window -t "${session}:0.${splitFrom}" -v -c "${dir}" -p ${rowSplitPercents[rowIdx - 1]}`
92
+ );
93
+ rowPaneIndices.push(nextPaneIndex);
94
+ nextPaneIndex++;
95
+ }
96
+
97
+ // Phase 2: Create panes within each row (horizontal splits).
98
+ // paneMap[row][col] = tmux pane index
99
+ const paneMap = [];
100
+ const firstPanesOfRows = new Set(rowPaneIndices);
101
+
102
+ for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) {
103
+ const row = rows[rowIdx];
104
+ const panes = row.panes ?? [];
105
+ const rowPane = rowPaneIndices[rowIdx];
106
+ const rowPanes = [rowPane];
107
+
108
+ // Compute pane sizes within this row and convert to split percentages
109
+ const paneSizes = computeSizes(panes);
110
+ const paneSplitPercents = toSplitPercents(paneSizes);
111
+
112
+ // Split this row's panes horizontally
113
+ for (let paneIdx = 1; paneIdx < panes.length; paneIdx++) {
114
+ const pane = panes[paneIdx];
115
+ const targetPane = rowPanes[paneIdx - 1];
116
+
117
+ // Per-pane working directory
118
+ const paneDir = pane.dir ? resolve(dir, pane.dir) : dir;
119
+
120
+ tmux(
121
+ `split-window -t "${session}:0.${targetPane}" -h -c "${paneDir}" -p ${paneSplitPercents[paneIdx - 1]}`
122
+ );
123
+ rowPanes.push(nextPaneIndex);
124
+ nextPaneIndex++;
125
+ }
126
+
127
+ paneMap.push(rowPanes);
128
+ }
129
+
130
+ // Build the command for a pane, transforming claude commands for team roles
131
+ function buildCommand(p) {
132
+ if (!p.command) return null;
133
+ if (!team || !p.role) return p.command;
134
+
135
+ if (p.role === "lead") {
136
+ return `claude --team "${team.name}"`;
137
+ } else if (p.role === "teammate") {
138
+ let cmd = `claude --teammate-mode in-process --team "${team.name}"`;
139
+ if (p.task) {
140
+ cmd += ` --task "${p.task.replace(/"/g, '\\"')}"`;
141
+ }
142
+ return cmd;
143
+ }
144
+ return p.command;
145
+ }
146
+
147
+ // Send commands, set titles, handle env/focus/dir for first panes
148
+ // Two-pass approach for teams: lead + non-teammates first, then teammates
149
+ let focusPane = paneMap[0][0];
150
+ const teammateCommands = [];
151
+
152
+ for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) {
153
+ const row = rows[rowIdx];
154
+ const panes = row.panes ?? [];
155
+ for (let paneIdx = 0; paneIdx < panes.length; paneIdx++) {
156
+ const p = panes[paneIdx];
157
+ const tmuxPane = paneMap[rowIdx][paneIdx];
158
+
159
+ if (p.title) {
160
+ tmux(`select-pane -t "${session}:0.${tmuxPane}" -T "${p.title}"`);
161
+ }
162
+
163
+ // Per-pane directory for first panes of each row (created with project root)
164
+ if (p.dir && firstPanesOfRows.has(tmuxPane)) {
165
+ const paneDir = resolve(dir, p.dir);
166
+ tmux(`send-keys -t "${session}:0.${tmuxPane}" "cd ${paneDir}" C-m`);
167
+ }
168
+
169
+ // Environment variables
170
+ if (p.env && typeof p.env === "object") {
171
+ for (const [key, val] of Object.entries(p.env)) {
172
+ tmux(`send-keys -t "${session}:0.${tmuxPane}" "export ${key}=${val}" C-m`);
173
+ }
174
+ }
175
+
176
+ const cmd = buildCommand(p);
177
+ if (cmd) {
178
+ if (team && p.role === "teammate") {
179
+ // Defer teammate launches to second pass
180
+ teammateCommands.push({ pane: tmuxPane, cmd });
181
+ } else {
182
+ tmux(`send-keys -t "${session}:0.${tmuxPane}" "${cmd}" C-m`);
183
+ }
184
+ }
185
+
186
+ if (p.focus) {
187
+ focusPane = tmuxPane;
188
+ }
189
+ }
190
+ }
191
+
192
+ // Second pass: launch teammates after a brief delay for the lead to initialize
193
+ if (teammateCommands.length > 0) {
194
+ execSync("sleep 2");
195
+ for (const { pane: p, cmd } of teammateCommands) {
196
+ tmux(`send-keys -t "${session}:0.${p}" "${cmd}" C-m`);
197
+ }
198
+ }
199
+
200
+ // Apply styling
201
+ const accent = theme.accent ?? "colour75";
202
+ const border = theme.border ?? "colour238";
203
+ const bg = theme.bg ?? "colour235";
204
+ const fg = theme.fg ?? "colour248";
205
+
206
+ tmux(`set-option -t "${session}" pane-border-status top`);
207
+ tmux(
208
+ `set-option -t "${session}" pane-border-format " #{?pane_active,#[bold]▸,·} #T "`
209
+ );
210
+ tmux(`set-option -t "${session}" pane-border-style "fg=${border}"`);
211
+ tmux(`set-option -t "${session}" pane-active-border-style "fg=${accent}"`);
212
+
213
+ tmux(`set-option -t "${session}" status-style "bg=${bg},fg=${fg}"`);
214
+ tmux(
215
+ `set-option -t "${session}" status-left "#[fg=colour0,bg=${accent},bold] ${session.toUpperCase()} IDE #[default] "`
216
+ );
217
+ tmux(`set-option -t "${session}" status-left-length 30`);
218
+ tmux(
219
+ `set-option -t "${session}" status-right "#[fg=colour243]%H:%M #[fg=${accent}]│ #[fg=${fg}]%b %d "`
220
+ );
221
+ tmux(`set-option -t "${session}" status-justify centre`);
222
+ tmux(
223
+ `set-option -t "${session}" window-status-current-format "#[fg=${accent},bold]●"`
224
+ );
225
+ tmux(
226
+ `set-option -t "${session}" window-status-format "#[fg=${border}]○"`
227
+ );
228
+
229
+ // Focus the correct pane
230
+ tmux(`select-pane -t "${session}:0.${focusPane}"`);
231
+
232
+ // Launch summary
233
+ const totalPanes = rows.reduce((sum, r) => sum + (r.panes?.length ?? 0), 0);
234
+ console.log(`Starting "${session}" (${rows.length} row${rows.length === 1 ? "" : "s"}, ${totalPanes} pane${totalPanes === 1 ? "" : "s"})...`);
235
+
236
+ // Attach
237
+ tmux(`attach -t "${session}"`);
238
+ }
@@ -0,0 +1,16 @@
1
+ export function getByPath(obj, path) {
2
+ return path.split(".").reduce((o, k) => o?.[k], obj);
3
+ }
4
+
5
+ export function setByPath(obj, path, value) {
6
+ const keys = path.split(".");
7
+ const last = keys.pop();
8
+ let i = 0;
9
+ const target = keys.reduce((o, k) => {
10
+ const nextKey = keys[i + 1] ?? last;
11
+ if (o[k] === undefined) o[k] = /^\d+$/.test(nextKey) ? [] : {};
12
+ i++;
13
+ return o[k];
14
+ }, obj);
15
+ target[last] = value;
16
+ }
@@ -0,0 +1,67 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { getByPath, setByPath } from "./dot-path.js";
4
+
5
+ describe("getByPath", () => {
6
+ it("gets a top-level key", () => {
7
+ assert.strictEqual(getByPath({ name: "test" }, "name"), "test");
8
+ });
9
+
10
+ it("gets a nested value", () => {
11
+ const obj = { rows: [{ panes: [{ title: "Shell" }] }] };
12
+ assert.strictEqual(getByPath(obj, "rows.0.panes.0.title"), "Shell");
13
+ });
14
+
15
+ it("returns undefined for non-existent path", () => {
16
+ assert.strictEqual(getByPath({}, "a.b.c"), undefined);
17
+ });
18
+
19
+ it("returns undefined for partially valid path", () => {
20
+ assert.strictEqual(getByPath({ a: { b: 1 } }, "a.b.c"), undefined);
21
+ });
22
+ });
23
+
24
+ describe("setByPath", () => {
25
+ it("sets a top-level key", () => {
26
+ const obj = {};
27
+ setByPath(obj, "name", "test");
28
+ assert.deepStrictEqual(obj, { name: "test" });
29
+ });
30
+
31
+ it("sets a nested value", () => {
32
+ const obj = { rows: [{ panes: [{ title: "old" }] }] };
33
+ setByPath(obj, "rows.0.panes.0.title", "new");
34
+ assert.strictEqual(obj.rows[0].panes[0].title, "new");
35
+ });
36
+
37
+ it("creates arrays for numeric keys", () => {
38
+ const obj = {};
39
+ setByPath(obj, "rows.0.title", "test");
40
+ assert.ok(Array.isArray(obj.rows));
41
+ assert.strictEqual(obj.rows[0].title, "test");
42
+ });
43
+
44
+ it("creates objects for string keys", () => {
45
+ const obj = {};
46
+ setByPath(obj, "a.b.c", "val");
47
+ assert.deepStrictEqual(obj, { a: { b: { c: "val" } } });
48
+ });
49
+
50
+ it("handles duplicate segment values (regression)", () => {
51
+ // Bug: rows.0.panes.0 — both "0" segments caused indexOf to return
52
+ // the same index, breaking the lookahead for array vs object creation
53
+ const obj = {};
54
+ setByPath(obj, "rows.0.panes.0", "value");
55
+ assert.ok(Array.isArray(obj.rows), "rows should be an array");
56
+ assert.ok(Array.isArray(obj.rows[0].panes), "panes should be an array");
57
+ assert.strictEqual(obj.rows[0].panes[0], "value");
58
+ });
59
+
60
+ it("handles deeper duplicate segments", () => {
61
+ const obj = {};
62
+ setByPath(obj, "a.0.b.0.c", "deep");
63
+ assert.ok(Array.isArray(obj.a));
64
+ assert.ok(Array.isArray(obj.a[0].b));
65
+ assert.strictEqual(obj.a[0].b[0].c, "deep");
66
+ });
67
+ });
@@ -0,0 +1,72 @@
1
+ export function output(data, { json } = {}) {
2
+ if (json) {
3
+ console.log(JSON.stringify(data, null, 2));
4
+ } else if (typeof data === "string") {
5
+ console.log(data);
6
+ } else {
7
+ console.log(data);
8
+ }
9
+ }
10
+
11
+ export function printLayout(config) {
12
+ const INNER = 40;
13
+ const rows = config.rows ?? [];
14
+ if (rows.length === 0) return;
15
+
16
+ for (let r = 0; r < rows.length; r++) {
17
+ const panes = rows[r].panes ?? [];
18
+ const count = panes.length || 1;
19
+ const widths = [];
20
+ let remaining = INNER;
21
+ for (let i = 0; i < count; i++) {
22
+ const w = i < count - 1 ? Math.floor(INNER / count) : remaining;
23
+ widths.push(w);
24
+ remaining -= w;
25
+ }
26
+
27
+ // Top border or mid divider
28
+ if (r === 0) {
29
+ let top = " ┌";
30
+ for (let i = 0; i < count; i++) {
31
+ top += "─".repeat(widths[i]);
32
+ top += i < count - 1 ? "┬" : "┐";
33
+ }
34
+ console.log(top);
35
+ } else {
36
+ console.log(" ├" + "─".repeat(INNER + count - 1) + "┤");
37
+ }
38
+
39
+ // Content line
40
+ const sizeLabel = rows[r].size ?? "";
41
+ let line = " │";
42
+ for (let i = 0; i < count; i++) {
43
+ const title = panes[i]?.title ?? "";
44
+ const w = widths[i];
45
+ const pad = Math.max(0, w - title.length);
46
+ const left = Math.floor(pad / 2);
47
+ const right = pad - left;
48
+ line += " ".repeat(left) + title + " ".repeat(right) + "│";
49
+ }
50
+ if (sizeLabel) line += " " + sizeLabel;
51
+ console.log(line);
52
+
53
+ // Bottom border (last row only)
54
+ if (r === rows.length - 1) {
55
+ let bot = " └";
56
+ for (let i = 0; i < count; i++) {
57
+ bot += "─".repeat(widths[i]);
58
+ bot += i < count - 1 ? "┴" : "┘";
59
+ }
60
+ console.log(bot);
61
+ }
62
+ }
63
+ }
64
+
65
+ export function outputError(message, code, { json } = {}) {
66
+ if (json) {
67
+ console.error(JSON.stringify({ error: message, code }, null, 2));
68
+ } else {
69
+ console.error(message);
70
+ }
71
+ process.exit(code ?? 1);
72
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Compute absolute sizes for items where some have explicit sizes and others don't.
3
+ * Items with `size` (e.g. "70%") keep their value; remaining space is split equally
4
+ * among items without a size.
5
+ */
6
+ export function computeSizes(items) {
7
+ let claimed = 0;
8
+ let unclaimed = 0;
9
+ for (const item of items) {
10
+ if (item.size) {
11
+ claimed += parseFloat(item.size);
12
+ } else {
13
+ unclaimed++;
14
+ }
15
+ }
16
+ const remaining = Math.max(0, 100 - claimed);
17
+ const defaultSize = unclaimed > 0 ? remaining / unclaimed : 0;
18
+ return items.map((item) => (item.size ? parseFloat(item.size) : defaultSize));
19
+ }
20
+
21
+ /**
22
+ * Convert absolute sizes (e.g. [70, 30]) to tmux split percentages for sequential splits.
23
+ * Returns array of -p values (one per split after the first item).
24
+ *
25
+ * Each tmux split divides the current pane. The percentage given to -p is
26
+ * the portion allocated to the NEW (bottom/right) pane.
27
+ */
28
+ export function toSplitPercents(sizes) {
29
+ const percents = [];
30
+ for (let i = 1; i < sizes.length; i++) {
31
+ const remaining = sizes.slice(i - 1).reduce((a, b) => a + b, 0);
32
+ const topShare = sizes[i - 1];
33
+ percents.push(Math.round(((remaining - topShare) / remaining) * 100));
34
+ }
35
+ return percents;
36
+ }
@@ -0,0 +1,74 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { computeSizes, toSplitPercents } from "./sizes.js";
4
+
5
+ describe("computeSizes", () => {
6
+ it("distributes remaining space to items without size", () => {
7
+ const result = computeSizes([{ size: "70%" }, {}]);
8
+ assert.deepStrictEqual(result, [70, 30]);
9
+ });
10
+
11
+ it("passes through all explicit sizes", () => {
12
+ const result = computeSizes([{ size: "60%" }, { size: "40%" }]);
13
+ assert.deepStrictEqual(result, [60, 40]);
14
+ });
15
+
16
+ it("splits equally when no sizes specified", () => {
17
+ const result = computeSizes([{}, {}, {}]);
18
+ const expected = [100 / 3, 100 / 3, 100 / 3];
19
+ assert.deepStrictEqual(result, expected);
20
+ });
21
+
22
+ it("handles 3 items with mixed sizes", () => {
23
+ const result = computeSizes([{ size: "60%" }, { size: "25%" }, {}]);
24
+ assert.deepStrictEqual(result, [60, 25, 15]);
25
+ });
26
+
27
+ it("handles single item without size", () => {
28
+ const result = computeSizes([{}]);
29
+ assert.deepStrictEqual(result, [100]);
30
+ });
31
+
32
+ it("handles single item with size", () => {
33
+ const result = computeSizes([{ size: "50%" }]);
34
+ assert.deepStrictEqual(result, [50]);
35
+ });
36
+
37
+ it("handles empty array", () => {
38
+ const result = computeSizes([]);
39
+ assert.deepStrictEqual(result, []);
40
+ });
41
+
42
+ it("clamps remaining to zero when sizes exceed 100", () => {
43
+ const result = computeSizes([{ size: "80%" }, { size: "30%" }, {}]);
44
+ assert.deepStrictEqual(result, [80, 30, 0]);
45
+ });
46
+ });
47
+
48
+ describe("toSplitPercents", () => {
49
+ it("converts 70/30 to [30]", () => {
50
+ assert.deepStrictEqual(toSplitPercents([70, 30]), [30]);
51
+ });
52
+
53
+ it("converts 60/25/15 correctly", () => {
54
+ const result = toSplitPercents([60, 25, 15]);
55
+ assert.deepStrictEqual(result, [40, 38]);
56
+ });
57
+
58
+ it("converts equal 50/50 to [50]", () => {
59
+ assert.deepStrictEqual(toSplitPercents([50, 50]), [50]);
60
+ });
61
+
62
+ it("converts equal thirds", () => {
63
+ const result = toSplitPercents([100 / 3, 100 / 3, 100 / 3]);
64
+ assert.deepStrictEqual(result, [67, 50]);
65
+ });
66
+
67
+ it("returns empty array for single item", () => {
68
+ assert.deepStrictEqual(toSplitPercents([100]), []);
69
+ });
70
+
71
+ it("returns empty array for empty input", () => {
72
+ assert.deepStrictEqual(toSplitPercents([]), []);
73
+ });
74
+ });