coxpit 5.23.1 → 5.26.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.1",
3
+ "version": "5.26.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
@@ -54,6 +54,11 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
54
54
  .toggle:hover{color:var(--ink);border-color:var(--line-hi)}
55
55
 
56
56
  .layout{display:grid;grid-template-columns:270px 1fr;height:calc(100dvh - 46px)}
57
+ /* 포커스 모드 — 트리·요청바 숨기고 페인만(⌘.) */
58
+ .layout.focusmode{grid-template-columns:1fr}
59
+ .layout.focusmode .rail{display:none}
60
+ .layout.focusmode .reqbar{display:none}
61
+ .tc-btn.on{color:var(--brand-ink);background:var(--brand);border-color:var(--brand)}
57
62
  .layout > *{min-height:0;min-width:0}
58
63
 
59
64
  /* ── workspace tree ── */
@@ -81,6 +86,15 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
81
86
 
82
87
  /* ── 탭 바 + 분할 트리 페인 ── */
83
88
  .stage{display:flex;flex-direction:column;min-width:0;background:var(--bg);position:relative}
89
+ /* ── 터미널 스크롤백 검색바(⌘F) ── */
90
+ .term-find{position:absolute;top:44px;right:14px;z-index:30;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)}
91
+ .term-find[hidden]{display:none} /* hidden 속성 존중 — 아래 규칙이 UA 를 이기지 않게 */
92
+ .term-find:not([hidden]){display:flex}
93
+ .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}
94
+ .term-find input:focus{border-color:var(--brand)}
95
+ .term-find .fc{font-family:var(--mono);font-size:10.5px;color:var(--faint);min-width:20px;text-align:center}
96
+ .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}
97
+ .term-find button:hover{color:var(--ink);background:var(--surface2)}
84
98
  .tabbar{display:flex;align-items:stretch;height:38px;border-bottom:1px solid var(--line);background:var(--panel);font-family:var(--mono)}
85
99
  .tabs{flex:1;display:flex;align-items:stretch;overflow-x:auto;overflow-y:hidden}
86
100
  .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 +199,19 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
185
199
  /* ── folder picker (자유 세션 폴더 지정) ── */
186
200
  .modal{position:fixed;inset:0;background:rgba(4,6,10,.6);display:none;align-items:center;justify-content:center;z-index:60}
187
201
  .modal.on{display:flex}
