coxpit 6.3.3 → 6.3.8

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/hud.ts +171 -74
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "6.3.3",
3
+ "version": "6.3.8",
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/hud.ts CHANGED
@@ -5,10 +5,18 @@
5
5
  // ① 알약으로 한눈에 본다(점 + 대기 수) ② 펼쳐서 나를 기다리는 것부터 훑는다
6
6
  // ③ 한 줄을 고르면 **목록 옆에** 상세가 열린다 ④ 대기 중인 에이전트에게 코크핏을 열지 않고 답한다
7
7
  //
8
- // [!] 이 실행분은 **서빙되는 페이지까지**다. 창(프레임리스·항상 위)·전역 단축키·크기 변경·
9
- // 드래그는 뒤따르는 데스크톱 작업(K1/K2)이다. 여기서는 원하는 크기 상태만
10
- // <html data-hud="pill|list|detail"> 에 적어 둔다 데스크톱이 나중에 그걸 읽어 창을 줄이고 늘린다.
11
- // 페이지 자신은 값으로 **안에서** 레이아웃만 바꾼다(브라우저에서 단독으로 돌아간다).
8
+ // [!] 이 페이지는 **브라우저에서 단독으로 돈다**. 창(프레임리스·항상 위)·전역 단축키·크기 변경은
9
+ // 데스크톱 (K1/K2, desktop/main.cjs)이고, 여기는 그쪽을 줄도 모른다 —
10
+ // 배치 상태를 <html data-hud="pill|list|detail"> 에 적고, 창이 붙어 있을 때만
11
+ // window.coxpitHud.size({state,w,h,fit}) "이만큼 필요하다"고 말할 뿐이다(없으면 아무 일도 없다).
12
+ // 창을 실제로 줄이고 늘리는 것도, 화면 밖으로 안 나가게 물리는 것도 전부 메인 프로세스다.
13
+ //
14
+ // 크기의 규칙 하나: **카드가 창을 채우고, 창은 내용을 따른다.** 예전에는 창이 고정 높이(380·440)라
15
+ // 내용보다 훨씬 컸고, 남는 자리는 투명해서 보이지도 않으면서 그 아래 것의 클릭을 먹었다.
16
+ // 이제 목록·상세는 자기 내용 높이를 재서 보내고(fit), 그 위에서 **사람이 끈 크기가 언제나 이긴다**.
17
+ // 그리고 어떤 줄도 두 줄로 접히지 않는다 — 접히는 것은 에이전트가 한 말(.d-q pre · .d-tail)뿐이다.
18
+ // 보이는데 열리지 않는 알약은 없느니만 못하다 — 눌러서 펼쳐져야 하고 그러면서도 끌어 옮길 수 있어야 하니,
19
+ // drag 영역과 클릭 대상은 같은 요소일 수 없다: 알약은 눌리고, 창은 알약 안의 홈(.pgrip)으로 옮긴다.
12
20
  //
13
21
  // 데이터는 전부 코크핏이 이미 읽는 것: GET /api/fleet(+ agentStates · real) 과 /ws 델타.
14
22
  // 새 창구는 하나뿐 — POST /api/runs/:id/input (살아 있는 tmux 에 한 줄 써 넣기, getScrollback 의 쓰기 쌍둥이).
@@ -37,8 +45,10 @@ export const HUD_HTML = /* html */ `<!doctype html>
37
45
  html,body{height:100%;overscroll-behavior:none}
38
46
  /* 지면은 보드·코크핏과 같은 --bg 다. (데스크톱 창이 프레임리스·투명으로 이걸 띄울 때는
39
47
  그 실행분에서 투명 배경을 덧입힌다 — 지금은 브라우저에서 단독으로 열려도 어두워야 한다.) */
48
+ /* 지면은 곧 창이다 — 카드가 뷰포트를 꽉 채우고, 여백은 둥근 모서리와 테두리가 살 만큼(5px)만 남긴다.
49
+ 남는 자리를 두면 그만큼이 **보이지 않는 채로 클릭만 먹는** 죽은 영역이 된다(늘 위에 뜨는 창이라 더 나쁘다). */
40
50
  body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:13px;line-height:1.45;
41
- -webkit-font-smoothing:antialiased;overflow:hidden;display:flex;align-items:flex-start;justify-content:center;padding:6px}
51
+ -webkit-font-smoothing:antialiased;overflow:hidden;display:flex;align-items:stretch;justify-content:center;padding:5px}
42
52
  button{font-family:var(--sans);cursor:pointer}
43
53
  :focus-visible{outline:2px solid rgba(78,201,176,.5);outline-offset:1px;border-radius:4px}
44
54
  *{scrollbar-width:thin;scrollbar-color:var(--line-hi) transparent}
@@ -49,17 +59,25 @@ export const HUD_HTML = /* html */ `<!doctype html>
49
59
  /* 세 가지 배치 = 한 장의 세 상태. data-hud 는 데스크톱이 창 크기를 맞출 때 읽어 갈 자리이기도 하다. */
