coxpit 5.23.0 → 5.25.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "5.23.0",
3
+ "version": "5.25.0",
4
4
  "description": "Self-hosted cockpit for running a fleet of AI coding agents across your own machines — parallel worktree runs, live board, compare & merge, web terminal, design capture.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -45,6 +45,7 @@
45
45
  "@fastify/websocket": "^11",
46
46
  "@libsql/client": "^0.14",
47
47
  "@xterm/addon-fit": "^0.10.0",
48
+ "@xterm/addon-search": "^0.15.0",
48
49
  "@xterm/addon-unicode11": "^0.8.0",
49
50
  "@xterm/addon-web-links": "^0.11.0",
50
51
  "@xterm/xterm": "^5.5.0",
package/src/cockpit.ts CHANGED
@@ -81,6 +81,13 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
81
81
 
82
82
  /* ── 탭 바 + 분할 트리 페인 ── */
83
83
  .stage{display:flex;flex-direction:column;min-width:0;background:var(--bg);position:relative}
84
+ /* ── 터미널 스크롤백 검색바(⌘F) ── */
85
+ .term-find{position:absolute;top:44px;right:14px;z-index:30;display:flex;align-items:center;gap:4px;background:var(--surface);border:1px solid var(--line-hi);border-radius:9px;padding:5px 6px;box-shadow:0 6px 20px rgba(0,0,0,.4)}
86
+ .term-find input{font-family:var(--mono);font-size:12px;color:var(--ink);background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:5px 8px;width:200px;outline:none}
87
+ .term-find input:focus{border-color:var(--brand)}
88
+ .term-find .fc{font-family:var(--mono);font-size:10.5px;color:var(--faint);min-width:20px;text-align:center}
89
+ .term-find button{font-family:var(--mono);font-size:13px;color:var(--muted);background:none;border:none;cursor:pointer;padding:2px 5px;border-radius:5px}
90
+ .term-find button:hover{color:var(--ink);background:var(--surface2)}
84
91
  .tabbar{display:flex;align-items:stretch;height:38px;border-bottom:1px solid var(--line);background:var(--panel);font-family:var(--mono)}
85
92
  .tabs{flex:1;display:flex;align-items:stretch;overflow-x:auto;overflow-y:hidden}
86
93
  .tab{display:inline-flex;align-items:center;gap:7px;padding:0 10px 0 12px;border-right:1px solid var(--line);font-size:12px;color:var(--muted);cursor:pointer;white-space:nowrap;max-width:220px;user-select:none;flex:none}
@@ -185,6 +192,19 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
185
192
  /* ── folder picker (자유 세션 폴더 지정) ── */
186
193
  .modal{position:fixed;inset:0;background:rgba(4,6,10,.6);display:none;align-items:center;justify-content:center;z-index:60}
187
194
  .modal.on{display:flex}
