vde-layout 0.0.4 → 0.0.6

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/dist/index.js CHANGED
@@ -8,6 +8,8 @@ import fs from "fs-extra";
8
8
  import path from "node:path";
9
9
  import os from "node:os";
10
10
  import { z } from "zod";
11
+ import { createInterface } from "node:readline/promises";
12
+ import { stdin, stdout } from "node:process";
11
13
  import { execa } from "execa";
12
14
  import { createHash } from "node:crypto";
13
15
 
@@ -26,7 +28,12 @@ const ErrorCodes = {
26
28
  NOT_IN_TMUX: "NOT_IN_TMUX",
27
29
  TMUX_NOT_FOUND: "TMUX_NOT_FOUND",
28
30
  TMUX_NOT_INSTALLED: "TMUX_NOT_INSTALLED",
29
- UNSUPPORTED_TMUX_VERSION: "UNSUPPORTED_TMUX_VERSION"
31
+ UNSUPPORTED_TMUX_VERSION: "UNSUPPORTED_TMUX_VERSION",
32
+ BACKEND_NOT_FOUND: "BACKEND_NOT_FOUND",
33
+ TERMINAL_COMMAND_FAILED: "TERMINAL_COMMAND_FAILED",
34
+ WEZTERM_NOT_FOUND: "WEZTERM_NOT_FOUND",
35
+ UNSUPPORTED_WEZTERM_VERSION: "UNSUPPORTED_WEZTERM_VERSION",
36
+ USER_CANCELLED: "USER_CANCELLED"
30
37
  };