50
60
  html[data-hud="pill"] #panel{display:none}
51
61
  html[data-hud="list"] #pill,html[data-hud="detail"] #pill{display:none}
52
- html[data-hud="pill"] body{align-items:flex-start}
62
+ html[data-hud="pill"] body{align-items:flex-start;padding:6px}
53
63
 
54
64
  /* ── K3. 접힌 알약 — 이것이 평상시의 전부다 ───────────────────────────── */
55
- .pill{display:inline-flex;align-items:center;gap:8px;height:26px;padding:0 11px;border-radius:999px;
65
+ /* 높이 30px = 손가락·커서가 놓치지 않는 최소치. 알약은 누르라고 있는 것이니 작아서는 안 된다. */
66
+ .pill{display:inline-flex;align-items:center;gap:8px;height:30px;padding:0 13px 0 8px;border-radius:999px;
56
67
  background:var(--surface);border:1px solid var(--line);box-shadow:0 8px 28px rgba(0,0,0,.35);
57
- font-family:var(--mono);font-size:11px;color:var(--muted);-webkit-app-region:drag}
68
+ font-family:var(--mono);font-size:12px;color:var(--muted)}
58
69
  .pill:hover{border-color:var(--line-hi);color:var(--ink)}
59
- .pill>*{-webkit-app-region:no-drag}
70
+ /* app-region: 프레임 없는 창(K1)에서 drag 영역은 클릭을 **삼킨다** — 창이 대신 움직인다.
71
+ 그래서 알약 자체는 no-drag(눌러서 펼치는 것이 알약의 일이고, 옮기는 일보다 훨씬 잦다)이고,
72
+ 창을 옮기는 손잡이는 알약 안의 작은 홈 하나뿐이다. 브라우저에서는 둘 다 아무 뜻도 없다. */
73
+ .pill,.pill>*{-webkit-app-region:no-drag}
74
+ .pill>.pgrip{-webkit-app-region:drag}
75
+ .pgrip{flex:none;color:var(--faint);font-family:var(--mono);font-size:10.5px;line-height:1;
76
+ letter-spacing:-.06em;padding:0 3px;opacity:.65;cursor:grab}
77
+ .pill:hover .pgrip{opacity:1}
60
78
  .pdots{display:inline-flex;align-items:center;gap:4px}
61
- .pwait{color:var(--blocked);font-size:11px;letter-spacing:.02em}
62
- .pquiet{color:var(--faint);font-size:10.5px}
79
+ .pwait{color:var(--blocked);font-size:12px;letter-spacing:.02em;white-space:nowrap}
80
+ .pquiet{color:var(--faint);font-size:11.5px;white-space:nowrap}
63
81
 
64
82
  /* 상태 점 — Part A 매핑 그대로(working=--running · waiting=--blocked 맥박 · idle=--faint · exited=--done) */
65
83
  .st{width:7px;height:7px;border-radius:50%;background:var(--faint);display:inline-block;flex:none}