195
+ /* ── ⌘K 커맨드 팔레트 ── */
196
+ #palette{align-items:flex-start}
197
+ .pal{width:min(620px,94vw);margin-top:12vh;display:flex;flex-direction:column;background:var(--surface);border:1px solid var(--line-hi);border-radius:14px;overflow:hidden;box-shadow:0 24px 70px rgba(0,0,0,.5)}
198
+ .pal>input{font-family:var(--mono);font-size:14px;color:var(--ink);background:transparent;border:none;border-bottom:1px solid var(--line);padding:15px 18px;outline:none}
199
+ .pal-list{max-height:52vh;overflow:auto;padding:6px;scrollbar-width:none}
200
+ .pal-list::-webkit-scrollbar{width:0;display:none}
201
+ .pal-row{display:flex;align-items:center;gap:10px;padding:9px 12px;border-radius:8px;cursor:pointer;font-family:var(--mono);font-size:12.5px;color:var(--muted)}
202
+ .pal-row.sel{background:var(--brand-dim);color:var(--ink)}
203
+ .pal-row .pk{font-size:9px;letter-spacing:.04em;color:var(--faint);border:1px solid var(--line);border-radius:999px;padding:1px 7px;flex:none;text-transform:uppercase}
204
+ .pal-row.sel .pk{border-color:rgba(78,201,176,.4);color:var(--brand)}
205
+ .pal-row .pl{flex:1;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
206
+ .pal-row .ph{font-size:10.5px;color:var(--faint);flex:none;max-width:40%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
207
+ .pal-empty{padding:18px;text-align:center;color:var(--faint);font-family:var(--mono);font-size:12px}
188
208
  .pick{width:min(560px,92vw);max-height:76vh;display:flex;flex-direction:column;background:var(--surface);border:1px solid var(--line-hi);border-radius:14px;overflow:hidden;font-family:var(--mono)}
189
209
  .pick-h{display:flex;align-items:center;gap:10px;padding:13px 15px;border-bottom:1px solid var(--line)}
190
210
  .pick-h .t{font-size:13px;color:var(--ink);font-weight:600}
@@ -215,6 +235,11 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
215
235
  .sec-row .shint{color:var(--faint);font-size:11px;font-family:var(--mono)}
216
236
  .sec-row .sdel{color:var(--faint);border:none;background:none;cursor:pointer;font-size:14px;padding:0 4px}
217
237
  .sec-row .sdel:hover{color:var(--failed)}
238
+ .leaf-h .pane-act{font-family:var(--mono);font-size:10px;color:var(--brand);opacity:.9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:110px}
239
+ .leaf-h .pane-act:empty{display:none}
240
+ .tnode.run .ract{margin-left:6px;font-size:10px;color:var(--brand);opacity:.85}
241
+ .leaf-h .zoom{color:var(--faint);border:none;background:none;cursor:pointer;font-size:12px;padding:0 2px}
242
+ .leaf-h .zoom:hover{color:var(--brand)}
218
243
  .leaf-h .lock{color:var(--faint);border:none;background:none;cursor:pointer;font-size:12px;padding:0 2px}
219
244
  .leaf-h .lock:hover{color:var(--brand)}
220
245
  .leaf-h .sendkey{font:inherit;font-family:var(--mono);font-size:11px;color:var(--ink);background:var(--panel);border:1px solid var(--brand);border-radius:5px;padding:1px 6px;width:150px;outline:none}
@@ -259,6 +284,9 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
259
284
  .rv-diff > span{display:block;min-height:1.2em}
260
285
  .dl-file{color:var(--brand)} .dl-hunk{color:var(--open)} .dl-ctx{color:var(--muted)}
261
286
  .dl-add{color:#7fdca0;background:rgba(88,179,104,.08)} .dl-del{color:#e58a92;background:rgba(226,91,103,.08)}
287
+ .rv-diff .ws{color:var(--faint);opacity:.55}
288
+ .rv-ws{font-family:var(--mono);font-size:11px;color:var(--muted);background:var(--surface);border:1px solid var(--line);border-radius:6px;padding:3px 8px;cursor:pointer}
289
+ .rv-ws:hover{color:var(--ink)} .rv-ws.on{color:var(--brand-ink);background:var(--brand);border-color:var(--brand)}
262
290
  .rv-empty{flex:1;display:flex;align-items:center;justify-content:center;color:var(--faint);font-family:var(--mono);font-size:13px;text-align:center;padding:24px}
263
291
 
264
292
  /* ── 모바일 대응 (드로어 트리 + 단일 터미널 + IME 입력바) ── */
@@ -358,6 +386,13 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
358
386
  </div>
359
387
  </div>
360
388
  <div class="panes" id="panes"></div>
389
+ <div class="term-find" id="termFind" hidden>
390
+ <input id="findInput" placeholder="터미널에서 찾기 (⌘F)" autocomplete="off" spellcheck="false" />
391
+ <span class="fc" id="findCount"></span>
392
+ <button type="button" id="findPrev" title="이전 (⇧⏎)">↑</button>
393
+ <button type="button" id="findNext" title="다음 (⏎)">↓</button>
394
+ <button type="button" id="findClose" title="닫기 (esc)">×</button>
395
+ </div>
361
396
  <div class="empty" id="empty">
362
397
  <div class="card">
363
398
  <div class="glyph">⌗ ⌗ ⌗</div>
@@ -421,6 +456,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
421
456
  <input id="rvVcmd" placeholder="예: npm test — 정착 시 자동 실행" autocomplete="off" />
422
457
  <button type="button" id="rvVsave">save</button>
423
458
  </span>
459
+ <button type="button" id="rvWs" class="rv-ws" title="공백 표시(스페이스·탭)">␣ 공백</button>
424
460
  <span id="rvHint" style="margin-left:auto;color:var(--faint)">정착하면 자동 검증 · 승자를 base 에 merge</span>
425
461
  </div>
426
462
  <div class="rv-cols" id="rvCols"></div>
@@ -479,12 +515,20 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
479
515
  </div>
480
516
  </div>
481
517
 
518
+ <div class="modal" id="palette">
519
+ <div class="pal">
520
+ <input id="palInput" placeholder="이동·명령 검색 (⌘K) · 세션·run·프로젝트·명령" autocomplete="off" spellcheck="false" />
521
+ <div class="pal-list" id="palList"></div>
522
+ </div>
523
+ </div>
524
+
482
525
  <div class="toast" id="toast"></div>
483
526
 
484
527
  <script src="/vendor/xterm.js"></script>
485
528
  <script src="/vendor/addon-fit.js"></script>
486
529
  <script src="/vendor/addon-unicode11.js"></script>
487
530
  <script src="/vendor/addon-web-links.js"></script>
531
+ <script src="/vendor/addon-search.js"></script>
488
532
  <script src="/vendor/marked.js"></script>
489
533
  <script>
490
534
  // 모바일 = 터미널 우선을 유지하되 좁은 화면에 맞춤(드로어 트리 + 단일 터미널 + IME 입력바).
@@ -496,6 +540,23 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
496
540
  function toast(msg){ var t=$('toast'); t.textContent=msg; t.classList.add('show'); clearTimeout(toast._h); toast._h=setTimeout(function(){ t.classList.remove('show'); }, 2600); }
497
541
  var V_GLYPH = { pass:'✓ verify', fail:'✗ verify', running:'⋯ verify', error:'! verify' };
498
542
  function vbadge(status){ if (!status || !V_GLYPH[status]) return ''; return '<span class="vbadge '+status+'" data-role="vbadge">'+V_GLYPH[status]+'</span>'; }
543
+ // 라이브 상태 — run 의 최신 이벤트에서 "지금 뭐 하는지"(도구명/사고)를 뽑는다. 실행 중일 때만.
544
+ function latestActivity(runId){
545
+ var r=runById[runId]; if(!r) return '';
546
+ if(r.status!=='running' && r.status!=='pending') return '';
547
+ var evs=r.events||[];
548
+ for(var i=evs.length-1;i>=0;i--){
549
+ var e=evs[i], k=e.kind;
550
+ if(k==='steer') return 'steer'; if(k==='ask') return 'asking';
551
+ if(k!=='assistant') continue;
552
+ try{ var o=JSON.parse(e.payload);
553
+ var c=o&&o.message&&o.message.content;
554
+ if(c&&c.length){ for(var j=c.length-1;j>=0;j--){ if(c[j].type==='tool_use') return c[j].name||'tool'; if(c[j].type==='text'&&(c[j].text||'').trim()) return 'thinking'; } }
555
+ else if(o&&o.text) return 'thinking';
556
+ }catch(_){}
557
+ }
558
+ return r.status==='pending' ? 'starting' : 'working';
559
+ }
499
560
 
500
561
  // ── fleet 상태 ──
501
562
  var fleet = { machines:[], repos:[], tasks:[], groups:[], runs:[], providers:[] };
@@ -595,8 +656,9 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
595
656
  + '<span class="n">'+esc(t.title)+'</span></div>';
596
657
  if (!isFold(tk)) rns.sort(function(a,b){return a.id-b.id;}).forEach(function(r){
597
658
  var open = tabs[r.id] ? ' open' : '';
659
+ var act=latestActivity(r.id);
598
660
  s += '<div class="tnode run'+open+'" data-run="'+r.id+'"><span class="st '+esc(r.status)+'"></span>'
599
- + '<span class="n">r'+r.id+' · '+esc(r.status)+'</span></div>';
661
+ + '<span class="n">r'+r.id+' · '+esc(r.status)+'</span>'+(act?'<span class="ract">'+esc(act)+'</span>':'')+'</div>';
600
662
  });
601
663
  return s;
602
664
  }
@@ -623,6 +685,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
623
685
  var tabOrder = []; // 탭 바 순서(runId)
624
686
  var layout = { leaf:true, id:'L0', tab:null }; // 분할 트리 루트
625
687
  var focusLeaf = 'L0';
688
+ var zoomLeaf = null; // 페인 최대화(줌) — 설정되면 그 리프만 전체 렌더
626
689
  var leafSeq = 1;
627
690
  var viewerSeq = 1; // 뷰어 탭 키(문자열 'v#')
628
691
  var MAX_LEAVES = 6;
@@ -682,6 +745,10 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
682
745
  }
683
746
  cb(links.length?links:undefined);
684
747
  }}); }catch(e){}
