code-squad-cli 1.2.22 → 1.3.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,520 @@
1
+ import { exec as execCallback } from 'child_process';
2
+ import { promisify } from 'util';
3
+ const exec = promisify(execCallback);
4
+ const execOptions = { maxBuffer: 1024 * 1024 };
5
+ /**
6
+ * tmux 명령어 실행 래퍼
7
+ */
8
+ export class TmuxAdapter {
9
+ /**
10
+ * tmux 설치 여부 확인
11
+ */
12
+ async isTmuxAvailable() {
13
+ try {
14
+ await exec('which tmux', execOptions);
15
+ return true;
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }
21
+ /**
22
+ * 현재 tmux 세션 내부인지 확인
23
+ */
24
+ isInsideTmux() {
25
+ return !!process.env.TMUX;
26
+ }
27
+ /**
28
+ * 세션 존재 여부 확인
29
+ */
30
+ async hasSession(name) {
31
+ try {
32
+ await exec(`tmux has-session -t "${name}"`, execOptions);
33
+ return true;
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
39
+ /**
40
+ * 새 tmux 세션 생성
41
+ */
42
+ async createSession(name, cwd) {
43
+ await exec(`tmux new-session -d -s "${name}" -c "${cwd}"`, execOptions);
44
+ }
45
+ /**
46
+ * 세션 생성 또는 기존 세션 반환
47
+ * @returns true if new session created, false if existing session
48
+ */
49
+ async ensureSession(name, cwd) {
50
+ if (await this.hasSession(name)) {
51
+ return false;
52
+ }
53
+ await this.createSession(name, cwd);
54
+ return true;
55
+ }
56
+ /**
57
+ * 세션에 attach (spawn 사용)
58
+ */
59
+ async attachSession(name) {
60
+ const { spawn } = await import('child_process');
61
+ return new Promise((resolve, reject) => {
62
+ const tmux = spawn('tmux', ['attach-session', '-t', name], { stdio: 'inherit' });
63
+ tmux.on('close', (code) => {
64
+ if (code === 0)
65
+ resolve();
66
+ else
67
+ reject(new Error(`tmux attach failed with code ${code}`));
68
+ });
69
+ tmux.on('error', reject);
70
+ });
71
+ }
72
+ /**
73
+ * 현재 pane 분할 (수직/수평)
74
+ * @param direction 'v' = 수직 (좌우), 'h' = 수평 (상하)
75
+ * @param cwd 새 pane의 작업 디렉토리
76
+ * @param percent 새 pane의 크기 비율 (옵션)
77
+ */
78
+ async splitWindow(direction, cwd, percent) {
79
+ const flag = direction === 'v' ? '-h' : '-v'; // tmux는 반대로 해석
80
+ const cwdFlag = cwd ? `-c "${cwd}"` : '';
81
+ const percentFlag = percent ? `-p ${percent}` : '';
82
+ await exec(`tmux split-window ${flag} ${cwdFlag} ${percentFlag}`, execOptions);
83
+ // 새로 생성된 pane ID 반환
84
+ const { stdout } = await exec('tmux display-message -p "#{pane_id}"', execOptions);
85
+ return stdout.trim();
86
+ }
87
+ /**
88
+ * 특정 pane 선택 (포커스)
89
+ */
90
+ async selectPane(paneId) {
91
+ await exec(`tmux select-pane -t "${paneId}"`, execOptions);
92
+ }
93
+ /**
94
+ * pane 목록 조회
95
+ */
96
+ async listPanes() {
97
+ try {
98
+ const { stdout } = await exec('tmux list-panes -F "#{pane_id}|#{pane_index}|#{pane_active}|#{pane_current_path}"', execOptions);
99
+ return stdout.trim().split('\n').filter(Boolean).map(line => {
100
+ const [id, index, active, cwd] = line.split('|');
101
+ return {
102
+ id,
103
+ index: parseInt(index, 10),
104
+ active: active === '1',
105
+ cwd,
106
+ };
107
+ });
108
+ }
109
+ catch {
110
+ return [];
111
+ }
112
+ }
113
+ /**
114
+ * pane 종료
115
+ */
116
+ async killPane(paneId) {
117
+ await exec(`tmux kill-pane -t "${paneId}"`, execOptions);
118
+ }
119
+ /**
120
+ * pane에 키 전송
121
+ */
122
+ async sendKeys(paneId, keys) {
123
+ // 특수문자 이스케이프
124
+ const escaped = keys.replace(/"/g, '\\"');
125
+ await exec(`tmux send-keys -t "${paneId}" "${escaped}"`, execOptions);
126
+ }
127
+ /**
128
+ * pane에 특수 키 전송 (C-l, C-c, Enter 등 tmux 키 이름)
129
+ */
130
+ async sendSpecialKey(paneId, keyName) {
131
+ await exec(`tmux send-keys -t "${paneId}" ${keyName}`, execOptions);
132
+ }
133
+ /**
134
+ * pane에 Enter 키 전송
135
+ */
136
+ async sendEnter(paneId) {
137
+ await exec(`tmux send-keys -t "${paneId}" Enter`, execOptions);
138
+ }
139
+ /**
140
+ * 현재 window의 레이아웃 설정
141
+ * @param layout 'even-horizontal' | 'even-vertical' | 'main-horizontal' | 'main-vertical' | 'tiled'
142
+ */
143
+ async setLayout(layout) {
144
+ await exec(`tmux select-layout ${layout}`, execOptions);
145
+ }
146
+ /**
147
+ * pane 크기 조정
148
+ */
149
+ async resizePane(paneId, direction, amount) {
150
+ await exec(`tmux resize-pane -t "${paneId}" -${direction} ${amount}`, execOptions);
151
+ }
152
+ /**
153
+ * pane 너비를 절대값으로 설정
154
+ */
155
+ async setPaneWidth(paneId, width) {
156
+ await exec(`tmux resize-pane -t "${paneId}" -x ${width}`, execOptions);
157
+ }
158
+ /**
159
+ * pane을 별도 window로 분리 (백그라운드 실행 유지)
160
+ * @returns 새로 생성된 window ID
161
+ */
162
+ async breakPaneToWindow(paneId) {
163
+ const { stdout } = await exec(`tmux break-pane -d -s "${paneId}" -P -F "#{window_id}"`, execOptions);
164
+ return stdout.trim();
165
+ }
166
+ /**
167
+ * 특정 session:index에 window 생성
168
+ * @returns 새로 생성된 window ID
169
+ */
170
+ async createWindowAtIndex(sessionName, index, cwd) {
171
+ const { stdout } = await exec(`tmux new-window -d -t "${sessionName}:${index}" -c "${cwd}" -P -F "#{window_id}"`, execOptions);
172
+ return stdout.trim();
173
+ }
174
+ /**
175
+ * 숨겨진 window 생성 (단일 pane)
176
+ */
177
+ async createHiddenWindow(cwd) {
178
+ const cwdFlag = cwd ? `-c "${cwd}"` : '';
179
+ const { stdout } = await exec(`tmux new-window -d ${cwdFlag} -P -F "#{window_id}"`, execOptions);
180
+ return stdout.trim();
181
+ }
182
+ /**
183
+ * window의 pane을 특정 pane과 swap
184
+ */
185
+ async swapPaneWithWindow(windowId, targetPaneId) {
186
+ await exec(`tmux swap-pane -d -s "${windowId}.0" -t "${targetPaneId}"`, execOptions);
187
+ }
188
+ /**
189
+ * window 내 단일 pane 너비 설정
190
+ */
191
+ async setWindowPaneWidth(windowId, width) {
192
+ await exec(`tmux resize-pane -t "${windowId}.0" -x ${width}`, execOptions);
193
+ }
194
+ /**
195
+ * pane index로 pane ID 조회 (현재 window)
196
+ */
197
+ async getPaneIdByIndex(index) {
198
+ try {
199
+ const { stdout } = await exec('tmux list-panes -F "#{pane_index}|#{pane_id}"', execOptions);
200
+ const match = stdout
201
+ .trim()
202
+ .split('\n')
203
+ .map(line => line.split('|'))
204
+ .find(([idx]) => parseInt(idx, 10) === index);
205
+ return match?.[1] ?? null;
206
+ }
207
+ catch {
208
+ return null;
209
+ }
210
+ }
211
+ /**
212
+ * 숨겨진 window에서 pane을 현재 window로 가져옴
213
+ * @param windowId 숨겨진 window ID
214
+ * @param targetPaneId 옆에 배치할 target pane
215
+ * @returns 가져온 pane의 새 ID
216
+ */
217
+ async joinPaneFromWindow(windowId, targetPaneId) {
218
+ // 수평 분할로 target pane 옆에 배치
219
+ await exec(`tmux join-pane -h -s "${windowId}.0" -t "${targetPaneId}"`, execOptions);
220
+ // 새로 join된 pane ID 반환
221
+ const { stdout } = await exec('tmux display-message -p "#{pane_id}"', execOptions);
222
+ return stdout.trim();
223
+ }
224
+ /**
225
+ * pane 숨기기 (width=0) - deprecated, use breakPaneToWindow
226
+ */
227
+ async hidePane(paneId) {
228
+ await exec(`tmux resize-pane -t "${paneId}" -x 0`, execOptions);
229
+ }
230
+ /**
231
+ * pane 표시 (지정 너비로 복원) - deprecated, use joinPaneFromWindow
232
+ */
233
+ async showPane(paneId, width) {
234
+ await exec(`tmux resize-pane -t "${paneId}" -x ${width}`, execOptions);
235
+ }
236
+ /**
237
+ * 현재 window 너비 조회
238
+ */
239
+ async getWindowWidth() {
240
+ const { stdout } = await exec('tmux display-message -p "#{window_width}"', execOptions);
241
+ return parseInt(stdout.trim(), 10);
242
+ }
243
+ /**
244
+ * pane 너비 조회
245
+ */
246
+ async getPaneWidth(paneId) {
247
+ const { stdout } = await exec(`tmux display-message -t "${paneId}" -p "#{pane_width}"`, execOptions);
248
+ return parseInt(stdout.trim(), 10);
249
+ }
250
+ /**
251
+ * pane 높이 조회
252
+ */
253
+ async getPaneHeight(paneId) {
254
+ const { stdout } = await exec(`tmux display-message -t "${paneId}" -p "#{pane_height}"`, execOptions);
255
+ return parseInt(stdout.trim(), 10);
256
+ }
257
+ /**
258
+ * 현재 세션 이름 조회
259
+ */
260
+ async getSessionName() {
261
+ try {
262
+ const { stdout } = await exec('tmux display-message -p "#{session_name}"', execOptions);
263
+ return stdout.trim();
264
+ }
265
+ catch {
266
+ return null;
267
+ }
268
+ }
269
+ /**
270
+ * 현재 pane ID 조회
271
+ */
272
+ async getCurrentPaneId() {
273
+ try {
274
+ const { stdout } = await exec('tmux display-message -p "#{pane_id}"', execOptions);
275
+ return stdout.trim();
276
+ }
277
+ catch {
278
+ return null;
279
+ }
280
+ }
281
+ /**
282
+ * UX 개선 설정 적용
283
+ * - 마우스 모드 활성화 (클릭 선택, 드래그 리사이징)
284
+ * - 활성 pane 테두리 강조
285
+ * - pane swap 단축키 (Ctrl+b m)
286
+ */
287
+ async applyUXSettings() {
288
+ const settings = [
289
+ 'set -g mouse on',
290
+ // extended-keys 활성화 (Shift+Tab 등 modifier key 조합 인식에 필요)
291
+ 'set -g extended-keys on',
292
+ "set -g pane-active-border-style 'fg=cyan,bold'",
293
+ "set -g pane-border-style 'fg=#444444'",
294
+ 'set -g pane-border-lines single',
295
+ 'set -g pane-border-status top',
296
+ "set -g pane-border-format ' #{pane_current_path} '",
297
+ // pane 번호 표시 시간 늘리기
298
+ 'set -g display-panes-time 5000',
299
+ // pane 번호 색상
300
+ "set -g display-panes-colour '#444444'",
301
+ "set -g display-panes-active-colour cyan",
302
+ ];
303
+ for (const setting of settings) {
304
+ try {
305
+ await exec(`tmux ${setting}`, execOptions);
306
+ }
307
+ catch {
308
+ // 일부 설정이 실패해도 계속 진행
309
+ }
310
+ }
311
+ // Ctrl+b m = pane 이동 메뉴 (대시보드 pane 0 제외)
312
+ try {
313
+ // 간단한 메뉴 - pane 0에서는 동작 안 함, Move Left는 pane 1에서 동작 안 함
314
+ await exec(`tmux bind-key m if-shell -F "#{!=:#{pane_index},0}" "display-menu -T 'Move Pane' -x P -y P 'Move Left' l 'if-shell -F \\"#{>:#{pane_index},1}\\" \\"swap-pane -U\\"' 'Move Right' r 'swap-pane -D' '' 'Swap #1' 1 'swap-pane -t 1' 'Swap #2' 2 'swap-pane -t 2' 'Swap #3' 3 'swap-pane -t 3' 'Swap #4' 4 'swap-pane -t 4' '' 'Cancel' q ''"`, execOptions);
315
+ }
316
+ catch {
317
+ // 실패해도 계속 진행
318
+ }
319
+ // Shift+Tab = 대시보드/터미널 간 포커스 토글 (prefix 없이)
320
+ // pane 0(대시보드)이면 pane 1(터미널)로, 그 외면 pane 0(대시보드)으로 이동
321
+ try {
322
+ await exec(`tmux bind-key -n BTab if-shell -F "#{==:#{pane_index},0}" "select-pane -t 1" "select-pane -t 0"`, execOptions);
323
+ }
324
+ catch {
325
+ // 실패해도 계속 진행
326
+ }
327
+ }
328
+ /**
329
+ * 대시보드 pane 리사이즈 훅 설정
330
+ */
331
+ async setDashboardResizeHook(paneId, width) {
332
+ try {
333
+ await exec(`tmux set -g @csq_dash_pane "${paneId}"`, execOptions);
334
+ await exec(`tmux set -g @csq_dash_width "${width}"`, execOptions);
335
+ await exec(`tmux set-hook after-resize-pane 'resize-pane -t "#{@csq_dash_pane}" -x #{@csq_dash_width}'`, execOptions);
336
+ }
337
+ catch {
338
+ // 실패해도 계속 진행
339
+ }
340
+ }
341
+ /**
342
+ * main-vertical 레이아웃 적용 (왼쪽 고정, 오른쪽 분할)
343
+ * @param mainWidth 왼쪽 메인 pane 너비 (columns)
344
+ */
345
+ async applyMainVerticalLayout(mainWidth = 30) {
346
+ try {
347
+ await exec(`tmux set-window-option main-pane-width ${mainWidth}`, execOptions);
348
+ await exec('tmux select-layout main-vertical', execOptions);
349
+ }
350
+ catch {
351
+ // 실패해도 계속 진행
352
+ }
353
+ }
354
+ /**
355
+ * 특정 pane 기준으로 분할
356
+ * @param targetPaneId 분할할 대상 pane
357
+ * @param direction 'v' = 수직 (좌우), 'h' = 수평 (상하)
358
+ * @param cwd 새 pane의 작업 디렉토리
359
+ */
360
+ async splitPane(targetPaneId, direction, cwd) {
361
+ const flag = direction === 'v' ? '-h' : '-v';
362
+ const cwdFlag = cwd ? `-c "${cwd}"` : '';
363
+ await exec(`tmux split-window ${flag} -t "${targetPaneId}" ${cwdFlag}`, execOptions);
364
+ const { stdout } = await exec('tmux display-message -p "#{pane_id}"', execOptions);
365
+ return stdout.trim();
366
+ }
367
+ /**
368
+ * 현재 세션 종료
369
+ */
370
+ async killCurrentSession() {
371
+ await exec('tmux kill-session', execOptions);
372
+ }
373
+ /**
374
+ * 현재 클라이언트 detach (세션은 유지)
375
+ */
376
+ async detachClient() {
377
+ await exec('tmux detach-client', execOptions);
378
+ }
379
+ /**
380
+ * Detach client and kill a window in one atomic tmux command.
381
+ * Prevents flash of another window between kill and detach.
382
+ */
383
+ async detachAndKillWindow(sessionName, windowIndex) {
384
+ await exec(`tmux detach-client \\; kill-window -t "${sessionName}:${windowIndex}"`, execOptions);
385
+ }
386
+ /**
387
+ * 특정 window 종료
388
+ */
389
+ async killWindow(windowId) {
390
+ await exec(`tmux kill-window -t "${windowId}"`, execOptions);
391
+ }
392
+ /**
393
+ * pane 화면 클리어 (scrollback 포함)
394
+ */
395
+ async clearPane(paneId) {
396
+ await exec(`tmux send-keys -t "${paneId}" -R`, execOptions);
397
+ await exec(`tmux clear-history -t "${paneId}"`, execOptions);
398
+ }
399
+ /**
400
+ * 클라이언트 강제 리프레시
401
+ */
402
+ async refreshClient() {
403
+ await exec('tmux refresh-client -S', execOptions);
404
+ }
405
+ /**
406
+ * pane 위치 swap
407
+ * @param paneId 이동할 pane
408
+ * @param direction 'left' | 'right' | 'up' | 'down'
409
+ */
410
+ async swapPane(paneId, direction) {
411
+ const dirMap = {
412
+ left: '-U', // swap with pane above/left
413
+ right: '-D', // swap with pane below/right
414
+ up: '-U',
415
+ down: '-D',
416
+ };
417
+ await exec(`tmux swap-pane -t "${paneId}" ${dirMap[direction]}`, execOptions);
418
+ }
419
+ /**
420
+ * 대시보드 제외하고 오른쪽 pane들 균등 분할
421
+ * @param dashPaneId 대시보드 pane ID
422
+ * @param dashWidth 대시보드 너비 (columns)
423
+ */
424
+ async distributeRightPanes(dashPaneId, dashWidth = 35) {
425
+ try {
426
+ // 전체 window 너비 가져오기
427
+ const { stdout: widthStr } = await exec('tmux display-message -p "#{window_width}"', execOptions);
428
+ const totalWidth = parseInt(widthStr.trim(), 10);
429
+ // 모든 pane 가져오기
430
+ const panes = await this.listPanes();
431
+ const rightPanes = panes.filter(p => p.id !== dashPaneId);
432
+ if (rightPanes.length === 0)
433
+ return;
434
+ // 오른쪽 영역 너비 계산 (전체 - 대시보드 - 구분선)
435
+ const rightAreaWidth = totalWidth - dashWidth - 1;
436
+ const paneWidth = Math.floor(rightAreaWidth / rightPanes.length);
437
+ // 각 pane 크기 조정
438
+ for (const pane of rightPanes) {
439
+ await exec(`tmux resize-pane -t "${pane.id}" -x ${paneWidth}`, execOptions);
440
+ }
441
+ // 대시보드 크기 복원
442
+ await exec(`tmux resize-pane -t "${dashPaneId}" -x ${dashWidth}`, execOptions);
443
+ }
444
+ catch {
445
+ // 실패해도 계속 진행
446
+ }
447
+ }
448
+ // ============================================================
449
+ // Window 관련 메서드 (Task 1, 8)
450
+ // ============================================================
451
+ /**
452
+ * 현재 세션의 모든 window 목록 조회
453
+ */
454
+ async listWindows() {
455
+ try {
456
+ const { stdout } = await exec('tmux list-windows -F "#{window_id}|#{window_index}|#{window_name}|#{pane_current_path}|#{window_active}"', execOptions);
457
+ return stdout.trim().split('\n').filter(Boolean).map(line => {
458
+ const [id, index, name, cwd, active] = line.split('|');
459
+ return {
460
+ id,
461
+ index: parseInt(index, 10),
462
+ name,
463
+ cwd,
464
+ active: active === '1',
465
+ };
466
+ });
467
+ }
468
+ catch {
469
+ return [];
470
+ }
471
+ }
472
+ /**
473
+ * 특정 window로 포커스 전환
474
+ */
475
+ async selectWindow(windowId) {
476
+ await exec(`tmux select-window -t "${windowId}"`, execOptions);
477
+ }
478
+ /**
479
+ * 새 window 생성
480
+ * @param cwd 작업 디렉토리 (옵션)
481
+ * @param name window 이름 (옵션)
482
+ * @returns 생성된 window ID
483
+ */
484
+ async createNewWindow(cwd, name) {
485
+ const cwdFlag = cwd ? `-c "${cwd}"` : '';
486
+ const nameFlag = name ? `-n "${name}"` : '';
487
+ const { stdout } = await exec(`tmux new-window -d ${cwdFlag} ${nameFlag} -P -F "#{window_id}"`, execOptions);
488
+ return stdout.trim();
489
+ }
490
+ /**
491
+ * 현재 window ID 조회
492
+ */
493
+ async getCurrentWindowId() {
494
+ try {
495
+ const { stdout } = await exec('tmux display-message -p "#{window_id}"', execOptions);
496
+ return stdout.trim();
497
+ }
498
+ catch {
499
+ return null;
500
+ }
501
+ }
502
+ /**
503
+ * 현재 window index 조회
504
+ */
505
+ async getCurrentWindowIndex() {
506
+ try {
507
+ const { stdout } = await exec('tmux display-message -p "#{window_index}"', execOptions);
508
+ return parseInt(stdout.trim(), 10);
509
+ }
510
+ catch {
511
+ return null;
512
+ }
513
+ }
514
+ /**
515
+ * window 이름 변경
516
+ */
517
+ async renameWindow(windowId, name) {
518
+ await exec(`tmux rename-window -t "${windowId}" "${name}"`, execOptions);
519
+ }
520
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * 대시보드 모드 실행
3
+ */
4
+ export declare function runDash(workspaceRoot: string): Promise<void>;