coxpit 6.3.0 → 6.3.3
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/README.md +1 -0
- package/package.json +1 -1
- package/src/.!31514!cockpit.ts +0 -0
- package/src/activity.ts +29 -0
- package/src/auth.ts +4 -1
- package/src/claudeshim.ts +110 -0
- package/src/cockpit.ts +322 -59
- package/src/config.ts +7 -0
- package/src/db/index.ts +21 -0
- package/src/db/schema.ts +5 -0
- package/src/hud.ts +672 -0
- package/src/index.ts +8 -0
- package/src/orchestrator.ts +265 -35
- package/src/server.ts +94 -15
- package/src/uistate.ts +65 -0
package/src/cockpit.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// 백엔드(server 라우트·term.ts·orchestrator)는 보드와 전부 공유. Phase 5에서 데스크톱 기본을 여기로 플립.
|
|
3
3
|
// Phase 2 = 워크스페이스 트리(/api/fleet 라이브) + 페인 그리드 터미널(오토타일=창분할, 각 페인 /ws/term attach).
|
|
4
4
|
import { HUMANIZE_JS } from './humanize';
|
|
5
|
+
import { ACTIVITY_JS } from './activity';
|
|
5
6
|
|
|
6
7
|
export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
7
8
|
<html lang="en">
|
|
@@ -533,16 +534,50 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
533
534
|
.tsend{font-family:var(--mono);font-size:13px;font-weight:600;color:var(--brand-ink);background:var(--brand);border:none;border-radius:8px;padding:9px 14px;cursor:pointer;flex:0 0 auto}
|
|
534
535
|
.tkey.scroll{color:var(--brand);border-color:rgba(78,201,176,.4)}
|
|
535
536
|
/* 히스토리 오버레이(읽기 전용, 자유 스크롤) — xterm·마우스모드 우회 */
|
|
536
|
-
.
|
|
537
|
-
|
|
538
|
-
.hist-
|
|
539
|
-
|
|
537
|
+
/* v6.4 — 폰에서 "집고 읽는" 뷰어: 헤더 툴바(.hv-btn) · 검색바 · 구조화된 줄(.hln) · 최신 FAB. 색은 전부 기존 토큰. */
|
|
538
|
+
/* 데스크톱은 가운데 팝업. 크기를 인라인이 아니라 여기 둬야 아래 모바일 규칙이 !important 없이 덮는다 */
|
|
539
|
+
.hist-pick{position:relative;width:min(680px,96vw);max-height:88vh}
|
|
540
|
+
.hist-lines,.hist-chat{min-height:0} /* 열 flex 자식 — 이게 없으면 줄이 늘어나 스크롤 대신 시트를 민다 */
|
|
541
|
+
.hist-pick .pick-h{flex-wrap:wrap;gap:8px;padding:10px 12px}
|
|
542
|
+
.hist-pick .pick-h .t{flex:1 1 90px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
543
|
+
.hist-pick .pick-h .hist-modes{margin-left:0}
|
|
544
|
+
.hv-tools{display:inline-flex;align-items:center;gap:5px;margin-left:auto}
|
|
545
|
+
.hist-pick .pick-h .x{margin-left:2px;padding:2px 6px}
|
|
546
|
+
.hv-btn{background:none;border:1px solid var(--line);border-radius:6px;color:var(--muted);font-size:13px;line-height:1;padding:5px 8px;min-width:30px;cursor:pointer;font-family:var(--mono)}
|
|
547
|
+
.hv-btn:hover{color:var(--ink);border-color:var(--line-hi)}
|
|
548
|
+
.hv-btn.on{color:var(--brand);border-color:var(--brand)}
|
|
549
|
+
.tkey.hv-main{font-weight:600;color:var(--brand);border-color:var(--brand);background:var(--brand-dim);min-width:56px}
|
|
550
|
+
.hist-findbar{display:flex;align-items:center;gap:5px;padding:7px 12px;border-bottom:1px solid var(--line);background:var(--surface2)}
|
|
551
|
+
.hist-findbar input{flex:1;min-width:0;font-family:var(--mono);font-size:16px;color:var(--ink);background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:5px 8px;outline:none}
|
|
552
|
+
.hist-findbar input:focus{border-color:var(--brand)}
|
|
553
|
+
.hist-findbar .fc{font-family:var(--mono);font-size:10.5px;color:var(--faint);min-width:34px;text-align:center}
|
|
554
|
+
.hist-pick mark.hl{background:var(--brand-dim);color:var(--ink);border-radius:2px;outline:1px solid var(--line-hi)}
|
|
555
|
+
.hist-pick mark.hl.cur{background:var(--blocked);color:var(--bg);outline:none}
|
|
556
|
+
.hist-lines{flex:1;overflow:auto;-webkit-overflow-scrolling:touch;overscroll-behavior:contain;padding:6px 0 14px;background:var(--bg)}
|
|
557
|
+
.hln{display:flex;gap:8px;padding:2px 11px;cursor:pointer}
|
|
558
|
+
.hln:active{background:var(--surface)}
|
|
559
|
+
.hln-tx{flex:1;min-width:0;font-family:var(--mono);font-size:12px;line-height:1.55;color:var(--ink);white-space:pre-wrap;word-break:break-all;user-select:text;-webkit-user-select:text;cursor:text}
|
|
560
|
+
.hlines.nowrap .hln-tx{white-space:pre;overflow-x:auto;word-break:normal}
|
|
561
|
+
.hln-cp{flex:0 0 auto;align-self:flex-start;color:var(--faint);background:none;border:0;font-family:var(--mono);font-size:12px;padding:0 4px;cursor:pointer}
|
|
562
|
+
.hln-cp:hover,.hln-cp:active{color:var(--brand)}
|
|
563
|
+
.hln-url{color:var(--running);text-decoration:underline;cursor:pointer}
|
|
564
|
+
.hln-path{color:var(--brand);text-decoration:underline;cursor:pointer}
|
|
565
|
+
.hln-code{position:relative;margin:8px 0;background:var(--panel);border:1px solid var(--line);border-radius:8px;overflow-x:auto;-webkit-overflow-scrolling:touch}
|
|
566
|
+
.hln-code pre{margin:0;padding:26px 11px 10px;font-family:var(--mono);font-size:11.5px;line-height:1.5;color:var(--ink);white-space:pre-wrap;word-break:break-all;user-select:text;-webkit-user-select:text}
|
|
567
|
+
.hist-chat.nowrap .hln-code pre{white-space:pre;word-break:normal}
|
|
568
|
+
.hln-code .cb-cp{position:absolute;top:4px;right:5px;font-family:var(--mono);font-size:10.5px;color:var(--muted);background:var(--surface);border:1px solid var(--line);border-radius:5px;padding:2px 7px;cursor:pointer}
|
|
569
|
+
.hln-code .cb-cp:active{color:var(--brand);border-color:var(--brand)}
|
|
570
|
+
.hist-fab{position:absolute;right:14px;bottom:14px;width:40px;height:40px;border-radius:50%;border:none;background:var(--brand);color:var(--brand-ink);font-family:var(--mono);font-size:17px;font-weight:700;cursor:pointer;box-shadow:0 4px 14px var(--bg)}
|
|
571
|
+
/* 터미널 탭의 정직한 한 줄 — TUI 는 대체 화면이라 스크롤백이 없다는 사실을 화면이 직접 말한다(가장 희미한 톤) */
|
|
572
|
+
.hist-note{flex:0 0 auto;padding:6px 12px;border-bottom:1px solid var(--line);background:var(--panel);color:var(--faint);font-size:11px;line-height:1.5}
|
|
573
|
+
.hist-findbar[hidden],.hist-lines[hidden],.hist-chat[hidden],.hist-fab[hidden],.hist-note[hidden]{display:none} /* hidden 속성 존중 — 클래스의 display 가 UA 를 이기지 않게 */
|
|
540
574
|
.hist-modes{display:inline-flex;gap:2px;margin-left:12px;border:1px solid var(--line);border-radius:7px;padding:2px}
|
|
541
575
|
.hm-tab{font-family:var(--mono);font-size:11px;color:var(--muted);background:none;border:none;border-radius:5px;padding:4px 9px;cursor:pointer}
|
|
542
576
|
.hm-tab.on{background:var(--brand-dim);color:var(--ink)}
|
|
543
577
|
.hist-chat{flex:1;overflow:auto;-webkit-overflow-scrolling:touch;overscroll-behavior:contain;display:flex;flex-direction:column;gap:10px;padding:12px;background:var(--bg)}
|
|
544
578
|
.ch-turn{display:flex} .ch-turn.user{justify-content:flex-end}
|
|
545
|
-
.ch-bubble{max-width:82%;padding:9px 12px;border-radius:12px;font-size:
|
|
579
|
+
.ch-bubble{max-width:82%;min-width:0;padding:9px 12px;border-radius:12px;font-size:13.5px;line-height:1.7;word-break:keep-all;overflow-wrap:anywhere;user-select:text;-webkit-user-select:text}
|
|
580
|
+
.ch-turn.asst .ch-bubble{max-width:100%}
|
|
546
581
|
.ch-turn.user .ch-bubble{background:var(--brand-dim);color:var(--ink);border:1px solid rgba(78,201,176,.25);border-bottom-right-radius:4px}
|
|
547
582
|
.ch-turn.asst .ch-bubble{background:var(--surface);color:var(--ink);border:1px solid var(--line);border-bottom-left-radius:4px}
|
|
548
583
|
.ch-tools{display:flex;flex-wrap:wrap;gap:5px;margin-top:7px}
|
|
@@ -552,7 +587,13 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
552
587
|
body.touch .term-ibar{display:flex}
|
|
553
588
|
body.touch .reqbar{display:none}
|
|
554
589
|
body.touch #splitRow, body.touch #splitCol{display:none}
|
|
590
|
+
/* 뷰어는 폰·터치에서 가운데 팝업이 아니라 화면 전체 — 전체화면 터미널(.modal.term)과 같은 문법.
|
|
591
|
+
헤더 / 검색바 / 줄·대화(flex:1, 여기만 스크롤) / FAB. 노치·홈바는 safe-area 로 비킨다. 아래 @media 에 쌍둥이. */
|
|
592
|
+
body.touch #histModal .hist-pick{position:fixed;inset:0;width:100%;max-width:100%;height:100%;max-height:100%;border:0;border-radius:0;padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)}
|
|
593
|
+
body.touch #histModal .hist-fab{bottom:calc(14px + env(safe-area-inset-bottom));right:calc(14px + env(safe-area-inset-right))}
|
|
555
594
|
@media (max-width:860px){
|
|
595
|
+
#histModal .hist-pick{position:fixed;inset:0;width:100%;max-width:100%;height:100%;max-height:100%;border:0;border-radius:0;padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)}
|
|
596
|
+
#histModal .hist-fab{bottom:calc(14px + env(safe-area-inset-bottom));right:calc(14px + env(safe-area-inset-right))}
|
|
556
597
|
/* 모바일 = 아이콘만(텍스트 라벨 숨김) + 폰트 최소화 */
|
|
557
598
|
.b-txt{display:none}
|
|
558
599
|
body{font-size:12px}
|
|
@@ -648,7 +689,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
648
689
|
</div>
|
|
649
690
|
<div class="term-ibar" id="termIbar">
|
|
650
691
|
<div class="tkeys">
|
|
651
|
-
<button type="button" class="tkey scroll" id="histBtn" title="뷰어 — 대화/터미널로 위 내용 보기(읽기 전용)">뷰어</button>
|
|
692
|
+
<button type="button" class="tkey scroll hv-main" id="histBtn" title="뷰어 — 대화/터미널로 위 내용 보기(읽기 전용)">뷰어</button>
|
|
652
693
|
<button type="button" class="tkey scroll" data-k="copymode" title="터미널 안에서 스크롤 — tmux copy-mode 진입(⇞/↑ 로 위로, esc 로 나가기)">⇡ 스크롤</button>
|
|
653
694
|
<button type="button" class="tkey" data-k="esc" title="Esc">esc</button>
|
|
654
695
|
<button type="button" class="tkey" data-k="tab" title="Tab">tab</button>
|
|
@@ -919,16 +960,30 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
919
960
|
</div>
|
|
920
961
|
|
|
921
962
|
<div class="modal" id="histModal">
|
|
922
|
-
<div class="pick
|
|
963
|
+
<div class="pick hist-pick">
|
|
923
964
|
<div class="pick-h"><span class="t"><span id="histTitle">뷰어</span></span>
|
|
924
965
|
<div class="hist-modes">
|
|
925
966
|
<button type="button" class="hm-tab on" id="hmChat" title="Claude Code 대화로 보기">대화</button>
|
|
926
967
|
<button type="button" class="hm-tab" id="hmRaw" title="터미널 스크롤백 원문">터미널</button>
|
|
927
968
|
</div>
|
|
928
|
-
<
|
|
929
|
-
|
|
969
|
+
<div class="hv-tools">
|
|
970
|
+
<button type="button" class="hv-btn on" id="histWrap" title="줄바꿈 켜기/끄기 (끄면 명령어를 한 줄로 옆 스크롤)">↵</button>
|
|
971
|
+
<button type="button" class="hv-btn" id="histFind" title="찾기">⌕</button>
|
|
972
|
+
<button type="button" class="hv-btn" id="histJump" title="맨위/맨아래로">↕</button>
|
|
973
|
+
<button type="button" class="hv-btn" id="histRefresh" title="지금 시점으로 다시 불러오기">↻</button>
|
|
974
|
+
<button class="x" id="histClose" title="닫기">×</button>
|
|
975
|
+
</div></div>
|
|
976
|
+
<div class="hist-findbar" id="histFindBar" hidden>
|
|
977
|
+
<input id="histFindIn" placeholder="뷰어에서 찾기" autocomplete="off" spellcheck="false" />
|
|
978
|
+
<span class="fc" id="histFindN"></span>
|
|
979
|
+
<button type="button" class="hv-btn" id="histFindPrev" title="이전 (⇧⏎)">‹</button>
|
|
980
|
+
<button type="button" class="hv-btn" id="histFindNext" title="다음 (⏎)">›</button>
|
|
981
|
+
<button type="button" class="hv-btn" id="histFindX" title="닫기 (esc)">×</button>
|
|
982
|
+
</div>
|
|
983
|
+
<div class="hist-note" id="histAltNote" hidden>claude 같은 전체화면 앱은 지금 화면만 잡혀요 — 전체 대화는 '대화' 탭</div>
|
|
930
984
|
<div class="hist-chat" id="histChat"></div>
|
|
931
|
-
<
|
|
985
|
+
<div class="hist-lines hlines" id="histLines" hidden></div>
|
|
986
|
+
<button type="button" class="hist-fab" id="histFab" title="맨아래(최신)로" hidden>↓</button>
|
|
932
987
|
</div>
|
|
933
988
|
</div>
|
|
934
989
|
|
|
@@ -981,6 +1036,23 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
981
1036
|
t.classList.add('show'); clearTimeout(toast._h);
|
|
982
1037
|
toast._h=setTimeout(function(){ t.classList.remove('show'); }, act?7000:2600);
|
|
983
1038
|
}
|
|
1039
|
+
// 복사 공용 헬퍼 — 터미널 선택복사와 뷰어(줄·링크·코드블록) 복사가 이 한 벌을 쓴다(v6.4).
|
|
1040
|
+
// clipboard API 우선(HTTPS·PWA), 실패하면 execCommand 폴백(iOS 포함). 항상 토스트로 확인. label 생략 = '복사됨'.
|
|
1041
|
+
function copyFallback(s, label){ var ok=false;
|
|
1042
|
+
try{ var ta=document.createElement('textarea'); ta.value=s; ta.setAttribute('readonly',''); ta.contentEditable='true';
|
|
1043
|
+
ta.style.position='fixed'; ta.style.top='-9999px'; ta.style.opacity='0'; document.body.appendChild(ta);
|
|
1044
|
+
var r=document.createRange(); r.selectNodeContents(ta); var sel=window.getSelection(); sel.removeAllRanges(); sel.addRange(r);
|
|
1045
|
+
try{ ta.setSelectionRange(0, s.length); }catch(e){}
|
|
1046
|
+
ok=document.execCommand('copy'); document.body.removeChild(ta);
|
|
1047
|
+
}catch(e){ ok=false; }
|
|
1048
|
+
toast(ok?((label||'복사됨')+' · '+s.length+'자'):'복사 실패 — 브라우저가 클립보드를 막았어요'); }
|
|
1049
|
+
function copyText(s, label){
|
|
1050
|
+
if(!s) return false;
|
|
1051
|
+
if(navigator.clipboard && navigator.clipboard.writeText){
|
|
1052
|
+
navigator.clipboard.writeText(s).then(function(){ toast((label||'복사됨')+' · '+s.length+'자'); }).catch(function(){ copyFallback(s, label); });
|
|
1053
|
+
} else { copyFallback(s, label); }
|
|
1054
|
+
return true;
|
|
1055
|
+
}
|
|
984
1056
|
// ── 확인 대화상자 (보드의 confirmUI 와 같은 계약: promise 로 true/false) ──
|
|
985
1057
|
// 결정은 여기서, 결과는 토스트에서. 네이티브 confirm 은 쓰지 않는다(DESIGN.md) — 뼈대는
|
|
986
1058
|
// 이미 있는 .pick 시트 그대로라 새 컴포넌트도, 새 색도 없다.
|
|
@@ -1014,23 +1086,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1014
1086
|
}
|
|
1015
1087
|
var V_GLYPH = { pass:'✓ verify', fail:'✗ verify', running:'⋯ verify', error:'! verify' };
|
|
1016
1088
|
function vbadge(status){ if (!status || !V_GLYPH[status]) return ''; return '<span class="vbadge '+status+'" data-role="vbadge">'+V_GLYPH[status]+'</span>'; }
|
|
1017
|
-
// 라이브 상태
|
|
1018
|
-
|
|
1019
|
-
var r=runById[runId]; if(!r) return '';
|
|
1020
|
-
if(r.status!=='running' && r.status!=='pending') return '';
|
|
1021
|
-
var evs=r.events||[];
|
|
1022
|
-
for(var i=evs.length-1;i>=0;i--){
|
|
1023
|
-
var e=evs[i], k=e.kind;
|
|
1024
|
-
if(k==='steer') return 'steer'; if(k==='ask') return 'asking';
|
|
1025
|
-
if(k!=='assistant') continue;
|
|
1026
|
-
try{ var o=JSON.parse(e.payload);
|
|
1027
|
-
var c=o&&o.message&&o.message.content;
|
|
1028
|
-
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'; } }
|
|
1029
|
-
else if(o&&o.text) return 'thinking';
|
|
1030
|
-
}catch(_){}
|
|
1031
|
-
}
|
|
1032
|
-
return r.status==='pending' ? 'starting' : 'working';
|
|
1033
|
-
}
|
|
1089
|
+
// 라이브 상태 + "지금" 한 줄 — HUD(v5.28 K)와 **같은 한 벌**(src/activity.ts). 사본을 만들지 않는다.
|
|
1090
|
+
${ACTIVITY_JS}
|
|
1034
1091
|
|
|
1035
1092
|
// ── fleet 상태 ──
|
|
1036
1093
|
var fleet = { machines:[], repos:[], tasks:[], groups:[], runs:[], providers:[] };
|
|
@@ -1526,6 +1583,10 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1526
1583
|
// 전경색은 xterm 이 자동으로 끌어올린다. 3 = 안 보이던 것만 구제하고 의도된 어두운 톤은 대체로 보존(2026-09-18).
|
|
1527
1584
|
minimumContrastRatio: 3,
|
|
1528
1585
|
theme: { background:'#0b0d12', foreground:'#dee4ec', cursor:'#4ec9b0', selectionBackground:'rgba(78,201,176,.25)', black:'#1c212c', brightBlack:'#5c6675' },
|
|
1586
|
+
// OSC 8 하이퍼링크(에이전트·gh·npm 이 찍는 것)는 WebLinksAddon 이 아니라 xterm 내장 제공자가 잡는다.
|
|
1587
|
+
// 핸들러를 안 주면 xterm 기본값이 네이티브 confirm 을 띄우고(DESIGN.md 위반) 빈 window.open 으로 열려다
|
|
1588
|
+
// 데스크톱 앱의 setWindowOpenHandler 에 막혀 조용히 아무 일도 안 났다. 애드온과 같은 길로 곧장 연다.
|
|
1589
|
+
linkHandler: { activate: function(ev, uri){ window.open(uri, '_blank', 'noopener,noreferrer'); } },
|
|
1529
1590
|
});
|
|
1530
1591
|
var fit=new window.FitAddon.FitAddon(); term.loadAddon(fit);
|
|
1531
1592
|
try{ term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion='11'; }catch(e){}
|
|
@@ -1570,20 +1631,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1570
1631
|
}catch(e){}
|
|
1571
1632
|
// 복사 배선: xterm 은 user-select:none 이라 네이티브 선택이 없다 → term.getSelection() 을 직접 클립보드로.
|
|
1572
1633
|
// ① 드래그 놓으면 자동 복사(select-to-copy) ② Cmd/Ctrl+C 로도 복사(선택 없으면 통과 → SIGINT).
|
|
1573
|
-
//
|
|
1574
|
-
var
|
|
1575
|
-
try{ var ta=document.createElement('textarea'); ta.value=s; ta.setAttribute('readonly',''); ta.contentEditable='true';
|
|
1576
|
-
ta.style.position='fixed'; ta.style.top='-9999px'; ta.style.opacity='0'; document.body.appendChild(ta);
|
|
1577
|
-
var r=document.createRange(); r.selectNodeContents(ta); var sel=window.getSelection(); sel.removeAllRanges(); sel.addRange(r);
|
|
1578
|
-
try{ ta.setSelectionRange(0, s.length); }catch(e){}
|
|
1579
|
-
ok=document.execCommand('copy'); document.body.removeChild(ta);
|
|
1580
|
-
}catch(e){ ok=false; }
|
|
1581
|
-
toast(ok?('복사됨 · '+s.length+'자'):'복사 실패 — 브라우저가 클립보드를 막았어요'); };
|
|
1582
|
-
var copySel=function(){ var s=''; try{ s=term.getSelection(); }catch(e){} if(!s) return false;
|
|
1583
|
-
if(navigator.clipboard && navigator.clipboard.writeText){
|
|
1584
|
-
navigator.clipboard.writeText(s).then(function(){ toast('복사됨 · '+s.length+'자'); }).catch(function(){ copyFallback(s); });
|
|
1585
|
-
} else { copyFallback(s); }
|
|
1586
|
-
return true; };
|
|
1634
|
+
// 복사 자체는 공용 copyText(뷰어와 같은 한 벌).
|
|
1635
|
+
var copySel=function(){ var s=''; try{ s=term.getSelection(); }catch(e){} return copyText(s); };
|
|
1587
1636
|
host.addEventListener('mouseup', function(){ copySel(); });
|
|
1588
1637
|
host.addEventListener('touchend', function(){ copySel(); });
|
|
1589
1638
|
term.attachCustomKeyEventHandler(function(ev){
|
|
@@ -1878,6 +1927,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1878
1927
|
var rid=focusedRunId(); if(rid!=null && tabs[rid] && tabs[rid].term) try{ tabs[rid].term.focus(); }catch(e){}
|
|
1879
1928
|
// 뷰어에서 고른 글을 주입할 때 "어느 터미널로"의 답 — 마지막으로 잡았던 터미널 페인(C3)
|
|
1880
1929
|
if(rid!=null && tabs[rid] && tabs[rid].kind!=='viewer') lastTermRunId=rid;
|
|
1930
|
+
persistOpenTabs(); // 포커스 이동은 render 를 타지 않는다 — 다시 열었을 때 착지할 탭을 여기서 기록
|
|
1881
1931
|
updateControls();
|
|
1882
1932
|
if (typeof reqMode!=='undefined' && reqMode!=='new') setMode(reqMode);
|
|
1883
1933
|
}
|
|
@@ -1921,6 +1971,58 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1921
1971
|
function persistSession(){
|
|
1922
1972
|
if(!sessionRestoreDone) return; // 첫 복원 전(초기 빈 render)에 저장하면 스냅샷을 덮어써 버린다
|
|
1923
1973
|
try{ localStorage.setItem(SESSION_KEY, JSON.stringify(serializeNode(layout))); }catch(e){}
|
|
1974
|
+
persistOpenTabs();
|
|
1975
|
+
}
|
|
1976
|
+
// ── 열려 있던 탭 전부 기억/복원 (정본 = 데몬 '/api/ui/opentabs') ──
|
|
1977
|
+
// 위 세션 스냅샷은 **페인에 보이던** 탭만 담는다. 폰은 페인이 하나라서, 새로고침하거나 데몬이 재시작하면
|
|
1978
|
+
// 마지막 하나만 남고 나머지 탭이 통째로 사라졌다 — tmux 세션은 멀쩡히 살아 있는데도. 그래서 탭 바를 따로 기억한다.
|
|
1979
|
+
// 기억하는 자리는 **데몬**이다: localStorage 는 캐시 비우기·기기 교체·옛 페이지에 쉽게 날아가고,
|
|
1980
|
+
// 그게 정확히 이 고침이 폰에 안 먹던 이유였다. 브라우저 사본은 데몬이 답을 못 주거나 아직 아무것도
|
|
1981
|
+
// 받아 적지 않았을 때의 폴백으로만 남긴다.
|
|
1982
|
+
// 터미널 탭(run)만 담는다: 뷰어 탭은 경로가 스냅샷 쪽에 이미 있고, 죽은 run 은 복원에서 건너뛴다.
|
|
1983
|
+
var OPENTABS_KEY='coxpit.opentabs', OPENTABS_API='/api/ui/opentabs', MAX_RESTORE_TABS=24;
|
|
1984
|
+
var otTimer=null, otRestoring=false;
|
|
1985
|
+
function persistOpenTabs(){
|
|
1986
|
+
if(!sessionRestoreDone || otRestoring) return; // 복원 중의 중간 상태를 정본에 덮어쓰지 않는다
|
|
1987
|
+
var ids=tabOrder.filter(function(id){ return !isViewer(id); }).slice(-MAX_RESTORE_TABS);
|
|
1988
|
+
var act=focusedRunId();
|
|
1989
|
+
var st={ids:ids, active:(act!=null && !isViewer(act))?act:null};
|
|
1990
|
+
try{ localStorage.setItem(OPENTABS_KEY, JSON.stringify(st)); }catch(e){} // 오프라인 폴백 사본
|
|
1991
|
+
// 포커스 이동은 연달아 일어난다 — 잠깐 모았다가 마지막 한 번만 데몬에 쓴다.
|
|
1992
|
+
if(otTimer) clearTimeout(otTimer);
|
|
1993
|
+
otTimer=setTimeout(function(){
|
|
1994
|
+
otTimer=null;
|
|
1995
|
+
try{ fetch(OPENTABS_API,{method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify(st)}).catch(function(){}); }catch(e){}
|
|
1996
|
+
}, 400);
|
|
1997
|
+
}
|
|
1998
|
+
async function restoreOpenTabs(){
|
|
1999
|
+
if(otRestoring) return; // 다시 불러도 안전 — 진행 중인 복원과 겹치지 않는다
|
|
2000
|
+
otRestoring=true;
|
|
2001
|
+
try{
|
|
2002
|
+
var st=null;
|
|
2003
|
+
try{ var res=await fetch(OPENTABS_API,{headers:{'accept':'application/json'}}); if(res.ok) st=await res.json(); }catch(e){ st=null; }
|
|
2004
|
+
// 브라우저 사본은 폴백일 뿐이다 — 데몬이 답을 못 줬거나(구버전·오프라인), 아직 한 번도 받아 적은 적이
|
|
2005
|
+
// 없을 때만(stored:false, 1회 이관) 본다. 데몬이 "빈 집합"이라 답했으면 그건 owner 가 닫은 것이다.
|
|
2006
|
+
if(!st || !st.ids || st.stored===false){
|
|
2007
|
+
var raw; try{ raw=localStorage.getItem(OPENTABS_KEY); }catch(e){ raw=null; }
|
|
2008
|
+
if(raw){ try{ var ls=JSON.parse(raw); if(ls && ls.ids && ls.ids.length) st=ls; }catch(e){} }
|
|
2009
|
+
}
|
|
2010
|
+
if(!st || !Array.isArray(st.ids) || !st.ids.length) return; // 기억해둔 게 없으면 위의 단일 세션 복원이 그대로 답이다
|
|
2011
|
+
var added=0, first=null;
|
|
2012
|
+
st.ids.slice(-MAX_RESTORE_TABS).forEach(function(id){
|
|
2013
|
+
var rid=+id;
|
|
2014
|
+
if(!rid || tabs[rid] || !runById[rid]) return; // 이미 열렸거나 플릿에서 사라진 run 은 건너뛴다(지어내지 않는다)
|
|
2015
|
+
ensureTab(rid); added++; if(first==null) first=rid;
|
|
2016
|
+
});
|
|
2017
|
+
var act=(st.active!=null && !isViewer(st.active)) ? +st.active : null;
|
|
2018
|
+
if(!act || !tabs[act]) act=first; // 마지막으로 보던 run 이 사라졌으면 되살린 첫 탭에 착지한다
|
|
2019
|
+
// 페인이 비었거나 하나(폰)일 때만 집어넣는다 — 데스크톱의 분할 배치는 위 스냅샷이 이미 복원했다.
|
|
2020
|
+
if(act!=null && !leafOfTab(act) && (focusedRunId()==null || countLeaves()===1)) openTab(act);
|
|
2021
|
+
else if(added){ render(); renderTree(); } // 탭 바에는 되살린 탭이 전부 보여야 한다
|
|
2022
|
+
} finally {
|
|
2023
|
+
otRestoring=false;
|
|
2024
|
+
persistOpenTabs(); // 복원 결과(= 지금 열린 탭)를 정본으로 굳힌다
|
|
2025
|
+
}
|
|
1924
2026
|
}
|
|
1925
2027
|
function restoreSession(){
|
|
1926
2028
|
if(sessionRestoreDone) return; // 1회만(이후 hydrate 는 통과)
|
|
@@ -1935,6 +2037,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1935
2037
|
if(!tabOrder.length){ layout={leaf:true,id:'L0',tab:null}; focusLeaf='L0'; } // 되살릴 게 없으면 깔끔한 빈 상태
|
|
1936
2038
|
render(); renderTree();
|
|
1937
2039
|
}catch(e){} }
|
|
2040
|
+
restoreOpenTabs(); // 스냅샷이 보여준 한 탭 말고, **열려 있던 탭 전부**를 탭 바에 되살린다
|
|
1938
2041
|
}
|
|
1939
2042
|
persistSession(); // 복원할 게 없거나 이미 탭이 있어도, 지금부터 현재 상태를 마지막-세션으로 기록한다
|
|
1940
2043
|
}
|
|
@@ -2320,41 +2423,207 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
2320
2423
|
}); });
|
|
2321
2424
|
|
|
2322
2425
|
// ── 뷰어 — 위 내용을 (대화) Claude Code 로그 / (터미널) 스크롤백 으로 읽기(읽기 전용) ──
|
|
2323
|
-
|
|
2426
|
+
// v6.4: 원문을 <pre> 한 덩어리로 붓지 않는다. 줄마다 DOM(.hln)이라 줄바꿈된 링크·경로를 한 번에 집고,
|
|
2427
|
+
// 줄을 통째로 복사하고, 찾고, 맨아래로 뛴다. xterm 은 입력용 그대로 두고 "읽기·집기"만 여기서 한다.
|
|
2428
|
+
var histMode='chat', histRid=null, histRaw=[], histCode=[];
|
|
2429
|
+
var histWrapOn = (function(){ try{ return localStorage.getItem('coxpit.histwrap')!=='0'; }catch(e){ return true; } })();
|
|
2430
|
+
function histSurface(){ return histMode==='chat' ? $('histChat') : $('histLines'); }
|
|
2324
2431
|
function openHistory(){ var rid=focusedRunId(); if(rid==null||isViewer(rid)){ toast('포커스한 세션이 없습니다'); return; } $('histModal').classList.add('on'); loadHist(); }
|
|
2325
|
-
function closeHistory(){ $('histModal').classList.remove('on'); }
|
|
2432
|
+
function closeHistory(){ closeHistFind(); $('histModal').classList.remove('on'); }
|
|
2326
2433
|
function setHistMode(m){
|
|
2327
2434
|
histMode=m; $('hmChat').classList.toggle('on',m==='chat'); $('hmRaw').classList.toggle('on',m==='raw');
|
|
2328
|
-
$('histChat').
|
|
2435
|
+
$('histChat').hidden = m!=='chat'; $('histLines').hidden = m!=='raw';
|
|
2436
|
+
// 터미널 탭에선 대체 화면 한계를 화면이 직접 말한다 — 빈 캡처를 보고 "고장났나" 하지 않도록(정직이 먼저).
|
|
2437
|
+
$('histAltNote').hidden = m!=='raw';
|
|
2329
2438
|
loadHist();
|
|
2330
2439
|
}
|
|
2440
|
+
// 줄 하나 → 링크·경로를 감싼 HTML. 원문을 먼저 토막내고 토막마다 esc 한다(esc 한 문자열에 정규식을 돌리면 & 가 URL 에 섞인다).
|
|
2441
|
+
// 경로 규칙은 터미널 링크파이어와 같다 — 확장자 있는 토큰 · 앞 글자가 URL 조각/토큰 중간이면 제외 · 알려진 확장자거나 경로형.
|
|
2442
|
+
// 한 정규식의 두 갈래라 같은 자리에서 URL 이 먼저 잡힌다(URL 우선).
|
|
2443
|
+
var HLN_RE=/(https?:\\/\\/[^\\s<>"'\\x60]+)|((?:~\\/|\\.{0,2}\\/)?[\\w.\\-\\/]*\\.[A-Za-z0-9]{1,8}(?::\\d+(?::\\d+)?)?)/g;
|
|
2444
|
+
function linkify(txt){
|
|
2445
|
+
var s=String(txt==null?'':txt), out='', last=0, m;
|
|
2446
|
+
HLN_RE.lastIndex=0;
|
|
2447
|
+
while((m=HLN_RE.exec(s))){
|
|
2448
|
+
var raw=m[0], at=m.index;
|
|
2449
|
+
if(m[1]){
|
|
2450
|
+
var url=raw.replace(/[.,;:!?)\\]}'"]+$/,''); // 문장 끝 구두점은 링크가 아니다
|
|
2451
|
+
// 링크처럼 보이면 링크처럼 열려야 한다 — href 있는 진짜 <a>. 길게 누르기·새 탭으로 열기·⌘클릭이 그제서야 산다.
|
|
2452
|
+
// 데스크톱 앱은 setWindowOpenHandler 가 target=_blank 를 시스템 브라우저로 넘긴다. data-url 은 복사 경로용으로 남긴다.
|
|
2453
|
+
out+=esc(s.slice(last,at))+'<a class="hln-url" href="'+esc(url)+'" target="_blank" rel="noopener noreferrer" data-url="'+esc(url)+'">'+esc(url)+'</a>';
|
|
2454
|
+
last=at+url.length; HLN_RE.lastIndex=last; continue;
|
|
2455
|
+
}
|
|
2456
|
+
if(raw.indexOf('://')>=0 || raw.slice(0,2)==='//') continue;
|
|
2457
|
+
var before = at>0 ? s.charAt(at-1) : ' ';
|
|
2458
|
+
if(before===':' || before==='/' || /[A-Za-z0-9]/.test(before)) continue;
|
|
2459
|
+
var pathPart=raw.replace(/:\\d+(?::\\d+)?$/,'');
|
|
2460
|
+
var ext=(pathPart.split('.').pop()||'').toLowerCase();
|
|
2461
|
+
if(!(VIEW_EXT[ext] || pathPart.indexOf('/')>=0)) continue;
|
|
2462
|
+
out+=esc(s.slice(last,at))+'<span class="hln-path" data-path="'+esc(raw)+'">'+esc(raw)+'</span>';
|
|
2463
|
+
last=at+raw.length;
|
|
2464
|
+
}
|
|
2465
|
+
return out+esc(s.slice(last));
|
|
2466
|
+
}
|
|
2467
|
+
// 터미널 탭 — 스크롤백을 논리적 줄 단위 DOM 으로. histRaw[i] = 그 줄의 원문(복사는 linkify 전 텍스트).
|
|
2468
|
+
function renderHistLines(text){
|
|
2469
|
+
var box=$('histLines');
|
|
2470
|
+
var lines=String(text||'').split(/\\r?\\n/);
|
|
2471
|
+
while(lines.length && !lines[lines.length-1].trim()) lines.pop(); // tmux 캡처 꼬리의 빈 줄
|
|
2472
|
+
histRaw=lines;
|
|
2473
|
+
if(!lines.length){ box.innerHTML='<div class="hist-empty">(스크롤백 없음)</div>'; return; }
|
|
2474
|
+
var h=new Array(lines.length);
|
|
2475
|
+
for(var i=0;i<lines.length;i++){
|
|
2476
|
+
h[i]='<div class="hln" data-i="'+i+'"><span class="hln-tx">'+(linkify(lines[i])||' ')+'</span>'
|
|
2477
|
+
+'<button type="button" class="hln-cp" title="이 줄 복사">⧉</button></div>';
|
|
2478
|
+
}
|
|
2479
|
+
box.innerHTML=h.join('');
|
|
2480
|
+
}
|
|
2481
|
+
// 대화 탭 — 코드펜스는 모노 블록(가로 스크롤 + 복사), 나머지 산문은 링크화. 펜스가 안 닫혔으면(작성 중) 끝까지 코드로.
|
|
2482
|
+
var FENCE='\\x60\\x60\\x60';
|
|
2483
|
+
function renderProse(s){ return linkify(s).replace(/\\n/g,'<br>'); }
|
|
2331
2484
|
function renderTurn(t){
|
|
2332
|
-
var
|
|
2485
|
+
var s=String(t.text||''), body='', i=0;
|
|
2486
|
+
for(;;){
|
|
2487
|
+
var a=s.indexOf(FENCE,i); if(a<0) break;
|
|
2488
|
+
var nl=s.indexOf('\\n',a+3); if(nl<0) break;
|
|
2489
|
+
var b=s.indexOf(FENCE,nl+1), end=(b<0?s.length:b);
|
|
2490
|
+
body+=renderProse(s.slice(i,a).replace(/\\n$/,''));
|
|
2491
|
+
var code=s.slice(nl+1,end).replace(/\\n$/,'');
|
|
2492
|
+
var ci=histCode.push(code)-1;
|
|
2493
|
+
body+='<div class="hln-code"><button type="button" class="cb-cp" data-ci="'+ci+'" title="코드 복사">⧉ 복사</button><pre>'+esc(code)+'</pre></div>';
|
|
2494
|
+
if(b<0){ i=s.length; break; }
|
|
2495
|
+
i=b+3; if(s.charAt(i)==='\\n') i++;
|
|
2496
|
+
}
|
|
2497
|
+
body+=renderProse(s.slice(i));
|
|
2333
2498
|
var tools = (t.tools && t.tools.length) ? '<div class="ch-tools">'+t.tools.map(function(x){return '<span class="ch-tool">'+esc(x)+'</span>';}).join('')+'</div>' : '';
|
|
2334
2499
|
return '<div class="ch-turn '+(t.role==='user'?'user':'asst')+'"><div class="ch-bubble">'+body+tools+'</div></div>';
|
|
2335
2500
|
}
|
|
2336
2501
|
async function loadHist(){
|
|
2337
|
-
var rid=focusedRunId(); if(rid==null) return;
|
|
2502
|
+
var rid=focusedRunId(); if(rid==null) return; histRid=rid;
|
|
2338
2503
|
var t=tabs[rid]; $('histTitle').textContent=(t?t.name:('r'+rid))+' · 뷰어';
|
|
2504
|
+
clearHistMarks();
|
|
2339
2505
|
if (histMode==='chat'){
|
|
2340
2506
|
var c=$('histChat'); c.innerHTML='<div class="hist-empty">불러오는 중…</div>';
|
|
2341
2507
|
try{
|
|
2342
2508
|
var d=await (await fetch('/api/runs/'+rid+'/chat')).json();
|
|
2343
|
-
|
|
2509
|
+
histCode=[];
|
|
2510
|
+
if (d.turns && d.turns.length){ c.innerHTML = d.turns.map(renderTurn).join(''); }
|
|
2344
2511
|
else { c.innerHTML='<div class="hist-empty">이 폴더에 Claude Code 대화 로그가 없습니다.<br>터미널 탭으로 원문을 보세요.</div>'; }
|
|
2345
2512
|
}catch(e){ c.innerHTML='<div class="hist-empty">불러오기 실패</div>'; }
|
|
2346
2513
|
} else {
|
|
2347
|
-
var b=$('
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2514
|
+
var b=$('histLines'); histRaw=[]; b.innerHTML='<div class="hist-empty">불러오는 중…</div>';
|
|
2515
|
+
// 히스토리 꼬리 2만 줄 — 서버는 전체를 뜨고 여기서 폰 DOM 이 버틸 만큼만 받는다(tmux 한도는 10만 줄)
|
|
2516
|
+
try{ var d2=await (await fetch('/api/runs/'+rid+'/scrollback?lines=20000')).json();
|
|
2517
|
+
if (d2 && d2.ok===false) b.innerHTML='<div class="hist-empty">스크롤백을 읽지 못했습니다</div>';
|
|
2518
|
+
else renderHistLines((d2 && d2.text) || '');
|
|
2519
|
+
}catch(e){ b.innerHTML='<div class="hist-empty">불러오기 실패</div>'; }
|
|
2351
2520
|
}
|
|
2352
|
-
|
|
2521
|
+
histToBottom();
|
|
2522
|
+
if (!$('histFindBar').hidden) runHistFind();
|
|
2523
|
+
}
|
|
2524
|
+
// 탭 vs 길게 누르기 — touchstart 좌표·시각 → touchend 가 250ms·10px 이내면 "탭". 길게 누르기·드래그는 그대로 통과(네이티브 선택).
|
|
2525
|
+
// 마우스는 click 그대로. 선택이 있으면 줄 복사보다 선택을 존중한다(⧉·코드 복사 버튼은 예외 — 명시적인 누름).
|
|
2526
|
+
var histTouch=null, histTapOK=true;
|
|
2527
|
+
function histTouchStart(e){ var p=e.touches&&e.touches[0]; histTouch=p?{x:p.clientX,y:p.clientY,t:Date.now()}:null; histTapOK=true; }
|
|
2528
|
+
function histTouchEnd(e){ var p=e.changedTouches&&e.changedTouches[0];
|
|
2529
|
+
histTapOK = !!(histTouch && p && (Date.now()-histTouch.t)<250 && Math.abs(p.clientX-histTouch.x)<10 && Math.abs(p.clientY-histTouch.y)<10);
|
|
2530
|
+
histTouch=null; }
|
|
2531
|
+
function histHasSel(){ try{ var sel=window.getSelection(); return !!(sel && !sel.isCollapsed && String(sel).length); }catch(e){ return false; } }
|
|
2532
|
+
function histClick(e){
|
|
2533
|
+
if(!histTapOK){ histTapOK=true; return; } // 롱프레스·드래그 뒤 따라오는 click 은 무시
|
|
2534
|
+
var cb=e.target.closest('.cb-cp'); if(cb){ copyText(histCode[+cb.getAttribute('data-ci')]||'', '복사됨'); return; }
|
|
2535
|
+
// URL 은 이제 진짜 링크다 — 평범한 탭은 브라우저가 열게 둔다(기본 동작을 막지 않는다).
|
|
2536
|
+
// 복사는 줄의 ⧉ 버튼·줄 탭이 이미 맡고 있고 그 원문에 URL 글자가 들어 있다. 선택 중일 때만 선택을 존중해 막는다.
|
|
2537
|
+
var u=e.target.closest('.hln-url'); if(u){ if(histHasSel()) e.preventDefault(); return; }
|
|
2538
|
+
var cp=e.target.closest('.hln-cp');
|
|
2539
|
+
if(!cp && histHasSel()) return;
|
|
2540
|
+
var p=e.target.closest('.hln-path');
|
|
2541
|
+
if(p){ if(histRid!=null){ var path=p.getAttribute('data-path')||''; closeHistory(); openPathFromTerm(histRid, path); } return; }
|
|
2542
|
+
var ln=e.target.closest('.hln'); if(ln) copyText(histRaw[+ln.getAttribute('data-i')]||'', '줄 복사됨');
|
|
2543
|
+
}
|
|
2544
|
+
// 찾기(⌕) — 가져온 텍스트 위에서만(순수 클라). 버튼 글자(⧉ 등)는 매치에서 뺀다.
|
|
2545
|
+
var histHits=[], histHitI=-1, histFindT=0;
|
|
2546
|
+
function clearHistMarks(){
|
|
2547
|
+
var ms=$('histModal').querySelectorAll('mark.hl'), pars=[];
|
|
2548
|
+
for(var i=0;i<ms.length;i++){ var mk=ms[i], par=mk.parentNode; if(!par) continue; par.replaceChild(document.createTextNode(mk.textContent||''), mk); if(pars.indexOf(par)<0) pars.push(par); }
|
|
2549
|
+
pars.forEach(function(p){ try{ p.normalize(); }catch(e){} });
|
|
2550
|
+
histHits=[]; histHitI=-1;
|
|
2551
|
+
}
|
|
2552
|
+
function runHistFind(){
|
|
2553
|
+
clearHistMarks();
|
|
2554
|
+
var q=($('histFindIn').value||'').toLowerCase();
|
|
2555
|
+
if(q){
|
|
2556
|
+
var w=document.createTreeWalker(histSurface(), NodeFilter.SHOW_TEXT, { acceptNode:function(n){ var pe=n.parentElement; return (pe && pe.closest('button')) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT; } });
|
|
2557
|
+
var nodes=[], n; while((n=w.nextNode())) nodes.push(n);
|
|
2558
|
+
nodes.forEach(function(tn){
|
|
2559
|
+
var s=tn.nodeValue||'', low=s.toLowerCase(), at=low.indexOf(q); if(at<0) return;
|
|
2560
|
+
var frag=document.createDocumentFragment(), last=0;
|
|
2561
|
+
while(at>=0){
|
|
2562
|
+
if(at>last) frag.appendChild(document.createTextNode(s.slice(last,at)));
|
|
2563
|
+
var mk=document.createElement('mark'); mk.className='hl'; mk.textContent=s.slice(at,at+q.length); frag.appendChild(mk); histHits.push(mk);
|
|
2564
|
+
last=at+q.length; at=low.indexOf(q,last);
|
|
2565
|
+
}
|
|
2566
|
+
if(last<s.length) frag.appendChild(document.createTextNode(s.slice(last)));
|
|
2567
|
+
tn.parentNode.replaceChild(frag, tn);
|
|
2568
|
+
});
|
|
2569
|
+
}
|
|
2570
|
+
histHitI = histHits.length ? histHits.length-1 : -1; // 최신(맨아래)에서 시작 — 위로 거슬러 읽는 게 보통이다(‹ = 이전)
|
|
2571
|
+
paintHistHit();
|
|
2572
|
+
}
|
|
2573
|
+
function stepHistFind(dir){
|
|
2574
|
+
if(!histHits.length){ runHistFind(); return; }
|
|
2575
|
+
histHitI=(histHitI+dir+histHits.length)%histHits.length; paintHistHit();
|
|
2576
|
+
}
|
|
2577
|
+
function paintHistHit(){
|
|
2578
|
+
var prev=$('histModal').querySelector('mark.hl.cur'); if(prev) prev.classList.remove('cur');
|
|
2579
|
+
var cur=histHits[histHitI]; if(cur){ cur.classList.add('cur'); try{ cur.scrollIntoView({block:'center',inline:'nearest'}); }catch(e){} }
|
|
2580
|
+
$('histFindN').textContent = histHits.length ? ((histHitI+1)+'/'+histHits.length) : ($('histFindIn').value ? '0' : '');
|
|
2581
|
+
updateHistFab();
|
|
2582
|
+
}
|
|
2583
|
+
function openHistFind(){ $('histFindBar').hidden=false; $('histFind').classList.add('on'); var i=$('histFindIn'); i.focus(); i.select(); if(i.value) runHistFind(); }
|
|
2584
|
+
function closeHistFind(){ $('histFindBar').hidden=true; $('histFind').classList.remove('on'); clearHistMarks(); $('histFindN').textContent=''; }
|
|
2585
|
+
// 이동(↕) + 최신 FAB(↓) — 맨아래에서 40px 넘게 올라가면 FAB 가 뜬다.
|
|
2586
|
+
function histNearBottom(el){ return (el.scrollHeight - el.scrollTop - el.clientHeight) < 40; }
|
|
2587
|
+
function updateHistFab(){ var el=histSurface(); $('histFab').hidden = !el || histNearBottom(el); }
|
|
2588
|
+
function histToBottom(){ var el=histSurface(); el.scrollTop=el.scrollHeight; updateHistFab(); }
|
|
2589
|
+
function histJump(){ var el=histSurface(); if(histNearBottom(el)) el.scrollTop=0; else el.scrollTop=el.scrollHeight; updateHistFab(); }
|
|
2590
|
+
// 줄바꿈(↵) — 기본 켬(읽기). 끄면 터미널 줄·대화 코드블록이 한 줄로 옆 스크롤(산문은 항상 줄바꿈).
|
|
2591
|
+
function applyHistWrap(){
|
|
2592
|
+
$('histLines').classList.toggle('nowrap',!histWrapOn); $('histChat').classList.toggle('nowrap',!histWrapOn);
|
|
2593
|
+
$('histWrap').classList.toggle('on',histWrapOn);
|
|
2594
|
+
}
|
|
2595
|
+
applyHistWrap();
|
|
2596
|
+
['histChat','histLines'].forEach(function(id){ var el=$(id);
|
|
2597
|
+
el.addEventListener('touchstart', histTouchStart, {passive:true});
|
|
2598
|
+
el.addEventListener('touchend', histTouchEnd, {passive:true});
|
|
2599
|
+
el.addEventListener('click', histClick);
|
|
2600
|
+
el.addEventListener('scroll', updateHistFab, {passive:true});
|
|
2601
|
+
});
|
|
2353
2602
|
$('histBtn').addEventListener('click', openHistory);
|
|
2354
2603
|
$('histRefresh').addEventListener('click', loadHist);
|
|
2355
2604
|
$('histClose').addEventListener('click', closeHistory);
|
|
2356
2605
|
$('hmChat').addEventListener('click', function(){ setHistMode('chat'); });
|
|
2357
2606
|
$('hmRaw').addEventListener('click', function(){ setHistMode('raw'); });
|
|
2607
|
+
$('histWrap').addEventListener('click', function(){
|
|
2608
|
+
histWrapOn=!histWrapOn; try{ localStorage.setItem('coxpit.histwrap', histWrapOn?'1':'0'); }catch(e){}
|
|
2609
|
+
applyHistWrap(); toast(histWrapOn?'줄바꿈 켬':'줄바꿈 끔 · 옆으로 스크롤');
|
|
2610
|
+
});
|
|
2611
|
+
$('histFind').addEventListener('click', function(){ if($('histFindBar').hidden) openHistFind(); else closeHistFind(); });
|
|
2612
|
+
$('histJump').addEventListener('click', histJump);
|
|
2613
|
+
$('histFab').addEventListener('click', histToBottom);
|
|
2614
|
+
$('histFindIn').addEventListener('input', function(){ clearTimeout(histFindT); histFindT=setTimeout(runHistFind, 150); });
|
|
2615
|
+
$('histFindIn').addEventListener('keydown', function(e){
|
|
2616
|
+
if(e.key==='Escape') return; // 아래 문서 핸들러가 닫는다
|
|
2617
|
+
e.stopPropagation();
|
|
2618
|
+
if(e.key==='Enter'){ e.preventDefault(); clearTimeout(histFindT); if(!histHits.length) runHistFind(); else stepHistFind(e.shiftKey?-1:1); }
|
|
2619
|
+
});
|
|
2620
|
+
$('histFindPrev').addEventListener('click', function(){ stepHistFind(-1); });
|
|
2621
|
+
$('histFindNext').addEventListener('click', function(){ stepHistFind(1); });
|
|
2622
|
+
$('histFindX').addEventListener('click', closeHistFind);
|
|
2623
|
+
document.addEventListener('keydown', function(e){
|
|
2624
|
+
if(e.key!=='Escape' || !$('histModal').classList.contains('on')) return;
|
|
2625
|
+
e.preventDefault(); if(!$('histFindBar').hidden) closeHistFind(); else closeHistory();
|
|
2626
|
+
});
|
|
2358
2627
|
$('histModal').addEventListener('click', function(e){ if(e.target===this) closeHistory(); });
|
|
2359
2628
|
// 터치 기기(아이패드 포함) 판별 → body.touch (화면폭 무관하게 입력바 노출)
|
|
2360
2629
|
if (window.matchMedia('(pointer:coarse)').matches || (navigator.maxTouchPoints||0) > 0) document.body.classList.add('touch');
|
|
@@ -3542,13 +3811,7 @@ ${HUMANIZE_JS}
|
|
|
3542
3811
|
render();
|
|
3543
3812
|
}
|
|
3544
3813
|
|
|
3545
|
-
// "지금" 한 줄
|
|
3546
|
-
function actNowText(runId){
|
|
3547
|
-
var r=runById[runId]; if(!r) return '';
|
|
3548
|
-
var live=(r.status==='running'||r.status==='pending'||r.status==='preparing');
|
|
3549
|
-
if(!(r.events||[]).length) return live ? 'starting…' : String(r.status||'');
|
|
3550
|
-
return latestActivity(runId) || String(r.status||'');
|
|
3551
|
-
}
|
|
3814
|
+
// "지금" 한 줄(actNowText)은 위 ACTIVITY_JS 에 산다 — HUD 와 같은 한 벌.
|
|
3552
3815
|
function actPath(runId){
|
|
3553
3816
|
var r=runById[runId]; if(!r) return '';
|
|
3554
3817
|
var task=taskById[r.taskId]; var rp=task&&repoById[task.repoId];
|
package/src/config.ts
CHANGED
|
@@ -82,8 +82,15 @@ export const config = {
|
|
|
82
82
|
portStrict: env.COXPIT_PORT_STRICT === '1' || (env.COXPIT_PORT_STRICT == null && stored.portStrict === true),
|
|
83
83
|
dbPath,
|
|
84
84
|
settingsPath,
|
|
85
|
+
// 소유자 UI 상태(열어둔 탭 등) — DB·settings 와 같은 데이터 폴더의 작은 JSON 한 장.
|
|
86
|
+
uiStatePath: path.join(path.dirname(path.resolve(dbPath)), 'ui-state.json'),
|
|
85
87
|
// 단일 데몬 락 파일 — DB 와 같은 폴더(그 DB 를 지키는 락이므로)
|
|
86
88
|
lockPath: path.join(path.dirname(path.resolve(dbPath)), 'daemon.lock.json'),
|
|
89
|
+
// 세션 tmux 의 PATH 맨 앞에 끼우는 얇은 심 폴더(부팅 때 `claude` 한 장을 쓴다).
|
|
90
|
+
// 사람이 세션에서 그냥 `claude` 를 쳐도 그 대화가 이 세션의 id 로 묶이게 하는 유일한 손잡이다.
|
|
91
|
+
// 이름이 'bin' 이면 안 된다 — 레거시 DB(cwd 의 ./coxpit.db)를 쓰는 개발 기동에서는 데이터 폴더가
|
|
92
|
+
// repo 루트가 되고, 그 repo 의 bin/ 은 package.json "files" 에 실려 npm 에 **발행된다**.
|
|
93
|
+
shimDir: path.join(path.dirname(path.resolve(dbPath)), 'shim'),
|
|
87
94
|
// 원격 머신 SSH 개인키 경로(선택). 없으면 ssh 기본 키/에이전트 사용.
|
|
88
95
|
sshKey: process.env.COXPIT_SSH_KEY ?? '',
|
|
89
96
|
// run 정착 시 POST 할 웹훅(선택) — 텔레그램 브릿지 등 사용자 연결용.
|
package/src/db/index.ts
CHANGED
|
@@ -8,8 +8,20 @@ const client = createClient({ url: `file:${config.dbPath}` });
|
|
|
8
8
|
|
|
9
9
|
export const db = drizzle(client, { schema });
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* 저널 모드 — 로컬 파일 DB 에서만 의미가 있다. WAL 이면 보드 하이드레이션(읽기)이
|
|
13
|
+
* 이벤트 적재(쓰기)를 막지 않고, synchronous=NORMAL 이면 이벤트 INSERT 마다 fsync 를
|
|
14
|
+
* 기다리지 않는다(크래시 시 최근 몇 이벤트 손실 가능 — 감사 로그라 감수한다).
|
|
15
|
+
* 원격 libSQL URL 은 PRAGMA 를 받지 않을 수 있어 **최선노력**이다: 실패해도 부트는 계속된다.
|
|
16
|
+
*/
|
|
17
|
+
async function applyPragmas(): Promise<void> {
|
|
18
|
+
try { await client.execute('PRAGMA journal_mode=WAL'); } catch { /* 원격/미지원 */ }
|
|
19
|
+
try { await client.execute('PRAGMA synchronous=NORMAL'); } catch { /* 원격/미지원 */ }
|
|
20
|
+
}
|
|
21
|
+
|
|
11
22
|
/** 스키마 부트스트랩(멱등). 정식 마이그레이션은 drizzle-kit(추후). */
|
|
12
23
|
export async function ensureSchema(): Promise<void> {
|
|
24
|
+
await applyPragmas();
|
|
13
25
|
await client.executeMultiple(`
|
|
14
26
|
CREATE TABLE IF NOT EXISTS machines (
|
|
15
27
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -115,6 +127,15 @@ export async function ensureSchema(): Promise<void> {
|
|
|
115
127
|
try { await client.execute("ALTER TABLE repos ADD COLUMN kind TEXT NOT NULL DEFAULT 'git'"); } catch { /* exists */ }
|
|
116
128
|
try { await client.execute("ALTER TABLE agent_runs ADD COLUMN title TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
|
|
117
129
|
try { await client.execute('ALTER TABLE agent_runs ADD COLUMN in_place INTEGER NOT NULL DEFAULT 0'); } catch { /* exists */ }
|
|
130
|
+
try { await client.execute("ALTER TABLE agent_runs ADD COLUMN claude_session_id TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
|
|
118
131
|
// DEFAULT 1 = 기존 run 은 real 로 남는다. 모르는 과거를 dry 로 칠하지 않기 위한 기본값이다(v5.28 H1).
|
|
119
132
|
try { await client.execute('ALTER TABLE agent_runs ADD COLUMN real INTEGER NOT NULL DEFAULT 1'); } catch { /* exists */ }
|
|
133
|
+
// 조인 키 인덱스(멱등) — 없으면 이 여섯 조회가 매번 테이블 전체를 훑는다.
|
|
134
|
+
// ALTER 뒤에 두는 건 의도다: tasks.parent_run_id 는 위에서 붙는 컬럼이다.
|
|
135
|
+
try { await client.execute('CREATE INDEX IF NOT EXISTS idx_events_run ON agent_events(run_id)'); } catch { /* exists */ }
|
|
136
|
+
try { await client.execute('CREATE INDEX IF NOT EXISTS idx_runs_task ON agent_runs(task_id)'); } catch { /* exists */ }
|
|
137
|
+
try { await client.execute('CREATE INDEX IF NOT EXISTS idx_tasks_repo ON tasks(repo_id)'); } catch { /* exists */ }
|
|
138
|
+
try { await client.execute('CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_run_id)'); } catch { /* exists */ }
|
|
139
|
+
try { await client.execute('CREATE INDEX IF NOT EXISTS idx_docsnap_run ON doc_snapshots(run_id)'); } catch { /* exists */ }
|
|
140
|
+
try { await client.execute('CREATE INDEX IF NOT EXISTS idx_sharelinks_run ON share_links(run_id)'); } catch { /* exists */ }
|
|
120
141
|
}
|
package/src/db/schema.ts
CHANGED
|
@@ -91,6 +91,11 @@ export const agentRuns = sqliteTable('agent_runs', {
|
|
|
91
91
|
tmuxWindow: text('tmux_window').notNull().default(''),
|
|
92
92
|
status: text('status').notNull().default('pending'), // pending | running | waiting | done | error
|
|
93
93
|
sessionId: text('session_id').notNull().default(''), // 에이전트 세션(steer 용 --resume 키)
|
|
94
|
+
// 이 coxpit 세션이 쓰는 claude 대화 하나의 id(= ~/.claude/projects/<cwd-enc>/<id>.jsonl 파일명).
|
|
95
|
+
// 세션을 열 때 미리 정해 두고 `claude --session-id` 로 묶는다 — 한 폴더(워크스페이스 루트 등)에
|
|
96
|
+
// 세션 여럿이 살면 "가장 최근 .jsonl" 은 거의 항상 남의 대화라, 뷰어가 이름을 미리 알아야 한다.
|
|
97
|
+
// 태그 없이 이미 돌고 있던 세션은 화면 내용으로 찾아낸 뒤 여기에 적어 다시 찾지 않는다.
|
|
98
|
+
claudeSessionId: text('claude_session_id').notNull().default(''),
|
|
94
99
|
prUrl: text('pr_url').notNull().default(''), // PR 모드로 올린 pull request URL
|
|
95
100
|
model: text('model').notNull().default(''), // 런치별 모델 지정(빈값 = CLI 기본)
|
|
96
101
|
// v5.28 Part H — 이 run 이 진짜 에이전트로 돌았나(real), 모의 스트림이었나(dry).
|