202
+ /* ── ⌘K 커맨드 팔레트 ── */
203
+ #palette{align-items:flex-start}
204
+ .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)}
205
+ .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}
206
+ .pal-list{max-height:52vh;overflow:auto;padding:6px;scrollbar-width:none}
207
+ .pal-list::-webkit-scrollbar{width:0;display:none}
208
+ .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)}
209
+ .pal-row.sel{background:var(--brand-dim);color:var(--ink)}
210
+ .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}
211
+ .pal-row.sel .pk{border-color:rgba(78,201,176,.4);color:var(--brand)}
212
+ .pal-row .pl{flex:1;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
213
+ .pal-row .ph{font-size:10.5px;color:var(--faint);flex:none;max-width:40%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
214
+ .pal-empty{padding:18px;text-align:center;color:var(--faint);font-family:var(--mono);font-size:12px}
188
215
  .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
216
  .pick-h{display:flex;align-items:center;gap:10px;padding:13px 15px;border-bottom:1px solid var(--line)}
190
217
  .pick-h .t{font-size:13px;color:var(--ink);font-weight:600}
@@ -215,6 +242,16 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
215
242
  .sec-row .shint{color:var(--faint);font-size:11px;font-family:var(--mono)}
216
243
  .sec-row .sdel{color:var(--faint);border:none;background:none;cursor:pointer;font-size:14px;padding:0 4px}
217
244
  .sec-row .sdel:hover{color:var(--failed)}
245
+ .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}
246
+ .leaf-h .pane-act:empty{display:none}
247
+ .tnode.run .ract{margin-left:6px;font-size:10px;color:var(--brand);opacity:.85}
248
+ .leaf-h .zoom{color:var(--faint);border:none;background:none;cursor:pointer;font-size:12px;padding:0 2px}
249
+ .leaf-h .zoom:hover{color:var(--brand)}
250
+ .leaf-h .pact{color:var(--faint);border:none;background:none;cursor:pointer;font-size:12px;padding:0 2px}
251
+ .leaf-h .pact:hover{color:var(--brand)}
252
+ .leaf-h .bsel{display:none;color:var(--faint);border:none;background:none;cursor:pointer;font-size:11px;padding:0 2px}
253
+ body.bcastmode .leaf-h .bsel{display:inline-block} /* 브로드캐스트 모드에서만 대상 선택 토글 노출 */
254
+ .leaf-h .bsel.on{color:var(--open)}
218
255
  .leaf-h .lock{color:var(--faint);border:none;background:none;cursor:pointer;font-size:12px;padding:0 2px}
219
256
  .leaf-h .lock:hover{color:var(--brand)}
220
257
  .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 +296,9 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
259
296
  .rv-diff > span{display:block;min-height:1.2em}
260
297
  .dl-file{color:var(--brand)} .dl-hunk{color:var(--open)} .dl-ctx{color:var(--muted)}
261
298
  .dl-add{color:#7fdca0;background:rgba(88,179,104,.08)} .dl-del{color:#e58a92;background:rgba(226,91,103,.08)}
299
+ .rv-diff .ws{color:var(--faint);opacity:.55}
300
+ .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}
301
+ .rv-ws:hover{color:var(--ink)} .rv-ws.on{color:var(--brand-ink);background:var(--brand);border-color:var(--brand)}
262
302
  .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
303
 
264
304
  /* ── 모바일 대응 (드로어 트리 + 단일 터미널 + IME 입력바) ── */
@@ -339,7 +379,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
339
379
  </header>
340
380
 
341
381
  <div class="scrim" id="scrim"></div>
342
- <div class="layout">
382
+ <div class="layout" id="layout">
343
383
  <aside class="rail" id="rail">
344
384
  <div class="lbl"><span>Workspace</span><span id="machName" style="color:var(--faint)">local</span></div>
345
385
  <div id="tree"></div>
@@ -354,10 +394,18 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
354
394
  <button class="tc-btn" id="splitCol" title="가로 분할 — 포커스 페인을 상하로" disabled>▬ split</button>
355
395
  <button class="tc-btn session" id="sessionBtn" title="자유 세션(폴더 지정 터미널) 열기">+<span class="b-txt"> Session</span></button>
356
396
  <button class="tc-btn" id="fileBtn" title="파일 보기 — md·html·pdf·이미지·텍스트 뷰어(터미널 옆 페인)">▤<span class="b-txt"> File</span></button>
397
+ <button class="tc-btn" id="focusBtn" title="포커스 모드 — 트리·요청바 숨기고 페인만 (⌘.)">◱<span class="b-txt"> Focus</span></button>
357
398
  <button class="tc-btn" id="closeBtn" title="포커스 페인 닫기(탭은 유지)" disabled>×<span class="b-txt"> pane</span></button>
358
399
  </div>
359
400
  </div>
360
401
  <div class="panes" id="panes"></div>
402
+ <div class="term-find" id="termFind" hidden>
403
+ <input id="findInput" placeholder="터미널에서 찾기 (⌘F)" autocomplete="off" spellcheck="false" />
404
+ <span class="fc" id="findCount"></span>
405
+ <button type="button" id="findPrev" title="이전 (⇧⏎)">↑</button>
406
+ <button type="button" id="findNext" title="다음 (⏎)">↓</button>
407
+ <button type="button" id="findClose" title="닫기 (esc)">×</button>
408
+ </div>
361
409
  <div class="empty" id="empty">
362
410
  <div class="card">
363
411
  <div class="glyph">⌗ ⌗ ⌗</div>
@@ -421,6 +469,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
421
469
  <input id="rvVcmd" placeholder="예: npm test — 정착 시 자동 실행" autocomplete="off" />
422
470
  <button type="button" id="rvVsave">save</button>
423
471
  </span>
472
+ <button type="button" id="rvWs" class="rv-ws" title="공백 표시(스페이스·탭)">␣ 공백</button>
424
473
  <span id="rvHint" style="margin-left:auto;color:var(--faint)">정착하면 자동 검증 · 승자를 base 에 merge</span>
425
474
  </div>
426
475
  <div class="rv-cols" id="rvCols"></div>
@@ -479,12 +528,20 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
479
528
  </div>
480
529
  </div>
481
530
 
531
+ <div class="modal" id="palette">
532
+ <div class="pal">
533
+ <input id="palInput" placeholder="이동·명령 검색 (⌘K) · 세션·run·프로젝트·명령" autocomplete="off" spellcheck="false" />
534
+ <div class="pal-list" id="palList"></div>
535
+ </div>
536
+ </div>
537
+
482
538
  <div class="toast" id="toast"></div>
483
539
 
484
540
  <script src="/vendor/xterm.js"></script>
485
541
  <script src="/vendor/addon-fit.js"></script>
486
542
  <script src="/vendor/addon-unicode11.js"></script>
487
543
  <script src="/vendor/addon-web-links.js"></script>
544
+ <script src="/vendor/addon-search.js"></script>
488
545
  <script src="/vendor/marked.js"></script>
489
546
  <script>
490
547
  // 모바일 = 터미널 우선을 유지하되 좁은 화면에 맞춤(드로어 트리 + 단일 터미널 + IME 입력바).
@@ -496,6 +553,23 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
496
553
  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
554
  var V_GLYPH = { pass:'✓ verify', fail:'✗ verify', running:'⋯ verify', error:'! verify' };
498
555
  function vbadge(status){ if (!status || !V_GLYPH[status]) return ''; return '<span class="vbadge '+status+'" data-role="vbadge">'+V_GLYPH[status]+'</span>'; }
556
+ // 라이브 상태 — run 의 최신 이벤트에서 "지금 뭐 하는지"(도구명/사고)를 뽑는다. 실행 중일 때만.
557
+ function latestActivity(runId){
558
+ var r=runById[runId]; if(!r) return '';
559
+ if(r.status!=='running' && r.status!=='pending') return '';
560
+ var evs=r.events||[];
561
+ for(var i=evs.length-1;i>=0;i--){
562
+ var e=evs[i], k=e.kind;
563
+ if(k==='steer') return 'steer'; if(k==='ask') return 'asking';
564
+ if(k!=='assistant') continue;
565
+ try{ var o=JSON.parse(e.payload);
566
+ var c=o&&o.message&&o.message.content;
567
+ 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'; } }
568
+ else if(o&&o.text) return 'thinking';
569
+ }catch(_){}
570
+ }
571
+ return r.status==='pending' ? 'starting' : 'working';
572
+ }
499
573
 
500
574
  // ── fleet 상태 ──
501
575
  var fleet = { machines:[], repos:[], tasks:[], groups:[], runs:[], providers:[] };
@@ -503,6 +577,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
503
577
  var fold = {}; // 접힘 상태(repo/goal/task 노드)
504
578
  function isFold(k){ return fold[k]===true; }
505
579
  function repoOfRun(runId){ var r=runById[runId]; var t=r&&taskById[r.taskId]; return t?t.repoId:null; }
580
+ function runIsSession(runId){ var r=runById[runId]; var t=r&&taskById[r.taskId]; var rp=t&&repoById[t.repoId]; return !!(rp&&rp.kind==='sessions'); }
506
581
 
507
582
  async function hydrate(){
508
583
  try{
@@ -595,8 +670,9 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
595
670
  + '<span class="n">'+esc(t.title)+'</span></div>';
596
671
  if (!isFold(tk)) rns.sort(function(a,b){return a.id-b.id;}).forEach(function(r){
597
672
  var open = tabs[r.id] ? ' open' : '';
673
+ var act=latestActivity(r.id);
598
674
  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>';
675
+ + '<span class="n">r'+r.id+' · '+esc(r.status)+'</span>'+(act?'<span class="ract">'+esc(act)+'</span>':'')+'</div>';
600
676
  });
601
677
  return s;
602
678
  }
@@ -623,6 +699,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
623
699
  var tabOrder = []; // 탭 바 순서(runId)
624
700
  var layout = { leaf:true, id:'L0', tab:null }; // 분할 트리 루트
625
701
  var focusLeaf = 'L0';
702
+ var zoomLeaf = null; // 페인 최대화(줌) — 설정되면 그 리프만 전체 렌더
703
+ var bcastSel = {}; // 브로드캐스트 선택 대상(tab id → true). 비어있으면 전체.
626
704
  var leafSeq = 1;
627
705
  var viewerSeq = 1; // 뷰어 탭 키(문자열 'v#')
628
706
  var MAX_LEAVES = 6;
@@ -682,6 +760,10 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
682
760
  }
683
761
  cb(links.length?links:undefined);
684
762
  }}); }catch(e){}