748
+ // 스크롤백 검색(⌘F) — SearchAddon. t.search 에 보관, 검색바가 findNext/Previous 호출.
749
+ var search=null; try{ search=new window.SearchAddon.SearchAddon(); term.loadAddon(search);
750
+ search.onDidChangeResults(function(e){ if($('termFind') && !$('termFind').hidden && focusedRunId()===runId){ var c=$('findCount'); if(c){ var n=(e&&e.resultCount)||0; c.textContent = n ? (((e.resultIndex>=0?e.resultIndex+1:0))+'/'+n) : '0'; } } });
751
+ }catch(e){}
685
752
  // 복사 배선: xterm 은 user-select:none 이라 네이티브 선택이 없다 → term.getSelection() 을 직접 클립보드로.
686
753
  // ① 드래그 놓으면 자동 복사(select-to-copy) ② Cmd/Ctrl+C 로도 복사(선택 없으면 통과 → SIGINT).
687
754
  var copySel=function(){ var s=''; try{ s=term.getSelection(); }catch(e){} if(!s) return false;
@@ -691,13 +758,15 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
691
758
  host.addEventListener('mouseup', function(){ copySel(); });
692
759
  host.addEventListener('touchend', function(){ copySel(); });
693
760
  term.attachCustomKeyEventHandler(function(ev){
694
- if(ev.type==='keydown' && (ev.metaKey||ev.ctrlKey) && (ev.key==='c'||ev.key==='C')){
695
- if(term.hasSelection()){ copySel(); return false; }
761
+ if(ev.type==='keydown' && (ev.metaKey||ev.ctrlKey)){
762
+ if(ev.key==='c'||ev.key==='C'){ if(term.hasSelection()){ copySel(); return false; } }
763
+ // 전역 단축키(⌘K·⌘F·⌘⏎·⌘1~9)는 터미널로 보내지 않는다 — 문서 핸들러가 처리.
764
+ if(ev.key==='k'||ev.key==='K'||ev.key==='f'||ev.key==='F'||ev.key==='Enter'||(ev.key>='1'&&ev.key<='9')) return false;
696
765
  }
697
766
  return true;
698
767
  });
699
768
  // term.open 은 host 가 DOM 에 붙은 뒤(attachHosts) 최초 1회 — detached 에서 open 하면 렌더러가 안 뜬다.
700
- var t={ runId:runId, name:tabName(runId), term:term, fit:fit, ws:null, retry:0, closing:false, host:host, ro:null, opened:false, connected:false };
769
+ var t={ runId:runId, name:tabName(runId), term:term, fit:fit, ws:null, retry:0, closing:false, host:host, ro:null, opened:false, connected:false, search:search };
701
770
  term.onData(function(d){ if(t.ws&&t.ws.readyState===1) t.ws.send(JSON.stringify({t:'i',d:d})); });
702
771
  tabs[runId]=t; tabOrder.push(runId);
703
772
  return t; // open·connect 는 attachHosts 에서(Phase 2 순서: open → connect)
@@ -759,7 +828,10 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
759
828
  var fp=document.createElement('iframe'); fp.src=rawUrl; // 브라우저/Electron 내장 PDF 뷰어(수동적 — sandbox 불필요)
760
829
  body.innerHTML=''; body.appendChild(fp);
761
830
  } else if(d.kind==='image'){
762
- body.innerHTML=''; var im=document.createElement('img'); im.src=rawUrl; im.alt=t.name; body.appendChild(im);
831
+ body.innerHTML=''; var im=document.createElement('img'); im.alt=t.name;
832
+ // 브라우저가 못 그리는 형식(HEIC/TIFF 등)이나 로드 실패 시 조용히 빈 화면 대신 이유+원본 링크
833
+ im.onerror=function(){ body.innerHTML='<div class="view-msg">이 이미지를 표시할 수 없습니다 · '+fmtSize(d.size||0)+'<br><span style="color:var(--faint)">브라우저가 못 그리는 형식일 수 있어요 (예: HEIC·TIFF)</span><br><br><a href="'+rawUrl+'" target="_blank">원본 열기 / 내려받기</a></div>'; };
834
+ im.src=rawUrl; body.appendChild(im);
763
835
  } else if(d.kind==='binary'){
764
836
  body.innerHTML='<div class="view-msg">미리보기 불가 · '+fmtSize(d.size||0)+(d.note?(' · '+esc(d.note)):'')+'<br><br><a href="'+rawUrl+'" target="_blank">원본 열기 / 내려받기</a></div>';
765
837
  } else { // text
@@ -849,15 +921,17 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
849
921
  var leaf=document.createElement('div'); leaf.className='leaf'+(node.id===focusLeaf?' focus':''); leaf.dataset.leaf=node.id;
850
922
  var t=node.tab!=null?tabs[node.tab]:null; var r=node.tab!=null?runById[node.tab]:null;
851
923
  var head=document.createElement('div'); head.className='leaf-h'; head.dataset.leafhead=node.id;
924
+ var zoomBtn = '<button class="zoom" data-zoom="'+node.id+'" title="이 페인 최대화 (⌘⏎)">'+(zoomLeaf?'⤡':'⤢')+'</button>';
852
925
  head.innerHTML = t
853
926
  ? (t.kind==='viewer'
854
927
  ? '<span class="st vdoc">▤</span><span class="nm">'+esc(t.name)+'</span>'
855
- + '<button class="x" title="이 페인 닫기(뷰어 유지)">×</button>'
928
+ + zoomBtn + '<button class="x" title="이 페인 닫기(뷰어 유지)">×</button>'
856
929
  : '<span class="st '+esc(r?r.status:'')+'"></span><span class="nm">'+esc(t.name)+'</span>'
930
+ + '<span data-role="act" class="pane-act">'+esc(latestActivity(node.tab))+'</span>'
857
931
  + '<span data-role="chip" class="chip '+esc(r?r.status:'')+'">'+esc(r?r.status:'')+'</span>'
858
932
  + '<span data-role="vslot">'+vbadge(r&&r.verifyStatus)+'</span>'
859
933
  + '<button class="lock" data-lock="'+node.id+'" title="이 페인에 시크릿/비밀번호 전송(터미널에 안 찍힘)">⊟</button>'
860
- + '<button class="x" title="이 페인 닫기(탭은 유지)">×</button>')
934
+ + zoomBtn + '<button class="x" title="이 페인 닫기(탭은 유지)">×</button>')
861
935
  : '<span class="nm" style="color:var(--faint)">빈 페인</span><button class="x" title="이 페인 닫기">×</button>';
862
936
  var body=document.createElement('div'); body.className='leaf-body'; body.dataset.leafbody=node.id;
863
937
  if (!t){ var em=document.createElement('div'); em.className='leaf-empty'; em.textContent='탭을 여기로 드래그하거나 탭을 클릭하세요'; body.appendChild(em); }
@@ -887,7 +961,11 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
887
961
  $('panes').style.display = n?'flex':'none';
888
962
  renderTabs();
889
963
  var host=$('panes'); host.innerHTML='';
890
- if (n){ host.appendChild(buildNode(layout)); attachHosts(); }
964
+ if (n){
965
+ var zl = zoomLeaf ? findLeaf(zoomLeaf) : null; // 줌: 그 리프만 전체 렌더(레이아웃은 메모리에 보존)
966
+ var root = zl ? { leaf:true, id:zl.id, tab:zl.tab } : layout;
967
+ host.appendChild(buildNode(root)); attachHosts();
968
+ }
891
969
  updateControls();
892
970
  if (typeof reqMode!=='undefined') setMode(reqMode);
893
971
  requestAnimationFrame(fitAllVisible);
@@ -900,9 +978,19 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
900
978
  updateControls();
901
979
  if (typeof reqMode!=='undefined' && reqMode!=='new') setMode(reqMode);
902
980
  }