@@ -71,96 +89,111 @@ export const HUD_HTML = /* html */ `<!doctype html>
71
89
  @media (prefers-reduced-motion:reduce){ .st.as-waiting{animation:none} }
72
90
 
73
91
  /* ── K4. 펼친 분류 목록 ─────────────────────────────────────────────── */
74
- .panel{width:100%;max-width:250px;display:flex;flex-direction:column;max-height:calc(100vh - 12px);
92
+ /* 카드는 창을 **채운다**(max-widthmax-height 없다). 창이 얼마만 해야 하는지는
93
+ 아래 fitHeight() 가 재서 창에 말해 주고, 그다음부터는 사람이 끈 크기가 이긴다.
94
+ 넘치면 카드 밖이 아니라 목록 안에서 스크롤한다 — 머리와 발은 늘 붙어 있어야 한다. */
95
+ .panel{width:100%;height:100%;min-height:0;display:flex;flex-direction:column;
75
96
  background:var(--surface);border:1px solid var(--line);border-radius:10px;
76
97
  box-shadow:0 8px 28px rgba(0,0,0,.35);overflow:hidden}
77
- html[data-hud="detail"] .panel{max-width:570px}
78
- .hh{display:flex;align-items:center;gap:7px;padding:7px 9px;border-bottom:1px solid var(--line);
79
- font-family:var(--mono);font-size:11px;-webkit-app-region:drag}
98
+ /* 머리는 **언제나 한 줄**이다. 좁아지면 꼬리부터 잘리고, 꼬리에 오는 것이 가장 덜 중요한 것
99
+ (대기 → 도는 것 → 노는 것)이라 idle 수가 맨 먼저 사라진다. 줄임표는 .subs 맡는다. */
100
+ .hh{display:flex;flex-wrap:nowrap;align-items:center;gap:7px;padding:7px 11px;border-bottom:1px solid var(--line);
101
+ font-family:var(--mono);font-size:12.5px;white-space:nowrap;-webkit-app-region:drag}
80
102
  .hh>button{-webkit-app-region:no-drag}
81
- .lead{color:var(--blocked);white-space:nowrap}
103
+ .lead{flex:none;color:var(--blocked);white-space:nowrap}
82
104
  .lead.clear{color:var(--done)}
83
- .subs{color:var(--faint);font-size:10.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
84
- .hx{margin-left:auto;background:none;border:none;color:var(--faint);font-family:var(--mono);font-size:13px;
85
- line-height:1;padding:2px 5px;border-radius:5px}
105
+ .subs{flex:1 1 auto;min-width:0;color:var(--faint);font-size:11.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
106
+ .hx{flex:none;background:none;border:none;color:var(--faint);font-family:var(--mono);font-size:14px;
107
+ line-height:1;padding:3px 6px;border-radius:5px}
86
108
  .hx:hover{color:var(--ink);background:var(--surface2)}
87
109
  .hbody{display:flex;min-height:0;flex:1}
88
- .hlist{width:250px;flex:none;overflow-y:auto;padding:5px 0}
89
- html[data-hud="list"] .hlist{width:100%}
90
- .hdetail{flex:1;min-width:0;border-left:1px solid var(--line);overflow-y:auto;padding:9px 11px;background:var(--panel)}
110
+ /* 목록은 남는 높이를 전부 먹고 **자기 안에서** 스크롤한다(flex:1 + min-height:0).
111
+ 상세일 때만 왼쪽 기둥으로 고정폭이 되고, 오른쪽이 나머지를 가진다. */
112
+ .hlist{width:100%;flex:1 1 auto;min-width:0;min-height:0;overflow-y:auto;padding:4px 0}
113
+ html[data-hud="detail"] .hlist{width:240px;flex:none}
114
+ .hdetail{flex:1;min-width:0;min-height:0;border-left:1px solid var(--line);overflow-y:auto;padding:10px 12px;background:var(--panel)}
91
115
  html[data-hud="list"] .hdetail{display:none}
92
- .lbl{font-family:var(--mono);font-size:9.5px;letter-spacing:.09em;text-transform:uppercase;color:var(--faint);
93
- padding:6px 11px 3px}
94
- .row{display:flex;align-items:center;gap:7px;width:100%;text-align:left;background:none;border:none;
95
- padding:5px 11px;font-family:var(--mono);font-size:11.5px;color:var(--muted)}
116
+ .lbl{font-family:var(--mono);font-size:10.5px;letter-spacing:.09em;text-transform:uppercase;color:var(--faint);
117
+ padding:8px 12px 3px}
118
+ /* 행도 한 줄이다 — 이름은 제 몫만 쥐고(flex:0 1 auto), 길은 남는 폭을 먹다가 줄임표로 끝내고,
119
+ 경과 시간은 절대 밀려나지 않는다(flex:none). 폭이 좁아져도 두 줄이 되는 일은 없다. */
120
+ .row{display:flex;align-items:center;gap:8px;width:100%;min-height:30px;text-align:left;background:none;border:none;
121
+ padding:7px 12px;font-family:var(--mono);font-size:13px;color:var(--muted);white-space:nowrap}
96
122
  .row:hover{background:var(--surface2);color:var(--ink)}
97
123
  .row.on{background:var(--brand-dim);color:var(--ink);box-shadow:inset 2px 0 0 var(--brand)}
98
- .row .rn{color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:46%}
124
+ .row .rn{flex:0 1 auto;min-width:0;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
99
125
  .row.wait .rn{color:var(--blocked)}
100
- .row .rp{color:var(--faint);font-size:10.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1}
101
- .row .rel{margin-left:auto;color:var(--faint);font-size:10px;flex:none}
102
- .dryc{flex:none;font-family:var(--mono);font-size:9px;color:var(--faint);border:1px solid var(--line-hi);
103
- border-radius:4px;padding:0 3px;letter-spacing:.04em}
104
- .cnt{display:block;width:100%;text-align:left;background:none;border:none;padding:5px 11px;
105
- font-family:var(--mono);font-size:10.5px;color:var(--faint)}
126
+ .row .rp{flex:1 1 0;min-width:0;color:var(--faint);font-size:11.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
127
+ .row .rel{flex:none;color:var(--faint);font-size:11px;white-space:nowrap}
128
+ .dryc{flex:none;font-family:var(--mono);font-size:10.5px;color:var(--faint);border:1px solid var(--line-hi);
129
+ border-radius:4px;padding:0 4px;letter-spacing:.04em;white-space:nowrap}
130
+ .cnt{display:block;width:100%;min-height:30px;text-align:left;background:none;border:none;padding:7px 12px;
131
+ font-family:var(--mono);font-size:12px;color:var(--faint);white-space:nowrap}
106
132
  .cnt:hover{color:var(--ink)}
107
- .clearbox{padding:11px;font-family:var(--mono);font-size:11px;color:var(--muted);line-height:1.6}
133
+ .clearbox{padding:12px;font-family:var(--mono);font-size:12.5px;color:var(--muted);line-height:1.6}
108
134
  .clearbox .big{color:var(--done);display:block;margin-bottom:3px}
109
- .hfoot{display:flex;align-items:center;gap:8px;padding:5px 9px;border-top:1px solid var(--line);
110
- font-family:var(--mono);font-size:9.5px;color:var(--faint)}
135
+ .hfoot{display:flex;align-items:center;gap:8px;padding:6px 11px;border-top:1px solid var(--line);
136
+ font-family:var(--mono);font-size:11px;color:var(--faint);white-space:nowrap;overflow:hidden}
111
137
  .hver{margin-left:auto;opacity:.8}
112
138
 
113
139
  /* ── K5. 상세(목록 오른쪽) ────────────────────────────────────────── */
114
- .d-h{display:flex;align-items:center;gap:6px;font-family:var(--mono);font-size:11px;margin-bottom:6px}
115
- .d-h .dnm{color:var(--ink)}
116
- .d-chip{font-size:9.5px;color:var(--faint)}
140
+ .d-h{display:flex;flex-wrap:nowrap;align-items:center;gap:7px;font-family:var(--mono);font-size:14px;
141
+ margin-bottom:6px;white-space:nowrap}
142
+ .d-h .dnm{flex:0 1 auto;min-width:0;color:var(--ink);overflow:hidden;text-overflow:ellipsis}
143
+ .d-chip{flex:none;font-size:11px;color:var(--faint)}
117
144
  .d-chip.as-working{color:var(--running)} .d-chip.as-waiting{color:var(--blocked)}
118
145
  .d-chip.as-idle{color:var(--faint)} .d-chip.as-exited{color:var(--done)}
119
- .d-meta{font-family:var(--mono);font-size:10px;color:var(--faint);margin-bottom:8px;
146
+ .d-meta{font-family:var(--mono);font-size:11.5px;color:var(--faint);margin-bottom:9px;
147
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
148
+ .d-q{border:1px solid var(--line-hi);border-radius:8px;background:var(--surface);padding:9px 10px;margin-bottom:9px}
149
+ .d-q .qk{display:block;font-family:var(--mono);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;
150
+ color:var(--faint);margin-bottom:5px}
151
+ /* 이 페이지에서 **줄이 접히는 곳은 여기뿐**이다 — 에이전트가 한 말은 줄여 쓸 수 없으니 접고,
152
+ 대신 높이를 물려서(판의 40% 남짓) 카드 하나가 창을 다 먹지 않게 한다. */
153
+ .d-q pre{margin:0;font-family:var(--mono);font-size:12.5px;color:var(--ink);white-space:pre-wrap;word-break:break-word;
154
+ max-height:min(40vh,260px);overflow:auto}
155
+ .d-q .unk{font-family:var(--mono);font-size:12.5px;color:var(--muted)}
156
+ .d-sec{font-family:var(--mono);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--faint);
157
+ margin:10px 0 4px}
158
+ .d-now{font-family:var(--mono);font-size:12.5px;color:var(--ink);margin-bottom:4px;
120
159
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
121
- .d-q{border:1px solid var(--line-hi);border-radius:8px;background:var(--surface);padding:8px 9px;margin-bottom:8px}
122
- .d-q .qk{display:block;font-family:var(--mono);font-size:9.5px;letter-spacing:.08em;text-transform:uppercase;
123
- color:var(--faint);margin-bottom:4px}
124
- .d-q pre{margin:0;font-family:var(--mono);font-size:11px;color:var(--ink);white-space:pre-wrap;word-break:break-word;
125
- max-height:150px;overflow:auto}
126
- .d-q .unk{font-family:var(--mono);font-size:11px;color:var(--muted)}
127
- .d-sec{font-family:var(--mono);font-size:9.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--faint);
128
- margin:9px 0 4px}
129
- .d-now{font-family:var(--mono);font-size:11.5px;color:var(--ink);margin-bottom:4px}
130
- .d-tl{border-left:1px solid var(--line);padding-left:8px;max-height:130px;overflow:auto}
131
- .d-l{display:flex;gap:6px;font-family:var(--mono);font-size:10.5px;line-height:1.5}
132
- .d-l .ak{flex:none;color:var(--faint);min-width:42px}
133
- .d-l .at{color:var(--muted);white-space:pre-wrap;word-break:break-word}
134
- .d-tail{margin:0;font-family:var(--mono);font-size:10.5px;color:var(--muted);background:var(--panel);
135
- border:1px solid var(--line);border-radius:7px;padding:7px 8px;max-height:210px;overflow:auto;
160
+ .d-tl{border-left:1px solid var(--line);padding-left:9px;max-height:min(30vh,170px);overflow:auto}
161
+ .d-l{display:flex;gap:7px;font-family:var(--mono);font-size:11.5px;line-height:1.55}
162
+ .d-l .ak{flex:none;color:var(--faint);min-width:46px}
163
+ .d-l .at{flex:1 1 0;min-width:0;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
164
+ /* 터미널 꼬리는 화면 그대로라 접는 것이 맞다(.d-q pre 와 같은 부류 — 사람이 쓴 것이 아니라 나온 것). */
165
+ .d-tail{margin:0;font-family:var(--mono);font-size:12px;color:var(--muted);background:var(--panel);
166
+ border:1px solid var(--line);border-radius:7px;padding:8px 9px;max-height:min(45vh,240px);overflow:auto;
136
167
  white-space:pre-wrap;word-break:break-word}
137
- .d-diff{margin:6px 0 0;font-family:var(--mono);font-size:10px;color:var(--muted);max-height:160px;overflow:auto;
138
- white-space:pre;background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:7px 8px}
139
- .d-bar{display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:9px;padding-top:8px;border-top:1px solid var(--line)}
140
- .dbtn{font-family:var(--mono);font-size:10px;color:var(--muted);background:none;border:1px solid var(--line);
141
- border-radius:6px;padding:3px 8px}
168
+ .d-diff{margin:7px 0 0;font-family:var(--mono);font-size:11px;color:var(--muted);max-height:min(35vh,200px);overflow:auto;
169
+ white-space:pre;background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:8px 9px}
170
+ .d-bar{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-top:10px;padding-top:9px;border-top:1px solid var(--line)}
171
+ .dbtn{font-family:var(--mono);font-size:12.5px;color:var(--muted);background:none;border:1px solid var(--line);
172
+ border-radius:6px;padding:5px 10px;white-space:nowrap}
142
173
  .dbtn:hover,.dbtn:focus-visible{color:var(--ink);border-color:var(--line-hi);background:var(--surface2)}
143
174
  .dbtn.prim{color:var(--brand);border-color:rgba(78,201,176,.4)}
144
175
  .dbtn.danger:hover{color:var(--failed);border-color:var(--failed)}
145
176
  /* 빠른 답은 대기의 색(--blocked)을 빌린다 — 코크핏 .qbtn 과 같은 주의색, 새 색 없음 */
146
- .qbtn{font-family:var(--mono);font-size:10.5px;color:var(--blocked);background:none;border:1px solid var(--line-hi);
147
- border-radius:999px;padding:3px 10px}
177
+ .qbtn{font-family:var(--mono);font-size:12.5px;color:var(--blocked);background:none;border:1px solid var(--line-hi);
178
+ border-radius:999px;padding:5px 12px;white-space:nowrap}
148
179
  .qbtn:hover,.qbtn:focus-visible{color:var(--ink);border-color:var(--blocked)}
149
- .d-steer{display:flex;gap:5px;margin-top:8px}
150
- .d-steer input{flex:1;min-width:0;font-family:var(--mono);font-size:11px;color:var(--ink);background:var(--panel);
151
- border:1px solid var(--line-hi);border-radius:6px;padding:5px 8px;outline:none}
180
+ .d-steer{display:flex;gap:6px;margin-top:9px}
181
+ .d-steer input{flex:1;min-width:0;font-family:var(--mono);font-size:12.5px;color:var(--ink);background:var(--panel);
182
+ border:1px solid var(--line-hi);border-radius:6px;padding:6px 9px;outline:none}
152
183
  .d-steer input:focus{border-color:var(--brand)}
153
- .d-empty{font-family:var(--mono);font-size:11px;color:var(--faint);padding:14px 4px}
184
+ .d-empty{font-family:var(--mono);font-size:12.5px;color:var(--faint);padding:16px 4px}
154
185
  .toast{position:fixed;left:50%;bottom:9px;transform:translateX(-50%);max-width:92%;
155
- font-family:var(--mono);font-size:10.5px;color:var(--ink);background:var(--surface2);
186
+ font-family:var(--mono);font-size:11.5px;color:var(--ink);background:var(--surface2);
156
187
  border:1px solid var(--line-hi);border-radius:7px;padding:5px 10px;opacity:0;pointer-events:none;
157
188
  transition:opacity .16s ease;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
158
189
  .toast.on{opacity:1}
159
190
  </style>
160
191
  </head>
161
192
  <body>
162
- <!-- K3 — 접힌 알약. 누르면 펼쳐진다(창 크기는 데스크톱이 나중에 data-hud 를 보고 맞춘다). -->
193
+ <!-- K3 — 접힌 알약. 누르면 펼쳐진다(창 크기는 데스크톱이 나중에 data-hud 를 보고 맞춘다).
194
+ 맨 앞의 홈(.pgrip)만이 창을 끄는 손잡이다 — 나머지는 전부 눌리는 자리다. -->
163
195
  <button type="button" id="pill" class="pill" aria-label="coxpit fleet">
196
+ <span class="pgrip" id="pillGrip" aria-hidden="true" title="끌어서 창 옮기기">⠿</span>
164
197
  <span class="pdots" id="pillDots"></span>
165
198
  <span class="pwait" id="pillWait" hidden></span>
166
199
  <span class="pquiet" id="pillQuiet" hidden>quiet</span>
@@ -194,12 +227,65 @@ export const HUD_HTML = /* html */ `<!doctype html>
194
227
  }
