yggtree 1.4.3 β†’ 1.4.4

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
@@ -146,7 +146,10 @@ Create, manage, and navigate Git worktrees as a primary workflow, not an afterth
146
146
  Work on multiple branches at the same time, each in its own isolated environment.
147
147
 
148
148
  πŸ§ͺ **Sandbox worktrees for experimentation**
149
- Prototyping something risky? Create a sandbox with a random name, try different strategies, and apply the winner back to your origin branch.
149
+ Prototyping something risky? Create a sandbox, try different strategies, and apply the winner back to your origin branch.
150
+
151
+ 🀝 **Handoff current work**
152
+ Started in your main checkout? Carry staged, unstaged, and untracked work into a named sandbox worktree and continue there.
150
153
 
151
154
  πŸ€– **AI-friendly isolation**
152
155
  One worktree per agent, per experiment, per idea. No shared state, no collisions.
@@ -217,7 +220,13 @@ Sometimes you don't want to "commit to a branch" yet. You just want to try somet
217
220
  3. **Apply**: `yggtree apply` to "push" those file changes back to your origin directory.
218
221
  4. **Unapply**: Don't like it? `yggtree unapply` restores your origin to exactly how it was before.
219
222
 
220
- Sandboxes are **not pushed to remote** and their names are randomly generated because they are meant to be temporary playgrounds.
223
+ Sandboxes are **not pushed to remote**. Omit the name for a generated temporary sandbox, or provide one when the work needs to be easy to find later.
224
+
225
+ Use `handoff` when you started in the origin checkout and want to continue that dirty work in a sandbox:
226
+
227
+ ```bash
228
+ yggtree handoff --name auth-refactor
229
+ ```
221
230
 
222
231
  ---
223
232
 
@@ -269,11 +278,13 @@ Options:
269
278
  * `--source local|remote`
270
279
  * `--no-bootstrap`
271
280
  * `--open / --no-open`
281
+ * `--enter / --no-enter`
272
282
  * `--exec "<command>"`
273
283
 
274
284
  Interactive flow:
275
285
 
276
286
  * Instead of asking for a free-form `exec` command, yggtree now asks if you want to open an editor after creation.
287
+ * After creation, yggtree enters the new worktree shell unless you pass `--no-enter`.
277
288
  * `--exec` remains available as an advanced explicit override.
278
289
 
279
290
  <details>
@@ -307,15 +318,17 @@ Options:
307
318
  * `-r, --ref <ref>`: skip picker and use a specific branch (`feature/x` or `origin/feature/x`)
308
319
  * `--no-bootstrap`
309
320
  * `--open / --no-open`: choose whether to open editors or run a startup command before the worktree shell starts
310
- * `--tool <command>`: open a specific editor/app and skip the open prompt (`cursor`, `code`, `codex-app`)
321
+ * `--tool <command>`: open a specific editor, app, or terminal target and skip the open prompt (`cursor`, `code`, `codex-app`, `cmux`, `tmux`)
311
322
  * `--no-enter`: finish after create/open and return to the caller
312
323
  * `--exec "<command>"`
313
324
 
314
325
  Interactive flow:
315
326
 
316
327
  * Yggtree asks what to open before starting the worktree shell.
317
- * Editor choices can be selected together.
318
- * Use `--tool` to skip the open prompt and launch one editor/app directly.
328
+ * Shell-entry flows use a single action picker, so pressing Enter on Cmux or Tmux chooses that terminal target directly.
329
+ * Plain `yggtree open` flows use the same single action picker and return after opening the selected target.
330
+ * Use `--tool` to skip the open prompt and launch one editor/app or terminal target directly.
331
+ * Cmux, Tmux, and `Other command...` are mutually exclusive because the open picker accepts one action.
319
332
  * `Other command...` runs a command in the Yggtree shell first, then leaves you there.
320
333
  * Use `--no-enter` when you only want the worktree created/opened and the command to return.
321
334
  * `--exec` remains available as an advanced explicit override.
@@ -354,6 +367,21 @@ Interactive flow:
354
367
 
355
368
  ---
356
369
 
370
+ ### `yggtree handoff`
371
+
372
+ Carry uncommitted work from the current checkout into a sandbox worktree.
373
+
374
+ Options:
375
+
376
+ * `-n, --name <name>`: Optional handoff name (prompted when omitted).
377
+ * `--no-bootstrap`
378
+ * `--open / --no-open`
379
+ * `--exec "<command>"`
380
+
381
+ This is the continuation-focused version of `create-sandbox --carry`: it keeps sandbox metadata and apply/unapply behavior, but defaults to carrying staged, unstaged, and untracked files.
382
+
383
+ ---
384
+
357
385
  ### `yggtree apply`