981
+ // 페인 최대화(줌) 토글 — 포커스 페인(또는 지정 leaf)만 전체로. 다시 누르면 복원.
982
+ function toggleZoom(id){
983
+ var target = id || focusLeaf;
984
+ if(zoomLeaf===target){ zoomLeaf=null; }
985
+ else { var l=findLeaf(target); if(!l || l.tab==null) return; zoomLeaf=target; focusLeaf=target; }
986
+ render();
987
+ }
988
+ // 페인 순서(트리 순회) → ⌘1..9 로 N번째 포커스
989
+ function leafOrder(){ var out=[]; eachLeaf(layout,function(l){ out.push(l); }); return out; }
903
990
 
904
991
  // 탭 열기 = 포커스 슬롯에 표시(강제 분할 없음). 기존 호출부(openRunPane) 호환.
905
992
  function openTab(runId){
993
+ zoomLeaf=null; // 새 탭은 보여야 하므로 줌 해제
906
994
  if(!tabs[runId]) ensureTab(runId); // 뷰어 탭은 ensureViewer 로 이미 생성됨 → 터미널로 오생성 방지
907
995
  var l=leafOfTab(runId);
908
996
  if (l){ setLeafFocus(l.id); return; }
@@ -915,6 +1003,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
915
1003
 
916
1004
  function splitFocused(dir){
917
1005
  if (!tabOrder.length) return;
1006
+ zoomLeaf=null;
918
1007
  if (isMobile()){ toast('창분할은 데스크톱 전용 — 모바일은 탭으로 전환하세요'); return; }
919
1008
  if (countLeaves()>=MAX_LEAVES){ toast('페인 최대 '+MAX_LEAVES+'개'); return; }
920
1009
  var l=findLeaf(focusLeaf) || firstLeaf(); if(!l) return;
@@ -924,6 +1013,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
924
1013
  focusLeaf=bId; render(); renderTree();
925
1014
  }
926
1015
  function closeSlot(id){
1016
+ zoomLeaf=null;
927
1017
  if (countLeaves()<=1){ var only=findLeaf(id)||firstLeaf(); if(only) only.tab=null; if(only) focusLeaf=only.id; render(); renderTree(); return; }
928
1018
  (function walk(node){
929
1019
  if(node.leaf) return false;
@@ -935,6 +1025,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
935
1025
  render(); renderTree();
936
1026
  }
937
1027
  function closeTab(runId){
1028
+ zoomLeaf=null;
938
1029
  var t=tabs[runId]; if(!t) return; t.closing=true;
939
1030
  try{ if(t.ws) t.ws.close(); }catch(e){}
940
1031
  try{ if(t.ro) t.ro.disconnect(); }catch(e){}
@@ -953,6 +1044,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
953
1044
  }
954
1045
  function tileTabs(ids){
955
1046
  ids=(ids||[]).filter(function(x){return x!=null;}); if(!ids.length) return;
1047
+ zoomLeaf=null;
956
1048
  ids.forEach(ensureTab);
957
1049
  layout=buildTiled(ids,'row'); var f=firstLeaf(); focusLeaf=f?f.id:'L0';
958
1050
  render(); renderTree();
@@ -970,6 +1062,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
970
1062
  var st=head.querySelector('.st'); if(st) st.className='st '+r.status;
971
1063
  var chip=head.querySelector('[data-role=chip]'); if(chip){ chip.className='chip '+r.status; chip.textContent=r.status; }
972
1064
  var vslot=head.querySelector('[data-role=vslot]'); if(vslot) vslot.innerHTML=vbadge(r.verifyStatus);
1065
+ var act=head.querySelector('[data-role=act]'); if(act) act.textContent=latestActivity(l.tab);
973
1066
  });
974
1067
  }
975
1068
 
@@ -1010,6 +1103,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1010
1103
 
1011
1104
  // ── 페인(슬롯) 이벤트: 포커스·닫기·드롭·리사이즈 ──
1012
1105
  $('panes').addEventListener('click', function(e){
1106
+ var zoomBtn=e.target.closest('[data-zoom]');
1107
+ if (zoomBtn){ e.stopPropagation(); toggleZoom(zoomBtn.getAttribute('data-zoom')); return; }
1013
1108
  var lockBtn=e.target.closest('[data-lock]');
1014
1109
  if (lockBtn){ e.stopPropagation(); startSecretSend(lockBtn.getAttribute('data-lock'), lockBtn); return; }
1015
1110
  var leaf=e.target.closest('[data-leaf]'); if(!leaf) return;
@@ -1047,6 +1142,94 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1047
1142
  $('menuBtn').addEventListener('click', function(){ setDrawer(!$('rail').classList.contains('open')); });
1048
1143
  $('scrim').addEventListener('click', function(){ setDrawer(false); });
1049
1144
 
1145
+ // ── 터미널 스크롤백 검색(⌘F) ──
1146
+ function focusedTermTab(){ var rid=focusedRunId(); var t=(rid!=null)?tabs[rid]:null; return (t && t.term && t.search) ? t : null; }
1147
+ var FIND_DECO={ matchBackground:'#3a4a2e', activeMatchBackground:'#4ec9b0', activeMatchColorOverviewRuler:'#4ec9b0', matchOverviewRuler:'#33415580' };
1148
+ function runFind(dir){ var t=focusedTermTab(); var q=$('findInput').value; if(!t){ return; } if(!q){ try{ t.search.clearDecorations(); }catch(e){} var c=$('findCount'); if(c) c.textContent=''; return; }
1149
+ try{ if(dir<0) t.search.findPrevious(q,{decorations:FIND_DECO}); else t.search.findNext(q,{decorations:FIND_DECO}); }catch(e){} }
1150
+ function openFind(){ var t=focusedTermTab(); if(!t){ toast('검색할 터미널 페인을 먼저 선택하세요'); return; } $('termFind').hidden=false; var i=$('findInput'); i.focus(); i.select(); if(i.value) runFind(1); }
1151
+ function closeFind(){ $('termFind').hidden=true; var t=focusedTermTab(); if(t){ try{ t.search.clearDecorations(); t.term.focus(); }catch(e){} } }
1152
+ $('findInput').addEventListener('input', function(){ runFind(1); });
1153
+ $('findInput').addEventListener('keydown', function(e){ if(e.key==='Enter'){ e.preventDefault(); runFind(e.shiftKey?-1:1); } else if(e.key==='Escape'){ e.preventDefault(); closeFind(); } });
1154
+ $('findNext').addEventListener('click', function(){ runFind(1); });
1155
+ $('findPrev').addEventListener('click', function(){ runFind(-1); });
1156
+ $('findClose').addEventListener('click', closeFind);
1157
+ document.addEventListener('keydown', function(e){
1158
+ if((e.metaKey||e.ctrlKey) && (e.key==='f'||e.key==='F') && !e.shiftKey && !e.altKey){
1159
+ if(focusedTermTab()){ e.preventDefault(); openFind(); }
1160
+ }
1161
+ });
1162
+
1163
+ // ── ⌘K 커맨드 팔레트 — 이동(세션·run·프로젝트) + 명령 ──
1164
+ var palItems=[], palSel=0;
1165
+ function paletteItems(){
1166
+ var items=[
1167
+ {k:'cmd', label:'새 작업 세션 열기', run:openSession},
1168
+ {k:'cmd', label:'파일 열기 (뷰어)', run:openFilePicker},
1169
+ {k:'cmd', label:'터미널 검색 (⌘F)', run:openFind},
1170
+ {k:'cmd', label:'세로 분할', run:function(){ splitFocused('row'); }},
1171
+ {k:'cmd', label:'가로 분할', run:function(){ splitFocused('col'); }},
1172
+ {k:'cmd', label:'포커스 페인 닫기', run:function(){ if(focusLeaf) closeSlot(focusLeaf); }},
1173
+ {k:'cmd', label:'뷰어 / 히스토리', run:openHistory},
1174
+ {k:'cmd', label:'Review 열기 (비교·머지)', run:showReview},
1175
+ {k:'cmd', label:'시크릿 (env 주입)', run:openSecrets},
1176
+ {k:'cmd', label:'보드로 전환', run:function(){ location.href='/'; }}
1177
+ ];
1178
+ // 세션 → 페인으로 열기
1179
+ var runs=[]; Object.keys(runById).forEach(function(id){ runs.push(runById[id]); });
1180
+ runs.sort(function(a,b){ return b.id-a.id; });
1181
+ runs.forEach(function(r){
1182
+ var task=taskById[r.taskId]; var repo=task&&repoById[task.repoId];
1183
+ if(repo && repo.kind==='sessions'){ items.push({k:'session', label:(task&&task.title)||('session r'+r.id), hint:'r'+r.id, run:function(){ openRunPane(r.id); }}); }
1184
+ });
1185
+ // run → 페인으로 열기 (최근 40개)
1186
+ var n=0; runs.forEach(function(r){
1187
+ var task=taskById[r.taskId]; var repo=task&&repoById[task.repoId];
1188
+ if(repo && repo.kind==='sessions') return; if(n>=40) return; n++;
1189
+ items.push({k:'run', label:'r'+r.id+' · '+(repo?repo.name:'?')+' / '+((task&&task.title)||''), hint:r.status, run:function(){ openRunPane(r.id); }});
1190
+ });
1191
+ // 프로젝트 → 요청바 대상 지정(팬아웃 준비)
1192
+ Object.keys(repoById).forEach(function(id){ var repo=repoById[id]; if(repo.kind==='sessions') return;
1193
+ items.push({k:'project', label:repo.name, hint:'팬아웃 대상 지정', run:function(){ try{ $('reqRepo').value=String(repo.id); }catch(e){} var i=$('reqInput'); if(i){ i.focus(); } }}); });
1194
+ return items;
1195
+ }
1196
+ function renderPalette(q){
1197
+ q=(q||'').trim().toLowerCase();
1198
+ var all=paletteItems();
1199
+ palItems = q ? all.filter(function(it){ return (it.label+' '+(it.hint||'')+' '+it.k).toLowerCase().indexOf(q)>=0; }) : all;
1200
+ palSel=0;
1201
+ if(!palItems.length){ $('palList').innerHTML='<div class="pal-empty">일치 없음</div>'; return; }
1202
+ $('palList').innerHTML = palItems.map(function(it,i){
1203
+ return '<div class="pal-row'+(i===0?' sel':'')+'" data-i="'+i+'"><span class="pk">'+esc(it.k)+'</span><span class="pl">'+esc(it.label)+'</span>'+(it.hint?'<span class="ph">'+esc(it.hint)+'</span>':'')+'</div>';
1204
+ }).join('');
1205
+ }
1206
+ function palMove(d){ if(!palItems.length) return; palSel=(palSel+d+palItems.length)%palItems.length;
1207
+ var rows=$('palList').querySelectorAll('.pal-row'); rows.forEach(function(el,i){ el.classList.toggle('sel', i===palSel); });
1208
+ var sel=rows[palSel]; if(sel) sel.scrollIntoView({block:'nearest'}); }
1209
+ function palRun(i){ var it=palItems[i]; if(!it) return; closePalette(); try{ it.run(); }catch(e){} }
1210
+ function openPalette(){ $('palette').classList.add('on'); var i=$('palInput'); i.value=''; renderPalette(''); i.focus(); }
1211
+ function closePalette(){ $('palette').classList.remove('on'); }
1212
+ $('palInput').addEventListener('input', function(){ renderPalette(this.value); });
1213
+ $('palInput').addEventListener('keydown', function(e){
1214
+ if(e.key==='ArrowDown'){ e.preventDefault(); palMove(1); }
1215
+ else if(e.key==='ArrowUp'){ e.preventDefault(); palMove(-1); }
1216
+ else if(e.key==='Enter'){ e.preventDefault(); palRun(palSel); }
1217
+ else if(e.key==='Escape'){ e.preventDefault(); closePalette(); }
1218
+ });
1219
+ $('palList').addEventListener('click', function(e){ var row=e.target.closest('[data-i]'); if(row) palRun(Number(row.dataset.i)); });
1220
+ $('palette').addEventListener('click', function(e){ if(e.target===this) closePalette(); });
1221
+ document.addEventListener('keydown', function(e){
1222
+ if((e.metaKey||e.ctrlKey) && (e.key==='k'||e.key==='K')){ e.preventDefault(); if($('palette').classList.contains('on')) closePalette(); else openPalette(); }
1223
+ });
1224
+ // ── 페인 단축키: ⌘⏎ 최대화 토글 · ⌘1~9 N번째 페인 포커스 ──
1225
+ function typingInField(e){ var el=e.target; if(!el) return false; var tag=el.tagName||''; return (tag==='INPUT'||tag==='TEXTAREA') && !el.classList.contains('xterm-helper-textarea'); }
1226
+ document.addEventListener('keydown', function(e){
1227
+ if(!(e.metaKey||e.ctrlKey) || e.altKey) return;
1228
+ if(!tabOrder.length || typingInField(e)) return;
1229
+ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); toggleZoom(focusLeaf); return; }
1230
+ if(e.key>='1' && e.key<='9'){ var order=leafOrder(); var idx=Number(e.key)-1; if(order[idx]){ e.preventDefault(); if(zoomLeaf){ zoomLeaf=order[idx].id; focusLeaf=order[idx].id; render(); } else setLeafFocus(order[idx].id); } }
1231
+ });
1232
+
1050
1233
  // ── 모바일 터미널 입력바 — 조합 완료 텍스트를 통째로 포커스 탭의 PTY 로(IME 안전) ──
1051
1234
  function focusedWs(){ var rid=focusedRunId(); return (rid!=null && tabs[rid]) ? tabs[rid].ws : null; }
1052
1235
  function termSendRaw(d){ var ws=focusedWs(); if(ws && ws.readyState===1) ws.send(JSON.stringify({t:'i',d:d})); }
@@ -1339,10 +1522,14 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1339
1522
  loadCompare(rvTaskId);
1340
1523
  }
