coxpit 5.25.0 → 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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/cockpit.ts +75 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "5.25.0",
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",
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 ── */
@@ -82,7 +87,9 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
82
87
  /* ── 탭 바 + 분할 트리 페인 ── */
83
88
  .stage{display:flex;flex-direction:column;min-width:0;background:var(--bg);position:relative}
84
89
  /* ── 터미널 스크롤백 검색바(⌘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)}
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}
86
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}
87
94
  .term-find input:focus{border-color:var(--brand)}
88
95
  .term-find .fc{font-family:var(--mono);font-size:10.5px;color:var(--faint);min-width:20px;text-align:center}
@@ -240,6 +247,11 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
240
247
  .tnode.run .ract{margin-left:6px;font-size:10px;color:var(--brand);opacity:.85}
241
248
  .leaf-h .zoom{color:var(--faint);border:none;background:none;cursor:pointer;font-size:12px;padding:0 2px}
242
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)}
243
255
  .leaf-h .lock{color:var(--faint);border:none;background:none;cursor:pointer;font-size:12px;padding:0 2px}
244
256
  .leaf-h .lock:hover{color:var(--brand)}
245
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}
@@ -367,7 +379,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
367
379
  </header>
368
380
 
369
381
  <div class="scrim" id="scrim"></div>
370
- <div class="layout">
382
+ <div class="layout" id="layout">
371
383
  <aside class="rail" id="rail">
372
384
  <div class="lbl"><span>Workspace</span><span id="machName" style="color:var(--faint)">local</span></div>
373
385
  <div id="tree"></div>
@@ -382,6 +394,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
382
394
  <button class="tc-btn" id="splitCol" title="가로 분할 — 포커스 페인을 상하로" disabled>▬ split</button>
383
395
  <button class="tc-btn session" id="sessionBtn" title="자유 세션(폴더 지정 터미널) 열기">+<span class="b-txt"> Session</span></button>
384
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>
385
398
  <button class="tc-btn" id="closeBtn" title="포커스 페인 닫기(탭은 유지)" disabled>×<span class="b-txt"> pane</span></button>
386
399
  </div>
387
400
  </div>
@@ -564,6 +577,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
564
577
  var fold = {}; // 접힘 상태(repo/goal/task 노드)
565
578
  function isFold(k){ return fold[k]===true; }
566
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'); }
567
581
 
568
582
  async function hydrate(){
569
583
  try{
@@ -686,6 +700,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
686
700
  var layout = { leaf:true, id:'L0', tab:null }; // 분할 트리 루트
687
701
  var focusLeaf = 'L0';
688
702
  var zoomLeaf = null; // 페인 최대화(줌) — 설정되면 그 리프만 전체 렌더
703
+ var bcastSel = {}; // 브로드캐스트 선택 대상(tab id → true). 비어있으면 전체.
689
704
  var leafSeq = 1;
690
705
  var viewerSeq = 1; // 뷰어 탭 키(문자열 'v#')
691
706
  var MAX_LEAVES = 6;
@@ -761,7 +776,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
761
776
  if(ev.type==='keydown' && (ev.metaKey||ev.ctrlKey)){
762
777
  if(ev.key==='c'||ev.key==='C'){ if(term.hasSelection()){ copySel(); return false; } }
763
778
  // 전역 단축키(⌘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;
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;
765
780
  }
766
781
  return true;
767
782
  });
@@ -930,6 +945,9 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
930
945
  + '<span data-role="act" class="pane-act">'+esc(latestActivity(node.tab))+'</span>'
931
946
  + '<span data-role="chip" class="chip '+esc(r?r.status:'')+'">'+esc(r?r.status:'')+'</span>'
932
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>'
933
951
  + '<button class="lock" data-lock="'+node.id+'" title="이 페인에 시크릿/비밀번호 전송(터미널에 안 찍힘)">⊟</button>'
934
952
  + zoomBtn + '<button class="x" title="이 페인 닫기(탭은 유지)">×</button>')
935
953
  : '<span class="nm" style="color:var(--faint)">빈 페인</span><button class="x" title="이 페인 닫기">×</button>';
@@ -988,6 +1006,28 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
988
1006
  // 페인 순서(트리 순회) → ⌘1..9 로 N번째 포커스
989
1007
  function leafOrder(){ var out=[]; eachLeaf(layout,function(l){ out.push(l); }); return out; }
990
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); }
1030
+
991
1031
  // 탭 열기 = 포커스 슬롯에 표시(강제 분할 없음). 기존 호출부(openRunPane) 호환.
992
1032
  function openTab(runId){
993
1033
  zoomLeaf=null; // 새 탭은 보여야 하므로 줌 해제
@@ -1105,6 +1145,14 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1105
1145
  $('panes').addEventListener('click', function(e){
1106
1146
  var zoomBtn=e.target.closest('[data-zoom]');
1107
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; }
1108
1156
  var lockBtn=e.target.closest('[data-lock]');
1109
1157
  if (lockBtn){ e.stopPropagation(); startSecretSend(lockBtn.getAttribute('data-lock'), lockBtn); return; }
1110
1158
  var leaf=e.target.closest('[data-leaf]'); if(!leaf) return;
@@ -1170,6 +1218,9 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1170
1218
  {k:'cmd', label:'세로 분할', run:function(){ splitFocused('row'); }},
1171
1219
  {k:'cmd', label:'가로 분할', run:function(){ splitFocused('col'); }},
1172
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},
1173
1224
  {k:'cmd', label:'뷰어 / 히스토리', run:openHistory},
1174
1225
  {k:'cmd', label:'Review 열기 (비교·머지)', run:showReview},
1175
1226
  {k:'cmd', label:'시크릿 (env 주입)', run:openSecrets},
@@ -1191,6 +1242,10 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1191
1242
  // 프로젝트 → 요청바 대상 지정(팬아웃 준비)
1192
1243
  Object.keys(repoById).forEach(function(id){ var repo=repoById[id]; if(repo.kind==='sessions') return;
1193
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); }}); });
1194
1249
  return items;
1195
1250
  }
1196
1251
  function renderPalette(q){
@@ -1221,10 +1276,15 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1221
1276
  document.addEventListener('keydown', function(e){
1222
1277
  if((e.metaKey||e.ctrlKey) && (e.key==='k'||e.key==='K')){ e.preventDefault(); if($('palette').classList.contains('on')) closePalette(); else openPalette(); }
1223
1278
  });
1224
- // ── 페인 단축키: ⌘⏎ 최대화 토글 · ⌘1~9 N번째 페인 포커스 ──
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번째 페인 포커스 ──
1225
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'); }
1226
1285
  document.addEventListener('keydown', function(e){
1227
1286
  if(!(e.metaKey||e.ctrlKey) || e.altKey) return;
1287
+ if(e.key==='.'){ if(!typingInField(e)){ e.preventDefault(); toggleFocus(); } return; }
1228
1288
  if(!tabOrder.length || typingInField(e)) return;
1229
1289
  if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); toggleZoom(focusLeaf); return; }
1230
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); } }
@@ -1445,10 +1505,12 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1445
1505
  else if (m==='steer'){ var rid=focusRun(); go.textContent='Steer ⏎';
1446
1506
  $('reqTgt').textContent = rid?('➤ r'+rid+' 에 후속 지시'):'포커스한 페인 없음';
1447
1507
  inp.placeholder = rid?('r'+rid+' 에이전트에게 다음 지시…'):'왼쪽 트리에서 run 을 열어 포커스하세요'; }
1448
- else { go.textContent='Send ⏎'; var n=tabOrder.length;
1449
- $('reqTgt').textContent = '⊞ 열린 탭 '+n+'개에 브로드캐스트';
1450
- 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');
1451
1512
  }
1513
+ function bcastCount(){ var c=0; Object.keys(bcastSel).forEach(function(k){ if(bcastSel[k] && tabs[k] && tabs[k].ws) c++; }); return c; }
1452
1514
  Array.prototype.forEach.call(document.querySelectorAll('.mode'), function(b){ b.addEventListener('click', function(){ setMode(b.dataset.mode); $('reqInput').focus(); }); });
1453
1515
 
1454
1516
  var submitting = false;
@@ -1485,13 +1547,14 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1485
1547
  else { var j = await res.json().catch(function(){return{};}); toast('steer 불가: '+(j.error||res.status)); }
1486
1548
  }catch(e){ toast('steer 실패: '+e); }
1487
1549
  finally{ submitting=false; $('reqGo').disabled=false; }
1488
- } else { // broadcast
1550
+ } else { // broadcast — 선택 페인이 있으면 거기로만, 없으면 열린 탭 전체
1489
1551
  if (!tabOrder.length){ toast('열린 탭이 없습니다'); return; }
1490
1552
  var payload = text + '\\r';
1553
+ var onlySel = bcastCount()>0;
1491
1554
  var sent=0;
1492
- 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++; } });
1493
1556
  inp.value='';
1494
- toast('브로드캐스트 → '+sent+'개 ');
1557
+ toast('브로드캐스트 → '+sent+'개'+(onlySel?' (선택)':' (전체)'));
1495
1558
  }
1496
1559
  }
1497
1560
  $('reqGo').addEventListener('click', submitReq);
@@ -1501,6 +1564,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1501
1564
  var reviewOn = false, rvTaskId = null;
1502
1565
  function showTerminal(){ reviewOn=false; $('review').classList.remove('on'); $('vtReview').classList.remove('on'); $('vtTerm').classList.add('on'); }
1503
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); }
1504
1569
  $('vtTerm').addEventListener('click', showTerminal);
1505
1570
  $('vtReview').addEventListener('click', showReview);
1506
1571
  function reviewableTasks(){