763
+ // 스크롤백 검색(⌘F) — SearchAddon. t.search 에 보관, 검색바가 findNext/Previous 호출.
764
+ var search=null; try{ search=new window.SearchAddon.SearchAddon(); term.loadAddon(search);
765
+ 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'; } } });
766
+ }catch(e){}
685
767
  // 복사 배선: xterm 은 user-select:none 이라 네이티브 선택이 없다 → term.getSelection() 을 직접 클립보드로.
686
768
  // ① 드래그 놓으면 자동 복사(select-to-copy) ② Cmd/Ctrl+C 로도 복사(선택 없으면 통과 → SIGINT).
687
769
  var copySel=function(){ var s=''; try{ s=term.getSelection(); }catch(e){} if(!s) return false;
@@ -691,13 +773,15 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
691
773
  host.addEventListener('mouseup', function(){ copySel(); });
692
774
  host.addEventListener('touchend', function(){ copySel(); });
693
775
  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; }
776
+ if(ev.type==='keydown' && (ev.metaKey||ev.ctrlKey)){
777
+ if(ev.key==='c'||ev.key==='C'){ if(term.hasSelection()){ copySel(); return false; } }
778
+ // 전역 단축키(⌘K·⌘F·⌘⏎·⌘1~9)는 터미널로 보내지 않는다 — 문서 핸들러가 처리.
779
+ if(ev.key==='k'||ev.key==='K'||ev.key==='f'||ev.key==='F'||ev.key==='Enter'||ev.key==='.'||(ev.key>='1'&&ev.key<='9')) return false;
696
780
  }
697
781
  return true;
698
782
  });
699
783
  // 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 };
784
+ 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
785
  term.onData(function(d){ if(t.ws&&t.ws.readyState===1) t.ws.send(JSON.stringify({t:'i',d:d})); });
702
786
  tabs[runId]=t; tabOrder.push(runId);
703
787
  return t; // open·connect 는 attachHosts 에서(Phase 2 순서: open → connect)
@@ -759,7 +843,10 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
759
843
  var fp=document.createElement('iframe'); fp.src=rawUrl; // 브라우저/Electron 내장 PDF 뷰어(수동적 — sandbox 불필요)
760
844
  body.innerHTML=''; body.appendChild(fp);