358
386
 
359
387
  Apply changes from the current sandbox back to the origin repository.
@@ -415,20 +443,22 @@ Notes:
415
443
 
416
444
  ### `yggtree open [worktree]`
417
445
 
418
- Open a worktree in an editor or supported desktop app.
446
+ Open a worktree in an editor, supported desktop app, or terminal target.
419
447
 
420
448
  Behavior:
421
449
 
422
450
  * If `[worktree]` is omitted, you can pick from the worktree list with type-to-filter search.
423
451
  * Detects available editor commands in your `PATH` (for example: `cursor`, `code`, `zed`, `webstorm`).
424
452
  * Detects Codex App on macOS and launches it with `open -b com.openai.codex`.
425
- * Lets you choose one or more editors/apps interactively, or pass `--tool`.
426
- * By default, `open` launches editors/apps and returns.
453
+ * Detects Cmux and Tmux when their CLI commands are available.
454
+ * Lets you choose one editor, app, or terminal target interactively, or pass `--tool`.
455
+ * Keeps Cmux, Tmux, and `Other command...` mutually exclusive by using a single action picker.
456
+ * By default, `open` launches the selected target and returns, except foreground terminal targets such as Tmux.
427
457
  * Use `wc --open` when you want to open a worktree and continue in its shell.
428
458
 
429
459
  Options:
430
460
 
431
- * `--tool <command>` (for example: `cursor`, `code`, `codex`, or `codex-app`)
461
+ * `--tool <command>` (for example: `cursor`, `code`, `codex`, `codex-app`, `cmux`, or `tmux`)
432
462
 
433
463
  <details>
434
464
  <summary>Examples</summary>
@@ -437,6 +467,7 @@ Options:
437
467
  yggtree open
438
468
  yggtree open feat/new-ui --tool cursor
439
469
  yggtree open feat/new-ui --tool codex-app
470
+ yggtree open feat/new-ui --tool tmux
440
471
  yggtree list --open
441
472
  ```
442
473
 
@@ -531,12 +562,12 @@ yggtree create feat/login-flow
531
562
  ---
532
563
 
533
564
  <details>
534
- <summary>Create a worktree without bootstrap and without opening a tool</summary>
565
+ <summary>Create a worktree without bootstrap, opening a tool, or entering</summary>
535
566
 
536
567
  **Command:**
537
568
 
538
569
  ```