31
38
  const createBaseError = (name, message, code, details = {}) => {
32
39
  const error = new Error(message);
@@ -44,6 +51,9 @@ const createValidationError = (message, code, details = {}) => {
44
51
  const createTmuxError = (message, code, details = {}) => {
45
52
  return createBaseError("TmuxError", message, code, details);
46
53
  };
54
+ const createEnvironmentError = (message, code, details = {}) => {
55
+ return createBaseError("EnvironmentError", message, code, details);
56
+ };
47
57
  const isVDELayoutError = (error) => {
48
58
  if (typeof error !== "object" || error === null) return false;
49
59
  if (!("code" in error)) return false;
@@ -73,11 +83,36 @@ const formatters = {
73
83
  const requiredVersion = error.details.requiredVersion;
74
84
  if (typeof requiredVersion !== "string") return "";
75
85
  return `\nRequired tmux version: ${requiredVersion} or higher\n`;
86
+ },
87
+ [ErrorCodes.BACKEND_NOT_FOUND]: (error) => {
88
+ const backend = typeof error.details.backend === "string" ? error.details.backend : "terminal backend";
89
+ const binary = typeof error.details.binary === "string" ? error.details.binary : backend;
90
+ const suggestion = backend === "wezterm" ? [
91
+ "",
92
+ `${backend} is required but not installed.`,
93
+ "Install wezterm using your package manager:",
94
+ " - macOS: brew install --cask wezterm",
95
+ " - Ubuntu/Debian: sudo apt-get install wezterm",
96
+ " - Fedora: sudo dnf install wezterm"
97
+ ].join("\n") : "";
98
+ return `\nMissing binary: ${binary}${suggestion}`;
99
+ },
100
+ [ErrorCodes.WEZTERM_NOT_FOUND]: () => {
101
+ return "\nwezterm command was not found.\nInstall wezterm using your package manager:\n - macOS: brew install --cask wezterm\n - Ubuntu/Debian: sudo apt-get install wezterm\n - Fedora: sudo dnf install wezterm\n";
102
+ },
103
+ [ErrorCodes.UNSUPPORTED_WEZTERM_VERSION]: (error) => {
104
+ const requiredVersion = typeof error.details.requiredVersion === "string" ? error.details.requiredVersion : "";
105
+ const detected = typeof error.details.detectedVersion === "string" ? error.details.detectedVersion : "";
106
+ const lines = ["", "Unsupported wezterm version detected."];
107
+ if (detected) lines.push(`Detected version: ${detected}`);
108
+ if (requiredVersion) lines.push(`Required version: ${requiredVersion} or higher`);
109
+ return lines.join("\n");
76
110
  }
77
111
  };
78
112
 
79
113
  //#endregion
80
114
  //#region src/models/schema.ts
115
+ const WindowModeSchema = z.enum(["new-window", "current-window"]);
81
116
  const TerminalPaneSchema = z.object({
82
117
  name: z.string().min(1),
83
118
  command: z.string().optional(),
@@ -102,9 +137,13 @@ const PresetSchema = z.object({
102
137
  name: z.string().min(1),
103
138
  description: z.string().optional(),
104
139
  layout: LayoutSchema.optional(),
105
- command: z.string().optional()
140
+ command: z.string().optional(),
141
+ windowMode: WindowModeSchema.optional()
142
+ });
143
+ const ConfigSchema = z.object({
144
+ defaults: z.object({ windowMode: WindowModeSchema.optional() }).optional(),
145
+ presets: z.record(PresetSchema)
106
146
  });
107
- const ConfigSchema = z.object({ presets: z.record(PresetSchema) });
108
147
 
109
148
  //#endregion
110
149
  //#region src/config/validator.ts
@@ -231,7 +270,7 @@ const createConfigLoader = (options = {}) => {
231
270
  const config = validateYAML(content);
232
271
  mergedConfig = mergeConfigs(mergedConfig, config);
233
272
  }
234
- return mergedConfig;
273
+ return applyDefaults(mergedConfig);
235
274
  };
236
275
  return {
237
276
  loadYAML: async () => {
@@ -290,10 +329,26 @@ const safeReadFile = async (filePath) => {
290
329
  }
291
330
  };
292
331
  const mergeConfigs = (base, override) => {
293
- return { presets: {
294
- ...base.presets,
295
- ...override.presets
296
- } };
332
+ const mergedPresets = { ...base.presets };
333
+ for (const [presetKey, overridePreset] of Object.entries(override.presets)) {
334
+ const basePreset = base.presets[presetKey];
335
+ if (basePreset !== void 0 && basePreset.windowMode !== void 0 && overridePreset.windowMode !== void 0 && basePreset.windowMode !== overridePreset.windowMode) console.warn(`[vde-layout] Preset "${presetKey}" windowMode conflict: "${basePreset.windowMode}" overridden by "${overridePreset.windowMode}"`);
336
+ mergedPresets[presetKey] = overridePreset;
337
+ }
338
+ const baseDefaults = base.defaults;
339
+ const overrideDefaults = override.defaults;
340
+ if (baseDefaults?.windowMode !== void 0 && overrideDefaults?.windowMode !== void 0 && baseDefaults.windowMode !== overrideDefaults.windowMode) console.warn(`[vde-layout] defaults.windowMode conflict: "${baseDefaults.windowMode}" overridden by "${overrideDefaults.windowMode}"`);
341
+ const mergedDefaults = baseDefaults !== void 0 || overrideDefaults !== void 0 ? {
342
+ ...baseDefaults ?? {},
343
+ ...overrideDefaults ?? {}
344
+ } : void 0;
345
+ return mergedDefaults === void 0 ? { presets: mergedPresets } : {
346
+ defaults: mergedDefaults,
347
+ presets: mergedPresets
348
+ };
349
+ };
350
+ const applyDefaults = (config) => {
351
+ return config;
297
352
  };
298
353
 
299
354
  //#endregion
@@ -333,12 +388,16 @@ const createState = (options = {}) => {
333
388
  if (typeof firstKey !== "string" || firstKey.length === 0) throw createConfigError("No presets defined", ErrorCodes.PRESET_NOT_FOUND);
334
389
  return config.presets[firstKey];
335
390
  };
391
+ const getDefaults = () => {
392
+ return ensureConfig().defaults;
393
+ };
336
394
  return {
337
395
  setConfigPath,
338
396
  loadConfig,
339
397
  getPreset,
340
398
  listPresets,
341
- getDefaultPreset
399
+ getDefaultPreset,
400
+ getDefaults
342
401
  };
343
402
  };
344
403
  const createPresetManager = (options = {}) => {
@@ -348,7 +407,57 @@ const createPresetManager = (options = {}) => {
348
407
  loadConfig: state.loadConfig,
349
408
  getPreset: state.getPreset,
350
409
  listPresets: state.listPresets,
351
- getDefaultPreset: state.getDefaultPreset
410
+ getDefaultPreset: state.getDefaultPreset,
411
+ getDefaults: state.getDefaults
412
+ };
413
+ };
414
+
415
+ //#endregion
416
+ //#region src/cli/window-mode.ts
417
+ const resolveWindowMode = ({ cli, preset, defaults }) => {
418
+ if (cli !== void 0) return {
419
+ mode: cli,
420
+ source: "cli"
421
+ };
422
+ if (preset !== void 0) return {
423
+ mode: preset,
424
+ source: "preset"
425
+ };
426
+ if (defaults !== void 0) return {
427
+ mode: defaults,
428
+ source: "defaults"
429
+ };
430
+ return {
431
+ mode: "new-window",
432
+ source: "fallback"
433
+ };
434
+ };
435
+
436
+ //#endregion
437
+ //#region src/cli/user-prompt.ts
438
+ const createPaneKillPrompter = (logger) => {
439
+ return async ({ panesToClose, dryRun }) => {
440
+ if (panesToClose.length === 0) return true;
441
+ const paneList = panesToClose.join(", ");
442
+ if (dryRun) {
443
+ logger.warn(`[DRY RUN] Would close panes: ${paneList}`);
444
+ return true;
445
+ }
446
+ logger.warn(`This operation will close the following panes: ${paneList}`);
447
+ if (stdin.isTTY !== true || stdout.isTTY !== true) {
448
+ logger.error("Cannot prompt for confirmation because the terminal is not interactive");
449
+ return false;
450
+ }
451
+ const rl = createInterface({
452
+ input: stdin,
453
+ output: stdout
454
+ });
455
+ try {
456
+ const normalized = (await rl.question("Continue? [y/N]: ")).trim().toLowerCase();
457
+ return normalized === "y" || normalized === "yes";
458
+ } finally {
459
+ await rl.close();
460
+ }
352
461
  };
353
462
  };
354
463
 
@@ -406,10 +515,10 @@ const createLogger = (options = {}) => {
406
515
 
407
516
  //#endregion
408
517
  //#region src/executor/real-executor.ts
409
- const parseCommand$1 = (commandOrArgs) => {
518
+ const parseCommand$2 = (commandOrArgs) => {
410
519
  return typeof commandOrArgs === "string" ? commandOrArgs.split(" ").filter((segment) => segment.length > 0).slice(1) : commandOrArgs;
411
520
  };
412
- const toCommandString$1 = (args) => {
521
+ const toCommandString$2 = (args) => {
413
522
  return ["tmux", ...args].join(" ");
414
523
  };
415
524
  const createRealExecutor = (options = {}) => {
@@ -419,8 +528,8 @@ const createRealExecutor = (options = {}) => {
419
528
  prefix: "[tmux]"
420
529
  });
421
530
  const execute = async (commandOrArgs) => {
422
- const args = parseCommand$1(commandOrArgs);
423
- const commandString = toCommandString$1(args);
531
+ const args = parseCommand$2(commandOrArgs);
532
+ const commandString = toCommandString$2(args);
424
533
  logger.info(`Executing: ${commandString}`);
425
534
  try {
426
535
  return (await execa("tmux", args)).stdout;
@@ -449,10 +558,10 @@ const createRealExecutor = (options = {}) => {
449
558
 
450
559
  //#endregion
451
560
  //#region src/executor/dry-run-executor.ts
452
- const parseCommand = (commandOrArgs) => {
561
+ const parseCommand$1 = (commandOrArgs) => {
453
562
  return typeof commandOrArgs === "string" ? commandOrArgs.split(" ").filter((segment) => segment.length > 0).slice(1) : commandOrArgs;
454
563
  };
455
- const toCommandString = (args) => {
564
+ const toCommandString$1 = (args) => {
456
565
  return ["tmux", ...args].join(" ");
457
566
  };
458
567
  const createDryRunExecutor = (options = {}) => {
@@ -462,8 +571,8 @@ const createDryRunExecutor = (options = {}) => {
462
571
  prefix: "[tmux] [DRY RUN]"
463
572
  });
464
573
  const execute = async (commandOrArgs) => {
465
- const args = parseCommand(commandOrArgs);
466
- const commandString = toCommandString(args);
574
+ const args = parseCommand$1(commandOrArgs);
575
+ const commandString = toCommandString$1(args);
467
576
  logger.info(`Would execute: ${commandString}`);
468
577
  return "";
469
578
  };
@@ -481,6 +590,130 @@ const createDryRunExecutor = (options = {}) => {
481
590
  };
482
591
  };
483
592
 
593
+ //#endregion
594
+ //#region src/executor/mock-executor.ts
595
+ const parseCommand = (commandOrArgs) => {
596
+ return typeof commandOrArgs === "string" ? commandOrArgs.split(" ").filter((segment) => segment.length > 0).slice(1) : commandOrArgs;
597
+ };
598
+ const isInTmuxSession = () => {
599
+ return Boolean(process.env.TMUX);
600
+ };
601
+ const toCommandString = (args) => {
602
+ return ["tmux", ...args].join(" ");
603
+ };
604
+ const createMockExecutor = () => {
605
+ let mockPaneCounter = 0;
606
+ let mockPaneIds = ["%0"];
607
+ let executedCommands = [];
608
+ const execute = async (commandOrArgs) => {
609
+ const args = parseCommand(commandOrArgs);
610
+ executedCommands.push(args);
611
+ if (args[0] === "new-window") {
612
+ mockPaneCounter = 0;
613
+ mockPaneIds = ["%0"];
614
+ return "%0";
615
+ }
616
+ if (args.includes("display-message") && args.includes("#{pane_id}")) return mockPaneIds[0] ?? "%0";
617
+ if (args.includes("list-panes") && args.includes("#{pane_id}")) return mockPaneIds.join("\n");
618
+ if (args[0] === "kill-pane" && args.includes("-a")) {
619
+ const targetIndex = args.indexOf("-t");
620
+ const targetPane = (targetIndex >= 0 && targetIndex + 1 < args.length ? args[targetIndex + 1] : mockPaneIds[0]) ?? "%0";
621
+ mockPaneIds = [targetPane];
622
+ const parsedCounter = Number(targetPane.replace("%", ""));
623
+ if (!Number.isNaN(parsedCounter)) mockPaneCounter = parsedCounter;
624
+ return "";
625
+ }
626
+ if (args.includes("split-window")) {
627
+ mockPaneCounter += 1;
628
+ const newPaneId = `%${mockPaneCounter}`;
629
+ mockPaneIds = [...mockPaneIds, newPaneId];
630
+ }
631
+ return "";
632
+ };
633
+ return {
634
+ execute,
635
+ async executeMany(commandsList) {
636
+ for (const args of commandsList) await execute(args);
637
+ },
638
+ isDryRun() {
639
+ return true;
640
+ },
641
+ logCommand() {},
642
+ getExecutedCommands() {
643
+ return executedCommands;
644
+ },
645
+ clearExecutedCommands() {
646
+ executedCommands = [];
647
+ },
648
+ setMockPaneIds(paneIds) {
649
+ mockPaneIds = [...paneIds];
650
+ },
651
+ getPaneIds() {
652
+ return mockPaneIds;
653
+ },
654
+ isInTmuxSession,
655
+ async verifyTmuxEnvironment() {
656
+ if (!isInTmuxSession()) throw createEnvironmentError("Must be run inside a tmux session", ErrorCodes.NOT_IN_TMUX, { hint: "Please start a tmux session and try again" });
657
+ },
658
+ getCommandString: toCommandString,
659
+ async getCurrentSessionName() {
660
+ return "mock-session";
661
+ }
662
+ };
663
+ };
664
+
665
+ //#endregion
666
+ //#region src/tmux/executor.ts
667
+ const createTmuxExecutor = (options = {}) => {
668
+ const executor = resolveExecutor(options);
669
+ const isInTmuxSession$1 = () => {
670
+ return Boolean(process.env.TMUX);
671
+ };
672
+ const verifyTmuxEnvironment = async () => {
673
+ if (!isInTmuxSession$1()) throw createEnvironmentError("Must be run inside a tmux session", ErrorCodes.NOT_IN_TMUX, { hint: "Please start a tmux session and try again" });
674
+ if (executor.isDryRun()) return;
675
+ try {
676
+ await execa("tmux", ["-V"]);
677
+ } catch (_error) {
678
+ throw createEnvironmentError("tmux is not installed", ErrorCodes.TMUX_NOT_FOUND, { hint: "Please install tmux" });
679
+ }
680
+ };
681
+ const execute = async (commandOrArgs) => {
682
+ return executor.execute(commandOrArgs);
683
+ };
684
+ const executeMany = async (commandsList) => {
685
+ for (const command of commandsList) await execute(command);
686
+ };
687
+ const getCommandString = (args) => {
688
+ return ["tmux", ...args].join(" ");
689
+ };
690
+ const getCurrentSessionName = async () => {
691
+ return execute([
692
+ "display-message",
693
+ "-p",
694
+ "#{session_name}"
695
+ ]);
696
+ };
697
+ return {
698
+ verifyTmuxEnvironment,
699
+ execute,
700
+ executeMany,
701
+ isInTmuxSession: isInTmuxSession$1,
702
+ getCurrentSessionName,
703
+ getCommandString,
704
+ getExecutor: () => executor
705
+ };
706
+ };
707
+ const resolveExecutor = (options) => {
708
+ if (options.executor) return options.executor;
709
+ if (options.dryRun === true) return createDryRunExecutor({ verbose: options.verbose });
710
+ if (isTestEnvironment()) return createMockExecutor();
711
+ return createRealExecutor({ verbose: options.verbose });
712
+ };
713
+ const isTestEnvironment = () => {
714
+ return process.env.VDE_TEST_MODE === "true" || process.env.NODE_ENV === "test" || process.env.VITEST === "true";
715
+ };
716
+
484
717
  //#endregion
485
718
  //#region src/core/errors.ts
486
719
  const createFunctionalError = (kind, error) => ({
@@ -501,25 +734,66 @@ const isFunctionalCoreError = (value) => {
501
734
  //#region src/executor/plan-runner.ts
502
735
  const DOUBLE_QUOTE = "\"";
503
736
  const ESCAPED_DOUBLE_QUOTE = "\\\"";
504
- const executePlan = async ({ emission, executor, windowName }) => {
737
+ const executePlan = async ({ emission, executor, windowName, windowMode, onConfirmKill }) => {
505
738
  const initialVirtualPaneId = emission.summary.initialPaneId;
506
739
  if (typeof initialVirtualPaneId !== "string" || initialVirtualPaneId.length === 0) raiseExecutionError("INVALID_PLAN", {
507
740
  message: "Plan emission is missing initial pane metadata",
508
741
  path: "plan.initialPaneId"
509
742
  });
510
743
  const paneMap = /* @__PURE__ */ new Map();
511
- const newWindowCommand = [
512
- "new-window",
513
- "-P",
514
- "-F",
515
- "#{pane_id}"
516
- ];
517
- if (typeof windowName === "string" && windowName.trim().length > 0) newWindowCommand.push("-n", windowName.trim());
518
- const initialPaneId = normalizePaneId(await executeCommand(executor, newWindowCommand, {
519
- code: ErrorCodes.TMUX_COMMAND_FAILED,
520
- message: "Failed to create tmux window",
521
- path: initialVirtualPaneId
522
- }));
744
+ const isDryRun = executor.isDryRun();
745
+ let initialPaneId;
746
+ if (windowMode === "current-window") {
747
+ const currentPaneId = await resolveCurrentPaneId({
748
+ executor,
749
+ contextPath: initialVirtualPaneId,
750
+ isDryRun
751
+ });
752
+ const panesToClose = (await listWindowPaneIds(executor, initialVirtualPaneId)).filter((paneId) => paneId !== currentPaneId);
753
+ if (panesToClose.length > 0) {
754
+ let confirmed = true;
755
+ if (onConfirmKill !== void 0) confirmed = await onConfirmKill({
756
+ panesToClose,
757
+ dryRun: isDryRun
758
+ });
759
+ if (confirmed !== true) throw createFunctionalError("execution", {
760
+ code: ErrorCodes.USER_CANCELLED,
761
+ message: "Aborted layout application for current window",
762
+ path: initialVirtualPaneId,
763
+ details: { panes: panesToClose }
764
+ });
765
+ await executeCommand(executor, [
766
+ "kill-pane",
767
+ "-a",
768
+ "-t",
769
+ currentPaneId
770
+ ], {
771
+ code: ErrorCodes.TMUX_COMMAND_FAILED,
772
+ message: "Failed to close existing panes",
773
+ path: initialVirtualPaneId,
774
+ details: { command: [
775
+ "kill-pane",
776
+ "-a",
777
+ "-t",
778
+ currentPaneId
779
+ ] }
780
+ });
781
+ }
782
+ initialPaneId = normalizePaneId(currentPaneId);
783
+ } else {
784
+ const newWindowCommand = [
785
+ "new-window",
786
+ "-P",
787
+ "-F",
788
+ "#{pane_id}"
789
+ ];
790
+ if (typeof windowName === "string" && windowName.trim().length > 0) newWindowCommand.push("-n", windowName.trim());
791
+ initialPaneId = normalizePaneId(await executeCommand(executor, newWindowCommand, {
792
+ code: ErrorCodes.TMUX_COMMAND_FAILED,
793
+ message: "Failed to create tmux window",
794
+ path: initialVirtualPaneId
795
+ }));
796
+ }
523
797
  registerPane(paneMap, initialVirtualPaneId, initialPaneId);
524
798
  let executedSteps = 0;
525
799
  for (const step of emission.steps) {
@@ -570,7 +844,7 @@ const executeSplitStep = async ({ step, executor, paneMap }) => {
570
844
  details: { command: splitCommand }
571
845
  });
572
846
  const panesAfter = await listPaneIds(executor, step);
573
- const newPaneId = ensureNonEmpty(findNewPaneId(panesBefore, panesAfter), () => raiseExecutionError("UNKNOWN_PANE", {
847
+ const newPaneId = ensureNonEmpty(findNewPaneId$1(panesBefore, panesAfter), () => raiseExecutionError("UNKNOWN_PANE", {
574
848
  message: "Unable to determine newly created pane",
575
849
  path: step.id
576
850
  }));
@@ -664,7 +938,27 @@ const executeCommand = async (executor, command, context) => {
664
938
  });
665
939
  }
666
940
  };
667
- const listPaneIds = async (executor, step) => {
941
+ const resolveCurrentPaneId = async ({ executor, contextPath, isDryRun }) => {
942
+ const envPaneId = process.env.TMUX_PANE;
943
+ if (typeof envPaneId === "string" && envPaneId.trim().length > 0) return normalizePaneId(envPaneId);
944
+ if (isDryRun) return "%0";
945
+ const paneId = (await executeCommand(executor, [
946
+ "display-message",
947
+ "-p",
948
+ "#{pane_id}"
949
+ ], {
950
+ code: ErrorCodes.TMUX_COMMAND_FAILED,
951
+ message: "Failed to resolve current tmux pane",
952
+ path: contextPath
953
+ })).trim();
954
+ if (paneId.length === 0) throw createFunctionalError("execution", {
955
+ code: ErrorCodes.NOT_IN_TMUX_SESSION,
956
+ message: "Unable to determine current tmux pane",
957
+ path: contextPath
958
+ });
959
+ return normalizePaneId(paneId);
960
+ };
961
+ const listWindowPaneIds = async (executor, contextPath) => {
668
962
  return (await executeCommand(executor, [
669
963
  "list-panes",
670
964
  "-F",
@@ -672,10 +966,13 @@ const listPaneIds = async (executor, step) => {
672
966
  ], {
673
967
  code: ErrorCodes.TMUX_COMMAND_FAILED,
674
968
  message: "Failed to list tmux panes",
675
- path: step.id
969
+ path: contextPath
676
970
  })).split("\n").map((pane) => pane.trim()).filter((pane) => pane.length > 0);
677
971
  };
678
- const findNewPaneId = (before, after) => {
972
+ const listPaneIds = async (executor, step) => {
973
+ return listWindowPaneIds(executor, step.id);
974
+ };
975
+ const findNewPaneId$1 = (before, after) => {
679
976
  const beforeSet = new Set(before);
680
977
  return after.find((id) => !beforeSet.has(id));
681
978
  };
@@ -728,6 +1025,749 @@ const raiseExecutionError = (code, error) => {
728
1025
  });
729
1026
  };
730
1027
 
1028
+ //#endregion
1029
+ //#region src/executor/backends/tmux-backend.ts
1030
+ const createTmuxBackend = (context) => {
1031
+ const tmuxExecutor = createTmuxExecutor({
1032
+ executor: context.executor,
1033
+ verbose: context.verbose,
1034
+ dryRun: context.dryRun
1035
+ });
1036
+ const buildDryRunSteps$1 = (emission) => {
1037
+ return emission.steps.map((step) => ({
1038
+ backend: "tmux",
1039
+ summary: step.summary,
1040
+ command: tmuxExecutor.getCommandString([...step.command])
1041
+ }));
1042
+ };
1043
+ const verifyEnvironment = async () => {
1044
+ if (context.dryRun) return;
1045
+ await tmuxExecutor.verifyTmuxEnvironment();
1046
+ };
1047
+ const applyPlan = async ({ emission, windowMode, windowName }) => {
1048
+ return {
1049
+ executedSteps: (await executePlan({
1050
+ emission,
1051
+ executor: tmuxExecutor.getExecutor(),
1052
+ windowMode,
1053
+ windowName,
1054
+ onConfirmKill: context.prompt
1055
+ })).executedSteps,
1056
+ focusPaneId: emission.summary.focusPaneId
1057
+ };
1058
+ };
1059
+ return {
1060
+ verifyEnvironment,
1061
+ applyPlan,
1062
+ getDryRunSteps: buildDryRunSteps$1
1063
+ };
1064
+ };
1065
+
1066
+ //#endregion
1067
+ //#region src/wezterm/cli.ts
1068
+ const WEZTERM_BINARY = "wezterm";
1069
+ const MINIMUM_VERSION = "20220624-141144-bd1b7c5d";
1070
+ const VERSION_REGEX = /(\d{8})-(\d{6})-([0-9a-fA-F]+)/i;
1071
+ const verifyWeztermAvailability = async () => {
1072
+ let stdout$1;
1073
+ try {
1074
+ stdout$1 = (await execa(WEZTERM_BINARY, ["--version"])).stdout;
1075
+ } catch (error) {
1076
+ const execaError = error;
1077
+ if (execaError.code === "ENOENT") throw createEnvironmentError("wezterm is not installed", ErrorCodes.BACKEND_NOT_FOUND, {
1078
+ backend: "wezterm",
1079
+ binary: WEZTERM_BINARY
1080
+ });
1081
+ throw createEnvironmentError("Failed to execute wezterm --version", ErrorCodes.WEZTERM_NOT_FOUND, {
1082
+ backend: "wezterm",
1083
+ binary: WEZTERM_BINARY,
1084
+ stderr: execaError.stderr
1085
+ });
1086
+ }
1087
+ const detectedVersion = extractVersion(stdout$1);
1088
+ if (detectedVersion === void 0) throw createEnvironmentError("Unable to determine wezterm version", ErrorCodes.UNSUPPORTED_WEZTERM_VERSION, {
1089
+ requiredVersion: MINIMUM_VERSION,
1090
+ detectedVersion: stdout$1.trim()
1091
+ });
1092
+ if (!isVersionSupported(detectedVersion, MINIMUM_VERSION)) throw createEnvironmentError("Unsupported wezterm version", ErrorCodes.UNSUPPORTED_WEZTERM_VERSION, {
1093
+ requiredVersion: MINIMUM_VERSION,
1094
+ detectedVersion
1095
+ });
1096
+ return { version: detectedVersion };
1097
+ };
1098
+ const runWeztermCli = async (args, errorContext) => {
1099
+ try {
1100
+ return (await execa(WEZTERM_BINARY, ["cli", ...args])).stdout;
1101
+ } catch (error) {
1102
+ const execaError = error;
1103
+ throw createFunctionalError("execution", {
1104
+ code: ErrorCodes.TERMINAL_COMMAND_FAILED,
1105
+ message: errorContext.message,
1106
+ path: errorContext.path,
1107
+ details: {
1108
+ command: [
1109
+ WEZTERM_BINARY,
1110
+ "cli",
1111
+ ...args
1112
+ ],
1113
+ stderr: execaError.stderr,
1114
+ exitCode: execaError.exitCode,
1115
+ backend: "wezterm",
1116
+ ...errorContext.details ?? {}
1117
+ }
1118
+ });
1119
+ }
1120
+ };
1121
+ const listWeztermWindows = async () => {
1122
+ const stdout$1 = await runWeztermCli([
1123
+ "list",
1124
+ "--format",
1125
+ "json"
1126
+ ], { message: "Failed to list wezterm panes" });
1127
+ const result = parseListResult(stdout$1);
1128
+ if (result === void 0) throw createFunctionalError("execution", {
1129
+ code: ErrorCodes.TERMINAL_COMMAND_FAILED,
1130
+ message: "Invalid wezterm list output",
1131
+ details: { stdout: stdout$1 }
1132
+ });
1133
+ return result;
1134
+ };
1135
+ const killWeztermPane = async (paneId) => {
1136
+ await runWeztermCli([
1137
+ "kill-pane",
1138
+ "--pane-id",
1139
+ paneId
1140
+ ], {
1141
+ message: `Failed to kill wezterm pane ${paneId}`,
1142
+ path: paneId
1143
+ });
1144
+ };
1145
+ const extractVersion = (raw) => {
1146
+ const match = raw.match(VERSION_REGEX);
1147
+ if (!match) return;
1148
+ const date = match[1];
1149
+ const time = match[2];
1150
+ const commit = match[3];
1151
+ if (date === void 0 || time === void 0 || commit === void 0) return;
1152
+ return `${date}-${time}-${commit.toLowerCase()}`;
1153
+ };
1154
+ const isVersionSupported = (detected, minimum) => {
1155
+ const parse$1 = (version) => {
1156
+ const match = version.match(VERSION_REGEX);
1157
+ if (!match) return;
1158
+ const date = match[1];
1159
+ const time = match[2];
1160
+ const commit = match[3];
1161
+ if (date === void 0 || time === void 0 || commit === void 0) return;
1162
+ const build = Number(`${date}${time}`);
1163
+ if (Number.isNaN(build)) return;
1164
+ return {
1165
+ build,
1166
+ commit: commit.toLowerCase()
1167
+ };
1168
+ };
1169
+ const detectedInfo = parse$1(detected);
1170
+ const minimumInfo = parse$1(minimum);
1171
+ if (detectedInfo === void 0 || minimumInfo === void 0) return false;
1172
+ if (detectedInfo.build > minimumInfo.build) return true;
1173
+ if (detectedInfo.build < minimumInfo.build) return false;
1174
+ return detectedInfo.commit >= minimumInfo.commit;
1175
+ };
1176
+ const toIdString = (value) => {
1177
+ if (typeof value === "string") return value;
1178
+ if (typeof value === "number") return value.toString();
1179
+ };
1180
+ const isNonEmptyString = (value) => {
1181
+ return typeof value === "string" && value.length > 0;
1182
+ };
1183
+ const parseListResult = (stdout$1) => {
1184
+ try {
1185
+ const parsed = JSON.parse(stdout$1);
1186
+ if (Array.isArray(parsed)) {
1187
+ const windowMap = /* @__PURE__ */ new Map();
1188
+ for (const entry of parsed) {
1189
+ if (typeof entry !== "object" || entry === null) continue;
1190
+ const listEntry = entry;
1191
+ const windowIdRaw = toIdString(listEntry.window_id);
1192
+ const paneIdRaw = toIdString(listEntry.pane_id);
1193
+ const tabIdRaw = toIdString(listEntry.tab_id) ?? windowIdRaw;
1194
+ if (!isNonEmptyString(windowIdRaw) || !isNonEmptyString(tabIdRaw) || !isNonEmptyString(paneIdRaw)) continue;
1195
+ const windowId = windowIdRaw;
1196
+ const tabId = tabIdRaw;
1197
+ const paneId = paneIdRaw;
1198
+ let windowRecord = windowMap.get(windowId);
1199
+ if (!windowRecord) {
1200
+ windowRecord = {
1201
+ windowId,
1202
+ isActive: false,
1203
+ tabs: /* @__PURE__ */ new Map()
1204
+ };
1205
+ windowMap.set(windowId, windowRecord);
1206
+ }
1207
+ let tabRecord = windowRecord.tabs.get(tabId);
1208
+ if (!tabRecord) {
1209
+ tabRecord = {
1210
+ tabId,
1211
+ isActive: false,
1212
+ panes: []
1213
+ };
1214
+ windowRecord.tabs.set(tabId, tabRecord);
1215
+ }
1216
+ const pane = {
1217
+ paneId,
1218
+ isActive: listEntry.is_active === true
1219
+ };
1220
+ windowRecord.isActive ||= listEntry.is_active === true;
1221
+ tabRecord.isActive ||= listEntry.is_active === true;
1222
+ tabRecord.panes.push(pane);
1223
+ }
1224
+ return { windows: Array.from(windowMap.values()).map((windowRecord) => ({
1225
+ windowId: windowRecord.windowId,
1226
+ isActive: windowRecord.isActive,
1227
+ tabs: Array.from(windowRecord.tabs.values()).map((tabRecord) => ({
1228
+ tabId: tabRecord.tabId,
1229
+ isActive: tabRecord.isActive,
1230
+ panes: tabRecord.panes.map((pane) => ({
1231
+ paneId: pane.paneId,
1232
+ isActive: pane.isActive
1233
+ }))
1234
+ }))
1235
+ })) };
1236
+ }
1237
+ if (typeof parsed === "object" && parsed !== null) {
1238
+ const candidate = parsed;
1239
+ const windows = Array.isArray(candidate.windows) ? candidate.windows : [];
1240
+ const mappedWindows = [];
1241
+ for (const window of windows) {
1242
+ if (typeof window !== "object" || window === null) continue;
1243
+ const rawWindow = window;
1244
+ const windowIdRaw = toIdString(rawWindow.window_id);
1245
+ if (!isNonEmptyString(windowIdRaw)) continue;
1246
+ const windowId = windowIdRaw;
1247
+ const mappedTabs = [];
1248
+ const tabs = Array.isArray(rawWindow.tabs) ? rawWindow.tabs : [];
1249
+ for (const tab of tabs) {
1250
+ if (typeof tab !== "object" || tab === null) continue;
1251
+ const rawTab = tab;
1252
+ const tabIdRaw = toIdString(rawTab.tab_id);
1253
+ if (!isNonEmptyString(tabIdRaw)) continue;
1254
+ const tabId = tabIdRaw;
1255
+ const paneRecords = Array.isArray(rawTab.panes) ? rawTab.panes : [];
1256
+ const mappedPanes = [];
1257
+ for (const pane of paneRecords) {
1258
+ if (typeof pane !== "object" || pane === null) continue;
1259
+ const rawPane = pane;
1260
+ const paneIdRaw = toIdString(rawPane.pane_id);
1261
+ if (!isNonEmptyString(paneIdRaw)) continue;
1262
+ const paneId = paneIdRaw;
1263
+ mappedPanes.push({
1264
+ paneId,
1265
+ isActive: rawPane.is_active === true
1266
+ });
1267
+ }
1268
+ mappedTabs.push({
1269
+ tabId,
1270
+ isActive: rawTab.is_active === true,
1271
+ panes: mappedPanes
1272
+ });
1273
+ }
1274
+ mappedWindows.push({
1275
+ windowId,
1276
+ isActive: rawWindow.is_active === true,
1277
+ tabs: mappedTabs
1278
+ });
1279
+ }
1280
+ return { windows: mappedWindows };
1281
+ }
1282
+ return;
1283
+ } catch {
1284
+ return;
1285
+ }
1286
+ };
1287
+
1288
+ //#endregion
1289
+ //#region src/executor/backends/wezterm-backend.ts
1290
+ const PANE_REGISTRATION_RETRIES = 5;
1291
+ const PANE_REGISTRATION_DELAY_MS = 100;
1292
+ const ensureVirtualPaneId = (emission) => {
1293
+ const { initialPaneId } = emission.summary;
1294
+ if (typeof initialPaneId !== "string" || initialPaneId.length === 0) throw createFunctionalError("execution", {
1295
+ code: ErrorCodes.INVALID_PANE,
1296
+ message: "Plan emission is missing initial pane metadata",
1297
+ path: "plan.initialPaneId"
1298
+ });
1299
+ return initialPaneId;
1300
+ };
1301
+ const registerPaneWithAncestors = (map, virtualId, realId) => {
1302
+ map.set(virtualId, realId);
1303
+ let ancestor = virtualId;
1304
+ while (ancestor.includes(".")) {
1305
+ ancestor = ancestor.slice(0, ancestor.lastIndexOf("."));
1306
+ if (!map.has(ancestor)) map.set(ancestor, realId);
1307
+ else break;
1308
+ }
1309
+ };
1310
+ const resolveRealPaneId = (paneMap, virtualId, context) => {
1311
+ const direct = paneMap.get(virtualId);
1312
+ if (typeof direct === "string" && direct.length > 0) return direct;
1313
+ let ancestor = virtualId;
1314
+ while (ancestor.includes(".")) {
1315
+ ancestor = ancestor.slice(0, ancestor.lastIndexOf("."));
1316
+ const candidate = paneMap.get(ancestor);
1317
+ if (typeof candidate === "string" && candidate.length > 0) {
1318
+ paneMap.set(virtualId, candidate);
1319
+ return candidate;
1320
+ }
1321
+ }
1322
+ for (const [key, value] of paneMap.entries()) if (key.startsWith(`${virtualId}.`)) {
1323
+ if (typeof value === "string" && value.length > 0) {
1324
+ paneMap.set(virtualId, value);
1325
+ return value;
1326
+ }
1327
+ }
1328
+ throw createFunctionalError("execution", {
1329
+ code: ErrorCodes.INVALID_PANE,
1330
+ message: `Unknown wezterm pane mapping for ${virtualId}`,
1331
+ path: context.stepId
1332
+ });
1333
+ };
1334
+ const buildDryRunSteps = (emission) => {
1335
+ const steps = [];
1336
+ for (const step of emission.steps) {
1337
+ if (step.kind === "split") {
1338
+ const target = step.targetPaneId ?? "<unknown>";
1339
+ const args = buildSplitArguments({
1340
+ targetPaneId: target,
1341
+ percent: extractPercent(step.command),
1342
+ horizontal: isHorizontalSplit(step.command)
1343
+ });
1344
+ steps.push({
1345
+ backend: "wezterm",
1346
+ summary: step.summary,
1347
+ command: `wezterm cli ${args.join(" ")}`
1348
+ });
1349
+ continue;
1350
+ }
1351
+ if (step.kind === "focus") {
1352
+ const target = step.targetPaneId ?? "<unknown>";
1353
+ steps.push({
1354
+ backend: "wezterm",
1355
+ summary: step.summary,
1356
+ command: `wezterm cli activate-pane --pane-id ${target}`
1357
+ });
1358
+ continue;
1359
+ }
1360
+ steps.push({
1361
+ backend: "wezterm",
1362
+ summary: step.summary,
1363
+ command: `wezterm cli # ${step.command.join(" ")}`
1364
+ });
1365
+ }
1366
+ for (const terminal of emission.terminals) {
1367
+ const paneId = terminal.virtualPaneId;
1368
+ if (typeof terminal.cwd === "string" && terminal.cwd.length > 0) {
1369
+ const cwdCommand = `cd "${terminal.cwd.split("\"").join("\\\"")}"`;
1370
+ steps.push({
1371
+ backend: "wezterm",
1372
+ summary: `set cwd for ${paneId}`,
1373
+ command: `wezterm cli send-text --pane-id ${paneId} --no-paste -- '${cwdCommand.replace(/'/g, "\\'")}'`
1374
+ });
1375
+ }
1376
+ if (terminal.env !== void 0) for (const [key, value] of Object.entries(terminal.env)) {
1377
+ const envCommand = `export ${key}="${String(value).split("\"").join("\\\"")}"`;
1378
+ steps.push({
1379
+ backend: "wezterm",
1380
+ summary: `set env ${key} for ${paneId}`,
1381
+ command: `wezterm cli send-text --pane-id ${paneId} --no-paste -- '${envCommand.replace(/'/g, "\\'")}'`
1382
+ });
1383
+ }
1384
+ if (typeof terminal.command === "string" && terminal.command.length > 0) steps.push({
1385
+ backend: "wezterm",
1386
+ summary: `run command for ${paneId}`,
1387
+ command: `wezterm cli send-text --pane-id ${paneId} --no-paste -- '${terminal.command.replace(/'/g, "\\'")}'`
1388
+ });
1389
+ }
1390
+ return steps;
1391
+ };
1392
+ const resolveCurrentWindow = async (context) => {
1393
+ const activeWindow = context.list.windows.find((window) => window.isActive) ?? context.list.windows[0];
1394
+ if (!activeWindow) throw createFunctionalError("execution", {
1395
+ code: ErrorCodes.TERMINAL_COMMAND_FAILED,
1396
+ message: "No active wezterm window detected",
1397
+ details: { hint: "Launch wezterm and ensure a window is focused, or run with --new-window." }
1398
+ });
1399
+ const activeTab = activeWindow.tabs.find((tab) => tab.isActive) ?? activeWindow.tabs[0];
1400
+ if (!activeTab) throw createFunctionalError("execution", {
1401
+ code: ErrorCodes.TERMINAL_COMMAND_FAILED,
1402
+ message: "No active wezterm tab detected",
1403
+ path: activeWindow.windowId,
1404
+ details: { hint: "Ensure a wezterm tab is focused before using --current-window." }
1405
+ });
1406
+ const activePane = activeTab.panes.find((pane) => pane.isActive) ?? activeTab.panes[0];
1407
+ if (!activePane) throw createFunctionalError("execution", {
1408
+ code: ErrorCodes.TERMINAL_COMMAND_FAILED,
1409
+ message: "No active wezterm pane detected",
1410
+ path: activeTab.tabId,
1411
+ details: { hint: "Ensure a wezterm pane is active before using --current-window." }
1412
+ });
1413
+ const panesToClose = activeTab.panes.filter((pane) => pane.paneId !== activePane.paneId).map((pane) => pane.paneId);
1414
+ if (panesToClose.length > 0) {
1415
+ let confirmed = true;
1416
+ if (context.prompt) confirmed = await context.prompt({
1417
+ panesToClose,
1418
+ dryRun: context.dryRun
1419
+ });
1420
+ if (confirmed !== true) throw createFunctionalError("execution", {
1421
+ code: ErrorCodes.USER_CANCELLED,
1422
+ message: "Aborted layout application for current wezterm window",
1423
+ path: activePane.paneId,
1424
+ details: { panes: panesToClose }
1425
+ });
1426
+ for (const paneId of panesToClose) {
1427
+ context.logCommand([
1428
+ "kill-pane",
1429
+ "--pane-id",
1430
+ paneId
1431
+ ]);
1432
+ await killWeztermPane(paneId);
1433
+ }
1434
+ }
1435
+ return {
1436
+ paneId: activePane.paneId,
1437
+ windowId: activeWindow.windowId,
1438
+ panesToClose
1439
+ };
1440
+ };
1441
+ const findActiveWindow = (list) => {
1442
+ return list.windows.find((window) => window.isActive) ?? list.windows[0];
1443
+ };
1444
+ const findWindowContainingPane = (list, paneId) => {
1445
+ for (const window of list.windows) for (const tab of window.tabs) for (const pane of tab.panes) if (pane.paneId === paneId) return window.windowId;
1446
+ };
1447
+ const delay = (ms) => {
1448
+ return new Promise((resolve) => {
1449
+ setTimeout(resolve, ms);
1450
+ });
1451
+ };
1452
+ const waitForPaneRegistration = async ({ paneId, listWindows, windowHint }) => {
1453
+ for (let attempt = 0; attempt < PANE_REGISTRATION_RETRIES; attempt += 1) {
1454
+ const snapshot = await listWindows();
1455
+ if (typeof windowHint === "string") try {
1456
+ if (collectPaneIdsForWindow(snapshot, windowHint).has(paneId)) return windowHint;
1457
+ } catch {}
1458
+ const located = findWindowContainingPane(snapshot, paneId);
1459
+ if (typeof located === "string" && located.length > 0) return located;
1460
+ if (attempt < PANE_REGISTRATION_RETRIES - 1) await delay(PANE_REGISTRATION_DELAY_MS);
1461
+ }
1462
+ throw createFunctionalError("execution", {
1463
+ code: ErrorCodes.TERMINAL_COMMAND_FAILED,
1464
+ message: "Unable to locate spawned wezterm window",
1465
+ details: {
1466
+ paneId,
1467
+ hint: "Verify that wezterm is running and the CLI client can connect."
1468
+ }
1469
+ });
1470
+ };
1471
+ const resolveInitialPane = async ({ windowMode, prompt, dryRun, listWindows, runCommand, logCommand }) => {
1472
+ if (windowMode === "current-window") {
1473
+ const list = await listWindows();
1474
+ return resolveCurrentWindow({
1475
+ list,
1476
+ prompt,
1477
+ dryRun,
1478
+ logCommand
1479
+ });
1480
+ }
1481
+ const existing = await listWindows();
1482
+ const activeWindow = findActiveWindow(existing);
1483
+ if (activeWindow) {
1484
+ const spawnOutput$1 = await runCommand([
1485
+ "spawn",
1486
+ "--window-id",
1487
+ activeWindow.windowId
1488
+ ], { message: "Failed to spawn wezterm tab" });
1489
+ const paneId$1 = extractSpawnPaneId(spawnOutput$1);
1490
+ if (paneId$1.length === 0) throw createFunctionalError("execution", {
1491
+ code: ErrorCodes.TERMINAL_COMMAND_FAILED,
1492
+ message: "wezterm spawn did not return a pane id",
1493
+ details: { stdout: spawnOutput$1 }
1494
+ });
1495
+ const windowId = await waitForPaneRegistration({
1496
+ paneId: paneId$1,
1497
+ listWindows,
1498
+ windowHint: activeWindow.windowId
1499
+ });
1500
+ return {
1501
+ paneId: paneId$1,
1502
+ windowId
1503
+ };
1504
+ }
1505
+ const spawnOutput = await runCommand(["spawn", "--new-window"], { message: "Failed to spawn wezterm window" });
1506
+ const paneId = extractSpawnPaneId(spawnOutput);
1507
+ if (paneId.length === 0) throw createFunctionalError("execution", {
1508
+ code: ErrorCodes.TERMINAL_COMMAND_FAILED,
1509
+ message: "wezterm spawn did not return a pane id",
1510
+ details: { stdout: spawnOutput }
1511
+ });
1512
+ const newWindowId = await waitForPaneRegistration({
1513
+ paneId,
1514
+ listWindows
1515
+ });
1516
+ return {
1517
+ paneId,
1518
+ windowId: newWindowId
1519
+ };
1520
+ };
1521
+ const collectPaneIdsForWindow = (list, windowId) => {
1522
+ const targetWindow = list.windows.find((window) => window.windowId === windowId);
1523
+ if (!targetWindow) throw createFunctionalError("execution", {
1524
+ code: ErrorCodes.TERMINAL_COMMAND_FAILED,
1525
+ message: `Wezterm window ${windowId} not found`,
1526
+ details: { windowId }
1527
+ });
1528
+ const paneIds = targetWindow.tabs.flatMap((tab) => tab.panes.map((pane) => pane.paneId));
1529
+ return new Set(paneIds);
1530
+ };
1531
+ const extractSpawnPaneId = (output) => {
1532
+ const trimmed = output.trim();
1533
+ if (trimmed.length === 0) return "";
1534
+ const tokens = (trimmed.split("\n").pop() ?? "").split(/\s+/).filter((segment) => segment.length > 0);
1535
+ if (tokens.length === 0) return "";
1536
+ const [paneId] = tokens;
1537
+ if (typeof paneId !== "string") return "";
1538
+ return paneId.trim();
1539
+ };
1540
+ const findNewPaneId = (before, after) => {
1541
+ for (const paneId of after) if (!before.has(paneId)) return paneId;
1542
+ };
1543
+ const isHorizontalSplit = (command) => {
1544
+ return command.includes("-h");
1545
+ };
1546
+ const extractPercent = (command) => {
1547
+ const index = command.findIndex((segment) => segment === "-p");
1548
+ if (index >= 0 && index + 1 < command.length) {
1549
+ const value = command[index + 1];
1550
+ if (typeof value === "string" && value.trim().length > 0) return value.trim();
1551
+ }
1552
+ return "50";
1553
+ };
1554
+ const buildSplitArguments = (params) => {
1555
+ return [
1556
+ "split-pane",
1557
+ params.horizontal ? "--right" : "--bottom",
1558
+ "--percent",
1559
+ params.percent,
1560
+ "--pane-id",
1561
+ params.targetPaneId
1562
+ ];
1563
+ };
1564
+ const applyFocusStep = async ({ step, paneMap, runCommand }) => {
1565
+ const targetVirtualId = step.targetPaneId;
1566
+ if (typeof targetVirtualId !== "string" || targetVirtualId.length === 0) throw createFunctionalError("execution", {
1567
+ code: ErrorCodes.INVALID_PANE,
1568
+ message: "Focus step missing target pane metadata",
1569
+ path: step.id
1570
+ });
1571
+ const targetRealId = resolveRealPaneId(paneMap, targetVirtualId, { stepId: step.id });
1572
+ await runCommand([
1573
+ "activate-pane",
1574
+ "--pane-id",
1575
+ targetRealId
1576
+ ], {
1577
+ message: `Failed to execute focus step ${step.id}`,
1578
+ path: step.id
1579
+ });
1580
+ };
1581
+ const escapeDoubleQuotes = (value) => {
1582
+ return value.split("\"").join("\\\"");
1583
+ };
1584
+ const appendCarriageReturn = (value) => {
1585
+ return value.endsWith("\r") ? value : `${value}\r`;
1586
+ };
1587
+ const sendTextToPane = async ({ paneId, text, runCommand, context }) => {
1588
+ await runCommand([
1589
+ "send-text",
1590
+ "--pane-id",
1591
+ paneId,
1592
+ "--no-paste",
1593
+ "--",
1594
+ appendCarriageReturn(text)
1595
+ ], context);
1596
+ };
1597
+ const applyTerminalCommands = async ({ terminals, paneMap, runCommand }) => {
1598
+ for (const terminal of terminals) {
1599
+ const realPaneId = resolveRealPaneId(paneMap, terminal.virtualPaneId, { stepId: terminal.virtualPaneId });
1600
+ if (typeof terminal.cwd === "string" && terminal.cwd.length > 0) {
1601
+ const escapedCwd = escapeDoubleQuotes(terminal.cwd);
1602
+ await sendTextToPane({
1603
+ paneId: realPaneId,
1604
+ text: `cd "${escapedCwd}"`,
1605
+ runCommand,
1606
+ context: {
1607
+ message: `Failed to change directory for pane ${terminal.virtualPaneId}`,
1608
+ path: terminal.virtualPaneId,
1609
+ details: { cwd: terminal.cwd }
1610
+ }
1611
+ });
1612
+ }
1613
+ if (terminal.env !== void 0) for (const [key, value] of Object.entries(terminal.env)) {
1614
+ const escapedValue = escapeDoubleQuotes(String(value));
1615
+ await sendTextToPane({
1616
+ paneId: realPaneId,
1617
+ text: `export ${key}="${escapedValue}"`,
1618
+ runCommand,
1619
+ context: {
1620
+ message: `Failed to set environment variable ${key}`,
1621
+ path: terminal.virtualPaneId
1622
+ }
1623
+ });
1624
+ }
1625
+ if (typeof terminal.command === "string" && terminal.command.length > 0) await sendTextToPane({
1626
+ paneId: realPaneId,
1627
+ text: terminal.command,
1628
+ runCommand,
1629
+ context: {
1630
+ message: `Failed to execute command for pane ${terminal.virtualPaneId}`,
1631
+ path: terminal.virtualPaneId,
1632
+ details: { command: terminal.command }
1633
+ }
1634
+ });
1635
+ }
1636
+ };
1637
+ const applySplitStep = async ({ step, paneMap, windowId, runCommand, listWindows, logPaneMapping }) => {
1638
+ const targetVirtualId = step.targetPaneId;
1639
+ if (typeof targetVirtualId !== "string" || targetVirtualId.length === 0) throw createFunctionalError("execution", {
1640
+ code: ErrorCodes.INVALID_PANE,
1641
+ message: "Split step missing target pane metadata",
1642
+ path: step.id
1643
+ });
1644
+ const targetRealId = resolveRealPaneId(paneMap, targetVirtualId, { stepId: step.id });
1645
+ const beforeList = await listWindows();
1646
+ const beforePaneIds = collectPaneIdsForWindow(beforeList, windowId);
1647
+ const args = buildSplitArguments({
1648
+ targetPaneId: targetRealId,
1649
+ percent: extractPercent(step.command),
1650
+ horizontal: isHorizontalSplit(step.command)
1651
+ });
1652
+ await runCommand(args, {
1653
+ message: `Failed to execute split step ${step.id}`,
1654
+ path: step.id
1655
+ });
1656
+ const afterList = await listWindows();
1657
+ const afterPaneIds = collectPaneIdsForWindow(afterList, windowId);
1658
+ const newPaneId = findNewPaneId(beforePaneIds, afterPaneIds);
1659
+ if (typeof newPaneId !== "string" || newPaneId.length === 0) throw createFunctionalError("execution", {
1660
+ code: ErrorCodes.TERMINAL_COMMAND_FAILED,
1661
+ message: "Unable to determine newly created wezterm pane",
1662
+ path: step.id
1663
+ });
1664
+ if (typeof step.createdPaneId === "string" && step.createdPaneId.length > 0) {
1665
+ registerPaneWithAncestors(paneMap, step.createdPaneId, newPaneId);
1666
+ logPaneMapping(step.createdPaneId, newPaneId);
1667
+ }
1668
+ };
1669
+ const createWeztermBackend = (context) => {
1670
+ const formatCommand = (args) => {
1671
+ return `wezterm cli ${args.join(" ")}`;
1672
+ };
1673
+ const logCommand = (args) => {
1674
+ const message = `[wezterm] ${formatCommand(args)}`;
1675
+ if (context.verbose) context.logger.info(message);
1676
+ else context.logger.debug(message);
1677
+ };
1678
+ const logPaneMapping = (virtualId, realId) => {
1679
+ const message = `[wezterm] pane ${virtualId} -> ${realId}`;
1680
+ if (context.verbose) context.logger.info(message);
1681
+ else context.logger.debug(message);
1682
+ };
1683
+ const runCommand = async (args, errorContext) => {
1684
+ const commandArgs = [...args];
1685
+ logCommand(commandArgs);
1686
+ return runWeztermCli(commandArgs, errorContext);
1687
+ };
1688
+ const listWindows = async () => {
1689
+ logCommand([
1690
+ "list",
1691
+ "--format",
1692
+ "json"
1693
+ ]);
1694
+ return listWeztermWindows();
1695
+ };
1696
+ const verifyEnvironment = async () => {
1697
+ if (context.dryRun) return;
1698
+ await verifyWeztermAvailability();
1699
+ };
1700
+ const applyPlan = async ({ emission, windowMode }) => {
1701
+ const initialVirtualPaneId = ensureVirtualPaneId(emission);
1702
+ const paneMap = /* @__PURE__ */ new Map();
1703
+ const { paneId: initialPaneId, windowId } = await resolveInitialPane({
1704
+ windowMode,
1705
+ prompt: context.prompt,
1706
+ dryRun: context.dryRun,
1707
+ listWindows,
1708
+ runCommand,
1709
+ logCommand
1710
+ });
1711
+ registerPaneWithAncestors(paneMap, initialVirtualPaneId, initialPaneId);
1712
+ logPaneMapping(initialVirtualPaneId, initialPaneId);
1713
+ let executedSteps = 0;
1714
+ for (const step of emission.steps) if (step.kind === "split") {
1715
+ await applySplitStep({
1716
+ step,
1717
+ paneMap,
1718
+ windowId,
1719
+ runCommand,
1720
+ listWindows,
1721
+ logPaneMapping
1722
+ });
1723
+ executedSteps += 1;
1724
+ } else if (step.kind === "focus") {
1725
+ await applyFocusStep({
1726
+ step,
1727
+ paneMap,
1728
+ runCommand
1729
+ });
1730
+ executedSteps += 1;
1731
+ }
1732
+ await applyTerminalCommands({
1733
+ terminals: emission.terminals,
1734
+ paneMap,
1735
+ runCommand
1736
+ });
1737
+ const focusVirtual = emission.summary.focusPaneId;
1738
+ const focusPaneId = typeof focusVirtual === "string" ? paneMap.get(focusVirtual) : void 0;
1739
+ return {
1740
+ executedSteps,
1741
+ focusPaneId
1742
+ };
1743
+ };
1744
+ return {
1745
+ verifyEnvironment,
1746
+ applyPlan,
1747
+ getDryRunSteps: buildDryRunSteps
1748
+ };
1749
+ };
1750
+
1751
+ //#endregion
1752
+ //#region src/executor/backend-factory.ts
1753
+ const createTerminalBackend = (kind, context) => {
1754
+ if (kind === "tmux") return createTmuxBackend(context);
1755
+ if (kind === "wezterm") return createWeztermBackend(context);
1756
+ throw new Error(`Unsupported backend "${kind}"`);
1757
+ };
1758
+
1759
+ //#endregion
1760
+ //#region src/executor/backend-resolver.ts
1761
+ const KNOWN_BACKENDS = ["tmux", "wezterm"];
1762
+ const resolveTerminalBackendKind = ({ cliFlag, env }) => {
1763
+ if (cliFlag !== void 0) {
1764
+ if (!KNOWN_BACKENDS.includes(cliFlag)) throw new Error(`Unknown backend "${cliFlag}"`);
1765
+ return cliFlag;
1766
+ }
1767
+ if (typeof env.TMUX === "string" && env.TMUX.trim().length > 0) return "tmux";
1768
+ return "tmux";
1769
+ };
1770
+
731
1771
  //#endregion
732
1772
  //#region src/core/diagnostics.ts
733
1773
  const severityRank = {
@@ -1277,11 +2317,10 @@ const createCli = (options = {}) => {
1277
2317
  console.log("");
1278
2318
  }
1279
2319
  };
1280
- const renderDryRun = (emission) => {
1281
- console.log(chalk.bold("\nPlanned tmux steps (dry-run)"));
1282
- emission.steps.forEach((step, index) => {
1283
- const commandString = step.command.join(" ");
1284
- console.log(` ${index + 1}. ${step.summary}: tmux ${commandString}`);
2320
+ const renderDryRun = (steps) => {
2321
+ console.log(chalk.bold("\nPlanned terminal steps (dry-run)"));
2322
+ steps.forEach((step, index) => {
2323
+ console.log(` ${index + 1}. [${step.backend}] ${step.summary}: ${step.command}`);
1285
2324
  });
1286
2325
  };
1287
2326
  const buildPresetDocument = (preset, presetName) => {
@@ -1297,6 +2336,11 @@ const createCli = (options = {}) => {
1297
2336
  const buildPresetSource = (presetName) => {
1298
2337
  return typeof presetName === "string" && presetName.length > 0 ? `preset://${presetName}` : "preset://default";
1299
2338
  };
2339
+ const determineCliWindowMode = (options$1) => {
2340
+ if (options$1.currentWindow === true && options$1.newWindow === true) throw new Error("Cannot use --current-window and --new-window at the same time");
2341
+ if (options$1.currentWindow === true) return "current-window";
2342
+ if (options$1.newWindow === true) return "new-window";
2343
+ };
1300
2344
  const handleFunctionalError = (error) => {
1301
2345
  const header = [`[${error.kind}]`, `[${error.code}]`];
1302
2346
  if (typeof error.path === "string" && error.path.length > 0) header.push(`[${error.path}]`);
@@ -1304,8 +2348,8 @@ const createCli = (options = {}) => {
1304
2348
  if (typeof error.source === "string" && error.source.length > 0) lines.push(`source: ${error.source}`);
1305
2349
  const commandDetail = error.details?.command;
1306
2350
  if (Array.isArray(commandDetail)) {
1307
- const tmuxCommand = commandDetail.filter((segment) => typeof segment === "string");
1308
- if (tmuxCommand.length > 0) lines.push(`command: tmux ${tmuxCommand.join(" ")}`);
2351
+ const parts = commandDetail.filter((segment) => typeof segment === "string");
2352
+ if (parts.length > 0) lines.push(`command: ${parts.join(" ")}`);
1309
2353
  } else if (typeof commandDetail === "string" && commandDetail.length > 0) lines.push(`command: ${commandDetail}`);
1310
2354
  const stderrDetail = error.details?.stderr;
1311
2355
  if (typeof stderrDetail === "string" && stderrDetail.length > 0) lines.push(`stderr: ${stderrDetail}`);
@@ -1361,12 +2405,36 @@ const createCli = (options = {}) => {
1361
2405
  try {
1362
2406
  await presetManager.loadConfig();
1363
2407
  const preset = typeof presetName === "string" && presetName.length > 0 ? presetManager.getPreset(presetName) : presetManager.getDefaultPreset();
1364
- const tmuxEnv = process.env.TMUX;
1365
- if (!(typeof tmuxEnv === "string" && tmuxEnv.length > 0) && options$1.dryRun !== true) throw new Error("Must be run inside a tmux session");
2408
+ const cliWindowMode = determineCliWindowMode({
2409
+ currentWindow: options$1.currentWindow,
2410
+ newWindow: options$1.newWindow
2411
+ });
2412
+ const defaults = presetManager.getDefaults();
2413
+ const windowModeResolution = resolveWindowMode({
2414
+ cli: cliWindowMode,
2415
+ preset: preset.windowMode,
2416
+ defaults: defaults?.windowMode
2417
+ });
2418
+ const windowMode = windowModeResolution.mode;
2419
+ logger.info(`Window mode: ${windowMode} (source: ${windowModeResolution.source})`);
2420
+ const confirmPaneClosure = createPaneKillPrompter(logger);
1366
2421
  const executor = createCommandExecutor({
1367
2422
  verbose: options$1.verbose,
1368
2423
  dryRun: options$1.dryRun
1369
2424
  });
2425
+ const backendKind = resolveTerminalBackendKind({
2426
+ cliFlag: options$1.backend,
2427
+ env: process.env
2428
+ });
2429
+ logger.info(`Terminal backend: ${backendKind}`);
2430
+ const backend = createTerminalBackend(backendKind, {
2431
+ executor,
2432
+ logger,
2433
+ dryRun: options$1.dryRun,
2434
+ verbose: options$1.verbose,
2435
+ prompt: confirmPaneClosure
2436
+ });
2437
+ await backend.verifyEnvironment();
1370
2438
  if (options$1.dryRun === true) console.log("[DRY RUN] No actual commands will be executed");
1371
2439
  let compileResult;
1372
2440
  let planResult;
@@ -1381,14 +2449,16 @@ const createCli = (options = {}) => {
1381
2449
  } catch (error) {
1382
2450
  return handlePipelineFailure(error);
1383
2451
  }
1384
- if (options$1.dryRun === true) renderDryRun(emission);
1385
- else try {
1386
- const executionResult = await executePlan({
2452
+ if (options$1.dryRun === true) {
2453
+ const dryRunSteps = backend.getDryRunSteps(emission);
2454
+ renderDryRun(dryRunSteps);
2455
+ } else try {
2456
+ const executionResult = await backend.applyPlan({
1387
2457
  emission,
1388
- executor,
2458
+ windowMode,
1389
2459
  windowName: preset.name ?? presetName ?? "vde-layout"
1390
2460
  });
1391
- logger.info(`Executed ${executionResult.executedSteps} tmux steps`);
2461
+ logger.info(`Executed ${executionResult.executedSteps} ${backendKind} steps`);
1392
2462
  } catch (error) {
1393
2463
  return handlePipelineFailure(error);
1394
2464
  }
@@ -1403,7 +2473,10 @@ const createCli = (options = {}) => {
1403
2473
  program.option("--verbose", "Show detailed logs", false);
1404
2474
  program.option("-V", "Show version (deprecated; use -v)");
1405
2475
  program.option("--dry-run", "Display commands without executing", false);
2476
+ program.option("--backend <backend>", "Select terminal backend (tmux or wezterm)");
1406
2477
  program.option("--config <path>", "Path to configuration file");
2478
+ program.option("--current-window", "Use the current tmux window for layout (kills other panes)", false);
2479
+ program.option("--new-window", "Always create a new tmux window for layout", false);
1407
2480
  program.command("list").description("List available presets").action(async () => {
1408
2481
  await listPresets();
1409
2482
  });
@@ -1414,7 +2487,10 @@ const createCli = (options = {}) => {
1414
2487
  const opts = program.opts();
1415
2488
  await executePreset(presetName, {
1416
2489
  verbose: opts.verbose === true,
1417
- dryRun: opts.dryRun === true
2490
+ dryRun: opts.dryRun === true,
2491
+ currentWindow: opts.currentWindow === true,
2492
+ newWindow: opts.newWindow === true,
2493
+ backend: opts.backend
1418
2494
  });
1419
2495
  });
1420
2496
  };