195
228
 
196
229
  // ── 레이아웃 상태 = 데스크톱이 읽어 갈 자리 ────────────────────────────
197
- // 창을 줄이고 늘리는 것은 **여기가 아니다**(K1, 데스크톱 작업). 이 페이지는 원하는 크기를
230
+ // 창을 줄이고 늘리는 것은 **여기가 아니다**(K1, 데스크톱 ). 이 페이지는 원하는 크기를
198
231
  // <html data-hud> 에 적어 두고, 자기 안에서는 그 값으로 배치만 바꾼다.
232
+ // 데스크톱 창에 실려 있으면 그 크기를 한 번 더 **말해 준다** — 창을 만지는 쪽은 언제나 저쪽이다.
233
+ // 폭은 이 표의 기본값(알약은 잰 값), 높이는 목록·상세 모두 **잰 값**이다(fit:true).
234
+ // 창은 이 값을 화면(workArea)으로 물리고, 사람이 손으로 끈 크기가 있으면 그쪽을 택한다.
235
+ var GUT=5; // 패널 상태의 지면 여백 — CSS body{padding:5px} 과 같은 값
236
+ var HUD_SIZE={ pill:{w:140,h:42}, list:{w:320,h:260}, detail:{w:680,h:400} };
237
+ // 스크롤 영역이 **실제로 담고 있는** 높이. scrollHeight 를 쓰면 안 된다 —
238
+ // 그 값은 clientHeight 아래로 내려가지 않아서, 창이 한 번 커지고 나면 내용이 줄어도
239
+ // 계속 "지금만큼 필요하다"고 답한다(창은 자랄 줄만 알고 줄 줄은 모르게 된다).
240
+ // 그래서 자식들이 차지한 자리를 직접 잰다. 스크롤된 상태에서도 맞게 scrollTop 을 더한다.
241
+ function contentH(el){
242
+ if(!el) return 0;
243
+ var kids=el.children; if(!kids.length) return 0;
244
+ var r=el.getBoundingClientRect(), bottom=null;
245
+ for(var i=0;i<kids.length;i++){
246
+ var k=kids[i].getBoundingClientRect();
247
+ if(!k.height) continue; // 숨겨진 것은 자리를 차지하지 않는다
248
+ var b=(k.top-r.top)+el.scrollTop+k.height;
249
+ if(bottom===null||b>bottom) bottom=b;
250
+ }
251
+ if(bottom===null) return 0;
252
+ var pad=0;
253
+ try{ pad=parseFloat(getComputedStyle(el).paddingBottom)||0; }catch(e){}
254
+ return Math.ceil(bottom+pad); // 위쪽 padding 은 첫 자식의 자리에 이미 들어 있다
255
+ }
256
+ // 카드가 **원하는 높이** = 머리 + 본문(물리지 않은 내용) + 발 + 테두리.
257
+ function fitHeight(){
258
+ var p=$('panel'); if(!p) return 0;
259
+ var hh=p.querySelector('.hh'), ft=p.querySelector('.hfoot');
260
+ var body=contentH($('hudList'));
261
+ if(layout==='detail') body=Math.max(body, contentH($('hudDetail'))); // 두 기둥 중 **긴 쪽**이 카드의 키다
262
+ if(!body) return 0; // 아직 아무것도 안 그렸다 — 기본값이 낫다
263
+ return Math.ceil((hh?hh.offsetHeight:0)+(ft?ft.offsetHeight:0)+body+2);
264
+ }
265
+ function reportSize(next){
266
+ var api=window.coxpitHud;
267
+ if(!api||typeof api.size!=='function') return; // 브라우저에서 단독으로 열린 경우 — 아무 일도 없다
268
+ var s=HUD_SIZE[next]||HUD_SIZE.pill;
269
+ var w=s.w, h=s.h, fit=false;
270
+ // 알약만은 **재서** 말한다 — 폭이 점 수에 따라 달라져서 고정값이면 잘리거나 빈자리가 남는다.
271
+ if(next==='pill'){
272
+ var el=$('pill');
273
+ if(el&&el.offsetWidth){ w=Math.ceil(el.offsetWidth)+12; h=Math.ceil(el.offsetHeight)+12; }
274
+ } else {
275
+ var m=fitHeight();
276
+ if(m>0) h=m+GUT*2;
277
+ fit=true; // 목록·상세의 높이는 **내용이 정한다**(창이 그만큼만 커진다)
278
+ }
279
+ try{ api.size({state:next, w:w, h:h, fit:fit}); }catch(e){}
280
+ }
281
+ // 내용은 자라고 줄어든다(행이 늘고, 늦게 온 터미널 꼬리가 채워지고, 접힌 것이 펴진다).
282
+ // 그때마다 한 번씩 다시 말해 준다 — 창이 내용을 따라가는 것은 한 번이 아니라 계속이다.
283
+ function reflow(){ if(layout!=='pill') reportSize(layout); }
199
284
  var layout='pill';
