@jasonfutch/worktree-manager 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,842 @@
1
+ import blessed from 'blessed';
2
+ import { GitWorktree } from '../git/worktree.js';
3
+ import { truncate } from '../utils/helpers.js';
4
+ import { spawn } from 'child_process';
5
+ import { escapeAppleScript, escapeWindowsArg } from '../utils/shell.js';
6
+ import { UI, PLATFORM, GIT, getInstallInstruction } from '../constants.js';
7
+ import { GitCommandError } from '../errors.js';
8
+ import { VERSION } from '../version.js';
9
+ export class WorktreeManagerTUI {
10
+ screen;
11
+ mainBox;
12
+ worktreeList;
13
+ detailBox;
14
+ statusBar;
15
+ helpBox;
16
+ inputBox = null;
17
+ git;
18
+ worktrees = [];
19
+ selectedIndex = 0;
20
+ repoPath;
21
+ repoName;
22
+ isModalOpen = false;
23
+ autoRefreshInterval = null;
24
+ AUTO_REFRESH_MS = 15000;
25
+ constructor(repoPath) {
26
+ this.repoPath = repoPath;
27
+ this.git = new GitWorktree(repoPath);
28
+ this.repoName = this.git.getRepoName();
29
+ // Create screen
30
+ this.screen = blessed.screen({
31
+ smartCSR: true,
32
+ title: `Worktree Manager - ${this.repoName}`,
33
+ fullUnicode: true
34
+ });
35
+ // Create main layout
36
+ this.mainBox = blessed.box({
37
+ parent: this.screen,
38
+ top: 0,
39
+ left: 0,
40
+ width: '100%',
41
+ height: '100%-1',
42
+ style: {
43
+ bg: 'default'
44
+ }
45
+ });
46
+ // Worktree list (left panel)
47
+ this.worktreeList = blessed.list({
48
+ parent: this.mainBox,
49
+ label: ` 🌳 Worktrees - ${this.repoName} `,
50
+ top: 0,
51
+ left: 0,
52
+ width: '50%',
53
+ height: '100%-3',
54
+ border: { type: 'line' },
55
+ style: {
56
+ border: { fg: 'cyan' },
57
+ selected: { bg: 'blue', fg: 'white', bold: true },
58
+ item: { fg: 'default' }
59
+ },
60
+ keys: false,
61
+ vi: false,
62
+ mouse: true,
63
+ scrollbar: {
64
+ ch: '│',
65
+ style: { fg: 'cyan' }
66
+ }
67
+ });
68
+ // Detail panel (right)
69
+ this.detailBox = blessed.box({
70
+ parent: this.mainBox,
71
+ label: ' 📋 Details ',
72
+ top: 0,
73
+ left: '50%',
74
+ width: '50%',
75
+ height: '40%',
76
+ border: { type: 'line' },
77
+ style: {
78
+ border: { fg: 'green' }
79
+ },
80
+ content: '',
81
+ tags: true,
82
+ padding: 1
83
+ });
84
+ // Help panel (bottom right)
85
+ this.helpBox = blessed.box({
86
+ parent: this.mainBox,
87
+ label: ' ⌨️ Keybindings ',
88
+ top: '40%',
89
+ left: '50%',
90
+ width: '50%',
91
+ height: '60%-3',
92
+ border: { type: 'line' },
93
+ style: {
94
+ border: { fg: 'yellow' }
95
+ },
96
+ tags: true,
97
+ padding: 1,
98
+ content: `{bold}Navigation{/bold}
99
+ {cyan-fg}↑/k{/} Move up {cyan-fg}↓/j{/} Move down
100
+ {cyan-fg}Enter{/} Select {cyan-fg}r{/} Refresh
101
+
102
+ {bold}Actions{/bold}
103
+ {green-fg}n{/} New worktree {red-fg}d{/} Delete worktree
104
+ {blue-fg}e{/} Open Editor {magenta-fg}a{/} Launch AI
105
+ {magenta-fg}t{/} Open Terminal
106
+
107
+ {bold}General{/bold}
108
+ {yellow-fg}q{/} Quit {yellow-fg}?{/} Help`
109
+ });
110
+ // Status bar
111
+ this.statusBar = blessed.box({
112
+ parent: this.screen,
113
+ bottom: 0,
114
+ left: 0,
115
+ width: '100%',
116
+ height: 1,
117
+ style: {
118
+ bg: 'blue',
119
+ fg: 'white'
120
+ },
121
+ content: ` v${VERSION} | Press ? for help | q to quit`,
122
+ tags: true
123
+ });
124
+ this.setupKeyBindings();
125
+ }
126
+ setupKeyBindings() {
127
+ // Quit
128
+ this.screen.key(['q', 'C-c'], () => {
129
+ this.stopAutoRefresh();
130
+ this.screen.destroy();
131
+ process.exit(0);
132
+ });
133
+ // Navigation
134
+ this.screen.key(['j', 'down'], () => this.moveSelection(1));
135
+ this.screen.key(['k', 'up'], () => this.moveSelection(-1));
136
+ // Refresh
137
+ this.screen.key(['r'], () => this.refresh());
138
+ // Create new worktree
139
+ this.screen.key(['n'], () => this.promptCreateWorktree());
140
+ // Delete worktree
141
+ this.screen.key(['d'], () => this.promptDeleteWorktree());
142
+ // Open in Editor
143
+ this.screen.key(['e'], () => this.openInEditor());
144
+ // Open terminal
145
+ this.screen.key(['t'], () => this.openTerminal());
146
+ // Launch AI
147
+ this.screen.key(['a'], () => this.launchAI());
148
+ // Enter - show details
149
+ this.screen.key(['enter'], () => this.showDetails());
150
+ // Show help modal
151
+ this.screen.key(['?'], () => this.showHelp());
152
+ // Focus list by default
153
+ this.worktreeList.focus();
154
+ }
155
+ moveSelection(delta) {
156
+ if (this.isModalOpen)
157
+ return;
158
+ const newIndex = Math.max(0, Math.min(this.worktrees.length - 1, this.selectedIndex + delta));
159
+ if (newIndex !== this.selectedIndex) {
160
+ this.selectedIndex = newIndex;
161
+ this.worktreeList.select(this.selectedIndex);
162
+ this.updateDetails();
163
+ this.screen.render();
164
+ }
165
+ }
166
+ updateDetails() {
167
+ const wt = this.worktrees[this.selectedIndex];
168
+ if (!wt) {
169
+ this.detailBox.setContent('No worktree selected');
170
+ return;
171
+ }
172
+ const mainBadge = wt.isMain ? ' {green-fg}[MAIN]{/}' : '';
173
+ const lockedBadge = wt.isLocked ? ' {red-fg}[LOCKED]{/}' : '';
174
+ this.detailBox.setContent(`{bold}Repo:{/} {magenta-fg}${this.repoName}{/}
175
+ {bold}Branch:{/} {cyan-fg}${wt.branch}{/}${mainBadge}${lockedBadge}
176
+ {bold}Path:{/} ${wt.path}
177
+ {bold}Commit:{/} {yellow-fg}${wt.commit}{/}
178
+ {bold}Name:{/} ${wt.name}
179
+ {bold}Status:{/} ${wt.isBare ? 'Bare' : 'Active'}`);
180
+ }
181
+ async refresh() {
182
+ this.setStatus(' Loading worktrees...', 'blue');
183
+ try {
184
+ this.worktrees = await this.git.list();
185
+ this.updateList();
186
+ this.updateDetails();
187
+ this.setStatus(` v${VERSION} | ${this.worktrees.length} worktree${this.worktrees.length === 1 ? '' : 's'} | ? for help`);
188
+ }
189
+ catch (error) {
190
+ const message = error instanceof GitCommandError
191
+ ? error.getUserMessage()
192
+ : (error instanceof Error ? error.message : String(error));
193
+ this.setStatus(` Error: ${message}`, 'red');
194
+ }
195
+ this.screen.render();
196
+ }
197
+ updateList() {
198
+ const items = this.worktrees.map((wt) => {
199
+ const prefix = wt.isMain ? '★ ' : ' ';
200
+ const locked = wt.isLocked ? ' 🔒' : '';
201
+ return `${prefix}${truncate(wt.branch, UI.BRANCH_TRUNCATE_LENGTH)}${locked}`;
202
+ });
203
+ this.worktreeList.setItems(items);
204
+ if (this.selectedIndex >= items.length) {
205
+ this.selectedIndex = Math.max(0, items.length - 1);
206
+ }
207
+ this.worktreeList.select(this.selectedIndex);
208
+ }
209
+ setStatus(message, color = 'blue') {
210
+ this.statusBar.style.bg = color;
211
+ this.statusBar.setContent(message);
212
+ this.screen.render();
213
+ }
214
+ showConfirm(message, label, borderColor) {
215
+ return new Promise((resolve) => {
216
+ const box = blessed.box({
217
+ parent: this.screen,
218
+ left: 'center',
219
+ top: 'center',
220
+ width: UI.MODAL_WIDTH,
221
+ height: 5,
222
+ border: { type: 'line' },
223
+ style: {
224
+ border: { fg: borderColor },
225
+ bg: 'default'
226
+ },
227
+ label: ` ${label} `,
228
+ content: `${message} (y/n)`,
229
+ padding: { left: 1, top: 1 }
230
+ });
231
+ this.screen.render();
232
+ const yesHandler = () => cleanup(true);
233
+ const noHandler = () => cleanup(false);
234
+ const cleanup = (result) => {
235
+ this.screen.unkey('y', yesHandler);
236
+ this.screen.unkey('Y', yesHandler);
237
+ this.screen.unkey('n', noHandler);
238
+ this.screen.unkey('N', noHandler);
239
+ this.screen.unkey('escape', noHandler);
240
+ box.destroy();
241
+ this.screen.render();
242
+ resolve(result);
243
+ };
244
+ this.screen.key(['y', 'Y'], yesHandler);
245
+ this.screen.key(['n', 'N', 'escape'], noHandler);
246
+ });
247
+ }
248
+ showTextInputConfirm(message, label, borderColor, requiredInput) {
249
+ return new Promise((resolve) => {
250
+ const box = blessed.box({
251
+ parent: this.screen,
252
+ left: 'center',
253
+ top: 'center',
254
+ width: UI.MODAL_WIDTH,
255
+ height: 8,
256
+ border: { type: 'line' },
257
+ style: {
258
+ border: { fg: borderColor },
259
+ bg: 'default'
260
+ },
261
+ label: ` ${label} `,
262
+ padding: { left: 1, top: 0 }
263
+ });
264
+ blessed.text({
265
+ parent: box,
266
+ top: 0,
267
+ left: 0,
268
+ content: message,
269
+ style: { fg: 'default' }
270
+ });
271
+ blessed.text({
272
+ parent: box,
273
+ top: 1,
274
+ left: 0,
275
+ content: `Type "${requiredInput}" to confirm (Esc to cancel):`,
276
+ style: { fg: 'gray' }
277
+ });
278
+ const input = blessed.textbox({
279
+ parent: box,
280
+ top: 2,
281
+ left: 0,
282
+ width: UI.MODAL_WIDTH - 6,
283
+ height: 3,
284
+ border: { type: 'line' },
285
+ style: {
286
+ border: { fg: 'cyan' },
287
+ focus: { border: { fg: 'green' } }
288
+ },
289
+ inputOnFocus: true
290
+ });
291
+ this.screen.render();
292
+ input.focus();
293
+ const cleanup = (result) => {
294
+ box.destroy();
295
+ this.screen.render();
296
+ resolve(result);
297
+ };
298
+ input.key(['escape'], () => cleanup(false));
299
+ input.key(['enter'], () => {
300
+ const value = input.getValue().trim();
301
+ cleanup(value === requiredInput);
302
+ });
303
+ });
304
+ }
305
+ isProtectedBranch(branch) {
306
+ const normalizedBranch = branch.toLowerCase();
307
+ return GIT.PROTECTED_BRANCHES.some(protected_branch => normalizedBranch === protected_branch.toLowerCase());
308
+ }
309
+ showHelp() {
310
+ if (this.isModalOpen)
311
+ return;
312
+ this.isModalOpen = true;
313
+ const helpContent = `{bold}{cyan-fg}Worktree Manager v${VERSION}{/}
314
+
315
+ {bold}Navigation{/}
316
+ {cyan-fg}↑/k{/} Move selection up
317
+ {cyan-fg}↓/j{/} Move selection down
318
+ {cyan-fg}Enter{/} Show worktree details
319
+ {cyan-fg}r{/} Refresh worktree list
320
+
321
+ {bold}Worktree Actions{/}
322
+ {green-fg}n{/} Create new worktree
323
+ {red-fg}d{/} Delete selected worktree
324
+
325
+ {bold}Open in Editor{/}
326
+ {blue-fg}e{/} Select editor (VS Code, Cursor, Zed, etc.)
327
+
328
+ {bold}Terminal & AI{/}
329
+ {magenta-fg}t{/} Open terminal in worktree
330
+ {magenta-fg}a{/} Launch AI (Claude, Gemini, Codex)
331
+
332
+ {bold}General{/}
333
+ {yellow-fg}?{/} Show this help
334
+ {yellow-fg}q{/} Quit application
335
+
336
+ {gray-fg}Press any key to close this help...{/}`;
337
+ const box = blessed.box({
338
+ parent: this.screen,
339
+ left: 'center',
340
+ top: 'center',
341
+ width: UI.MODAL_WIDTH,
342
+ height: UI.HELP_MODAL_HEIGHT,
343
+ border: { type: 'line' },
344
+ style: {
345
+ border: { fg: 'cyan' },
346
+ bg: 'default'
347
+ },
348
+ label: ' Help ',
349
+ content: helpContent,
350
+ tags: true,
351
+ padding: { left: 1, top: 1 }
352
+ });
353
+ this.screen.render();
354
+ const closeHelp = () => {
355
+ this.screen.unkey('escape', closeHelp);
356
+ this.screen.unkey('enter', closeHelp);
357
+ this.screen.unkey('space', closeHelp);
358
+ this.screen.removeListener('keypress', closeHelp);
359
+ box.destroy();
360
+ this.isModalOpen = false;
361
+ this.screen.render();
362
+ };
363
+ // Listen for specific keys to close (not '?' or 'q' to avoid conflicts)
364
+ this.screen.key(['escape', 'enter', 'space'], closeHelp);
365
+ this.screen.once('keypress', closeHelp);
366
+ }
367
+ async promptCreateWorktree() {
368
+ if (this.isModalOpen)
369
+ return;
370
+ this.isModalOpen = true;
371
+ this.setStatus(' Loading branches...', 'blue');
372
+ // Fetch available branches
373
+ const branches = await this.git.getBranches();
374
+ if (branches.length === 0) {
375
+ this.setStatus(' Warning: Could not load branches. Using default "main"', 'yellow');
376
+ }
377
+ const form = blessed.form({
378
+ parent: this.screen,
379
+ keys: false,
380
+ left: 'center',
381
+ top: 'center',
382
+ width: 60,
383
+ height: UI.CREATE_FORM_HEIGHT,
384
+ border: { type: 'line' },
385
+ style: {
386
+ border: { fg: 'green' },
387
+ bg: 'default'
388
+ },
389
+ label: ' Create New Worktree '
390
+ });
391
+ blessed.text({
392
+ parent: form,
393
+ top: 1,
394
+ left: 2,
395
+ content: 'Branch name:',
396
+ style: { fg: 'default' }
397
+ });
398
+ const branchInput = blessed.textbox({
399
+ parent: form,
400
+ top: 2,
401
+ left: 2,
402
+ width: 54,
403
+ height: 3,
404
+ border: { type: 'line' },
405
+ style: {
406
+ border: { fg: 'cyan' },
407
+ focus: { border: { fg: 'green' } }
408
+ },
409
+ inputOnFocus: true
410
+ });
411
+ blessed.text({
412
+ parent: form,
413
+ top: 6,
414
+ left: 2,
415
+ content: 'Base branch:',
416
+ style: { fg: 'default' }
417
+ });
418
+ const baseBranchList = blessed.list({
419
+ parent: form,
420
+ top: 7,
421
+ left: 2,
422
+ width: 54,
423
+ height: 8,
424
+ border: { type: 'line' },
425
+ style: {
426
+ border: { fg: 'cyan' },
427
+ selected: { bg: 'blue', fg: 'white' },
428
+ focus: { border: { fg: 'green' } }
429
+ },
430
+ keys: true,
431
+ vi: true,
432
+ items: branches.length > 0 ? branches : ['main']
433
+ });
434
+ // Pre-select 'main' if it exists
435
+ const mainIndex = branches.indexOf('main');
436
+ if (mainIndex >= 0) {
437
+ baseBranchList.select(mainIndex);
438
+ }
439
+ blessed.text({
440
+ parent: form,
441
+ top: 16,
442
+ left: 2,
443
+ content: 'Tab to switch | ↑↓ to select branch | Enter to create | Esc to cancel',
444
+ style: { fg: 'gray' }
445
+ });
446
+ branchInput.focus();
447
+ this.setStatus(' Creating new worktree...', 'blue');
448
+ const submitForm = async () => {
449
+ this.isModalOpen = false;
450
+ const branch = branchInput.getValue().replace(/\t/g, '').trim();
451
+ const selectedIdx = baseBranchList.selected;
452
+ const branchItems = branches.length > 0 ? branches : ['main'];
453
+ const baseBranch = branchItems[selectedIdx] || 'main';
454
+ form.destroy();
455
+ this.worktreeList.focus();
456
+ if (branch) {
457
+ this.setStatus(` Creating worktree: ${branch} from ${baseBranch}...`, 'blue');
458
+ try {
459
+ await this.git.create({ branch, baseBranch });
460
+ await this.refresh();
461
+ this.setStatus(` Created worktree: ${branch}`, 'green');
462
+ }
463
+ catch (error) {
464
+ const message = error instanceof GitCommandError
465
+ ? error.getUserMessage()
466
+ : (error instanceof Error ? error.message : String(error));
467
+ this.setStatus(` Error: ${message}`, 'red');
468
+ }
469
+ }
470
+ else {
471
+ this.setStatus(' Cancelled - no branch name provided', 'yellow');
472
+ }
473
+ this.screen.render();
474
+ };
475
+ const cancelForm = () => {
476
+ this.isModalOpen = false;
477
+ form.destroy();
478
+ this.worktreeList.focus();
479
+ this.setStatus(' Cancelled', 'blue');
480
+ this.screen.render();
481
+ };
482
+ branchInput.key(['escape'], cancelForm);
483
+ branchInput.key(['enter'], submitForm);
484
+ branchInput.key(['tab'], () => {
485
+ branchInput.setValue(branchInput.getValue().replace(/\t/g, ''));
486
+ branchInput.cancel();
487
+ baseBranchList.focus();
488
+ });
489
+ baseBranchList.key(['escape'], cancelForm);
490
+ baseBranchList.key(['enter'], submitForm);
491
+ baseBranchList.key(['tab'], () => {
492
+ branchInput.focus();
493
+ });
494
+ this.screen.render();
495
+ }
496
+ async promptDeleteWorktree() {
497
+ if (this.isModalOpen)
498
+ return;
499
+ const wt = this.worktrees[this.selectedIndex];
500
+ if (!wt)
501
+ return;
502
+ if (wt.isMain) {
503
+ this.setStatus(' Cannot delete main worktree', 'red');
504
+ return;
505
+ }
506
+ this.isModalOpen = true;
507
+ const confirmed = await this.showConfirm(`Delete worktree "${wt.branch}"?`, 'Confirm Delete', 'red');
508
+ if (!confirmed) {
509
+ this.isModalOpen = false;
510
+ this.worktreeList.focus();
511
+ this.setStatus(' Delete cancelled', 'blue');
512
+ return;
513
+ }
514
+ const branchName = wt.branch;
515
+ this.setStatus(` Deleting worktree: ${branchName}...`, 'blue');
516
+ // Show loading indicator in center of screen
517
+ const loadingBox = blessed.box({
518
+ parent: this.screen,
519
+ left: 'center',
520
+ top: 'center',
521
+ width: 40,
522
+ height: 5,
523
+ border: { type: 'line' },
524
+ style: {
525
+ border: { fg: 'yellow' },
526
+ bg: 'default'
527
+ },
528
+ label: ' Please Wait ',
529
+ content: `\n Deleting worktree...\n This may take a moment.`,
530
+ tags: true
531
+ });
532
+ this.screen.render();
533
+ try {
534
+ await this.git.remove(wt.path, true);
535
+ loadingBox.destroy();
536
+ await this.refresh();
537
+ this.setStatus(` Deleted worktree: ${branchName}`, 'green');
538
+ // Ask about branch deletion (only for non-protected branches)
539
+ if (!this.isProtectedBranch(branchName)) {
540
+ const deleteBranch = await this.showTextInputConfirm(`Also delete branch "${branchName}"?`, 'Delete Branch', 'yellow', 'yes');
541
+ if (deleteBranch) {
542
+ this.setStatus(` Deleting branch: ${branchName}...`, 'blue');
543
+ try {
544
+ await this.git.deleteBranch(branchName, true);
545
+ this.setStatus(` Deleted worktree and branch: ${branchName}`, 'green');
546
+ }
547
+ catch (branchError) {
548
+ const message = branchError instanceof GitCommandError
549
+ ? branchError.getUserMessage()
550
+ : (branchError instanceof Error ? branchError.message : String(branchError));
551
+ this.setStatus(` Worktree deleted, but branch deletion failed: ${message}`, 'yellow');
552
+ }
553
+ }
554
+ else {
555
+ this.setStatus(` Deleted worktree: ${branchName} (branch kept)`, 'green');
556
+ }
557
+ }
558
+ }
559
+ catch (error) {
560
+ loadingBox.destroy();
561
+ const message = error instanceof GitCommandError
562
+ ? error.getUserMessage()
563
+ : (error instanceof Error ? error.message : String(error));
564
+ this.setStatus(` Error: ${message}`, 'red');
565
+ }
566
+ this.isModalOpen = false;
567
+ this.worktreeList.focus();
568
+ }
569
+ openInEditor() {
570
+ if (this.isModalOpen)
571
+ return;
572
+ const wt = this.worktrees[this.selectedIndex];
573
+ if (!wt)
574
+ return;
575
+ this.isModalOpen = true;
576
+ const editors = [
577
+ { name: 'VS Code', command: 'code' },
578
+ { name: 'Cursor', command: 'cursor' },
579
+ { name: 'Zed', command: 'zed' },
580
+ { name: 'WebStorm', command: 'webstorm' },
581
+ { name: 'Sublime Text', command: 'subl' },
582
+ { name: 'Neovim', command: 'nvim', terminal: true }
583
+ ];
584
+ const list = blessed.list({
585
+ parent: this.screen,
586
+ left: 'center',
587
+ top: 'center',
588
+ width: UI.SELECTOR_WIDTH,
589
+ height: UI.EDITOR_SELECTOR_HEIGHT,
590
+ border: { type: 'line' },
591
+ style: {
592
+ border: { fg: 'blue' },
593
+ selected: { bg: 'blue', fg: 'white' },
594
+ bg: 'default'
595
+ },
596
+ label: ' Select Editor ',
597
+ keys: true,
598
+ vi: true,
599
+ items: editors.map(e => ` ${e.name} (${e.command})`)
600
+ });
601
+ list.focus();
602
+ this.screen.render();
603
+ const cleanup = () => {
604
+ list.destroy();
605
+ this.isModalOpen = false;
606
+ this.worktreeList.focus();
607
+ this.screen.render();
608
+ };
609
+ list.key(['escape'], cleanup);
610
+ list.key(['enter'], () => {
611
+ const selectedIdx = list.selected;
612
+ const editor = editors[selectedIdx];
613
+ cleanup();
614
+ this.openEditorTool(editor.command, editor.name, editor.terminal);
615
+ });
616
+ }
617
+ openEditorTool(command, name, terminal = false) {
618
+ const wt = this.worktrees[this.selectedIndex];
619
+ if (!wt)
620
+ return;
621
+ this.setStatus(` Opening ${wt.branch} in ${name}...`, 'blue');
622
+ const handleError = (err) => {
623
+ if (err.code === 'ENOENT') {
624
+ const hint = getInstallInstruction(command);
625
+ this.setStatus(` ${name} not found. ${hint}`, 'red');
626
+ }
627
+ else {
628
+ this.setStatus(` Error: ${err.message}`, 'red');
629
+ }
630
+ };
631
+ try {
632
+ let proc;
633
+ if (terminal) {
634
+ // Terminal-based editors need to open in a new terminal
635
+ if (PLATFORM.IS_MAC) {
636
+ const escapedPath = escapeAppleScript(wt.path);
637
+ const script = `
638
+ tell application "Terminal"
639
+ do script "cd '${escapedPath}' && ${command} ."
640
+ activate
641
+ end tell
642
+ `;
643
+ proc = spawn('osascript', ['-e', script], { detached: true, stdio: 'ignore' });
644
+ }
645
+ else if (PLATFORM.IS_WIN) {
646
+ const escapedPath = escapeWindowsArg(wt.path);
647
+ proc = spawn('cmd', ['/c', 'start', 'cmd', '/k', `cd /d ${escapedPath} && ${command} .`], {
648
+ detached: true,
649
+ stdio: 'ignore',
650
+ shell: true
651
+ });
652
+ }
653
+ else {
654
+ // Linux - pass path safely via argument array
655
+ proc = spawn('gnome-terminal', ['--working-directory', wt.path, '--', command, '.'], {
656
+ detached: true,
657
+ stdio: 'ignore'
658
+ });
659
+ }
660
+ }
661
+ else {
662
+ // GUI editors can open directly - pass path via array (safe)
663
+ proc = spawn(command, [wt.path], { detached: true, stdio: 'ignore' });
664
+ }
665
+ proc.on('error', handleError);
666
+ proc.unref();
667
+ this.setStatus(` Opened ${wt.branch} in ${name}`, 'green');
668
+ }
669
+ catch (error) {
670
+ this.setStatus(` Error: ${error instanceof Error ? error.message : error}`, 'red');
671
+ }
672
+ }
673
+ openTerminal() {
674
+ const wt = this.worktrees[this.selectedIndex];
675
+ if (!wt)
676
+ return;
677
+ this.setStatus(` Opening terminal in ${wt.branch}...`, 'blue');
678
+ try {
679
+ if (PLATFORM.IS_MAC) {
680
+ // Open in new Terminal.app tab - path passed as argument (safe)
681
+ spawn('open', ['-a', 'Terminal', wt.path], { detached: true, stdio: 'ignore' });
682
+ }
683
+ else if (PLATFORM.IS_WIN) {
684
+ // Windows - escape path for cmd.exe
685
+ const escapedPath = escapeWindowsArg(wt.path);
686
+ spawn('cmd', ['/c', 'start', 'cmd', '/k', `cd /d ${escapedPath}`], {
687
+ detached: true,
688
+ stdio: 'ignore',
689
+ shell: true
690
+ });
691
+ }
692
+ else if (PLATFORM.IS_WSL) {
693
+ // WSL - pass path directly to wt.exe
694
+ spawn('wt.exe', ['-d', wt.path], { detached: true, stdio: 'ignore' });
695
+ }
696
+ else {
697
+ // Linux - try common terminal emulators, pass path via argument array (safe)
698
+ const terminals = ['gnome-terminal', 'konsole', 'xterm', 'terminator'];
699
+ let launched = false;
700
+ for (const term of terminals) {
701
+ try {
702
+ const proc = spawn(term, ['--working-directory', wt.path], {
703
+ detached: true,
704
+ stdio: 'ignore'
705
+ });
706
+ proc.on('error', () => { });
707
+ proc.unref();
708
+ launched = true;
709
+ break;
710
+ }
711
+ catch {
712
+ // Try next terminal
713
+ }
714
+ }
715
+ if (!launched) {
716
+ this.setStatus(' No terminal emulator found. Install gnome-terminal, konsole, xterm, or terminator', 'red');
717
+ return;
718
+ }
719
+ }
720
+ this.setStatus(` Opened terminal in ${wt.branch}`, 'green');
721
+ }
722
+ catch (error) {
723
+ this.setStatus(` Error: ${error instanceof Error ? error.message : error}`, 'red');
724
+ }
725
+ }
726
+ launchAI() {
727
+ if (this.isModalOpen)
728
+ return;
729
+ const wt = this.worktrees[this.selectedIndex];
730
+ if (!wt)
731
+ return;
732
+ this.isModalOpen = true;
733
+ const aiTools = [
734
+ { name: 'Claude', command: 'claude' },
735
+ { name: 'Gemini', command: 'gemini' },
736
+ { name: 'Codex', command: 'codex' }
737
+ ];
738
+ const list = blessed.list({
739
+ parent: this.screen,
740
+ left: 'center',
741
+ top: 'center',
742
+ width: UI.SELECTOR_WIDTH,
743
+ height: UI.AI_SELECTOR_HEIGHT,
744
+ border: { type: 'line' },
745
+ style: {
746
+ border: { fg: 'magenta' },
747
+ selected: { bg: 'blue', fg: 'white' },
748
+ bg: 'default'
749
+ },
750
+ label: ' Select AI Tool ',
751
+ keys: true,
752
+ vi: true,
753
+ items: aiTools.map(t => ` ${t.name} (${t.command})`)
754
+ });
755
+ list.focus();
756
+ this.screen.render();
757
+ const cleanup = () => {
758
+ list.destroy();
759
+ this.isModalOpen = false;
760
+ this.worktreeList.focus();
761
+ this.screen.render();
762
+ };
763
+ list.key(['escape'], cleanup);
764
+ list.key(['enter'], () => {
765
+ const selectedIdx = list.selected;
766
+ const tool = aiTools[selectedIdx];
767
+ cleanup();
768
+ this.launchAITool(tool.command, tool.name);
769
+ });
770
+ }
771
+ launchAITool(command, name) {
772
+ const wt = this.worktrees[this.selectedIndex];
773
+ if (!wt)
774
+ return;
775
+ this.setStatus(` Launching ${name} in ${wt.branch}...`, 'blue');
776
+ try {
777
+ let proc;
778
+ if (PLATFORM.IS_MAC) {
779
+ const escapedPath = escapeAppleScript(wt.path);
780
+ const script = `
781
+ tell application "Terminal"
782
+ do script "cd '${escapedPath}' && ${command}"
783
+ activate
784
+ end tell
785
+ `;
786
+ proc = spawn('osascript', ['-e', script], { detached: true, stdio: 'ignore' });
787
+ }
788
+ else if (PLATFORM.IS_WIN) {
789
+ const escapedPath = escapeWindowsArg(wt.path);
790
+ proc = spawn('cmd', ['/c', 'start', 'cmd', '/k', `cd /d ${escapedPath} && ${command}`], {
791
+ detached: true,
792
+ stdio: 'ignore',
793
+ shell: true
794
+ });
795
+ }
796
+ else {
797
+ // Linux - pass arguments via array (safe)
798
+ proc = spawn('gnome-terminal', ['--working-directory', wt.path, '--', command], {
799
+ detached: true,
800
+ stdio: 'ignore'
801
+ });
802
+ }
803
+ proc.on('error', (err) => {
804
+ if (err.code === 'ENOENT') {
805
+ const hint = getInstallInstruction(command);
806
+ this.setStatus(` ${name} not found. ${hint}`, 'red');
807
+ }
808
+ });
809
+ proc.unref();
810
+ this.setStatus(` Launched ${name} in ${wt.branch}`, 'magenta');
811
+ }
812
+ catch (error) {
813
+ this.setStatus(` Error: ${error instanceof Error ? error.message : error}`, 'red');
814
+ }
815
+ }
816
+ showDetails() {
817
+ // Details are already shown in the detail panel
818
+ this.updateDetails();
819
+ this.screen.render();
820
+ }
821
+ startAutoRefresh() {
822
+ this.stopAutoRefresh();
823
+ this.autoRefreshInterval = setInterval(async () => {
824
+ // Only auto-refresh when no modal is open
825
+ if (!this.isModalOpen) {
826
+ await this.refresh();
827
+ }
828
+ }, this.AUTO_REFRESH_MS);
829
+ }
830
+ stopAutoRefresh() {
831
+ if (this.autoRefreshInterval) {
832
+ clearInterval(this.autoRefreshInterval);
833
+ this.autoRefreshInterval = null;
834
+ }
835
+ }
836
+ async start() {
837
+ await this.refresh();
838
+ this.startAutoRefresh();
839
+ this.worktreeList.focus();
840
+ this.screen.render();
841
+ }
842
+ }