539
- yggtree create feat/cleanup-api --no-bootstrap --no-open
570
+ yggtree create feat/cleanup-api --no-bootstrap --no-open --no-enter
540
571
  ```
541
572
 
542
573
  **When to use:**
@@ -636,7 +667,7 @@ yggtree create-sandbox --carry
636
667
  **Scenario:**
637
668
 
638
669
  1. You have 5 files changed in your main repo but aren't sure about the direction.
639
- 2. Run `create-sandbox --carry` to move those changes into an isolated `sandbox-a3f2_feature-branch` folder.
670
+ 2. Run `handoff --name risky-refactor` to carry those changes into an isolated `sandbox-risky-refactor` folder.
640
671
  3. Experiment freely.
641
672
  4. If it works: `yggtree apply`.
642
673
  5. If it fails: Just delete the sandbox or `unapply`.
@@ -7,15 +7,17 @@ import { WORKTREES_ROOT } from '../../lib/paths.js';
7
7
  import { log, ui, createSpinner } from '../../lib/ui.js';
8
8
  import { promptAndCopyEnvFiles } from '../../lib/env-files.js';
9
9
  import { detectInstalledOpenTools, launchOpenTool, promptOpenToolSelection, } from './open.js';
10
+ import { enterCommand } from './enter.js';
10
11
  import { execa } from 'execa';
11
12
  import fs from 'fs-extra';
13
+ export function shouldEnterCreatedWorktree(options) {
14
+ return options.enter !== false;
15
+ }
12
16
  export async function createCommandNew(options) {
13
17
  try {
14
18
  const repoRoot = await getRepoRoot();
15
19
  log.info(`Repo: ${chalk.dim(repoRoot)}`);
16
- if (options.enter !== undefined) {
17
- log.warning('`--enter` / `--no-enter` is deprecated. Use `--open` / `--no-open` instead.');
18
- }
20
+ const shouldEnterShell = shouldEnterCreatedWorktree(options);
19
21
  // 1. Gather inputs
20
22
  const currentBranch = await getCurrentBranch();
21
23
  const answers = await inquirer.prompt([
@@ -68,9 +70,7 @@ export async function createCommandNew(options) {
68
70
  const shouldBootstrap = options.bootstrap !== undefined ? options.bootstrap : answers.bootstrap;
69
71
  const shouldOpenTool = options.open !== undefined
70
72
  ? options.open
71
- : options.enter !== undefined
72
- ? options.enter
73
- : Boolean(answers.shouldOpenTool);
73
+ : Boolean(answers.shouldOpenTool);
74
74
  let selectedTool;
75
75
  if (options.exec === undefined && shouldOpenTool) {
76
76
  const installedTools = await detectInstalledOpenTools();
@@ -153,35 +153,47 @@ export async function createCommandNew(options) {
153
153
  if (shouldBootstrap) {
154
154
  await runBootstrap(wtPath, repoRoot);
155
155
  }
156
- // 5. Exec Command
156
+ // 5. Post-create actions
157
157
  if (options.exec && options.exec.trim()) {
158
- log.info(`Executing: ${options.exec} in ${ui.path(wtPath)}`);
159
- try {
160
- await execa(options.exec, {
161
- cwd: wtPath,
162
- stdio: 'inherit',
163
- shell: true
164
- });
158
+ if (shouldEnterShell) {
159
+ await enterCommand(wtPath, { exec: options.exec });
165
160
  }
166
- catch (error) {
167
- log.actionableError(error.message, options.exec, wtPath, [
168
- `cd ${wtPath} && ${options.exec}`,
169
- 'Check your command syntax and environment variables'
170
- ]);
161
+ else {
162
+ log.info(`Executing: ${options.exec} in ${ui.path(wtPath)}`);
163
+ try {
164
+ await execa(options.exec, {
165
+ cwd: wtPath,
166
+ stdio: 'inherit',
167
+ shell: true
168
+ });
169
+ }
170
+ catch (error) {
171
+ log.actionableError(error.message, options.exec, wtPath, [
172
+ `cd ${wtPath} && ${options.exec}`,
173
+ 'Check your command syntax and environment variables'
174
+ ]);
175
+ }
171
176
  }
172
177
  }
173
- else if (selectedTool) {
174
- try {
175
- log.info(`Opening ${ui.path(wtPath)} in ${chalk.cyan(selectedTool.name)}...`);
176
- await launchOpenTool(selectedTool, wtPath);
178
+ else {
179
+ if (selectedTool) {
180
+ try {
181
+ log.info(`Opening ${ui.path(wtPath)} in ${chalk.cyan(selectedTool.name)}...`);
182
+ await launchOpenTool(selectedTool, wtPath);
183
+ }
184
+ catch (error) {
185
+ log.warning(`Could not open ${selectedTool.name}: ${error.message}`);
186
+ }
177
187
  }
178
- catch (error) {
179
- log.warning(`Could not open ${selectedTool.name}: ${error.message}`);
188
+ if (shouldEnterShell) {
189
+ await enterCommand(wtPath);
180
190
  }
181
191
  }
182
192
  // 6. Final Output
183
193
  log.success('Worktree ready!');
184
- log.header(`cd "${wtPath}"`);
194
+ if (!shouldEnterShell) {
195
+ log.header(`cd "${wtPath}"`);
196
+ }
185
197
  }
186
198
  catch (error) {
187
199
  log.actionableError(error.message, 'yggtree wt create');
@@ -74,6 +74,30 @@ export function findExistingBranchWorktree(worktrees, branchName) {
74
74
  !wt.prunable &&
75
75
  fs.pathExistsSync(wt.path));
76
76
  }
77
+ function normalizeExistingPath(value) {
78
+ try {
79
+ return fs.realpathSync(value);
80
+ }
81
+ catch {
82
+ return path.resolve(value);
83
+ }
84
+ }
85
+ function findWorktreeByPath(worktrees, wtPath) {
86
+ const normalizedPath = normalizeExistingPath(wtPath);
87
+ return worktrees.find(wt => normalizeExistingPath(wt.path) === normalizedPath);
88
+ }
89
+ export function getWorktreePathCollisionMessage(worktreeName, wtPath, worktrees) {
90
+ if (!fs.pathExistsSync(wtPath))
91
+ return undefined;
92
+ const occupyingWorktree = findWorktreeByPath(worktrees, wtPath);
93
+ if (occupyingWorktree?.branch) {
94
+ return `Worktree name "${worktreeName}" is already used by branch "${occupyingWorktree.branch}".`;
95
+ }
96
+ if (occupyingWorktree?.prunable) {
97
+ return `Worktree path exists but Git marks its metadata as prunable: ${occupyingWorktree.prunable}.`;
98
+ }
99
+ return 'Worktree path already exists.';
100
+ }
77
101
  export async function createCommand(options) {
78
102
  try {
79
103
  const repoRoot = await ensureRepoContext();
@@ -179,6 +203,8 @@ export async function createCommand(options) {
179
203
  }
180
204
  // 3. Gather remaining inputs
181
205
  const defaultSlug = toSlug(selectedBranch.branchName);
206
+ const repoName = await getRepoName();
207
+ const resolveWorktreePath = (name) => path.join(WORKTREES_ROOT, repoName, toSlug(name));
182
208
  const answers = await inquirer.prompt([
183
209
  {
184
210
  type: 'input',
@@ -186,7 +212,12 @@ export async function createCommand(options) {
186
212
  message: 'Worktree name (slug):',
187
213
  default: options.name || defaultSlug,
188
214
  when: !options.name,
189
- validate: (input) => input.trim().length > 0 || 'Name is required',
215
+ validate: (input) => {
216
+ const slug = toSlug(input);
217
+ if (!slug)
218
+ return 'Name is required';
219
+ return getWorktreePathCollisionMessage(slug, resolveWorktreePath(input), existingWorktrees) || true;
220
+ },
190
221
  },
191
222
  {
192
223
  type: 'confirm',
@@ -206,6 +237,20 @@ export async function createCommand(options) {
206
237
  },
207
238
  ]);
208
239
  const name = options.name || answers.name;
240
+ const slug = toSlug(name);
241
+ const wtPath = resolveWorktreePath(name);
242
+ // 4. Validation
243
+ if (!slug)
244
+ throw new Error('Invalid name');
245
+ const pathCollisionMessage = getWorktreePathCollisionMessage(slug, wtPath, existingWorktrees);
246
+ if (pathCollisionMessage) {
247
+ log.actionableError(pathCollisionMessage, `yggtree wc --name ${slug}`, wtPath, [
248
+ 'Choose a different worktree name with --name',
249
+ `Inspect the occupying path: ls ${wtPath}`,
250
+ 'Use yggtree wt prune only if Git reports stale worktree metadata',
251
+ ]);
252
+ return;
253
+ }
209
254
  const shouldBootstrap = options.bootstrap !== undefined ? options.bootstrap : answers.bootstrap;
210
255
  const shouldOpenTool = options.tool
211
256
  ? true
@@ -234,12 +279,6 @@ export async function createCommand(options) {
234
279
  });
235
280
  }
236
281
  }
237
- const slug = toSlug(name);
238
- const repoName = await getRepoName();
239
- const wtPath = path.join(WORKTREES_ROOT, repoName, slug);
240
- // 4. Validation
241
- if (!slug)
242
- throw new Error('Invalid name');
243
282
  // 5. Execution (checkout-style: attach to selected branch)
244
283
  const spinner = createSpinner(`Creating worktree at ${ui.path(wtPath)}...`).start();
245
284
  try {
@@ -0,0 +1,7 @@
1
+ import { createSandboxCommand } from './create-sandbox.js';
2
+ export async function handoffCommand(options = {}) {
3
+ await createSandboxCommand({
4
+ ...options,
5
+ carry: true,
6
+ });
7
+ }
@@ -25,9 +25,13 @@ export const OPEN_TOOL_CANDIDATES = [
25
25
  aliases: ['codex', 'codex-app'],
26
26
  bundleId: 'com.openai.codex',
27
27
  },
28
+ { id: 'cmux', name: 'Cmux Panel', command: 'cmux', kind: 'terminal', aliases: ['cmux', 'cmux-panel'] },
29
+ { id: 'tmux', name: 'Tmux Session', command: 'tmux', kind: 'terminal', aliases: ['tmux', 'tmux-session'] },
28
30
  ];
29
31
  const OTHER_COMMAND_ACTION = '__other_command__';
30
32
  const NO_OPEN_ACTION = '__no_open__';
33
+ const OPEN_ACTION_PAGE_SIZE = 16;
34
+ const CMUX_TERMINAL_STARTUP_DELAY_MS = 350;
31
35
  const yggtreeAccent = chalk.hex('#DEADED');
32
36
  function truncateEnd(value, maxLen) {
33
37
  if (maxLen <= 0)
@@ -61,9 +65,6 @@ function formatWorktreeChoiceLabel(type, branchName, displayPath, terminalColumn
61
65
  function openRail() {
62
66
  return yggtreeAccent('β”‚');
63
67
  }
64
- function openSectionLabel(label) {
65
- return `${yggtreeAccent('β”Œ')} ${chalk.bold(label)}`;
66
- }
67
68
  function formatOpenColumns(name, command, detail) {
68
69
  const paddedName = name.padEnd(16);
69
70
  const paddedCommand = command.padEnd(13);
@@ -73,6 +74,51 @@ function formatOpenColumns(name, command, detail) {
73
74
  export function formatOpenToolChoice(tool) {
74
75
  return formatOpenColumns(tool.name, tool.command, '');
75
76
  }
77
+ function shellQuote(value) {
78
+ return `'${value.replace(/'/g, `'\\''`)}'`;
79
+ }
80
+ function formatTerminalSessionName(wtPath) {
81
+ const basename = path.basename(wtPath) || 'worktree';
82
+ const slug = basename.toLowerCase().replace(/[^a-z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '');
83
+ return `yggtree-${slug || 'worktree'}`.slice(0, 80);
84
+ }
85
+ export function buildTmuxLaunchCommand(wtPath, env = process.env) {
86
+ const sessionName = formatTerminalSessionName(wtPath);
87
+ if (env.TMUX) {
88
+ return {
89
+ executable: 'tmux',
90
+ args: ['new-window', '-c', wtPath, '-n', sessionName],
91
+ };
92
+ }
93
+ return {
94
+ executable: 'tmux',
95
+ args: ['new-session', '-A', '-s', sessionName, '-c', wtPath],
96
+ };
97
+ }
98
+ export function buildCmuxNewPaneCommand() {
99
+ return {
100
+ executable: 'cmux',
101
+ args: ['new-pane', '--type', 'terminal', '--direction', 'right', '--focus', 'true'],
102
+ };
103
+ }
104
+ export function parseCmuxSurfaceRef(output) {
105
+ return output.match(/\bsurface:\S+/)?.[0];
106
+ }
107
+ function delay(ms) {
108
+ return new Promise(resolve => setTimeout(resolve, ms));
109
+ }
110
+ export function buildCmuxSendCommand(surfaceRef, wtPath) {
111
+ return {
112
+ executable: 'cmux',
113
+ args: ['send', '--surface', surfaceRef, `cd ${shellQuote(wtPath)}`],
114
+ };
115
+ }
116
+ export function buildCmuxEnterCommand(surfaceRef) {
117
+ return {
118
+ executable: 'cmux',
119
+ args: ['send-key', '--surface', surfaceRef, 'Enter'],
120
+ };
121
+ }
76
122
  export function formatOpenSpecialChoice(value, allowShellAction) {
77
123
  if (value === OTHER_COMMAND_ACTION) {
78
124
  return formatOpenColumns('Other command', 'custom', 'run first, then stay in shell');
@@ -146,8 +192,23 @@ export function resolveOpenToolOption(input, installed) {
146
192
  tool.command.toLowerCase() === normalized ||
147
193
  tool.aliases?.some(alias => alias.toLowerCase() === normalized));
148
194
  }
195
+ export function resolveOpenToolCandidate(input) {
196
+ const normalized = input.trim().toLowerCase();
197
+ return OPEN_TOOL_CANDIDATES.find(tool => tool.id === normalized ||
198
+ tool.command.toLowerCase() === normalized ||
199
+ tool.aliases?.some(alias => alias.toLowerCase() === normalized));
200
+ }
201
+ async function resolveKnownOpenToolOption(input) {
202
+ const candidate = resolveOpenToolCandidate(input);
203
+ if (!candidate)
204
+ return undefined;
205
+ const exists = candidate.bundleId
206
+ ? await macOSAppBundleExists(candidate.bundleId)
207
+ : await commandExists(candidate.command);
208
+ return exists ? candidate : undefined;
209
+ }
149
210
  export async function resolveOpenToolAction(input, installed) {
150
- let chosenTool = resolveOpenToolOption(input, installed);
211
+ let chosenTool = resolveOpenToolOption(input, installed) || await resolveKnownOpenToolOption(input);
151
212
  if (!chosenTool && await commandExists(input)) {
152
213
  chosenTool = {
153
214
  id: 'custom',
@@ -159,27 +220,11 @@ export async function resolveOpenToolAction(input, installed) {
159
220
  return chosenTool ? { type: 'tool', tool: chosenTool } : undefined;
160
221
  }
161
222
  export async function promptOpenToolSelection(installedTools, message = 'Select tool to open:') {
162
- const ideChoices = installedTools
163
- .filter(tool => tool.kind === 'editor')
223
+ const choices = installedTools
164
224
  .map(tool => ({
165
225
  name: formatOpenToolChoice(tool),
166
226
  value: tool,
167
227
  }));
168
- const appChoices = installedTools
169
- .filter(tool => tool.kind === 'app')
170
- .map(tool => ({
171
- name: formatOpenToolChoice(tool),
172
- value: tool,
173
- }));
174
- const choices = [];
175
- if (ideChoices.length > 0) {
176
- choices.push(new inquirer.Separator(openSectionLabel('Editors & IDEs')));
177
- choices.push(...ideChoices);
178
- }
179
- if (appChoices.length > 0) {
180
- choices.push(new inquirer.Separator(openSectionLabel('Apps')));
181
- choices.push(...appChoices);
182
- }
183
228
  const { selectedTool } = await inquirer.prompt([
184
229
  {
185
230
  type: 'list',
@@ -187,79 +232,55 @@ export async function promptOpenToolSelection(installedTools, message = 'Select
187
232
  message,
188
233
  choices,
189
234
  loop: false,
190
- pageSize: 10,
235
+ pageSize: OPEN_ACTION_PAGE_SIZE,
191
236
  },
192
237
  ]);
193
238
  return selectedTool;
194
239
  }
195
- export function buildOpenActionsFromSelection(selectedValues, installedTools, otherCommand) {
196
- if (selectedValues.includes(NO_OPEN_ACTION))
240
+ export function buildOpenActionsFromSelection(selectedValue, installedTools, otherCommand) {
241
+ if (selectedValue === NO_OPEN_ACTION)
197
242
  return [];
198
- const actions = selectedValues
199
- .filter(value => value.startsWith('tool:'))
200
- .map(value => installedTools.find(tool => `tool:${tool.id}` === value))
201
- .filter((tool) => Boolean(tool))
202
- .map(tool => ({ type: 'tool', tool }));
203
- if (selectedValues.includes(OTHER_COMMAND_ACTION) && otherCommand?.trim()) {
204
- actions.push({ type: 'other-command', command: otherCommand.trim() });
243
+ if (selectedValue.startsWith('tool:')) {
244
+ const tool = installedTools.find(candidate => `tool:${candidate.id}` === selectedValue);
245
+ return tool ? [{ type: 'tool', tool }] : [];
205
246
  }
206
- return actions;
207
- }
208
- export function validateOpenActionSelection(selectedValues) {
209
- if (selectedValues.includes(NO_OPEN_ACTION) && selectedValues.length > 1) {
210
- return 'Choose either "Nothing" or one or more actions, not both.';
247
+ if (selectedValue === OTHER_COMMAND_ACTION && otherCommand?.trim()) {
248
+ return [{ type: 'other-command', command: otherCommand.trim() }];
211
249
  }
212
- return true;
250
+ return [];
213
251
  }
214
- export async function promptOpenActions(installedTools, options = {}) {
215
- const allowShellAction = options.allowShellAction ?? true;
216
- const choices = [];
217
- if (installedTools.length > 0) {
218
- const editorTools = installedTools.filter(tool => tool.kind === 'editor');
219
- const appTools = installedTools.filter(tool => tool.kind === 'app');
220
- if (editorTools.length > 0) {
221
- choices.push(new inquirer.Separator(openSectionLabel('Editors & IDEs')));
222
- choices.push(...editorTools.map(tool => ({
223
- name: formatOpenToolChoice(tool),
224
- value: `tool:${tool.id}`,
225
- })));
226
- }
227
- if (appTools.length > 0) {
228
- choices.push(new inquirer.Separator(openSectionLabel('Apps')));
229
- choices.push(...appTools.map(tool => ({
230
- name: formatOpenToolChoice(tool),
231
- value: `tool:${tool.id}`,
232
- })));
233
- }
234
- }
252
+ export function buildOpenActionChoices(installedTools, allowShellAction) {
253
+ const choices = installedTools.map(tool => ({
254
+ name: formatOpenToolChoice(tool),
255
+ value: `tool:${tool.id}`,
256
+ }));
235
257
  if (allowShellAction) {
236
- if (choices.length > 0)
237
- choices.push(new inquirer.Separator(' '));
238
- choices.push(new inquirer.Separator(openSectionLabel('Shell')));
239
258
  choices.push({
240
259
  name: formatOpenSpecialChoice(OTHER_COMMAND_ACTION, allowShellAction),
241
260
  value: OTHER_COMMAND_ACTION,
242
261
  });
243
262
  }
244
- if (choices.length > 0)
245
- choices.push(new inquirer.Separator(' '));
246
263
  choices.push({
247
264
  name: formatOpenSpecialChoice(NO_OPEN_ACTION, allowShellAction),
248
265
  value: NO_OPEN_ACTION,
249
266
  });
250
- const { selectedValues } = await inquirer.prompt([
267
+ return choices;
268
+ }
269
+ export async function promptOpenActions(installedTools, options = {}) {
270
+ const allowShellAction = options.allowShellAction ?? true;
271
+ const choices = buildOpenActionChoices(installedTools, allowShellAction);
272
+ const { selectedValue } = await inquirer.prompt([
251
273
  {
252
- type: 'checkbox',
253
- name: 'selectedValues',
274
+ type: 'list',
275
+ name: 'selectedValue',
254
276
  message: options.message || 'Open anything before entering the shell?',
255
277
  choices,
256
278
  loop: false,
257
- pageSize: 10,
258
- validate: validateOpenActionSelection,
279
+ pageSize: OPEN_ACTION_PAGE_SIZE,
259
280
  },
260
281
  ]);
261
282
  let otherCommand;
262
- if (selectedValues.includes(OTHER_COMMAND_ACTION)) {
283
+ if (selectedValue === OTHER_COMMAND_ACTION) {
263
284
  const answer = await inquirer.prompt([
264
285
  {
265
286
  type: 'input',
@@ -270,9 +291,39 @@ export async function promptOpenActions(installedTools, options = {}) {
270
291
  ]);
271
292
  otherCommand = answer.otherCommand;
272
293
  }
273
- return buildOpenActionsFromSelection(selectedValues, installedTools, otherCommand);
294
+ return buildOpenActionsFromSelection(selectedValue, installedTools, otherCommand);
274
295
  }
275
296
  export async function launchOpenTool(tool, wtPath) {
297
+ if (tool.id === 'cmux') {
298
+ const newPaneCommand = buildCmuxNewPaneCommand();
299
+ const newPaneResult = await execa(newPaneCommand.executable, newPaneCommand.args, {
300
+ cwd: wtPath,
301
+ });
302
+ const surfaceRef = parseCmuxSurfaceRef(newPaneResult.stdout);
303
+ if (!surfaceRef) {
304
+ throw new Error(`Cmux did not return a terminal surface handle: ${newPaneResult.stdout || '<empty output>'}`);
305
+ }
306
+ await delay(CMUX_TERMINAL_STARTUP_DELAY_MS);
307
+ const sendCommand = buildCmuxSendCommand(surfaceRef, wtPath);
308
+ await execa(sendCommand.executable, sendCommand.args, {
309
+ cwd: wtPath,
310
+ stdio: 'ignore',
311
+ });
312
+ const enterCommand = buildCmuxEnterCommand(surfaceRef);
313
+ await execa(enterCommand.executable, enterCommand.args, {
314
+ cwd: wtPath,
315
+ stdio: 'ignore',
316
+ });
317
+ return;
318
+ }
319
+ if (tool.id === 'tmux') {
320
+ const { executable, args } = buildTmuxLaunchCommand(wtPath);
321
+ await execa(executable, args, {
322
+ cwd: wtPath,
323
+ stdio: 'inherit',
324
+ });
325
+ return;
326
+ }
276
327
  const { executable, args } = buildOpenToolLaunchCommand(tool, wtPath);
277
328
  await execa(executable, args, {
278
329
  cwd: wtPath,
@@ -292,10 +343,14 @@ export function buildOpenToolLaunchCommand(tool, wtPath) {
292
343
  };
293
344
  }
294
345
  export async function runOpenActions(actions, wtPath, options = {}) {
346
+ let openedTerminal = false;
295
347
  for (const action of actions) {
296
348
  if (action.type === 'tool' && action.tool) {
297
349
  log.info(`Opening ${ui.path(wtPath)} in ${chalk.cyan(action.tool.name)}...`);
298
350
  await launchOpenTool(action.tool, wtPath);
351
+ if (action.tool.kind === 'terminal') {
352
+ openedTerminal = true;
353
+ }
299
354
  }
300
355
  }
301
356
  const shellAction = actions.find(action => action.type === 'other-command');
@@ -303,7 +358,7 @@ export async function runOpenActions(actions, wtPath, options = {}) {
303
358
  await enterCommand(wtPath, { exec: shellAction.command });
304
359
  return;
305
360
  }
306
- if (options.enter !== false) {
361
+ if (options.enter !== false && !openedTerminal) {
307
362
  await enterCommand(wtPath);
308
363
  }
309
364
  }
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ import { pathCommand } from './commands/wt/path.js';
15
15
  import { openCommand } from './commands/wt/open.js';
16
16
  import { applyCommand } from './commands/wt/apply.js';
17
17
  import { unapplyCommand } from './commands/wt/unapply.js';
18
+ import { handoffCommand } from './commands/wt/handoff.js';
18
19
  import { getVersion } from './lib/version.js';
19
20
  import { notifyIfUpdateAvailable } from './lib/update-check.js';
20
21
  import { findSandboxRoot } from './lib/sandbox.js';
@@ -41,8 +42,8 @@ function registerWorktreeCommands(parent) {
41
42
  .option('--no-bootstrap', 'Skip bootstrap (npm install + submodules)')
42
43
  .option('--open', 'Open an editor after creation')
43
44
  .option('--no-open', 'Skip opening an editor after creation')
44
- .addOption(new Option('--enter', 'Deprecated alias for --open').hideHelp())
45
- .addOption(new Option('--no-enter', 'Deprecated alias for --no-open').hideHelp())
45
+ .option('--enter', 'Enter the worktree sub-shell after creation')
46
+ .option('--no-enter', 'Do not enter the worktree sub-shell after creation')
46
47
  .option('--exec <command>', 'Command to execute after creation')
47
48
  .action(async (branch, options) => {
48
49
  await createCommandNew({
@@ -66,7 +67,7 @@ function registerWorktreeCommands(parent) {
66
67
  .option('--no-bootstrap', 'Skip bootstrap (npm install + submodules)')
67
68
  .option('--open', 'Open editors or run a startup command before entering')
68
69
  .option('--no-open', 'Skip opening editors or startup commands before entering')
69
- .option('--tool <command>', 'Editor or app command to open after checkout (skips open prompt)')
70
+ .option('--tool <command>', 'Editor, app, or terminal command to open after checkout (skips open prompt)')
70
71
  .addOption(new Option('--enter', 'Enter the worktree sub-shell after checkout/opening').hideHelp())
71
72
  .option('--no-enter', 'Do not enter the worktree sub-shell after checkout/opening')
72
73
  .option('--exec <command>', 'Command to execute after creation')
@@ -87,8 +88,8 @@ function registerWorktreeCommands(parent) {
87
88
  await deleteCommand(options);
88
89
  });
89
90
  parent.command('open [worktree]')
90
- .description('Open a worktree in an editor or supported app')
91
- .option('--tool <command>', 'Editor or app command to use (e.g. cursor, code, codex-app)')
91
+ .description('Open a worktree in an editor, supported app, or terminal target')
92
+ .option('--tool <command>', 'Editor, app, or terminal command to use (e.g. cursor, code, codex-app, tmux)')
92
93
  .option('--enter', 'Enter the worktree sub-shell after opening')
93
94
  .addOption(new Option('--no-enter', 'Do not enter the worktree sub-shell after opening').hideHelp())
94
95
  .action(async (worktree, options) => {
@@ -126,6 +127,18 @@ function registerWorktreeCommands(parent) {
126
127
  .action(async (options) => {
127
128
  await createSandboxCommand(options);
128
129
  });
130
+ parent.command('handoff')
131
+ .description('Carry uncommitted work into a sandbox worktree')
132
+ .option('-n, --name <name>', 'Optional handoff name (prompted when omitted)')
133
+ .option('--no-bootstrap', 'Skip bootstrap (npm install + submodules)')
134
+ .option('--open', 'Open an editor after creation')
135
+ .option('--no-open', 'Skip opening an editor after creation')
136
+ .addOption(new Option('--enter', 'Deprecated alias for --open').hideHelp())
137
+ .addOption(new Option('--no-enter', 'Deprecated alias for --no-open').hideHelp())
138
+ .option('--exec <command>', 'Command to execute after creation')
139
+ .action(async (options) => {
140
+ await handoffCommand(options);
141
+ });
129
142
  parent.command('apply')
130
143
  .description('Apply sandbox changes to origin directory')
131
144
  .action(applyCommand);
@@ -158,6 +171,7 @@ program
158
171
  { name: `🌿 Grow New Realm ${chalk.dim('(create worktree)')}`, value: 'create-smart' },
159
172
  { name: `πŸ”€ Traverse to Another Realm ${chalk.dim('(checkout existing branch in new worktree)')}`, value: 'worktree-checkout' },
160
173
  { name: `🌳 Grow Many Realms ${chalk.dim('(create multiple worktrees)')}`, value: 'create-multi' },
174
+ { name: `🀝 Hand Off Current Work ${chalk.dim('(carry dirty work into a sandbox)')}`, value: 'handoff' },
161
175
  { name: `πŸ§ͺ Forge Sandbox Realm ${chalk.dim('(create sandbox worktree)')}`, value: 'create-sandbox' },
162
176
  { name: `πŸ—ΊοΈ Survey Realms ${chalk.dim('(list worktrees)')}`, value: 'list' },
163
177
  { name: `🧭 Open Realm in Editor ${chalk.dim('(open worktree in editor)')}`, value: 'open' },
@@ -240,6 +254,9 @@ program
240
254
  case 'create-sandbox':
241
255
  await createSandboxCommand({ bootstrap: true });
242
256
  break;
257
+ case 'handoff':
258
+ await handoffCommand({ bootstrap: true });
259
+ break;
243
260
  case 'bifrost':
244
261
  await bifrostCommand();
245
262
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yggtree",
3
- "version": "1.4.3",
3
+ "version": "1.4.4",
4
4
  "packageManager": "pnpm@11.0.9",
5
5
  "description": "Interactive CLI for managing git worktrees and configs",
6
6
  "main": "dist/index.js",