1341
1524
  $('rvTask').addEventListener('change', function(){ rvTaskId=Number(this.value); syncVcmd(); loadCompare(rvTaskId); });
1525
+ $('rvWs').addEventListener('click', function(){ rvShowWs=!rvShowWs; this.classList.toggle('on', rvShowWs); if(rvTaskId!=null) loadCompare(rvTaskId); });
1526
+ var rvShowWs=false;
1527
+ function revealWs(e){ return e.replace(/ /g,'<span class="ws">·</span>').replace(/\\t/g,'<span class="ws">→\\u2003</span>'); }
1342
1528
  function diffHTML(text){
1343
1529
  if (!text || !text.trim()) return '<span style="color:var(--faint)">no changes</span>';
1344
1530
  return text.split('\\n').map(function(l){
1345
1531
  var e = esc(l) || '&nbsp;';
1532
+ if (rvShowWs && l.indexOf('diff --git')!==0 && l.indexOf('@@')!==0) e = revealWs(e);
1346
1533
  if (l.indexOf('diff --git')===0 || l.indexOf('+++')===0 || l.indexOf('---')===0) return '<span class="dl-file">'+e+'</span>';
1347
1534
  if (l.indexOf('@@')===0) return '<span class="dl-hunk">'+e+'</span>';
1348
1535
  if (l.charAt(0)==='+') return '<span class="dl-add">'+e+'</span>';
package/src/server.ts CHANGED
@@ -37,6 +37,7 @@ const VENDOR: Record<string, { pkg: string; rel: string; type: string }> = {
37
37
  'addon-fit.js': { pkg: '@xterm/addon-fit/package.json', rel: 'lib/addon-fit.js', type: 'text/javascript' },
38
38
  'addon-unicode11.js': { pkg: '@xterm/addon-unicode11/package.json', rel: 'lib/addon-unicode11.js', type: 'text/javascript' },
39
39
  'addon-web-links.js': { pkg: '@xterm/addon-web-links/package.json', rel: 'lib/addon-web-links.js', type: 'text/javascript' },
40
+ 'addon-search.js': { pkg: '@xterm/addon-search/package.json', rel: 'lib/addon-search.js', type: 'text/javascript' },
40
41
  'marked.js': { pkg: 'marked/package.json', rel: 'marked.min.js', type: 'text/javascript' },
41
42
  };
42
43
 
@@ -365,7 +366,10 @@ export async function buildServer(): Promise<FastifyInstance> {
365
366
  const m = authMode();
366
367
  return {
367
368
  effective: {
368
- port: config.port, portStrict: config.portStrict, host: config.host, filesRoot: config.filesRoot,
369
+ port: config.port, portStrict: config.portStrict, host: config.host,
370
+ // filesRoot applies immediately (no restart) — report the LIVE value, not the boot-time one,
371
+ // so the Settings field shows what's actually in force. Precedence: env > settings.json > home.
372
+ filesRoot: process.env.COXPIT_FILES_ROOT ?? s.filesRoot ?? '',
369
373
  webhookUrl: config.webhookUrl, publicUrl: config.publicUrl,
370
374
  agent: { provider: config.agent.provider, model: config.agent.model, count: config.agent.count, real: config.agent.real },
371
375
  },