200
285
  function setLayout(next){
201
286
  layout=next;
202
287
  document.documentElement.setAttribute('data-hud', next);
288
+ reportSize(next);
203
289
  if(next==='pill'){ try{ $('pill').focus(); }catch(e){} }
204
290
  }
205
291
  function expand(){ if(layout==='pill'){ setLayout('list'); render(); } }
@@ -321,6 +407,7 @@ ${ACTIVITY_JS}
321
407
  cur=(mk!=null)?ids.indexOf(mk):-1;
322
408
  if(cur<0) curId=null;
323
409
  paintPill();
410
+ reflow(); // 행이 늘거나 줄었다 = 창이 원하는 높이도 달라졌다
324
411
  }
325
412
 
326
413
  // ── K3. 알약 — 점 + 대기 수. 대기가 없으면 조용하다(맥박 없음). ──────────
@@ -335,6 +422,8 @@ ${ACTIVITY_JS}
335
422
  if(b.wait.length){ w.hidden=false; w.textContent='\\u25d4 '+b.wait.length; q.hidden=true; }
336
423
  else { w.hidden=true; q.hidden=false; q.textContent=b.run.length?(b.run.length+' running'):'quiet'; }
337
424
  $('pill').title = b.wait.length ? (b.wait.length+' 개가 답을 기다립니다') : (b.run.length+' 개가 도는 중');