761
845
  } else if(d.kind==='image'){
762
- body.innerHTML=''; var im=document.createElement('img'); im.src=rawUrl; im.alt=t.name; body.appendChild(im);
846
+ body.innerHTML=''; var im=document.createElement('img'); im.alt=t.name;
847
+ // 브라우저가 못 그리는 형식(HEIC/TIFF 등)이나 로드 실패 시 조용히 빈 화면 대신 이유+원본 링크
848
+ 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>'; };
849
+ im.src=rawUrl; body.appendChild(im);
763
850
  } else if(d.kind==='binary'){
764
851
  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
852
  } else { // text
@@ -849,15 +936,20 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
849
936
  var leaf=document.createElement('div'); leaf.className='leaf'+(node.id===focusLeaf?' focus':''); leaf.dataset.leaf=node.id;
850
937
  var t=node.tab!=null?tabs[node.tab]:null; var r=node.tab!=null?runById[node.tab]:null;
851
938
  var head=document.createElement('div'); head.className='leaf-h'; head.dataset.leafhead=node.id;
939
+ var zoomBtn = '<button class="zoom" data-zoom="'+node.id+'" title="이 페인 최대화 (⌘⏎)">'+(zoomLeaf?'⤡':'⤢')+'</button>';
852
940
  head.innerHTML = t
853
941
  ? (t.kind==='viewer'
854
942
  ? '<span class="st vdoc">▤</span><span class="nm">'+esc(t.name)+'</span>'
855
- + '<button class="x" title="이 페인 닫기(뷰어 유지)">×</button>'
943
+ + zoomBtn + '<button class="x" title="이 페인 닫기(뷰어 유지)">×</button>'
856
944
  : '<span class="st '+esc(r?r.status:'')+'"></span><span class="nm">'+esc(t.name)+'</span>'
945
+ + '<span data-role="act" class="pane-act">'+esc(latestActivity(node.tab))+'</span>'
857
946
  + '<span data-role="chip" class="chip '+esc(r?r.status:'')+'">'+esc(r?r.status:'')+'</span>'
858
947
  + '<span data-role="vslot">'+vbadge(r&&r.verifyStatus)+'</span>'
948
+ + '<button class="bsel'+(bcastSel[node.tab]?' on':'')+'" data-bcast="'+node.tab+'" title="브로드캐스트 대상 토글">'+(bcastSel[node.tab]?'◉':'◯')+'</button>'
949
+ + (runIsSession(node.tab)?'':'<button class="pact" data-diff="'+node.id+'" title="이 run 의 diff (Review)">⧉</button>')
950
+ + '<button class="pact" data-hist="'+node.id+'" title="이 run 히스토리 (대화·터미널)">↺</button>'
859
951
  + '<button class="lock" data-lock="'+node.id+'" title="이 페인에 시크릿/비밀번호 전송(터미널에 안 찍힘)">⊟</button>'
860
- + '<button class="x" title="이 페인 닫기(탭은 유지)">×</button>')
952
+ + zoomBtn + '<button class="x" title="이 페인 닫기(탭은 유지)">×</button>')
861
953
  : '<span class="nm" style="color:var(--faint)">빈 페인</span><button class="x" title="이 페인 닫기">×</button>';
862
954
  var body=document.createElement('div'); body.className='leaf-body'; body.dataset.leafbody=node.id;
863
955
  if (!t){ var em=document.createElement('div'); em.className='leaf-empty'; em.textContent='탭을 여기로 드래그하거나 탭을 클릭하세요'; body.appendChild(em); }
@@ -887,7 +979,11 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
887
979
  $('panes').style.display = n?'flex':'none';
888
980
  renderTabs();
889
981
  var host=$('panes'); host.innerHTML='';
890
- if (n){ host.appendChild(buildNode(layout)); attachHosts(); }
982
+ if (n){
983
+ var zl = zoomLeaf ? findLeaf(zoomLeaf) : null; // 줌: 그 리프만 전체 렌더(레이아웃은 메모리에 보존)
984
+ var root = zl ? { leaf:true, id:zl.id, tab:zl.tab } : layout;
985
+ host.appendChild(buildNode(root)); attachHosts();
986
+ }
891
987
  updateControls();
892
988
  if (typeof reqMode!=='undefined') setMode(reqMode);
893
989
  requestAnimationFrame(fitAllVisible);
@@ -900,9 +996,41 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
900
996
  updateControls();
901
997
  if (typeof reqMode!=='undefined' && reqMode!=='new') setMode(reqMode);
902
998
  }
999
+ // 페인 최대화(줌) 토글 — 포커스 페인(또는 지정 leaf)만 전체로. 다시 누르면 복원.
1000
+ function toggleZoom(id){
1001
+ var target = id || focusLeaf;
1002
+ if(zoomLeaf===target){ zoomLeaf=null; }
1003
+ else { var l=findLeaf(target); if(!l || l.tab==null) return; zoomLeaf=target; focusLeaf=target; }
1004
+ render();
1005
+ }
1006
+ // 페인 순서(트리 순회) → ⌘1..9 로 N번째 포커스
1007
+ function leafOrder(){ var out=[]; eachLeaf(layout,function(l){ out.push(l); }); return out; }
1008
+
1009
+ // ── 레이아웃 저장/복원 (localStorage, 기기별) ──
1010
+ // 트리 + 각 페인의 탭을 복원 키(run=runId · viewer=경로)로 직렬화한다.
1011
+ function serializeNode(node){
1012
+ if(node.leaf){ var d=null; if(node.tab!=null){ var t=tabs[node.tab]; if(t){ d = (t.kind==='viewer') ? {type:'viewer',path:t.path,name:t.name} : {type:'run',runId:node.tab}; } }
1013
+ return {leaf:true, tab:d}; }
1014
+ return {split:true, dir:node.dir, ratio:node.ratio, a:serializeNode(node.a), b:serializeNode(node.b)};
1015
+ }
1016
+ function rebuildNode(sn){
1017
+ if(sn.leaf){ var tabId=null;
1018
+ if(sn.tab){ if(sn.tab.type==='viewer'){ var vt=ensureViewer(sn.tab.path, sn.tab.name); tabId=vt.runId; }
1019
+ else if(sn.tab.type==='run' && runById[sn.tab.runId]){ ensureTab(sn.tab.runId); tabId=sn.tab.runId; } }
1020
+ return {leaf:true, id:newLeafId(), tab:tabId}; }
1021
+ return {split:true, id:newLeafId(), dir:sn.dir, ratio:sn.ratio, a:rebuildNode(sn.a), b:rebuildNode(sn.b)};
1022
+ }
1023
+ function loadLayouts(){ try{ return JSON.parse(localStorage.getItem('coxpit.layouts')||'{}')||{}; }catch(e){ return {}; } }
1024
+ function saveLayouts(o){ try{ localStorage.setItem('coxpit.layouts', JSON.stringify(o)); }catch(e){} }
1025
+ function saveLayout(){ if(!tabOrder.length){ toast('저장할 페인이 없습니다'); return; } var nm=prompt('레이아웃 이름'); if(!nm||!nm.trim()) return; nm=nm.trim();
1026
+ var o=loadLayouts(); o[nm]={tree:serializeNode(layout), ts:Date.now()}; saveLayouts(o); toast('레이아웃 저장 · '+nm); }
1027
+ function restoreLayout(nm){ var o=loadLayouts(); var s=o[nm]; if(!s){ toast('없는 레이아웃'); return; }
1028
+ layout=rebuildNode(s.tree); zoomLeaf=null; var f=firstLeaf(); focusLeaf=f?f.id:'L0'; render(); renderTree(); toast('레이아웃 복원 · '+nm); }
1029
+ function deleteLayout(nm){ var o=loadLayouts(); delete o[nm]; saveLayouts(o); toast('레이아웃 삭제 · '+nm); }
903
1030
 
