claude-code-plus-plus 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1535 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Sidebar application - runs in the left tmux pane
4
+ * Displays worktrees and manages Claude sessions
5
+ *
6
+ * Each session is a separate tmux pane that stays alive.
7
+ * Switching sessions swaps which pane is visible.
8
+ *
9
+ * State is saved to ~/.claude-plus-plus/<project>/ for potential restore.
10
+ * However, primary persistence is via tmux - just detach (Ctrl+B d) and reattach!
11
+ */
12
+ import { Tmux } from './tmux.js';
13
+ import { WorktreeManager } from './core/worktree-manager.js';
14
+ import { saveState } from './state.js';
15
+ import { DEFAULT_CONFIG } from './types.js';
16
+ import { basename, dirname, resolve } from 'path';
17
+ import { writeFileSync, existsSync, readFileSync, unlinkSync, appendFileSync } from 'fs';
18
+ import { fileURLToPath } from 'url';
19
+ // Debug logging to file
20
+ function debugLog(...args) {
21
+ const msg = `[${new Date().toISOString()}] ${args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ')}\n`;
22
+ appendFileSync('/tmp/claude-pp-debug.log', msg);
23
+ }
24
+ const __filename = fileURLToPath(import.meta.url);
25
+ const __dirname = dirname(__filename);
26
+ // ANSI escape codes for sidebar rendering
27
+ const ESC = '\x1b';
28
+ const CSI = `${ESC}[`;
29
+ const ansi = {
30
+ clearScreen: `${CSI}2J`,
31
+ moveTo: (row, col) => `${CSI}${row};${col}H`,
32
+ reset: `${CSI}0m`,
33
+ bold: `${CSI}1m`,
34
+ dim: `${CSI}2m`,
35
+ inverse: `${CSI}7m`,
36
+ fg: {
37
+ black: `${CSI}30m`,
38
+ red: `${CSI}31m`,
39
+ green: `${CSI}32m`,
40
+ yellow: `${CSI}33m`,
41
+ blue: `${CSI}34m`,
42
+ magenta: `${CSI}35m`,
43
+ cyan: `${CSI}36m`,
44
+ white: `${CSI}37m`,
45
+ gray: `${CSI}90m`,
46
+ },
47
+ hideCursor: `${CSI}?25l`,
48
+ showCursor: `${CSI}?25h`,
49
+ // Mouse support
50
+ enableMouse: `${CSI}?1000h${CSI}?1006h`,
51
+ disableMouse: `${CSI}?1000l${CSI}?1006l`,
52
+ };
53
+ const SIDEBAR_WIDTH = 25;
54
+ class Sidebar {
55
+ tmux;
56
+ worktreeManager;
57
+ repoPath;
58
+ sessionName;
59
+ state;
60
+ rightPaneId; // The "slot" on the right where we show sessions
61
+ sidebarPaneId;
62
+ running = false;
63
+ clickableRegions = [];
64
+ constructor(repoPath, sessionName, rightPaneId, sidebarPaneId) {
65
+ this.repoPath = repoPath;
66
+ this.sessionName = sessionName;
67
+ this.tmux = new Tmux(sessionName);
68
+ this.worktreeManager = new WorktreeManager(repoPath);
69
+ this.rightPaneId = rightPaneId;
70
+ this.sidebarPaneId = sidebarPaneId;
71
+ this.state = {
72
+ worktrees: [],
73
+ sessions: [],
74
+ selectedIndex: 0,
75
+ activeSessionId: null,
76
+ visiblePaneId: rightPaneId, // Initially the right pane is visible
77
+ showQuitModal: false,
78
+ quitModalSelection: 'detach',
79
+ collapsed: false,
80
+ showNewWorktreeInput: false,
81
+ newWorktreeBranch: '',
82
+ showRenameInput: false,
83
+ renameTarget: null,
84
+ renameValue: '',
85
+ showNewSessionInput: false,
86
+ newSessionWorktree: null,
87
+ newSessionName: '',
88
+ terminalCommandMode: false,
89
+ terminalCommandBuffer: '',
90
+ showDeleteConfirmModal: false,
91
+ deleteConfirmTarget: null,
92
+ deleteConfirmSelection: 'no',
93
+ };
94
+ }
95
+ async init() {
96
+ // Load worktrees
97
+ this.state.worktrees = await this.worktreeManager.list();
98
+ // If no git worktrees, create a fallback for current directory
99
+ if (this.state.worktrees.length === 0) {
100
+ this.state.worktrees = [{
101
+ id: 'current',
102
+ path: this.repoPath,
103
+ branch: basename(this.repoPath),
104
+ isMain: true,
105
+ sessions: [],
106
+ }];
107
+ }
108
+ }
109
+ /**
110
+ * Ensure sidebar stays at fixed width after pane operations
111
+ */
112
+ enforceSidebarWidth() {
113
+ if (!this.state.collapsed) {
114
+ this.tmux.resizePane(this.sidebarPaneId, SIDEBAR_WIDTH);
115
+ }
116
+ }
117
+ /**
118
+ * Toggle sidebar collapsed/expanded state
119
+ */
120
+ toggleSidebar() {
121
+ this.state.collapsed = !this.state.collapsed;
122
+ if (this.state.collapsed) {
123
+ // Collapse to 2 columns (enough to show session count)
124
+ this.tmux.resizePane(this.sidebarPaneId, 2);
125
+ }
126
+ else {
127
+ // Expand to full width
128
+ this.tmux.resizePane(this.sidebarPaneId, SIDEBAR_WIDTH);
129
+ }
130
+ this.render();
131
+ }
132
+ /**
133
+ * Save current state to disk (for potential future restore)
134
+ */
135
+ async persistState() {
136
+ const state = {
137
+ version: 1,
138
+ projectPath: this.repoPath,
139
+ tmuxSessionName: this.sessionName,
140
+ sessions: this.state.sessions.map(s => ({
141
+ id: s.id,
142
+ worktreeId: s.worktreeId,
143
+ mainPaneId: s.mainPaneId,
144
+ terminalManagerPaneId: s.terminalManagerPaneId,
145
+ terminals: s.terminals,
146
+ activeTerminalIndex: s.activeTerminalIndex,
147
+ title: s.title,
148
+ })),
149
+ activeSessionId: this.state.activeSessionId,
150
+ selectedIndex: this.state.selectedIndex,
151
+ sidebarPaneId: this.sidebarPaneId,
152
+ rightPaneId: this.rightPaneId,
153
+ };
154
+ try {
155
+ await saveState(state);
156
+ }
157
+ catch (err) {
158
+ // Silently fail - state saving is best-effort
159
+ }
160
+ }
161
+ start() {
162
+ this.running = true;
163
+ // Set up terminal
164
+ process.stdout.write(ansi.hideCursor);
165
+ process.stdout.write(ansi.enableMouse);
166
+ // Set up raw mode for input
167
+ if (process.stdin.isTTY) {
168
+ process.stdin.setRawMode(true);
169
+ }
170
+ process.stdin.resume();
171
+ process.stdin.on('data', this.handleInput.bind(this));
172
+ // Handle resize - sync collapsed state based on actual width
173
+ process.stdout.on('resize', () => {
174
+ this.syncCollapsedState();
175
+ this.render();
176
+ });
177
+ // Initial render
178
+ this.render();
179
+ }
180
+ /**
181
+ * Sync collapsed state based on actual pane width
182
+ * This handles external resize (tmux hotkey, manual drag)
183
+ */
184
+ syncCollapsedState() {
185
+ const width = process.stdout.columns || SIDEBAR_WIDTH;
186
+ // If width is less than half of SIDEBAR_WIDTH, consider it collapsed
187
+ this.state.collapsed = width < SIDEBAR_WIDTH / 2;
188
+ }
189
+ stop() {
190
+ this.running = false;
191
+ process.stdout.write(ansi.disableMouse);
192
+ process.stdout.write(ansi.showCursor);
193
+ process.stdout.write(ansi.reset);
194
+ if (process.stdin.isTTY) {
195
+ process.stdin.setRawMode(false);
196
+ }
197
+ process.stdin.pause();
198
+ }
199
+ handleInput(data) {
200
+ const key = data.toString();
201
+ debugLog('handleInput received key:', JSON.stringify(key), 'hex:', data.toString('hex'));
202
+ // Handle mouse events (SGR extended mode)
203
+ const mouseMatch = key.match(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/);
204
+ if (mouseMatch) {
205
+ const button = parseInt(mouseMatch[1], 10);
206
+ const col = parseInt(mouseMatch[2], 10);
207
+ const row = parseInt(mouseMatch[3], 10);
208
+ const isRelease = mouseMatch[4] === 'm';
209
+ // Handle left click release (button 0)
210
+ if (button === 0 && isRelease) {
211
+ this.handleClick(row, col);
212
+ }
213
+ return;
214
+ }
215
+ // Handle quit modal if open
216
+ if (this.state.showQuitModal) {
217
+ this.handleQuitModalInput(key);
218
+ return;
219
+ }
220
+ // Handle new worktree input if open
221
+ if (this.state.showNewWorktreeInput) {
222
+ this.handleNewWorktreeInput(key, data);
223
+ return;
224
+ }
225
+ // Handle rename input if open
226
+ if (this.state.showRenameInput) {
227
+ this.handleRenameInput(key, data);
228
+ return;
229
+ }
230
+ // Handle new session input if open
231
+ if (this.state.showNewSessionInput) {
232
+ this.handleNewSessionInput(key, data);
233
+ return;
234
+ }
235
+ // Handle delete confirmation modal if open
236
+ if (this.state.showDeleteConfirmModal) {
237
+ this.handleDeleteConfirmInput(key);
238
+ return;
239
+ }
240
+ // Handle terminal manager command mode
241
+ if (this.state.terminalCommandMode) {
242
+ if (key === '\r') {
243
+ // Enter - execute the command
244
+ this.executeTerminalCommand(this.state.terminalCommandBuffer);
245
+ this.state.terminalCommandMode = false;
246
+ this.state.terminalCommandBuffer = '';
247
+ }
248
+ else if (key === '\x1b') {
249
+ // Escape - cancel command mode
250
+ this.state.terminalCommandMode = false;
251
+ this.state.terminalCommandBuffer = '';
252
+ }
253
+ else {
254
+ // Accumulate command characters
255
+ this.state.terminalCommandBuffer += key;
256
+ }
257
+ return;
258
+ }
259
+ // Ctrl+U - enter terminal manager command mode
260
+ if (key === '\x15') {
261
+ this.state.terminalCommandMode = true;
262
+ this.state.terminalCommandBuffer = '';
263
+ return;
264
+ }
265
+ // Ctrl+C - show quit modal
266
+ if (key === '\x03') {
267
+ this.state.showQuitModal = true;
268
+ this.state.quitModalSelection = 'detach'; // Default to detach
269
+ this.render();
270
+ return;
271
+ }
272
+ // Ctrl+G - toggle sidebar collapsed/expanded
273
+ if (key === '\x07') {
274
+ this.toggleSidebar();
275
+ return;
276
+ }
277
+ // If collapsed, only respond to Ctrl+G (to expand)
278
+ if (this.state.collapsed) {
279
+ return;
280
+ }
281
+ // Escape - do nothing in main view (could add functionality later)
282
+ if (key === '\x1b' && data.length === 1) {
283
+ return;
284
+ }
285
+ // Ctrl+T - new terminal for active session (works globally via tmux binding)
286
+ if (key === '\x14') {
287
+ this.createTerminalForSession();
288
+ return;
289
+ }
290
+ // 'n' - new worktree
291
+ if (key === 'n') {
292
+ this.state.showNewWorktreeInput = true;
293
+ this.state.newWorktreeBranch = '';
294
+ this.render();
295
+ return;
296
+ }
297
+ // Arrow up or 'k'
298
+ if (key === '\x1b[A' || key === 'k') {
299
+ this.state.selectedIndex = Math.max(0, this.state.selectedIndex - 1);
300
+ this.render();
301
+ return;
302
+ }
303
+ // Arrow down or 'j'
304
+ if (key === '\x1b[B' || key === 'j') {
305
+ const maxIndex = this.getMaxIndex();
306
+ this.state.selectedIndex = Math.min(maxIndex, this.state.selectedIndex + 1);
307
+ this.render();
308
+ return;
309
+ }
310
+ // Enter - activate selected item
311
+ if (key === '\r') {
312
+ this.activateSelected();
313
+ return;
314
+ }
315
+ // 'd' - delete selected item (session or worktree)
316
+ if (key === 'd') {
317
+ debugLog('d key pressed, selectedIndex:', this.state.selectedIndex);
318
+ this.showDeleteConfirmation();
319
+ return;
320
+ }
321
+ // 'r' - rename selected item
322
+ if (key === 'r') {
323
+ this.startRename();
324
+ return;
325
+ }
326
+ }
327
+ handleClick(row, col) {
328
+ // If sidebar is collapsed, expand it on click
329
+ if (this.state.collapsed) {
330
+ this.toggleSidebar();
331
+ return;
332
+ }
333
+ // If any modal is open, ignore clicks (or close modal)
334
+ if (this.state.showQuitModal || this.state.showNewWorktreeInput ||
335
+ this.state.showRenameInput || this.state.showNewSessionInput) {
336
+ return;
337
+ }
338
+ // Find clicked region
339
+ const region = this.clickableRegions.find(r => r.row === row && col >= r.startCol && col <= r.endCol);
340
+ if (!region)
341
+ return;
342
+ if (region.type === 'worktree') {
343
+ const worktree = region.item;
344
+ // Check for special buttons
345
+ if (worktree.id === '__collapse__') {
346
+ // Collapse button clicked
347
+ this.toggleSidebar();
348
+ return;
349
+ }
350
+ if (worktree.id === '__new_worktree__') {
351
+ // New worktree button clicked
352
+ this.state.showNewWorktreeInput = true;
353
+ this.state.newWorktreeBranch = '';
354
+ this.render();
355
+ return;
356
+ }
357
+ // Click on worktree - open new session modal
358
+ this.state.showNewSessionInput = true;
359
+ this.state.newSessionWorktree = worktree;
360
+ this.state.newSessionName = '';
361
+ this.render();
362
+ }
363
+ else if (region.type === 'session') {
364
+ const session = region.item;
365
+ if (session.id === this.state.activeSessionId) {
366
+ // Already active - focus the Claude pane
367
+ this.tmux.selectPane(session.mainPaneId);
368
+ }
369
+ else {
370
+ // Switch to this session
371
+ this.switchToSession(session);
372
+ }
373
+ }
374
+ }
375
+ handleQuitModalInput(key) {
376
+ // Escape - close modal
377
+ if (key === '\x1b' && key.length === 1) {
378
+ this.state.showQuitModal = false;
379
+ this.render();
380
+ return;
381
+ }
382
+ // Arrow up/down or j/k - toggle selection
383
+ if (key === '\x1b[A' || key === 'k' || key === '\x1b[B' || key === 'j') {
384
+ this.state.quitModalSelection = this.state.quitModalSelection === 'detach' ? 'kill' : 'detach';
385
+ this.render();
386
+ return;
387
+ }
388
+ // Enter - confirm selection
389
+ if (key === '\r') {
390
+ if (this.state.quitModalSelection === 'detach') {
391
+ // Detach from tmux (session keeps running, sidebar keeps running)
392
+ // Close modal first so sidebar shows normal view when reattached
393
+ this.state.showQuitModal = false;
394
+ this.render();
395
+ // Detach the client - sidebar process stays alive in the pane
396
+ this.tmux.detachClient();
397
+ }
398
+ else {
399
+ // Kill the session
400
+ this.stop();
401
+ this.tmux.killSession();
402
+ process.exit(0);
403
+ }
404
+ return;
405
+ }
406
+ // 'q' or another Ctrl+C - close modal
407
+ if (key === 'q' || key === '\x03') {
408
+ this.state.showQuitModal = false;
409
+ this.render();
410
+ return;
411
+ }
412
+ }
413
+ handleNewWorktreeInput(key, data) {
414
+ // Escape - cancel
415
+ if (key === '\x1b' && data.length === 1) {
416
+ this.state.showNewWorktreeInput = false;
417
+ this.state.newWorktreeBranch = '';
418
+ this.render();
419
+ return;
420
+ }
421
+ // Ctrl+C - cancel
422
+ if (key === '\x03') {
423
+ this.state.showNewWorktreeInput = false;
424
+ this.state.newWorktreeBranch = '';
425
+ this.render();
426
+ return;
427
+ }
428
+ // Enter - create worktree
429
+ if (key === '\r') {
430
+ if (this.state.newWorktreeBranch.trim()) {
431
+ this.createNewWorktree(this.state.newWorktreeBranch.trim());
432
+ }
433
+ this.state.showNewWorktreeInput = false;
434
+ this.state.newWorktreeBranch = '';
435
+ this.render();
436
+ return;
437
+ }
438
+ // Backspace
439
+ if (key === '\x7f' || key === '\b') {
440
+ this.state.newWorktreeBranch = this.state.newWorktreeBranch.slice(0, -1);
441
+ this.render();
442
+ return;
443
+ }
444
+ // Regular character input (printable ASCII)
445
+ if (data.length === 1 && data[0] >= 32 && data[0] < 127) {
446
+ // Only allow valid branch name characters
447
+ if (/[a-zA-Z0-9\-_\/.]/.test(key)) {
448
+ this.state.newWorktreeBranch += key;
449
+ this.render();
450
+ }
451
+ return;
452
+ }
453
+ }
454
+ async createNewWorktree(branchName) {
455
+ try {
456
+ // Create a new branch and worktree from HEAD
457
+ const worktree = await this.worktreeManager.create(branchName, true);
458
+ this.state.worktrees.push(worktree);
459
+ // Select the new worktree
460
+ this.state.selectedIndex = this.getMaxIndex();
461
+ this.render();
462
+ }
463
+ catch (err) {
464
+ // Could show error in UI, for now just log
465
+ // The worktree manager will throw if branch exists, etc.
466
+ }
467
+ }
468
+ startRename() {
469
+ const item = this.getItemAtIndex(this.state.selectedIndex);
470
+ if (!item)
471
+ return;
472
+ // Don't allow renaming main worktree
473
+ if (item.type === 'worktree' && item.item.isMain) {
474
+ return;
475
+ }
476
+ this.state.showRenameInput = true;
477
+ this.state.renameTarget = item;
478
+ if (item.type === 'worktree') {
479
+ this.state.renameValue = item.item.branch;
480
+ }
481
+ else {
482
+ this.state.renameValue = item.item.title;
483
+ }
484
+ this.render();
485
+ }
486
+ handleRenameInput(key, data) {
487
+ // Escape - cancel
488
+ if (key === '\x1b' && data.length === 1) {
489
+ this.state.showRenameInput = false;
490
+ this.state.renameTarget = null;
491
+ this.state.renameValue = '';
492
+ this.render();
493
+ return;
494
+ }
495
+ // Ctrl+C - cancel
496
+ if (key === '\x03') {
497
+ this.state.showRenameInput = false;
498
+ this.state.renameTarget = null;
499
+ this.state.renameValue = '';
500
+ this.render();
501
+ return;
502
+ }
503
+ // Enter - confirm rename
504
+ if (key === '\r') {
505
+ if (this.state.renameValue.trim() && this.state.renameTarget) {
506
+ this.performRename(this.state.renameTarget, this.state.renameValue.trim());
507
+ }
508
+ this.state.showRenameInput = false;
509
+ this.state.renameTarget = null;
510
+ this.state.renameValue = '';
511
+ this.render();
512
+ return;
513
+ }
514
+ // Backspace
515
+ if (key === '\x7f' || key === '\b') {
516
+ this.state.renameValue = this.state.renameValue.slice(0, -1);
517
+ this.render();
518
+ return;
519
+ }
520
+ // Regular character input (printable ASCII)
521
+ if (data.length === 1 && data[0] >= 32 && data[0] < 127) {
522
+ if (this.state.renameTarget?.type === 'worktree') {
523
+ // Only allow valid branch name characters for worktrees
524
+ if (/[a-zA-Z0-9\-_\/.]/.test(key)) {
525
+ this.state.renameValue += key;
526
+ this.render();
527
+ }
528
+ }
529
+ else {
530
+ // Allow any printable character for session names
531
+ this.state.renameValue += key;
532
+ this.render();
533
+ }
534
+ return;
535
+ }
536
+ }
537
+ handleNewSessionInput(key, data) {
538
+ // Escape - cancel
539
+ if (key === '\x1b' && data.length === 1) {
540
+ this.state.showNewSessionInput = false;
541
+ this.state.newSessionWorktree = null;
542
+ this.state.newSessionName = '';
543
+ this.render();
544
+ return;
545
+ }
546
+ // Ctrl+C - cancel
547
+ if (key === '\x03') {
548
+ this.state.showNewSessionInput = false;
549
+ this.state.newSessionWorktree = null;
550
+ this.state.newSessionName = '';
551
+ this.render();
552
+ return;
553
+ }
554
+ // Enter - create session
555
+ if (key === '\r') {
556
+ if (this.state.newSessionName.trim() && this.state.newSessionWorktree) {
557
+ this.createSessionForWorktree(this.state.newSessionWorktree, this.state.newSessionName.trim());
558
+ }
559
+ this.state.showNewSessionInput = false;
560
+ this.state.newSessionWorktree = null;
561
+ this.state.newSessionName = '';
562
+ return;
563
+ }
564
+ // Backspace
565
+ if (key === '\x7f' || key === '\b') {
566
+ this.state.newSessionName = this.state.newSessionName.slice(0, -1);
567
+ this.render();
568
+ return;
569
+ }
570
+ // Regular character input (printable ASCII)
571
+ if (data.length === 1 && data[0] >= 32 && data[0] < 127) {
572
+ this.state.newSessionName += key;
573
+ this.render();
574
+ return;
575
+ }
576
+ }
577
+ showDeleteConfirmation() {
578
+ const item = this.getItemAtIndex(this.state.selectedIndex);
579
+ if (!item)
580
+ return;
581
+ // Don't allow deleting main worktree
582
+ if (item.type === 'worktree' && item.item.isMain) {
583
+ return;
584
+ }
585
+ this.state.showDeleteConfirmModal = true;
586
+ this.state.deleteConfirmTarget = item;
587
+ this.state.deleteConfirmSelection = 'no'; // Default to No for safety
588
+ this.render();
589
+ }
590
+ handleDeleteConfirmInput(key) {
591
+ // Escape - cancel
592
+ if (key === '\x1b') {
593
+ this.state.showDeleteConfirmModal = false;
594
+ this.state.deleteConfirmTarget = null;
595
+ this.render();
596
+ return;
597
+ }
598
+ // Arrow up/down or j/k - toggle selection
599
+ if (key === '\x1b[A' || key === 'k' || key === '\x1b[B' || key === 'j') {
600
+ this.state.deleteConfirmSelection = this.state.deleteConfirmSelection === 'yes' ? 'no' : 'yes';
601
+ this.render();
602
+ return;
603
+ }
604
+ // Enter - confirm current selection
605
+ if (key === '\r') {
606
+ this.state.showDeleteConfirmModal = false;
607
+ const target = this.state.deleteConfirmTarget;
608
+ const confirmed = this.state.deleteConfirmSelection === 'yes';
609
+ this.state.deleteConfirmTarget = null;
610
+ if (confirmed && target) {
611
+ this.deleteSelectedItem().catch(err => debugLog('deleteSelectedItem error:', err));
612
+ }
613
+ else {
614
+ this.render();
615
+ }
616
+ return;
617
+ }
618
+ // 'y' - quick confirm
619
+ if (key === 'y' || key === 'Y') {
620
+ this.state.showDeleteConfirmModal = false;
621
+ const target = this.state.deleteConfirmTarget;
622
+ this.state.deleteConfirmTarget = null;
623
+ if (target) {
624
+ this.deleteSelectedItem().catch(err => debugLog('deleteSelectedItem error:', err));
625
+ }
626
+ return;
627
+ }
628
+ // 'n' - quick cancel
629
+ if (key === 'n' || key === 'N') {
630
+ this.state.showDeleteConfirmModal = false;
631
+ this.state.deleteConfirmTarget = null;
632
+ this.render();
633
+ return;
634
+ }
635
+ }
636
+ async performRename(target, newName) {
637
+ if (target.type === 'session') {
638
+ // Simple session rename - just update the title
639
+ const session = target.item;
640
+ session.title = newName;
641
+ this.persistState();
642
+ }
643
+ else {
644
+ // Worktree rename - need to rename branch and move worktree atomically
645
+ const worktree = target.item;
646
+ const oldBranch = worktree.branch;
647
+ try {
648
+ const newPath = await this.worktreeManager.rename(worktree.path, oldBranch, newName);
649
+ // Update local state
650
+ worktree.branch = newName;
651
+ worktree.path = newPath;
652
+ // Re-render to show updated name
653
+ this.render();
654
+ }
655
+ catch (err) {
656
+ // Rename failed - worktree manager handles rollback
657
+ // Could show error in UI
658
+ }
659
+ }
660
+ }
661
+ getMaxIndex() {
662
+ let count = 0;
663
+ for (const wt of this.state.worktrees) {
664
+ count++; // worktree itself
665
+ count += this.getSessionsForWorktree(wt.id).length;
666
+ }
667
+ return Math.max(0, count - 1);
668
+ }
669
+ getSessionsForWorktree(worktreeId) {
670
+ return this.state.sessions.filter(s => s.worktreeId === worktreeId);
671
+ }
672
+ getItemAtIndex(index) {
673
+ let currentIndex = 0;
674
+ for (const wt of this.state.worktrees) {
675
+ if (currentIndex === index) {
676
+ return { type: 'worktree', item: wt };
677
+ }
678
+ currentIndex++;
679
+ const sessions = this.getSessionsForWorktree(wt.id);
680
+ for (const session of sessions) {
681
+ if (currentIndex === index) {
682
+ return { type: 'session', item: session };
683
+ }
684
+ currentIndex++;
685
+ }
686
+ }
687
+ return null;
688
+ }
689
+ activateSelected() {
690
+ const item = this.getItemAtIndex(this.state.selectedIndex);
691
+ if (!item)
692
+ return;
693
+ if (item.type === 'worktree') {
694
+ // Show "Create Session" modal for this worktree
695
+ const worktree = item.item;
696
+ const existingSessions = this.getSessionsForWorktree(worktree.id);
697
+ const defaultName = `${existingSessions.length + 1}: ${worktree.branch}`;
698
+ this.state.showNewSessionInput = true;
699
+ this.state.newSessionWorktree = worktree;
700
+ this.state.newSessionName = defaultName;
701
+ this.render();
702
+ }
703
+ else {
704
+ // Switch to this session
705
+ this.switchToSession(item.item);
706
+ }
707
+ }
708
+ createSessionForWorktree(worktree, customTitle) {
709
+ const sessionId = `session-${Date.now()}`;
710
+ const sessionNum = this.getSessionsForWorktree(worktree.id).length + 1;
711
+ // Build claude command
712
+ let claudeCmd = DEFAULT_CONFIG.claudeCodeCommand;
713
+ if (DEFAULT_CONFIG.dangerouslySkipPermissions) {
714
+ claudeCmd += ' --dangerously-skip-permissions';
715
+ }
716
+ let paneId;
717
+ if (this.state.sessions.length === 0) {
718
+ // First session - use the existing right pane
719
+ paneId = this.rightPaneId;
720
+ // Kill any running process (like welcome screen) with Ctrl+C, then clear and run claude
721
+ this.tmux.sendControlKey(paneId, 'C-c');
722
+ this.tmux.sendKeys(paneId, `cd "${worktree.path}" && clear && ${claudeCmd}`, true);
723
+ }
724
+ else {
725
+ // Additional session - need to break current session's panes to background first
726
+ const currentSession = this.state.activeSessionId
727
+ ? this.state.sessions.find(s => s.id === this.state.activeSessionId)
728
+ : null;
729
+ if (currentSession) {
730
+ // Sync terminal state from terminal manager
731
+ this.syncTerminalState(currentSession);
732
+ // Break active terminal (if any)
733
+ if (currentSession.terminals.length > 0) {
734
+ const activeTerminal = currentSession.terminals[currentSession.activeTerminalIndex];
735
+ if (activeTerminal) {
736
+ this.tmux.breakPane(activeTerminal.id);
737
+ }
738
+ }
739
+ // Break terminal manager pane (if any)
740
+ if (currentSession.terminalManagerPaneId) {
741
+ this.tmux.breakPane(currentSession.terminalManagerPaneId);
742
+ }
743
+ // Break main pane
744
+ this.tmux.breakPane(currentSession.mainPaneId);
745
+ }
746
+ // Create new pane next to sidebar
747
+ paneId = this.tmux.splitHorizontal(80, worktree.path);
748
+ // Run claude in the new pane
749
+ this.tmux.sendKeys(paneId, `${claudeCmd}`, true);
750
+ }
751
+ const session = {
752
+ id: sessionId,
753
+ worktreeId: worktree.id,
754
+ mainPaneId: paneId,
755
+ terminalManagerPaneId: null,
756
+ terminals: [],
757
+ activeTerminalIndex: 0,
758
+ title: customTitle || `${sessionNum}: ${worktree.branch}`,
759
+ };
760
+ this.state.sessions.push(session);
761
+ this.state.activeSessionId = sessionId;
762
+ this.state.visiblePaneId = paneId;
763
+ worktree.sessions.push(sessionId);
764
+ // Ensure sidebar stays at fixed width
765
+ this.enforceSidebarWidth();
766
+ // Focus back to sidebar
767
+ this.tmux.selectPane(this.sidebarPaneId);
768
+ // Save state
769
+ this.persistState();
770
+ this.render();
771
+ }
772
+ /**
773
+ * Get the state file path for terminal manager
774
+ */
775
+ getTerminalStateFile(sessionId) {
776
+ return `/tmp/claude-pp-term-${sessionId}.json`;
777
+ }
778
+ /**
779
+ * Write terminal manager state file
780
+ */
781
+ writeTerminalState(session) {
782
+ const worktree = this.state.worktrees.find(w => w.id === session.worktreeId);
783
+ const stateFile = this.getTerminalStateFile(session.id);
784
+ const state = {
785
+ sessionId: session.id,
786
+ worktreePath: worktree?.path || this.repoPath,
787
+ tmuxSession: this.sessionName,
788
+ sidebarPaneId: this.sidebarPaneId,
789
+ terminalManagerPaneId: session.terminalManagerPaneId,
790
+ terminals: session.terminals,
791
+ activeIndex: session.activeTerminalIndex,
792
+ };
793
+ writeFileSync(stateFile, JSON.stringify(state, null, 2));
794
+ }
795
+ /**
796
+ * Execute a command from terminal manager (via Ctrl+U protocol)
797
+ * Format: "{action} {index}" where action is S (switch) or D (delete)
798
+ */
799
+ executeTerminalCommand(command) {
800
+ const parts = command.trim().split(/\s+/);
801
+ if (parts.length < 2)
802
+ return;
803
+ const action = parts[0];
804
+ const index = parseInt(parts[1], 10);
805
+ if (isNaN(index))
806
+ return;
807
+ const session = this.state.activeSessionId
808
+ ? this.state.sessions.find(s => s.id === this.state.activeSessionId)
809
+ : null;
810
+ if (!session)
811
+ return;
812
+ if (action === 'S') {
813
+ // Switch terminal tabs
814
+ if (index >= 0 && index < session.terminals.length && index !== session.activeTerminalIndex) {
815
+ this.switchTerminalTab(session, index);
816
+ }
817
+ }
818
+ else if (action === 'D') {
819
+ // Delete terminal at index
820
+ this.deleteTerminalAtIndex(session, index);
821
+ }
822
+ }
823
+ /**
824
+ * Switch to a different terminal tab within a session
825
+ */
826
+ switchTerminalTab(session, targetIndex) {
827
+ const currentTerminal = session.terminals[session.activeTerminalIndex];
828
+ const newTerminal = session.terminals[targetIndex];
829
+ // Break current terminal to background
830
+ this.tmux.breakPane(currentTerminal.id);
831
+ // Join new terminal below the manager pane
832
+ if (session.terminalManagerPaneId) {
833
+ this.tmux.joinPane(newTerminal.id, session.terminalManagerPaneId, false);
834
+ // Ensure terminal manager stays at 1 row
835
+ this.tmux.resizePane(session.terminalManagerPaneId, undefined, 1);
836
+ }
837
+ // Update state
838
+ session.activeTerminalIndex = targetIndex;
839
+ // Write updated state file for terminal manager
840
+ this.writeTerminalState(session);
841
+ // Persist state
842
+ this.persistState();
843
+ }
844
+ /**
845
+ * Delete a terminal at the given index
846
+ */
847
+ deleteTerminalAtIndex(session, index) {
848
+ if (index < 0 || index >= session.terminals.length)
849
+ return;
850
+ const terminal = session.terminals[index];
851
+ const wasActive = index === session.activeTerminalIndex;
852
+ // Kill the terminal pane
853
+ this.tmux.killPane(terminal.id);
854
+ // Remove from terminals array
855
+ session.terminals.splice(index, 1);
856
+ if (session.terminals.length === 0) {
857
+ // No more terminals - kill terminal manager pane
858
+ if (session.terminalManagerPaneId) {
859
+ this.tmux.killPane(session.terminalManagerPaneId);
860
+ session.terminalManagerPaneId = null;
861
+ }
862
+ // Clean up state file
863
+ try {
864
+ const stateFile = this.getTerminalStateFile(session.id);
865
+ unlinkSync(stateFile);
866
+ }
867
+ catch (err) {
868
+ // File might not exist
869
+ }
870
+ }
871
+ else {
872
+ // Adjust activeTerminalIndex
873
+ if (index < session.activeTerminalIndex) {
874
+ // Deleted terminal was before the active one
875
+ session.activeTerminalIndex--;
876
+ }
877
+ else if (session.activeTerminalIndex >= session.terminals.length) {
878
+ // Active index is now out of bounds
879
+ session.activeTerminalIndex = session.terminals.length - 1;
880
+ }
881
+ // If deleted was the visible terminal, show the new active one
882
+ if (wasActive) {
883
+ const newActiveTerminal = session.terminals[session.activeTerminalIndex];
884
+ if (newActiveTerminal && session.terminalManagerPaneId) {
885
+ // Join the new active terminal below the manager
886
+ this.tmux.joinPane(newActiveTerminal.id, session.terminalManagerPaneId, false);
887
+ // Ensure terminal manager stays at 1 row
888
+ this.tmux.resizePane(session.terminalManagerPaneId, undefined, 1);
889
+ }
890
+ }
891
+ // Update terminal manager state file
892
+ this.writeTerminalState(session);
893
+ }
894
+ // Save state
895
+ this.persistState();
896
+ this.render();
897
+ }
898
+ /**
899
+ * Sync terminal state from state file (terminal manager may have changed activeIndex)
900
+ */
901
+ syncTerminalState(session) {
902
+ try {
903
+ const stateFile = this.getTerminalStateFile(session.id);
904
+ if (!existsSync(stateFile))
905
+ return;
906
+ const data = readFileSync(stateFile, 'utf-8');
907
+ const state = JSON.parse(data);
908
+ // Sync activeIndex from terminal manager
909
+ if (typeof state.activeIndex === 'number' && state.activeIndex >= 0 && state.activeIndex < session.terminals.length) {
910
+ session.activeTerminalIndex = state.activeIndex;
911
+ }
912
+ }
913
+ catch (err) {
914
+ // Failed to sync, use existing state
915
+ }
916
+ }
917
+ /**
918
+ * Get the command to run terminal manager
919
+ */
920
+ getTerminalManagerCommand(sessionId) {
921
+ const stateFile = this.getTerminalStateFile(sessionId);
922
+ const tsPath = resolve(__dirname, 'terminal-manager.ts');
923
+ const jsPath = resolve(__dirname, 'terminal-manager.js');
924
+ if (existsSync(tsPath)) {
925
+ return `npx tsx "${tsPath}" "${stateFile}"`;
926
+ }
927
+ else {
928
+ return `node "${jsPath}" "${stateFile}"`;
929
+ }
930
+ }
931
+ createTerminalForSession() {
932
+ // Must have an active session
933
+ if (!this.state.activeSessionId)
934
+ return;
935
+ const session = this.state.sessions.find(s => s.id === this.state.activeSessionId);
936
+ if (!session)
937
+ return;
938
+ // Get the worktree for this session to get the path
939
+ const worktree = this.state.worktrees.find(w => w.id === session.worktreeId);
940
+ if (!worktree)
941
+ return;
942
+ const terminalNum = session.terminals.length + 1;
943
+ const terminalTitle = `Terminal ${terminalNum}`;
944
+ if (session.terminals.length === 0) {
945
+ // First terminal - need to create terminal manager pane + terminal pane
946
+ // Split main pane vertically (70% Claude, 30% terminal area)
947
+ this.tmux.selectPane(session.mainPaneId);
948
+ const terminalAreaPaneId = this.tmux.splitVertical(30, worktree.path);
949
+ // Split terminal area: top part for manager, rest for terminal
950
+ this.tmux.selectPane(terminalAreaPaneId);
951
+ const terminalPaneId = this.tmux.splitVertical(90, worktree.path);
952
+ // The terminalAreaPaneId is now the manager pane
953
+ const terminalManagerPaneId = terminalAreaPaneId;
954
+ // Resize manager pane to exactly 1 row
955
+ this.tmux.resizePane(terminalManagerPaneId, undefined, 1);
956
+ // Update session
957
+ session.terminalManagerPaneId = terminalManagerPaneId;
958
+ session.terminals.push({ id: terminalPaneId, title: terminalTitle });
959
+ session.activeTerminalIndex = 0;
960
+ // Write state file before starting terminal manager
961
+ this.writeTerminalState(session);
962
+ // Start terminal manager in the manager pane
963
+ const managerCmd = this.getTerminalManagerCommand(session.id);
964
+ this.tmux.sendKeys(terminalManagerPaneId, managerCmd, true);
965
+ // Focus the terminal pane
966
+ this.tmux.selectPane(terminalPaneId);
967
+ }
968
+ else {
969
+ // Additional terminal - create pane, background it, switch to it
970
+ // Get the currently visible terminal
971
+ const currentTerminal = session.terminals[session.activeTerminalIndex];
972
+ // Split from current terminal to create new one
973
+ this.tmux.selectPane(currentTerminal.id);
974
+ const newTerminalPaneId = this.tmux.splitVertical(50, worktree.path);
975
+ // Break the current terminal to background
976
+ this.tmux.breakPane(currentTerminal.id);
977
+ // The new terminal is now visible
978
+ session.terminals.push({ id: newTerminalPaneId, title: terminalTitle });
979
+ session.activeTerminalIndex = session.terminals.length - 1;
980
+ // Ensure terminal manager stays at 1 row after the split
981
+ if (session.terminalManagerPaneId) {
982
+ this.tmux.resizePane(session.terminalManagerPaneId, undefined, 1);
983
+ }
984
+ // Update terminal manager state
985
+ this.writeTerminalState(session);
986
+ // Focus the new terminal
987
+ this.tmux.selectPane(newTerminalPaneId);
988
+ }
989
+ // Ensure sidebar stays at fixed width
990
+ this.enforceSidebarWidth();
991
+ // Save state
992
+ this.persistState();
993
+ this.render();
994
+ }
995
+ switchToSession(session) {
996
+ if (session.id === this.state.activeSessionId) {
997
+ // Already active, nothing to do
998
+ return;
999
+ }
1000
+ // Get current session (if any)
1001
+ const currentSession = this.state.activeSessionId
1002
+ ? this.state.sessions.find(s => s.id === this.state.activeSessionId)
1003
+ : null;
1004
+ // Check if target session is already visible (e.g., just created)
1005
+ const targetAlreadyVisible = this.state.visiblePaneId === session.mainPaneId;
1006
+ if (!targetAlreadyVisible) {
1007
+ // Break current session's panes to background (if any)
1008
+ if (currentSession) {
1009
+ // Sync terminal state from file (terminal manager may have changed activeIndex)
1010
+ if (currentSession.terminals.length > 0) {
1011
+ this.syncTerminalState(currentSession);
1012
+ }
1013
+ // Break active terminal first (if any)
1014
+ if (currentSession.terminals.length > 0) {
1015
+ const activeTerminal = currentSession.terminals[currentSession.activeTerminalIndex];
1016
+ if (activeTerminal) {
1017
+ this.tmux.breakPane(activeTerminal.id);
1018
+ }
1019
+ // Break terminal manager
1020
+ if (currentSession.terminalManagerPaneId) {
1021
+ this.tmux.breakPane(currentSession.terminalManagerPaneId);
1022
+ }
1023
+ }
1024
+ // Break main pane
1025
+ this.tmux.breakPane(currentSession.mainPaneId);
1026
+ }
1027
+ // Join new session's main pane next to sidebar (horizontal)
1028
+ this.tmux.joinPane(session.mainPaneId, this.sidebarPaneId, true);
1029
+ // Join terminal manager and active terminal if session has terminals
1030
+ if (session.terminals.length > 0 && session.terminalManagerPaneId) {
1031
+ // Sync to get current activeIndex from terminal manager
1032
+ this.syncTerminalState(session);
1033
+ // Join terminal manager below main pane
1034
+ this.tmux.joinPane(session.terminalManagerPaneId, session.mainPaneId, false);
1035
+ // Join active terminal below terminal manager
1036
+ const activeTerminal = session.terminals[session.activeTerminalIndex];
1037
+ if (activeTerminal) {
1038
+ this.tmux.joinPane(activeTerminal.id, session.terminalManagerPaneId, false);
1039
+ }
1040
+ // Ensure terminal manager is exactly 1 row
1041
+ this.tmux.resizePane(session.terminalManagerPaneId, undefined, 1);
1042
+ // Update terminal manager state file
1043
+ this.writeTerminalState(session);
1044
+ }
1045
+ // Ensure sidebar stays at fixed width
1046
+ this.enforceSidebarWidth();
1047
+ }
1048
+ this.state.activeSessionId = session.id;
1049
+ this.state.visiblePaneId = session.mainPaneId;
1050
+ // Focus back to sidebar
1051
+ this.tmux.selectPane(this.sidebarPaneId);
1052
+ // Save state
1053
+ this.persistState();
1054
+ this.render();
1055
+ }
1056
+ async deleteSelectedItem() {
1057
+ const item = this.getItemAtIndex(this.state.selectedIndex);
1058
+ if (!item)
1059
+ return;
1060
+ if (item.type === 'session') {
1061
+ this.deleteSelectedSession();
1062
+ }
1063
+ else if (item.type === 'worktree') {
1064
+ await this.deleteSelectedWorktree();
1065
+ }
1066
+ }
1067
+ async deleteSelectedWorktree() {
1068
+ debugLog('deleteSelectedWorktree called, selectedIndex:', this.state.selectedIndex);
1069
+ const item = this.getItemAtIndex(this.state.selectedIndex);
1070
+ if (!item) {
1071
+ debugLog('deleteSelectedWorktree: No item at index', this.state.selectedIndex);
1072
+ return;
1073
+ }
1074
+ if (item.type !== 'worktree') {
1075
+ debugLog('deleteSelectedWorktree: Item is not a worktree, type:', item.type);
1076
+ return;
1077
+ }
1078
+ const worktree = item.item;
1079
+ debugLog('deleteSelectedWorktree: Deleting worktree:', worktree.branch, 'isMain:', worktree.isMain, 'path:', worktree.path, 'id:', worktree.id);
1080
+ // Don't allow deleting the main worktree
1081
+ if (worktree.isMain) {
1082
+ // Main worktree cannot be deleted - it's the original repo
1083
+ debugLog('deleteSelectedWorktree: Cannot delete main worktree');
1084
+ return;
1085
+ }
1086
+ // Delete all sessions associated with this worktree first
1087
+ const sessionsToDelete = this.state.sessions.filter(s => s.worktreeId === worktree.id);
1088
+ for (const session of sessionsToDelete) {
1089
+ // Kill all terminal panes
1090
+ for (const terminal of session.terminals) {
1091
+ this.tmux.killPane(terminal.id);
1092
+ }
1093
+ // Kill terminal manager pane
1094
+ if (session.terminalManagerPaneId) {
1095
+ this.tmux.killPane(session.terminalManagerPaneId);
1096
+ }
1097
+ // Kill main pane
1098
+ this.tmux.killPane(session.mainPaneId);
1099
+ // Clean up state file
1100
+ try {
1101
+ const stateFile = this.getTerminalStateFile(session.id);
1102
+ unlinkSync(stateFile);
1103
+ }
1104
+ catch (err) {
1105
+ // Ignore
1106
+ }
1107
+ }
1108
+ // Remove sessions from state
1109
+ this.state.sessions = this.state.sessions.filter(s => s.worktreeId !== worktree.id);
1110
+ // If active session was deleted, switch to another
1111
+ if (sessionsToDelete.some(s => s.id === this.state.activeSessionId)) {
1112
+ this.state.activeSessionId = null;
1113
+ this.state.visiblePaneId = null;
1114
+ if (this.state.sessions.length > 0) {
1115
+ const nextSession = this.state.sessions[0];
1116
+ this.tmux.joinPane(nextSession.mainPaneId, this.sidebarPaneId, true);
1117
+ if (nextSession.terminals.length > 0 && nextSession.terminalManagerPaneId) {
1118
+ this.tmux.joinPane(nextSession.terminalManagerPaneId, nextSession.mainPaneId, false);
1119
+ const activeTerminal = nextSession.terminals[nextSession.activeTerminalIndex];
1120
+ if (activeTerminal) {
1121
+ this.tmux.joinPane(activeTerminal.id, nextSession.terminalManagerPaneId, false);
1122
+ }
1123
+ this.tmux.resizePane(nextSession.terminalManagerPaneId, undefined, 1);
1124
+ this.writeTerminalState(nextSession);
1125
+ }
1126
+ this.state.activeSessionId = nextSession.id;
1127
+ this.state.visiblePaneId = nextSession.mainPaneId;
1128
+ this.enforceSidebarWidth();
1129
+ }
1130
+ }
1131
+ // Delete the worktree via git
1132
+ debugLog('About to remove worktree via git, path:', worktree.path);
1133
+ try {
1134
+ await this.worktreeManager.remove(worktree.path, true);
1135
+ debugLog('Git worktree remove succeeded');
1136
+ }
1137
+ catch (err) {
1138
+ debugLog('Git worktree remove failed:', err);
1139
+ }
1140
+ // Remove worktree from state
1141
+ debugLog('Removing worktree from state, id:', worktree.id);
1142
+ this.state.worktrees = this.state.worktrees.filter(w => w.id !== worktree.id);
1143
+ debugLog('Worktrees after filter:', this.state.worktrees.map(w => w.branch));
1144
+ // Adjust selected index if needed
1145
+ const totalItems = this.state.worktrees.reduce((count, wt) => {
1146
+ return count + 1 + this.getSessionsForWorktree(wt.id).length;
1147
+ }, 0);
1148
+ if (this.state.selectedIndex >= totalItems) {
1149
+ this.state.selectedIndex = Math.max(0, totalItems - 1);
1150
+ }
1151
+ // Focus back to sidebar
1152
+ this.tmux.selectPane(this.sidebarPaneId);
1153
+ // Save state
1154
+ this.persistState();
1155
+ debugLog('deleteSelectedWorktree complete, calling render');
1156
+ this.render();
1157
+ }
1158
+ deleteSelectedSession() {
1159
+ const item = this.getItemAtIndex(this.state.selectedIndex);
1160
+ if (!item || item.type !== 'session')
1161
+ return;
1162
+ const session = item.item;
1163
+ // Kill all terminal panes
1164
+ for (const terminal of session.terminals) {
1165
+ this.tmux.killPane(terminal.id);
1166
+ }
1167
+ // Kill terminal manager pane if exists
1168
+ if (session.terminalManagerPaneId) {
1169
+ this.tmux.killPane(session.terminalManagerPaneId);
1170
+ }
1171
+ // Kill the main pane
1172
+ this.tmux.killPane(session.mainPaneId);
1173
+ // Clean up state file
1174
+ try {
1175
+ const stateFile = this.getTerminalStateFile(session.id);
1176
+ unlinkSync(stateFile);
1177
+ }
1178
+ catch (err) {
1179
+ // State file might not exist
1180
+ }
1181
+ // Remove from sessions array
1182
+ this.state.sessions = this.state.sessions.filter(s => s.id !== session.id);
1183
+ // Remove from worktree
1184
+ const worktree = this.state.worktrees.find(w => w.id === session.worktreeId);
1185
+ if (worktree) {
1186
+ worktree.sessions = worktree.sessions.filter(id => id !== session.id);
1187
+ }
1188
+ // If this was the active/visible session, switch to another
1189
+ if (this.state.activeSessionId === session.id) {
1190
+ this.state.activeSessionId = null;
1191
+ this.state.visiblePaneId = null;
1192
+ // Find another session to show
1193
+ if (this.state.sessions.length > 0) {
1194
+ const nextSession = this.state.sessions[0];
1195
+ // Join the next session's main pane next to sidebar
1196
+ this.tmux.joinPane(nextSession.mainPaneId, this.sidebarPaneId, true);
1197
+ // Join terminal manager and active terminal if exists
1198
+ if (nextSession.terminals.length > 0 && nextSession.terminalManagerPaneId) {
1199
+ this.tmux.joinPane(nextSession.terminalManagerPaneId, nextSession.mainPaneId, false);
1200
+ const activeTerminal = nextSession.terminals[nextSession.activeTerminalIndex];
1201
+ if (activeTerminal) {
1202
+ this.tmux.joinPane(activeTerminal.id, nextSession.terminalManagerPaneId, false);
1203
+ }
1204
+ this.writeTerminalState(nextSession);
1205
+ }
1206
+ this.state.activeSessionId = nextSession.id;
1207
+ this.state.visiblePaneId = nextSession.mainPaneId;
1208
+ // Ensure sidebar stays at fixed width
1209
+ this.enforceSidebarWidth();
1210
+ }
1211
+ else {
1212
+ // No more sessions - create an empty pane for future use
1213
+ this.tmux.selectPane(this.sidebarPaneId);
1214
+ const newPaneId = this.tmux.splitHorizontal(80);
1215
+ this.rightPaneId = newPaneId;
1216
+ this.state.visiblePaneId = newPaneId;
1217
+ // Show welcome message
1218
+ this.tmux.sendKeys(newPaneId, 'echo "Press Enter in sidebar to start a Claude session"', true);
1219
+ // Ensure sidebar stays at fixed width
1220
+ this.enforceSidebarWidth();
1221
+ this.tmux.selectPane(this.sidebarPaneId);
1222
+ }
1223
+ }
1224
+ // Adjust selection index
1225
+ this.state.selectedIndex = Math.max(0, this.state.selectedIndex - 1);
1226
+ // Save state
1227
+ this.persistState();
1228
+ this.render();
1229
+ }
1230
+ render() {
1231
+ const cols = process.stdout.columns || 20;
1232
+ const rows = process.stdout.rows || 24;
1233
+ // Clear clickable regions
1234
+ this.clickableRegions = [];
1235
+ let output = ansi.clearScreen;
1236
+ output += ansi.moveTo(1, 1);
1237
+ // Show collapsed view
1238
+ if (this.state.collapsed) {
1239
+ this.renderCollapsed(rows);
1240
+ return;
1241
+ }
1242
+ // Show quit modal if open
1243
+ if (this.state.showQuitModal) {
1244
+ this.renderQuitModal(cols, rows);
1245
+ return;
1246
+ }
1247
+ // Show new worktree input if open
1248
+ if (this.state.showNewWorktreeInput) {
1249
+ this.renderNewWorktreeInput(cols, rows);
1250
+ return;
1251
+ }
1252
+ // Show rename input if open
1253
+ if (this.state.showRenameInput) {
1254
+ this.renderRenameInput(cols, rows);
1255
+ return;
1256
+ }
1257
+ // Show new session input if open
1258
+ if (this.state.showNewSessionInput) {
1259
+ this.renderNewSessionInput(cols, rows);
1260
+ return;
1261
+ }
1262
+ // Show delete confirmation modal if open
1263
+ if (this.state.showDeleteConfirmModal) {
1264
+ this.renderDeleteConfirmModal(cols, rows);
1265
+ return;
1266
+ }
1267
+ // Header with collapse button
1268
+ const headerText = 'Claude++';
1269
+ const collapseBtn = '◀';
1270
+ const headerPadding = cols - headerText.length - 2; // -2 for collapse button and space
1271
+ output += `${ansi.bold}${ansi.fg.cyan}${headerText}${ansi.reset}`;
1272
+ output += ' '.repeat(Math.max(1, headerPadding));
1273
+ output += `${ansi.fg.gray}${collapseBtn}${ansi.reset}\n`;
1274
+ output += `${ansi.dim}${'─'.repeat(cols - 1)}${ansi.reset}\n`;
1275
+ // Track current row for click regions (row 1 = header, row 2 = separator, row 3+ = content)
1276
+ // Add clickable region for collapse button (row 1, right side)
1277
+ this.clickableRegions.push({
1278
+ row: 1,
1279
+ startCol: cols - 2,
1280
+ endCol: cols,
1281
+ type: 'worktree', // Reusing type, will handle specially
1282
+ item: { id: '__collapse__' },
1283
+ });
1284
+ let currentRow = 3;
1285
+ // Worktrees and sessions
1286
+ let currentIndex = 0;
1287
+ for (const wt of this.state.worktrees) {
1288
+ const isSelected = currentIndex === this.state.selectedIndex;
1289
+ const wtSessions = this.getSessionsForWorktree(wt.id);
1290
+ const hasActiveSessions = wtSessions.some(s => s.id === this.state.activeSessionId);
1291
+ // Worktree line with + button
1292
+ let line = '';
1293
+ if (isSelected) {
1294
+ line += ansi.inverse;
1295
+ }
1296
+ if (hasActiveSessions) {
1297
+ line += ansi.fg.green;
1298
+ }
1299
+ const icon = wt.isMain ? '◆' : '◇';
1300
+ const name = wt.branch.slice(0, cols - 5); // Leave room for " +"
1301
+ line += `${icon} ${name}`;
1302
+ // Pad to right and add + button
1303
+ const textLen = name.length + 2; // icon + space + name
1304
+ const padding = Math.max(0, cols - textLen - 3);
1305
+ line += ansi.reset;
1306
+ line += ' '.repeat(padding);
1307
+ line += `${ansi.fg.cyan}+${ansi.reset}`;
1308
+ // Record clickable region for worktree (entire row)
1309
+ this.clickableRegions.push({
1310
+ row: currentRow,
1311
+ startCol: 1,
1312
+ endCol: cols,
1313
+ type: 'worktree',
1314
+ item: wt,
1315
+ });
1316
+ output += line + '\n';
1317
+ currentRow++;
1318
+ currentIndex++;
1319
+ // Sessions under this worktree
1320
+ for (const session of wtSessions) {
1321
+ const isSessionSelected = currentIndex === this.state.selectedIndex;
1322
+ const isActive = session.id === this.state.activeSessionId;
1323
+ let sLine = '';
1324
+ if (isSessionSelected) {
1325
+ sLine += ansi.inverse;
1326
+ }
1327
+ if (isActive) {
1328
+ sLine += ansi.fg.yellow;
1329
+ }
1330
+ else {
1331
+ sLine += ansi.fg.gray;
1332
+ }
1333
+ const title = session.title.slice(0, cols - 4);
1334
+ sLine += ` └${title}`;
1335
+ sLine += ansi.reset;
1336
+ // Record clickable region for session (entire row)
1337
+ this.clickableRegions.push({
1338
+ row: currentRow,
1339
+ startCol: 1,
1340
+ endCol: cols,
1341
+ type: 'session',
1342
+ item: session,
1343
+ });
1344
+ output += sLine + '\n';
1345
+ currentRow++;
1346
+ currentIndex++;
1347
+ }
1348
+ }
1349
+ // "New Worktree" button
1350
+ output += '\n';
1351
+ currentRow++;
1352
+ const newWtText = '+ New Worktree';
1353
+ output += `${ansi.fg.cyan}${newWtText}${ansi.reset}\n`;
1354
+ // Record clickable region for new worktree button
1355
+ this.clickableRegions.push({
1356
+ row: currentRow,
1357
+ startCol: 1,
1358
+ endCol: cols,
1359
+ type: 'worktree',
1360
+ item: { id: '__new_worktree__' },
1361
+ });
1362
+ currentRow++;
1363
+ // Help section at bottom (tabular format)
1364
+ const helpY = rows - 7;
1365
+ output += ansi.moveTo(helpY, 1);
1366
+ output += `${ansi.dim}${'─'.repeat(cols - 1)}${ansi.reset}\n`;
1367
+ output += `${ansi.fg.cyan}↵${ansi.reset} ${ansi.dim}new/switch${ansi.reset}\n`;
1368
+ output += `${ansi.fg.cyan}n${ansi.reset} ${ansi.dim}worktree${ansi.reset}\n`;
1369
+ output += `${ansi.fg.cyan}^T${ansi.reset} ${ansi.dim}terminal${ansi.reset}\n`;
1370
+ output += `${ansi.fg.cyan}r${ansi.reset} ${ansi.dim}rename${ansi.reset}\n`;
1371
+ output += `${ansi.fg.cyan}d${ansi.reset} ${ansi.dim}delete${ansi.reset}\n`;
1372
+ output += `${ansi.fg.cyan}^G${ansi.reset} ${ansi.dim}hide${ansi.reset}\n`;
1373
+ process.stdout.write(output);
1374
+ }
1375
+ renderCollapsed(rows) {
1376
+ let output = ansi.clearScreen;
1377
+ // Show expand indicator
1378
+ output += ansi.moveTo(1, 1);
1379
+ output += `${ansi.fg.cyan}▸${ansi.reset}`;
1380
+ // Show session count
1381
+ const sessionCount = this.state.sessions.length;
1382
+ if (sessionCount > 0) {
1383
+ output += ansi.moveTo(3, 1);
1384
+ output += `${ansi.fg.green}${sessionCount}${ansi.reset}`;
1385
+ }
1386
+ process.stdout.write(output);
1387
+ }
1388
+ renderQuitModal(cols, rows) {
1389
+ let output = ansi.clearScreen;
1390
+ // Center the modal vertically
1391
+ const modalStartY = Math.floor(rows / 2) - 3;
1392
+ output += ansi.moveTo(modalStartY, 1);
1393
+ output += `${ansi.bold}${ansi.fg.yellow} Quit?${ansi.reset}\n`;
1394
+ output += `${ansi.dim}${'─'.repeat(cols - 1)}${ansi.reset}\n\n`;
1395
+ // Detach option
1396
+ const detachSelected = this.state.quitModalSelection === 'detach';
1397
+ if (detachSelected) {
1398
+ output += `${ansi.inverse}${ansi.fg.green} ▸ Detach ${ansi.reset}\n`;
1399
+ }
1400
+ else {
1401
+ output += `${ansi.fg.gray} Detach${ansi.reset}\n`;
1402
+ }
1403
+ output += `${ansi.dim} (keeps running)${ansi.reset}\n\n`;
1404
+ // Kill option
1405
+ const killSelected = this.state.quitModalSelection === 'kill';
1406
+ if (killSelected) {
1407
+ output += `${ansi.inverse}${ansi.fg.red} ▸ Kill ${ansi.reset}\n`;
1408
+ }
1409
+ else {
1410
+ output += `${ansi.fg.gray} Kill${ansi.reset}\n`;
1411
+ }
1412
+ output += `${ansi.dim} (ends sessions)${ansi.reset}\n`;
1413
+ // Help
1414
+ output += ansi.moveTo(rows - 2, 1);
1415
+ output += `${ansi.dim}↑↓ select ↵ confirm Esc cancel${ansi.reset}`;
1416
+ process.stdout.write(output);
1417
+ }
1418
+ renderNewWorktreeInput(cols, rows) {
1419
+ let output = ansi.clearScreen;
1420
+ output += ansi.moveTo(1, 1);
1421
+ output += `${ansi.bold}${ansi.fg.cyan}New Worktree${ansi.reset}\n`;
1422
+ output += `${ansi.dim}${'─'.repeat(cols - 1)}${ansi.reset}\n\n`;
1423
+ output += `${ansi.fg.white}Branch name:${ansi.reset}\n`;
1424
+ output += `${ansi.fg.yellow}> ${this.state.newWorktreeBranch}${ansi.reset}`;
1425
+ output += `${ansi.inverse} ${ansi.reset}`; // Cursor
1426
+ output += '\n\n';
1427
+ output += `${ansi.dim}Creates a new branch and${ansi.reset}\n`;
1428
+ output += `${ansi.dim}worktree from HEAD${ansi.reset}\n`;
1429
+ // Help
1430
+ output += ansi.moveTo(rows - 2, 1);
1431
+ output += `${ansi.dim}↵ create Esc cancel${ansi.reset}`;
1432
+ process.stdout.write(output);
1433
+ }
1434
+ renderRenameInput(cols, rows) {
1435
+ let output = ansi.clearScreen;
1436
+ const isWorktree = this.state.renameTarget?.type === 'worktree';
1437
+ const title = isWorktree ? 'Rename Branch' : 'Rename Session';
1438
+ output += ansi.moveTo(1, 1);
1439
+ output += `${ansi.bold}${ansi.fg.cyan}${title}${ansi.reset}\n`;
1440
+ output += `${ansi.dim}${'─'.repeat(cols - 1)}${ansi.reset}\n\n`;
1441
+ output += `${ansi.fg.white}New name:${ansi.reset}\n`;
1442
+ output += `${ansi.fg.yellow}> ${this.state.renameValue}${ansi.reset}`;
1443
+ output += `${ansi.inverse} ${ansi.reset}`; // Cursor
1444
+ if (isWorktree) {
1445
+ output += '\n\n';
1446
+ output += `${ansi.dim}Renames branch and${ansi.reset}\n`;
1447
+ output += `${ansi.dim}moves worktree dir${ansi.reset}\n`;
1448
+ }
1449
+ // Help
1450
+ output += ansi.moveTo(rows - 2, 1);
1451
+ output += `${ansi.dim}↵ rename Esc cancel${ansi.reset}`;
1452
+ process.stdout.write(output);
1453
+ }
1454
+ renderNewSessionInput(cols, rows) {
1455
+ let output = ansi.clearScreen;
1456
+ const worktreeName = this.state.newSessionWorktree?.branch || '';
1457
+ output += ansi.moveTo(1, 1);
1458
+ output += `${ansi.bold}${ansi.fg.cyan}New Session${ansi.reset}\n`;
1459
+ output += `${ansi.dim}${'─'.repeat(cols - 1)}${ansi.reset}\n\n`;
1460
+ output += `${ansi.fg.gray}Worktree: ${worktreeName}${ansi.reset}\n\n`;
1461
+ output += `${ansi.fg.white}Session name:${ansi.reset}\n`;
1462
+ output += `${ansi.fg.yellow}> ${this.state.newSessionName}${ansi.reset}`;
1463
+ output += `${ansi.inverse} ${ansi.reset}`; // Cursor
1464
+ output += '\n\n';
1465
+ output += `${ansi.dim}Creates Claude session${ansi.reset}\n`;
1466
+ output += `${ansi.dim}in this worktree${ansi.reset}\n`;
1467
+ // Help
1468
+ output += ansi.moveTo(rows - 2, 1);
1469
+ output += `${ansi.dim}↵ create Esc cancel${ansi.reset}`;
1470
+ process.stdout.write(output);
1471
+ }
1472
+ renderDeleteConfirmModal(cols, rows) {
1473
+ let output = ansi.clearScreen;
1474
+ const target = this.state.deleteConfirmTarget;
1475
+ if (!target)
1476
+ return;
1477
+ const isSession = target.type === 'session';
1478
+ const itemName = isSession
1479
+ ? target.item.title
1480
+ : target.item.branch;
1481
+ // Center the modal vertically
1482
+ const modalStartY = Math.floor(rows / 2) - 5;
1483
+ output += ansi.moveTo(modalStartY, 1);
1484
+ output += `${ansi.bold}${ansi.fg.red}Delete ${isSession ? 'Session' : 'Worktree'}?${ansi.reset}\n`;
1485
+ output += `${ansi.dim}${'─'.repeat(cols - 1)}${ansi.reset}\n\n`;
1486
+ output += `${ansi.fg.white}${itemName}${ansi.reset}\n\n`;
1487
+ if (isSession) {
1488
+ output += `${ansi.dim}You can restore this later${ansi.reset}\n`;
1489
+ output += `${ansi.dim}using ${ansi.reset}${ansi.fg.cyan}claude /resume${ansi.reset}\n`;
1490
+ }
1491
+ else {
1492
+ output += `${ansi.dim}This will remove the${ansi.reset}\n`;
1493
+ output += `${ansi.dim}worktree and all sessions${ansi.reset}\n`;
1494
+ }
1495
+ output += '\n';
1496
+ // Yes option
1497
+ const yesSelected = this.state.deleteConfirmSelection === 'yes';
1498
+ if (yesSelected) {
1499
+ output += `${ansi.inverse}${ansi.fg.red} ▸ Yes, delete ${ansi.reset}\n`;
1500
+ }
1501
+ else {
1502
+ output += `${ansi.fg.gray} Yes, delete${ansi.reset}\n`;
1503
+ }
1504
+ output += '\n';
1505
+ // No option
1506
+ const noSelected = this.state.deleteConfirmSelection === 'no';
1507
+ if (noSelected) {
1508
+ output += `${ansi.inverse}${ansi.fg.green} ▸ No, cancel ${ansi.reset}\n`;
1509
+ }
1510
+ else {
1511
+ output += `${ansi.fg.gray} No, cancel${ansi.reset}\n`;
1512
+ }
1513
+ // Help
1514
+ output += ansi.moveTo(rows - 2, 1);
1515
+ output += `${ansi.dim}↑↓ select ↵ confirm Esc cancel${ansi.reset}`;
1516
+ process.stdout.write(output);
1517
+ }
1518
+ }
1519
+ // Main entry point for sidebar
1520
+ async function main() {
1521
+ const args = process.argv.slice(2);
1522
+ if (args.length < 4) {
1523
+ console.error('Usage: sidebar <repoPath> <sessionName> <rightPaneId> <sidebarPaneId>');
1524
+ process.exit(1);
1525
+ }
1526
+ const [repoPath, sessionName, rightPaneId, sidebarPaneId] = args;
1527
+ const sidebar = new Sidebar(repoPath, sessionName, rightPaneId, sidebarPaneId);
1528
+ await sidebar.init();
1529
+ sidebar.start();
1530
+ }
1531
+ main().catch((err) => {
1532
+ console.error('Sidebar error:', err);
1533
+ process.exit(1);
1534
+ });
1535
+ //# sourceMappingURL=sidebar.js.map