425
+ // 점이 늘고 줄면 알약의 폭도 달라진다 — 떠 있는 창이라면 그때마다 다시 말해 준다.
426
+ if(layout==='pill') reportSize('pill');
338
427
  }
339
428
 
340
429
  // ── K5. 상세 — 고른 행 하나. 목록은 그대로 옆에 남는다. ────────────────
@@ -414,11 +503,12 @@ ${ACTIVITY_JS}
414
503
  var el=$('hudDetail').querySelector('[data-role=dtl]'); if(!el) return;
415
504
  var r=runById[runId];
416
505
  var lines=humanLines((r&&r.events)||[]).slice(-6);
417
- if(!lines.length){ el.innerHTML='<div class="d-l"><span class="at">아직 이벤트가 없습니다</span></div>'; return; }
506
+ if(!lines.length){ el.innerHTML='<div class="d-l"><span class="at">아직 이벤트가 없습니다</span></div>'; reflow(); return; }
418
507
  var h=''; lines.forEach(function(l){
419
508
  h+='<div class="d-l"><span class="ak">'+esc(l.k)+'</span><span class="at">'+esc(String(l.t).slice(0,200))+'</span></div>';
420
509
  });
421
510
  el.innerHTML=h;
511
+ reflow();
422
512
  }
423
513
  // 기다리는 에이전트가 **무엇을 묻는지** — 코크핏을 열지 않고 알아야 하는 한 가지.