904
1031
  // 탭 열기 = 포커스 슬롯에 표시(강제 분할 없음). 기존 호출부(openRunPane) 호환.
905
1032
  function openTab(runId){
1033
+ zoomLeaf=null; // 새 탭은 보여야 하므로 줌 해제
906
1034
  if(!tabs[runId]) ensureTab(runId); // 뷰어 탭은 ensureViewer 로 이미 생성됨 → 터미널로 오생성 방지
907
1035
  var l=leafOfTab(runId);
908
1036
  if (l){ setLeafFocus(l.id); return; }
@@ -915,6 +1043,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
915
1043
 
916
1044
  function splitFocused(dir){
917
1045
  if (!tabOrder.length) return;
1046
+ zoomLeaf=null;
918
1047
  if (isMobile()){ toast('창분할은 데스크톱 전용 — 모바일은 탭으로 전환하세요'); return; }
919
1048
  if (countLeaves()>=MAX_LEAVES){ toast('페인 최대 '+MAX_LEAVES+'개'); return; }
920
1049
  var l=findLeaf(focusLeaf) || firstLeaf(); if(!l) return;
@@ -924,6 +1053,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
924
1053
  focusLeaf=bId; render(); renderTree();
925
1054
  }
926
1055
  function closeSlot(id){
1056
+ zoomLeaf=null;
927
1057
  if (countLeaves()<=1){ var only=findLeaf(id)||firstLeaf(); if(only) only.tab=null; if(only) focusLeaf=only.id; render(); renderTree(); return; }
928
1058
  (function walk(node){
929
1059
  if(node.leaf) return false;
@@ -935,6 +1065,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
935
1065
  render(); renderTree();
936
1066
  }
937
1067
  function closeTab(runId){
1068
+ zoomLeaf=null;
938
1069
  var t=tabs[runId]; if(!t) return; t.closing=true;
939
1070
  try{ if(t.ws) t.ws.close(); }catch(e){}
940
1071
  try{ if(t.ro) t.ro.disconnect(); }catch(e){}
@@ -953,6 +1084,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
953
1084
  }
954
1085
  function tileTabs(ids){
955
1086
  ids=(ids||[]).filter(function(x){return x!=null;}); if(!ids.length) return;
1087
+ zoomLeaf=null;
956
1088
  ids.forEach(ensureTab);
957
1089
  layout=buildTiled(ids,'row'); var f=firstLeaf(); focusLeaf=f?f.id:'L0';
958
1090
  render(); renderTree();
@@ -970,6 +1102,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
970
1102
  var st=head.querySelector('.st'); if(st) st.className='st '+r.status;
971
1103
  var chip=head.querySelector('[data-role=chip]'); if(chip){ chip.className='chip '+r.status; chip.textContent=r.status; }
972
1104
  var vslot=head.querySelector('[data-role=vslot]'); if(vslot) vslot.innerHTML=vbadge(r.verifyStatus);
1105
+ var act=head.querySelector('[data-role=act]'); if(act) act.textContent=latestActivity(l.tab);
973
1106
  });
974
1107
  }
975
1108
 
@@ -1010,6 +1143,16 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1010
1143
 
1011
1144
  // ── 페인(슬롯) 이벤트: 포커스·닫기·드롭·리사이즈 ──
1012
1145
  $('panes').addEventListener('click', function(e){
1146
+ var zoomBtn=e.target.closest('[data-zoom]');
1147
+ if (zoomBtn){ e.stopPropagation(); toggleZoom(zoomBtn.getAttribute('data-zoom')); return; }
1148
+ var bcBtn=e.target.closest('[data-bcast]');
1149
+ if (bcBtn){ e.stopPropagation(); var bid=bcBtn.getAttribute('data-bcast'); bcastSel[bid]=!bcastSel[bid];
1150
+ bcBtn.classList.toggle('on', !!bcastSel[bid]); bcBtn.textContent = bcastSel[bid]?'◉':'◯';
1151
+ if(reqMode==='bcast') setMode('bcast'); return; }
1152
+ var diffBtn=e.target.closest('[data-diff]');
1153
+ if (diffBtn){ e.stopPropagation(); var dl=findLeaf(diffBtn.getAttribute('data-diff')); if(dl&&dl.tab!=null) openReviewForRun(dl.tab); return; }
1154
+ var histBtn=e.target.closest('[data-hist]');
1155
+ if (histBtn){ e.stopPropagation(); var hl=findLeaf(histBtn.getAttribute('data-hist')); if(hl&&hl.tab!=null){ setLeafFocus(hl.id); openHistory(); } return; }
1013
1156
  var lockBtn=e.target.closest('[data-lock]');
1014
1157
  if (lockBtn){ e.stopPropagation(); startSecretSend(lockBtn.getAttribute('data-lock'), lockBtn); return; }
1015
1158
  var leaf=e.target.closest('[data-leaf]'); if(!leaf) return;
@@ -1047,6 +1190,106 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1047
1190
  $('menuBtn').addEventListener('click', function(){ setDrawer(!$('rail').classList.contains('open')); });
1048
1191
  $('scrim').addEventListener('click', function(){ setDrawer(false); });
1049
1192
 
1193
+ // ── 터미널 스크롤백 검색(⌘F) ──
1194
+ function focusedTermTab(){ var rid=focusedRunId(); var t=(rid!=null)?tabs[rid]:null; return (t && t.term && t.search) ? t : null; }
1195
+ var FIND_DECO={ matchBackground:'#3a4a2e', activeMatchBackground:'#4ec9b0', activeMatchColorOverviewRuler:'#4ec9b0', matchOverviewRuler:'#33415580' };
1196
+ 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; }
1197
+ try{ if(dir<0) t.search.findPrevious(q,{decorations:FIND_DECO}); else t.search.findNext(q,{decorations:FIND_DECO}); }catch(e){} }
1198
+ function openFind(){ var t=focusedTermTab(); if(!t){ toast('검색할 터미널 페인을 먼저 선택하세요'); return; } $('termFind').hidden=false; var i=$('findInput'); i.focus(); i.select(); if(i.value) runFind(1); }
1199
+ function closeFind(){ $('termFind').hidden=true; var t=focusedTermTab(); if(t){ try{ t.search.clearDecorations(); t.term.focus(); }catch(e){} } }
1200
+ $('findInput').addEventListener('input', function(){ runFind(1); });
1201
+ $('findInput').addEventListener('keydown', function(e){ if(e.key==='Enter'){ e.preventDefault(); runFind(e.shiftKey?-1:1); } else if(e.key==='Escape'){ e.preventDefault(); closeFind(); } });
1202
+ $('findNext').addEventListener('click', function(){ runFind(1); });
1203
+ $('findPrev').addEventListener('click', function(){ runFind(-1); });
1204
+ $('findClose').addEventListener('click', closeFind);
1205
+ document.addEventListener('keydown', function(e){
1206
+ if((e.metaKey||e.ctrlKey) && (e.key==='f'||e.key==='F') && !e.shiftKey && !e.altKey){
1207
+ if(focusedTermTab()){ e.preventDefault(); openFind(); }
1208
+ }
1209
+ });
1210
+
1211
+ // ── ⌘K 커맨드 팔레트 — 이동(세션·run·프로젝트) + 명령 ──
1212
+ var palItems=[], palSel=0;
1213
+ function paletteItems(){
1214
+ var items=[
1215
+ {k:'cmd', label:'새 작업 세션 열기', run:openSession},
1216
+ {k:'cmd', label:'파일 열기 (뷰어)', run:openFilePicker},
1217
+ {k:'cmd', label:'터미널 검색 (⌘F)', run:openFind},
1218
+ {k:'cmd', label:'세로 분할', run:function(){ splitFocused('row'); }},
1219
+ {k:'cmd', label:'가로 분할', run:function(){ splitFocused('col'); }},
1220
+ {k:'cmd', label:'포커스 페인 닫기', run:function(){ if(focusLeaf) closeSlot(focusLeaf); }},
1221
+ {k:'cmd', label:'포커스 모드 (트리 숨김, ⌘.)', run:toggleFocus},
1222
+ {k:'cmd', label:'페인 최대화 토글 (⌘⏎)', run:function(){ toggleZoom(focusLeaf); }},
1223
+ {k:'cmd', label:'레이아웃 저장 (현재 페인 배치)', run:saveLayout},
1224
+ {k:'cmd', label:'뷰어 / 히스토리', run:openHistory},
1225
+ {k:'cmd', label:'Review 열기 (비교·머지)', run:showReview},
1226
+ {k:'cmd', label:'시크릿 (env 주입)', run:openSecrets},
1227
+ {k:'cmd', label:'보드로 전환', run:function(){ location.href='/'; }}
1228
+ ];
1229
+ // 세션 → 페인으로 열기
1230
+ var runs=[]; Object.keys(runById).forEach(function(id){ runs.push(runById[id]); });
1231
+ runs.sort(function(a,b){ return b.id-a.id; });
1232
+ runs.forEach(function(r){
1233
+ var task=taskById[r.taskId]; var repo=task&&repoById[task.repoId];
1234
+ 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); }}); }
1235
+ });
1236
+ // run → 페인으로 열기 (최근 40개)
1237
+ var n=0; runs.forEach(function(r){
1238
+ var task=taskById[r.taskId]; var repo=task&&repoById[task.repoId];
1239
+ if(repo && repo.kind==='sessions') return; if(n>=40) return; n++;
1240
+ items.push({k:'run', label:'r'+r.id+' · '+(repo?repo.name:'?')+' / '+((task&&task.title)||''), hint:r.status, run:function(){ openRunPane(r.id); }});
1241
+ });
1242
+ // 프로젝트 → 요청바 대상 지정(팬아웃 준비)
1243
+ Object.keys(repoById).forEach(function(id){ var repo=repoById[id]; if(repo.kind==='sessions') return;
1244
+ 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(); } }}); });
1245
+ // 저장된 레이아웃 → 복원 / 삭제
1246
+ var lays=loadLayouts(); var names=Object.keys(lays);
1247
+ names.forEach(function(nm){ items.push({k:'layout', label:'레이아웃 · '+nm, hint:'복원', run:function(){ restoreLayout(nm); }}); });
1248
+ names.forEach(function(nm){ items.push({k:'layout', label:'레이아웃 삭제 · '+nm, hint:'삭제', run:function(){ deleteLayout(nm); }}); });
1249
+ return items;
1250
+ }
1251
+ function renderPalette(q){
1252
+ q=(q||'').trim().toLowerCase();
1253
+ var all=paletteItems();
1254
+ palItems = q ? all.filter(function(it){ return (it.label+' '+(it.hint||'')+' '+it.k).toLowerCase().indexOf(q)>=0; }) : all;
1255
+ palSel=0;
1256
+ if(!palItems.length){ $('palList').innerHTML='<div class="pal-empty">일치 없음</div>'; return; }
1257
+ $('palList').innerHTML = palItems.map(function(it,i){
1258
+ 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>';
1259
+ }).join('');
1260
+ }
1261
+ function palMove(d){ if(!palItems.length) return; palSel=(palSel+d+palItems.length)%palItems.length;
1262
+ var rows=$('palList').querySelectorAll('.pal-row'); rows.forEach(function(el,i){ el.classList.toggle('sel', i===palSel); });
1263
+ var sel=rows[palSel]; if(sel) sel.scrollIntoView({block:'nearest'}); }
1264
+ function palRun(i){ var it=palItems[i]; if(!it) return; closePalette(); try{ it.run(); }catch(e){} }
1265
+ function openPalette(){ $('palette').classList.add('on'); var i=$('palInput'); i.value=''; renderPalette(''); i.focus(); }
1266
+ function closePalette(){ $('palette').classList.remove('on'); }
1267
+ $('palInput').addEventListener('input', function(){ renderPalette(this.value); });
1268
+ $('palInput').addEventListener('keydown', function(e){
1269
+ if(e.key==='ArrowDown'){ e.preventDefault(); palMove(1); }
1270
+ else if(e.key==='ArrowUp'){ e.preventDefault(); palMove(-1); }
1271
+ else if(e.key==='Enter'){ e.preventDefault(); palRun(palSel); }
1272
+ else if(e.key==='Escape'){ e.preventDefault(); closePalette(); }
1273
+ });
1274
+ $('palList').addEventListener('click', function(e){ var row=e.target.closest('[data-i]'); if(row) palRun(Number(row.dataset.i)); });
1275
+ $('palette').addEventListener('click', function(e){ if(e.target===this) closePalette(); });
1276
+ document.addEventListener('keydown', function(e){
1277
+ if((e.metaKey||e.ctrlKey) && (e.key==='k'||e.key==='K')){ e.preventDefault(); if($('palette').classList.contains('on')) closePalette(); else openPalette(); }
1278
+ });
1279
+ // ── 포커스 모드 — 트리·요청바 숨기고 페인만 ──
1280
+ function toggleFocus(){ var on=$('layout').classList.toggle('focusmode'); $('focusBtn').classList.toggle('on', on); requestAnimationFrame(fitAllVisible); setTimeout(fitAllVisible, 120); }
1281
+ $('focusBtn').addEventListener('click', toggleFocus);
1282
+
1283
+ // ── 페인 단축키: ⌘. 포커스 모드 · ⌘⏎ 최대화 토글 · ⌘1~9 N번째 페인 포커스 ──
1284
+ 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'); }
1285
+ document.addEventListener('keydown', function(e){
1286
+ if(!(e.metaKey||e.ctrlKey) || e.altKey) return;
1287
+ if(e.key==='.'){ if(!typingInField(e)){ e.preventDefault(); toggleFocus(); } return; }
1288
+ if(!tabOrder.length || typingInField(e)) return;
1289
+ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); toggleZoom(focusLeaf); return; }
1290
+ 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); } }
1291
+ });
1292
+
1050
1293
  // ── 모바일 터미널 입력바 — 조합 완료 텍스트를 통째로 포커스 탭의 PTY 로(IME 안전) ──
