vde-layout 0.0.8 → 0.1.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/README.md CHANGED
@@ -112,6 +112,8 @@ layout:
112
112
  focus: true # optional; only one pane should be true
113
113
  delay: 500 # optional; wait (ms) before running command
114
114
  title: "Server" # optional; tmux pane title
115
+ ephemeral: true # optional; close pane after command completes
116
+ closeOnError: false # optional; if ephemeral, close on error (default: false)
115
117
  - type: vertical # nested split
116
118
  ratio: [1, 1]
117
119
  panes:
@@ -119,6 +121,71 @@ layout:
119
121
  - name: "shell"
120
122
  ```
121
123
 
124
+ ### Template Tokens
125
+ You can reference dynamically-assigned pane IDs within pane commands using template tokens. These tokens are resolved after the layout finishes splitting panes but before commands execute:
126
+
127
+ - **`{{this_pane}}`** – References the current pane receiving the command
128
+ - **`{{focus_pane}}`** – References the pane that will receive focus
129
+ - **`{{pane_id:<name>}}`** – References a specific pane by its name
130
+
131
+ Example:
132
+ ```yaml
133
+ presets:
134
+ cross-pane-demo:
135
+ name: Cross Pane Coordination
136
+ layout:
137
+ type: vertical
138
+ ratio: [2, 1]
139
+ panes:
140
+ - name: editor
141
+ command: 'echo "Editor pane ID: {{this_pane}}"'
142
+ focus: true
143
+ - name: terminal
144
+ command: 'echo "I can reference the editor pane: {{pane_id:editor}}"'
145
+ ```
146
+
147
+ **Common use cases:**
148
+ - Send commands to other panes: `tmux send-keys -t {{pane_id:editor}} "npm test" Enter`
149
+ - Display pane information for debugging: `echo "Current: {{this_pane}}, Focus: {{focus_pane}}"`
150
+ - Coordinate tasks across multiple panes within your preset configuration
151
+
152
+ ### Ephemeral Panes
153
+ Ephemeral panes automatically close after their command completes. This is useful for one-time tasks like builds, tests, or initialization scripts.
154
+
155
+ ```yaml
156
+ panes:
157
+ - name: build
158
+ command: npm run build
159
+ ephemeral: true # Pane closes when command finishes
160
+ ```
161
+
162
+ **Error handling:**
163
+ - By default, ephemeral panes remain open if the command fails, allowing you to inspect errors
164
+ - Set `closeOnError: true` to close the pane regardless of success or failure
165
+
166
+ ```yaml
167
+ panes:
168
+ - name: quick-test
169
+ command: npm test
170
+ ephemeral: true
171
+ closeOnError: false # Default: stays open on error
172
+
173
+ - name: build-and-exit
174
+ command: npm run build
175
+ ephemeral: true
176
+ closeOnError: true # Closes even if build fails
177
+ ```
178
+
179
+ **Combining with template tokens:**
180
+ ```yaml
181
+ panes:
182
+ - name: editor
183
+ command: nvim
184
+ - name: test-runner
185
+ command: 'tmux send-keys -t {{pane_id:editor}} ":!npm test" Enter'
186
+ ephemeral: true # Run once and close
187
+ ```
188
+
122
189
  ### Ratio Normalization
123
190
  Ratios can be any set of positive integers. vde-layout normalizes them to percentages:
124
191
  - `[1, 1]` → `[50, 50]`
package/dist/index.js CHANGED
@@ -120,7 +120,9 @@ const TerminalPaneSchema = z.object({
120
120
  env: z.record(z.string()).optional(),
121
121
  delay: z.number().int().positive().optional(),
122
122
  title: z.string().optional(),
123
- focus: z.boolean().optional()
123
+ focus: z.boolean().optional(),
124
+ ephemeral: z.boolean().optional(),
125
+ closeOnError: z.boolean().optional()
124
126
  }).strict();
125
127
  const SplitPaneSchema = z.lazy(() => z.object({
126
128
  type: z.enum(["horizontal", "vertical"]),
@@ -730,6 +732,70 @@ const isFunctionalCoreError = (value) => {
730
732
  return (candidate.kind === "compile" || candidate.kind === "plan" || candidate.kind === "emit" || candidate.kind === "execution") && typeof candidate.code === "string" && typeof candidate.message === "string";
731
733
  };
732
734
 
735
+ //#endregion
736
+ //#region src/utils/template-tokens.ts
737
+ const TemplateTokenErrorImpl = function TemplateTokenError$1(message, tokenType, availablePanes) {
738
+ const error = new Error(message);
739
+ Object.setPrototypeOf(error, TemplateTokenErrorImpl.prototype);
740
+ error.name = "TemplateTokenError";
741
+ error.tokenType = tokenType;
742
+ error.availablePanes = availablePanes;
743
+ return error;
744
+ };
745
+ TemplateTokenErrorImpl.prototype = Object.create(Error.prototype);
746
+ TemplateTokenErrorImpl.prototype.constructor = TemplateTokenErrorImpl;
747
+ const TemplateTokenError = TemplateTokenErrorImpl;
748
+ /**
749
+ * Replaces template tokens in a command string with actual pane IDs.
750
+ *
751
+ * Uses a single-pass regex replacement to avoid nested token issues.
752
+ * All tokens are replaced in a single pass, preventing already-replaced
753
+ * values from being re-processed.
754
+ *
755
+ * @param input - Object containing the command and pane ID mappings
756
+ * @returns The command string with all template tokens replaced
757
+ * @throws TemplateTokenError if a referenced pane name is not found in the mapping
758
+ */
759
+ const replaceTemplateTokens = ({ command, currentPaneRealId, focusPaneRealId, nameToRealIdMap }) => {
760
+ return command.replace(/\{\{(this_pane|focus_pane|pane_id:([^}]+))\}\}/g, (match, tokenContent, paneName) => {
761
+ if (tokenContent === "this_pane") return currentPaneRealId;
762
+ if (tokenContent === "focus_pane") return focusPaneRealId;
763
+ if (tokenContent.startsWith("pane_id:") && paneName !== void 0) {
764
+ const trimmedName = paneName.trim();
765
+ const paneId = nameToRealIdMap.get(trimmedName);
766
+ if (paneId === void 0) throw new TemplateTokenError(`Pane name "${trimmedName}" not found. Available panes: ${Array.from(nameToRealIdMap.keys()).join(", ")}`, "pane_id", Array.from(nameToRealIdMap.keys()));
767
+ return paneId;
768
+ }
769
+ return match;
770
+ });
771
+ };
772
+ /**
773
+ * Builds a mapping from pane names to real pane IDs.
774
+ *
775
+ * **Duplicate Name Handling:**
776
+ * If multiple panes share the same name, the last one in the terminals
777
+ * array wins. This follows the iteration order of the layout tree.
778
+ * It's recommended to use unique names for panes to avoid ambiguity
779
+ * in template token references.
780
+ *
781
+ * **Virtual to Real ID Resolution:**
782
+ * Only panes with successfully resolved real IDs (present in paneMap)
783
+ * are included in the resulting map. This ensures that template tokens
784
+ * only reference panes that have been properly created.
785
+ *
786
+ * @param terminals - Array of emitted terminals from the layout plan
787
+ * @param paneMap - Map from virtual pane IDs to real pane IDs (backend-specific)
788
+ * @returns A map from pane names to real pane IDs
789
+ */
790
+ const buildNameToRealIdMap = (terminals, paneMap) => {
791
+ const nameToRealIdMap = /* @__PURE__ */ new Map();
792
+ for (const terminal of terminals) {
793
+ const realId = paneMap.get(terminal.virtualPaneId);
794
+ if (realId !== void 0) nameToRealIdMap.set(terminal.name, realId);
795
+ }
796
+ return nameToRealIdMap;
797
+ };
798
+
733
799
  //#endregion
734
800
  //#region src/executor/plan-runner.ts
735
801
  const DOUBLE_QUOTE = "\"";
@@ -812,7 +878,8 @@ const executePlan = async ({ emission, executor, windowName, windowMode, onConfi
812
878
  await executeTerminalCommands({
813
879
  terminals: emission.terminals,
814
880
  executor,
815
- paneMap
881
+ paneMap,
882
+ focusPaneVirtualId: emission.summary.focusPaneId
816
883
  });
817
884
  const finalRealFocus = resolvePaneId(paneMap, emission.summary.focusPaneId);
818
885
  if (typeof finalRealFocus === "string" && finalRealFocus.length > 0) await executeCommand(executor, [
@@ -868,7 +935,16 @@ const executeFocusStep = async ({ step, executor, paneMap }) => {
868
935
  details: { command }
869
936
  });
870
937
  };
871
- const executeTerminalCommands = async ({ terminals, executor, paneMap }) => {
938
+ const executeTerminalCommands = async ({ terminals, executor, paneMap, focusPaneVirtualId }) => {
939
+ const nameToRealIdMap = buildNameToRealIdMap(terminals, paneMap);
940
+ if (!paneMap.has(focusPaneVirtualId)) raiseExecutionError("UNKNOWN_PANE", {
941
+ message: `Unknown focus pane: ${focusPaneVirtualId}`,
942
+ path: focusPaneVirtualId
943
+ });
944
+ const focusPaneRealId = ensureNonEmpty(resolvePaneId(paneMap, focusPaneVirtualId), () => raiseExecutionError("UNKNOWN_PANE", {
945
+ message: `Unknown focus pane: ${focusPaneVirtualId}`,
946
+ path: focusPaneVirtualId
947
+ }));
872
948
  for (const terminal of terminals) {
873
949
  const realPaneId = ensureNonEmpty(resolvePaneId(paneMap, terminal.virtualPaneId), () => raiseExecutionError("UNKNOWN_PANE", {
874
950
  message: `Unknown terminal pane: ${terminal.virtualPaneId}`,
@@ -903,18 +979,44 @@ const executeTerminalCommands = async ({ terminals, executor, paneMap }) => {
903
979
  path: terminal.virtualPaneId
904
980
  });
905
981
  }
906
- if (typeof terminal.command === "string" && terminal.command.length > 0) await executeCommand(executor, [
907
- "send-keys",
908
- "-t",
909
- realPaneId,
910
- terminal.command,
911
- "Enter"
912
- ], {
913
- code: ErrorCodes.TMUX_COMMAND_FAILED,
914
- message: `Failed to execute command for pane ${terminal.virtualPaneId}`,
915
- path: terminal.virtualPaneId,
916
- details: { command: terminal.command }
917
- });
982
+ if (typeof terminal.command === "string" && terminal.command.length > 0) {
983
+ const focusPaneRealIdForCommand = terminal.command.includes("{{focus_pane}}") ? focusPaneRealId : "";
984
+ let commandWithTokensReplaced;
985
+ try {
986
+ commandWithTokensReplaced = replaceTemplateTokens({
987
+ command: terminal.command,
988
+ currentPaneRealId: realPaneId,
989
+ focusPaneRealId: focusPaneRealIdForCommand,
990
+ nameToRealIdMap
991
+ });
992
+ } catch (error) {
993
+ if (error instanceof TemplateTokenError) throw createFunctionalError("execution", {
994
+ code: "TEMPLATE_TOKEN_ERROR",
995
+ message: `Template token resolution failed for pane ${terminal.virtualPaneId}: ${error.message}`,
996
+ path: terminal.virtualPaneId,
997
+ details: {
998
+ command: terminal.command,
999
+ tokenType: error.tokenType,
1000
+ availablePanes: error.availablePanes
1001
+ }
1002
+ });
1003
+ throw error;
1004
+ }
1005
+ if (terminal.ephemeral === true) if (terminal.closeOnError === true) commandWithTokensReplaced = `${commandWithTokensReplaced}; exit`;
1006
+ else commandWithTokensReplaced = `${commandWithTokensReplaced}; [ $? -eq 0 ] && exit`;
1007
+ await executeCommand(executor, [
1008
+ "send-keys",
1009
+ "-t",
1010
+ realPaneId,
1011
+ commandWithTokensReplaced,
1012
+ "Enter"
1013
+ ], {
1014
+ code: ErrorCodes.TMUX_COMMAND_FAILED,
1015
+ message: `Failed to execute command for pane ${terminal.virtualPaneId}`,
1016
+ path: terminal.virtualPaneId,
1017
+ details: { command: terminal.command }
1018
+ });
1019
+ }
918
1020
  }
919
1021
  };
920
1022
  const executeCommand = async (executor, command, context) => {
@@ -1398,20 +1500,22 @@ const buildDryRunSteps = (emission) => {
1398
1500
  return steps;
1399
1501
  };
1400
1502
  const resolveCurrentWindow = async (context) => {
1401
- const activeWindow = context.list.windows.find((window) => window.isActive) ?? context.list.windows[0];
1503
+ const preferredPaneId = typeof context.preferredPaneId === "string" && context.preferredPaneId.length > 0 ? context.preferredPaneId : void 0;
1504
+ const preferredWindowId = preferredPaneId !== void 0 ? findWindowContainingPane(context.list, preferredPaneId) : void 0;
1505
+ const activeWindow = (preferredWindowId !== void 0 ? context.list.windows.find((window) => window.windowId === preferredWindowId) : void 0) ?? context.list.windows.find((window) => window.isActive) ?? context.list.windows[0];
1402
1506
  if (!activeWindow) throw createFunctionalError("execution", {
1403
1507
  code: ErrorCodes.TERMINAL_COMMAND_FAILED,
1404
1508
  message: "No active wezterm window detected",
1405
1509
  details: { hint: "Launch wezterm and ensure a window is focused, or run with --new-window." }
1406
1510
  });
1407
- const activeTab = activeWindow.tabs.find((tab) => tab.isActive) ?? activeWindow.tabs[0];
1511
+ const activeTab = (preferredPaneId !== void 0 ? activeWindow.tabs.find((tab) => tab.panes.some((pane) => pane.paneId === preferredPaneId)) : void 0) ?? activeWindow.tabs.find((tab) => tab.isActive) ?? activeWindow.tabs[0];
1408
1512
  if (!activeTab) throw createFunctionalError("execution", {
1409
1513
  code: ErrorCodes.TERMINAL_COMMAND_FAILED,
1410
1514
  message: "No active wezterm tab detected",
1411
1515
  path: activeWindow.windowId,
1412
1516
  details: { hint: "Ensure a wezterm tab is focused before using --current-window." }
1413
1517
  });
1414
- const activePane = activeTab.panes.find((pane) => pane.isActive) ?? activeTab.panes[0];
1518
+ const activePane = (preferredPaneId !== void 0 ? activeTab.panes.find((pane) => pane.paneId === preferredPaneId) : void 0) ?? activeTab.panes.find((pane) => pane.isActive) ?? activeTab.panes[0];
1415
1519
  if (!activePane) throw createFunctionalError("execution", {
1416
1520
  code: ErrorCodes.TERMINAL_COMMAND_FAILED,
1417
1521
  message: "No active wezterm pane detected",
@@ -1485,7 +1589,8 @@ const waitForPaneRegistration = async ({ paneId, listWindows, windowHint }) => {
1485
1589
  }
1486
1590
  });
1487
1591
  };
1488
- const resolveInitialPane = async ({ windowMode, prompt, dryRun, listWindows, runCommand, logCommand, initialCwd, workspaceHint, initialList }) => {
1592
+ const resolveInitialPane = async ({ windowMode, prompt, dryRun, listWindows, runCommand, logCommand, initialCwd, workspaceHint, initialList, preferredPaneId }) => {
1593
+ const preferredPaneIdValue = typeof preferredPaneId === "string" && preferredPaneId.length > 0 ? preferredPaneId : void 0;
1489
1594
  if (windowMode === "current-window") {
1490
1595
  const snapshot = initialList ?? await listWindows();
1491
1596
  const scoped = filterWindowsByWorkspace(snapshot, workspaceHint);
@@ -1493,12 +1598,14 @@ const resolveInitialPane = async ({ windowMode, prompt, dryRun, listWindows, run
1493
1598
  list: scoped,
1494
1599
  prompt,
1495
1600
  dryRun,
1496
- logCommand
1601
+ logCommand,
1602
+ preferredPaneId: preferredPaneIdValue
1497
1603
  });
1498
1604
  }
1499
1605
  const existingSnapshot = initialList ?? await listWindows();
1500
1606
  const scopedExisting = filterWindowsByWorkspace(existingSnapshot, workspaceHint);
1501
- const activeWindow = findActiveWindow(scopedExisting);
1607
+ const scopedPreferredWindowId = preferredPaneIdValue !== void 0 ? findWindowContainingPane(scopedExisting, preferredPaneIdValue) : void 0;
1608
+ const activeWindow = (scopedPreferredWindowId !== void 0 ? scopedExisting.windows.find((window) => window.windowId === scopedPreferredWindowId) : void 0) ?? findActiveWindow(scopedExisting);
1502
1609
  if (activeWindow) {
1503
1610
  const args$1 = [
1504
1611
  "spawn",
@@ -1618,7 +1725,14 @@ const sendTextToPane = async ({ paneId, text, runCommand, context }) => {
1618
1725
  appendCarriageReturn(text)
1619
1726
  ], context);
1620
1727
  };
1621
- const applyTerminalCommands = async ({ terminals, paneMap, runCommand }) => {
1728
+ const applyTerminalCommands = async ({ terminals, paneMap, runCommand, focusPaneVirtualId }) => {
1729
+ const nameToRealIdMap = buildNameToRealIdMap(terminals, paneMap);
1730
+ if (!paneMap.has(focusPaneVirtualId)) throw createFunctionalError("execution", {
1731
+ code: ErrorCodes.INVALID_PANE,
1732
+ message: `Unknown focus pane: ${focusPaneVirtualId}`,
1733
+ path: focusPaneVirtualId
1734
+ });
1735
+ const focusPaneRealId = resolveRealPaneId(paneMap, focusPaneVirtualId, { stepId: focusPaneVirtualId });
1622
1736
  for (const terminal of terminals) {
1623
1737
  const realPaneId = resolveRealPaneId(paneMap, terminal.virtualPaneId, { stepId: terminal.virtualPaneId });
1624
1738
  if (typeof terminal.cwd === "string" && terminal.cwd.length > 0) {
@@ -1646,16 +1760,42 @@ const applyTerminalCommands = async ({ terminals, paneMap, runCommand }) => {
1646
1760
  }
1647
1761
  });
1648
1762
  }
1649
- if (typeof terminal.command === "string" && terminal.command.length > 0) await sendTextToPane({
1650
- paneId: realPaneId,
1651
- text: terminal.command,
1652
- runCommand,
1653
- context: {
1654
- message: `Failed to execute command for pane ${terminal.virtualPaneId}`,
1655
- path: terminal.virtualPaneId,
1656
- details: { command: terminal.command }
1763
+ if (typeof terminal.command === "string" && terminal.command.length > 0) {
1764
+ const focusPaneRealIdForCommand = terminal.command.includes("{{focus_pane}}") ? focusPaneRealId : "";
1765
+ let commandWithTokensReplaced;
1766
+ try {
1767
+ commandWithTokensReplaced = replaceTemplateTokens({
1768
+ command: terminal.command,
1769
+ currentPaneRealId: realPaneId,
1770
+ focusPaneRealId: focusPaneRealIdForCommand,
1771
+ nameToRealIdMap
1772
+ });
1773
+ } catch (error) {
1774
+ if (error instanceof TemplateTokenError) throw createFunctionalError("execution", {
1775
+ code: "TEMPLATE_TOKEN_ERROR",
1776
+ message: `Template token resolution failed for pane ${terminal.virtualPaneId}: ${error.message}`,
1777
+ path: terminal.virtualPaneId,
1778
+ details: {
1779
+ command: terminal.command,
1780
+ tokenType: error.tokenType,
1781
+ availablePanes: error.availablePanes
1782
+ }
1783
+ });
1784
+ throw error;
1657
1785
  }
1658
- });
1786
+ if (terminal.ephemeral === true) if (terminal.closeOnError === true) commandWithTokensReplaced = `${commandWithTokensReplaced}; exit`;
1787
+ else commandWithTokensReplaced = `${commandWithTokensReplaced}; [ $? -eq 0 ] && exit`;
1788
+ await sendTextToPane({
1789
+ paneId: realPaneId,
1790
+ text: commandWithTokensReplaced,
1791
+ runCommand,
1792
+ context: {
1793
+ message: `Failed to execute command for pane ${terminal.virtualPaneId}`,
1794
+ path: terminal.virtualPaneId,
1795
+ details: { command: terminal.command }
1796
+ }
1797
+ });
1798
+ }
1659
1799
  }
1660
1800
  };
1661
1801
  const applySplitStep = async ({ step, paneMap, windowId, runCommand, listWindows, logPaneMapping }) => {
@@ -1744,7 +1884,8 @@ const createWeztermBackend = (context) => {
1744
1884
  logCommand,
1745
1885
  initialCwd,
1746
1886
  workspaceHint,
1747
- initialList: cachedInitialList
1887
+ initialList: cachedInitialList,
1888
+ preferredPaneId: context.paneId
1748
1889
  });
1749
1890
  registerPaneWithAncestors(paneMap, initialVirtualPaneId, initialPaneId);
1750
1891
  logPaneMapping(initialVirtualPaneId, initialPaneId);
@@ -1770,7 +1911,8 @@ const createWeztermBackend = (context) => {
1770
1911
  await applyTerminalCommands({
1771
1912
  terminals: emission.terminals,
1772
1913
  paneMap,
1773
- runCommand
1914
+ runCommand,
1915
+ focusPaneVirtualId: emission.summary.focusPaneId
1774
1916
  });
1775
1917
  const focusVirtual = emission.summary.focusPaneId;
1776
1918
  const focusPaneId = typeof focusVirtual === "string" ? paneMap.get(focusVirtual) : void 0;
@@ -2035,6 +2177,8 @@ const parseTerminalPane = (node) => {
2035
2177
  const command = typeof node.command === "string" ? node.command : void 0;
2036
2178
  const cwd = typeof node.cwd === "string" ? node.cwd : void 0;
2037
2179
  const focus = node.focus === true ? true : void 0;
2180
+ const ephemeral = node.ephemeral === true ? true : void 0;
2181
+ const closeOnError = node.closeOnError === true ? true : void 0;
2038
2182
  const env = normalizeEnv(node.env);
2039
2183
  const options = collectOptions(node, new Set([
2040
2184
  "name",
@@ -2042,6 +2186,8 @@ const parseTerminalPane = (node) => {
2042
2186
  "cwd",
2043
2187
  "env",
2044
2188
  "focus",
2189
+ "ephemeral",
2190
+ "closeOnError",
2045
2191
  "options",
2046
2192
  "title",
2047
2193
  "delay"
@@ -2053,6 +2199,8 @@ const parseTerminalPane = (node) => {
2053
2199
  cwd,
2054
2200
  env,
2055
2201
  focus,
2202
+ ephemeral,
2203
+ closeOnError,
2056
2204
  options
2057
2205
  };
2058
2206
  };
@@ -2173,7 +2321,9 @@ const createTerminalNode = ({ id, terminal, focusOverride }) => {
2173
2321
  cwd: terminal.cwd,
2174
2322
  env: terminal.env,
2175
2323
  options: terminal.options,
2176
- focus: focusOverride === true ? true : terminal.focus === true
2324
+ focus: focusOverride === true ? true : terminal.focus === true,
2325
+ ephemeral: terminal.ephemeral,
2326
+ closeOnError: terminal.closeOnError
2177
2327
  };
2178
2328
  };
2179
2329
  const ensureFocus = (node, focusPaneId) => {
@@ -2277,7 +2427,9 @@ const collectTerminals = (node) => {
2277
2427
  cwd: node.cwd,
2278
2428
  env: node.env,
2279
2429
  focus: node.focus,
2280
- name: node.name
2430
+ name: node.name,
2431
+ ephemeral: node.ephemeral,
2432
+ closeOnError: node.closeOnError
2281
2433
  }];
2282
2434
  return node.panes.flatMap((pane) => collectTerminals(pane));
2283
2435
  };