424
514
  // 출처는 둘뿐이다: 터미널의 지금 화면(스크롤백 꼬리) 또는 파싱된 스트림의 마지막 말.
@@ -435,14 +525,17 @@ ${ACTIVITY_JS}
435
525
  if(tail){
436
526
  var keep=tail.split('\\n').slice(-14).join('\\n');
437
527
  box.innerHTML='<span class="qk">터미널 마지막 화면</span><pre>'+esc(keep)+'</pre>';
528
+ reflow();
438
529
  return;
439
530
  }
440
- if(lastSaid){ box.innerHTML='<span class="qk">마지막 말</span><pre>'+esc(lastSaid.slice(0,600))+'</pre>'; return; }
531
+ if(lastSaid){ box.innerHTML='<span class="qk">마지막 말</span><pre>'+esc(lastSaid.slice(0,600))+'</pre>'; reflow(); return; }
441
532
  box.innerHTML='<span class="qk">pending</span><div class="unk">무엇을 묻는지 읽지 못했습니다 \\u2014 터미널을 열어 확인하세요</div>';
533
+ reflow();
442
534
  }).catch(function(){
443
535
  if(sel!==runId) return;
444
536
  var box=$('hudDetail').querySelector('[data-role=qbox]'); if(!box) return;
445
537
  box.innerHTML='<span class="qk">pending</span><div class="unk">무엇을 묻는지 읽지 못했습니다 \\u2014 터미널을 열어 확인하세요</div>';
538
+ reflow();
446
539
  });