1051
1294
  function focusedWs(){ var rid=focusedRunId(); return (rid!=null && tabs[rid]) ? tabs[rid].ws : null; }
1052
1295
  function termSendRaw(d){ var ws=focusedWs(); if(ws && ws.readyState===1) ws.send(JSON.stringify({t:'i',d:d})); }
@@ -1262,10 +1505,12 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1262
1505
  else if (m==='steer'){ var rid=focusRun(); go.textContent='Steer ⏎';
1263
1506
  $('reqTgt').textContent = rid?('➤ r'+rid+' 에 후속 지시'):'포커스한 페인 없음';
1264
1507
  inp.placeholder = rid?('r'+rid+' 에이전트에게 다음 지시…'):'왼쪽 트리에서 run 을 열어 포커스하세요'; }
1265
- else { go.textContent='Send ⏎'; var n=tabOrder.length;
1266
- $('reqTgt').textContent = '⊞ 열린 탭 '+n+'개에 브로드캐스트';
1267
- inp.placeholder = n?('열린 '+n+'개 터미널에 그대로 전송 (엔터 포함)'):'열린 탭이 없습니다'; }
1508
+ else { go.textContent='Send ⏎'; var n=tabOrder.length; var sel=bcastCount();
1509
+ $('reqTgt').textContent = sel ? ('⊞ 선택 '+sel+'개 페인에 전송') : ('⊞ 열린 탭 '+n+' 전체에 전송');
1510
+ inp.placeholder = n?(sel?('선택한 '+sel+'개에만 그대로 전송'):'전체 '+n+'개 터미널에 전송 페인 ◯ 로 대상 선택'):'열린 탭이 없습니다'; }
1511
+ document.body.classList.toggle('bcastmode', m==='bcast');
1268
1512
  }
