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.
- package/LICENSE +21 -0
- package/README.md +74 -0
- package/assets/screenshot.png +0 -0
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +2 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/worktree-manager.d.ts +26 -0
- package/dist/core/worktree-manager.d.ts.map +1 -0
- package/dist/core/worktree-manager.js +177 -0
- package/dist/core/worktree-manager.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +208 -0
- package/dist/index.js.map +1 -0
- package/dist/sidebar.d.ts +13 -0
- package/dist/sidebar.d.ts.map +1 -0
- package/dist/sidebar.js +1535 -0
- package/dist/sidebar.js.map +1 -0
- package/dist/state.d.ts +53 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +101 -0
- package/dist/state.js.map +1 -0
- package/dist/terminal-manager.d.ts +16 -0
- package/dist/terminal-manager.d.ts.map +1 -0
- package/dist/terminal-manager.js +364 -0
- package/dist/terminal-manager.js.map +1 -0
- package/dist/tmux.d.ts +163 -0
- package/dist/tmux.d.ts.map +1 -0
- package/dist/tmux.js +360 -0
- package/dist/tmux.js.map +1 -0
- package/dist/types.d.ts +13 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/package.json +57 -0
package/dist/sidebar.js
ADDED
|
@@ -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
|