447
540
  }
448
541
  function loadTail(runId){
@@ -452,19 +545,22 @@ ${ACTIVITY_JS}
452
545
  if(d&&d.ok&&String(d.text||'').trim()){
453
546
  el.textContent=String(d.text).replace(/\\s+$/,'').split('\\n').slice(-30).join('\\n');
454
547
  } else el.textContent=(d&&d.text)||'터미널 화면을 읽지 못했습니다';
548
+ reflow();
455
549
  }).catch(function(){
456
550
  if(sel!==runId) return;
457
551
  var el=$('hudDetail').querySelector('[data-role=tail]'); if(el) el.textContent='터미널 화면을 읽지 못했습니다';
552
+ reflow();
458
553
  });
459
554
  }
460
555
  function loadDiff(runId){
461
556
  var el=$('hudDetail').querySelector('[data-role=ddiff]'); if(!el) return;
462
- el.hidden=false; el.textContent='Loading\\u2026';
557
+ el.hidden=false; el.textContent='Loading\\u2026'; reflow();
463
558
  fetch('/api/runs/'+runId+'/diff').then(function(x){ return x.json(); }).then(function(d){
464
559
  if(sel!==runId) return;
465
560
  if(d&&d.ok) el.textContent=String((d.stat?d.stat+'\\n\\n':'')+(d.diff||'')).split('\\n').slice(0,60).join('\\n')||'변경 없음';
466
561
  else el.textContent=(d&&d.stat)||'worktree 가 없어 diff 를 볼 수 없습니다';
467
- }).catch(function(){ if(sel===runId) el.textContent='diff 를 가져오지 못했습니다'; });
562
+ reflow();
563
+ }).catch(function(){ if(sel===runId){ el.textContent='diff 를 가져오지 못했습니다'; reflow(); } });
468
564
  }
469
565
 
470
566
  // ── 행동 — 전부 이미 있는 창구다(새로 생긴 것은 input 하나뿐) ───────────
@@ -543,7 +639,8 @@ ${ACTIVITY_JS}
543
639
  }
544
640
  btn.dataset.armed='1';
545
641
  btn.textContent='정말 삭제? (폴더는 보존)';
546
- setTimeout(function(){ if(btn&&btn.dataset.armed==='1'){ btn.dataset.armed=''; btn.textContent='삭제'; } }, 4000);
642
+ reflow(); // 버튼이 길어지면 줄이 늘 수 있다 — 창도 같이 따라간다
643
+ setTimeout(function(){ if(btn&&btn.dataset.armed==='1'){ btn.dataset.armed=''; btn.textContent='삭제'; reflow(); } }, 4000);
547
644
  }
548
645
 
549
646
  // ── 클릭 위임 ────────────────────────────────────────────────────────