1513
+ function bcastCount(){ var c=0; Object.keys(bcastSel).forEach(function(k){ if(bcastSel[k] && tabs[k] && tabs[k].ws) c++; }); return c; }
1269
1514
  Array.prototype.forEach.call(document.querySelectorAll('.mode'), function(b){ b.addEventListener('click', function(){ setMode(b.dataset.mode); $('reqInput').focus(); }); });
1270
1515
 
1271
1516
  var submitting = false;
@@ -1302,13 +1547,14 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1302
1547
  else { var j = await res.json().catch(function(){return{};}); toast('steer 불가: '+(j.error||res.status)); }
1303
1548
  }catch(e){ toast('steer 실패: '+e); }
1304
1549
  finally{ submitting=false; $('reqGo').disabled=false; }
1305
- } else { // broadcast
1550
+ } else { // broadcast — 선택 페인이 있으면 거기로만, 없으면 열린 탭 전체
1306
1551
  if (!tabOrder.length){ toast('열린 탭이 없습니다'); return; }
1307
1552
  var payload = text + '\\r';
1553
+ var onlySel = bcastCount()>0;
1308
1554
  var sent=0;
1309
- tabOrder.forEach(function(rid){ var t=tabs[rid]; if (t && t.ws && t.ws.readyState===1){ t.ws.send(JSON.stringify({t:'i',d:payload})); sent++; } });
1555
+ tabOrder.forEach(function(rid){ if(onlySel && !bcastSel[rid]) return; var t=tabs[rid]; if (t && t.ws && t.ws.readyState===1){ t.ws.send(JSON.stringify({t:'i',d:payload})); sent++; } });
1310
1556
  inp.value='';
1311
- toast('브로드캐스트 → '+sent+'개 ');
1557
+ toast('브로드캐스트 → '+sent+'개'+(onlySel?' (선택)':' (전체)'));
1312
1558
  }
1313
1559
  }
1314
1560
  $('reqGo').addEventListener('click', submitReq);
@@ -1318,6 +1564,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1318
1564
  var reviewOn = false, rvTaskId = null;
1319
1565
  function showTerminal(){ reviewOn=false; $('review').classList.remove('on'); $('vtReview').classList.remove('on'); $('vtTerm').classList.add('on'); }
1320
1566
  function showReview(){ reviewOn=true; $('review').classList.add('on'); $('vtReview').classList.add('on'); $('vtTerm').classList.remove('on'); renderReviewPicker(); }
1567
+ // 페인 헤더 → 이 run 의 diff 를 Review 로 (그 run 의 태스크 선택 후 compare)
1568
+ function openReviewForRun(runId){ var r=runById[runId]; if(!r){ toast('run 정보 없음'); return; } showReview(); rvTaskId=r.taskId; try{ $('rvTask').value=String(r.taskId); }catch(e){} syncVcmd(); loadCompare(r.taskId); }
1321
1569
  $('vtTerm').addEventListener('click', showTerminal);
1322
1570
  $('vtReview').addEventListener('click', showReview);
1323
1571
  function reviewableTasks(){
@@ -1339,10 +1587,14 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1339
1587
  loadCompare(rvTaskId);
1340
1588
  }
1341
1589
  $('rvTask').addEventListener('change', function(){ rvTaskId=Number(this.value); syncVcmd(); loadCompare(rvTaskId); });
1590
+ $('rvWs').addEventListener('click', function(){ rvShowWs=!rvShowWs; this.classList.toggle('on', rvShowWs); if(rvTaskId!=null) loadCompare(rvTaskId); });
1591
+ var rvShowWs=false;
1592
+ function revealWs(e){ return e.replace(/ /g,'<span class="ws">·</span>').replace(/\\t/g,'<span class="ws">→\\u2003</span>'); }
1342
1593
  function diffHTML(text){
1343
1594
  if (!text || !text.trim()) return '<span style="color:var(--faint)">no changes</span>';
1344
1595
  return text.split('\\n').map(function(l){
1345
1596
  var e = esc(l) || '&nbsp;';
1597
+ if (rvShowWs && l.indexOf('diff --git')!==0 && l.indexOf('@@')!==0) e = revealWs(e);
1346
1598
  if (l.indexOf('diff --git')===0 || l.indexOf('+++')===0 || l.indexOf('---')===0) return '<span class="dl-file">'+e+'</span>';
1347
1599
  if (l.indexOf('@@')===0) return '<span class="dl-hunk">'+e+'</span>';
1348
1600
  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