tabby-agentdeck 0.1.0 → 0.2.1

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/dist/config.d.ts CHANGED
@@ -1,20 +1,27 @@
1
1
  import { ConfigProvider } from 'tabby-core';
2
+ /**
3
+ * 마우스로 만진 배치만 따로 모아 둔다 — 설정 탭의 "배치 초기화" 가 이 값으로 되돌린다.
4
+ * 기본값 정의와 초기화 대상이 어긋나지 않도록 아래 defaults 도 이걸 펼쳐 쓴다.
5
+ */
6
+ export declare const LAYOUT_DEFAULTS: {
7
+ /**
8
+ * 사이드바가 붙는 방향. 헤더를 잡고 창 가장자리로 끌어다 놓으면 바뀐다.
9
+ * 'left' | 'right' | 'top' | 'bottom'
10
+ */
11
+ sidebarDock: string;
12
+ /**
13
+ * 좌/우 도킹일 때의 사이드바 폭 (px). 0 이면 자동 —
14
+ * 화면비(aspectW:aspectH)로 터미널 폭을 역산하고 남는 만큼을 사이드바가 갖는다.
15
+ * 경계선을 드래그하는 순간 그 폭이 여기 박히고, 그 뒤로는 자동 계산을 쓰지 않는다.
16
+ */
17
+ sidebarWidth: number;
18
+ /** 상/하 도킹일 때의 사이드바 높이 (px). 경계선 드래그로 바뀐다 */
19
+ sidebarHeight: number;
20
+ };
2
21
  /** @hidden */
3
22
  export declare class AgentDeckConfigProvider extends ConfigProvider {
4
23
  defaults: {
5
24
  agentDeck: {
6
- /** 레이아웃/사이드바 전체 on-off */
7
- enabled: boolean;
8
- /** 터미널 뷰포트 가로:세로 비율 */
9
- aspectW: number;
10
- aspectH: number;
11
- /**
12
- * 사이드바 폭 제한 (px).
13
- * sidebarMax 를 키우면 사이드바가 그만큼 넓어지고, 그래도 남는 폭은 터미널이 흡수한다
14
- * (4:3 보다 "창을 꽉 채운다" 가 우선). config.yaml 의 agentDeck 아래에서 조절한다.
15
- */
16
- sidebarMin: number;
17
- sidebarMax: number;
18
25
  /**
19
26
  * 훅이 상태를 밀어 넣는 TCP 채널의 포트 (127.0.0.1 전용).
20
27
  *
@@ -27,11 +34,20 @@ export declare class AgentDeckConfigProvider extends ConfigProvider {
27
34
  /**
28
35
  * 외부 통보 채널(TCP + 상태 파일 폴링)을 열지.
29
36
  *
30
- * 기본은 꺼 둔다 — 라벨은 Enter 시점 입력창에서(`enterAsLabel`), 상태는 출력 패턴에서
31
- * 플러그인이 스스로 얻으므로 바깥에서 밀어 넣어 줄 것이 없다. 켜 두면 쓰지도 않는
32
- * 400ms 폴링과 포트 점유만 남는다. 외부 도구가 상태를 직접 보고하고 싶을 때만 켠다.
37
+ * 기본으로 켠다. 에이전트 훅이 보내 주는 상태가 가장 정확한 원천이기 때문이다 —
38
+ * 출력 패턴으로 추측하는 경로(detect.ts)는 훅이 없는 환경을 위한 폴백일 뿐이고,
39
+ * TUI 가 화면을 어떻게 그리는지에 기대는 만큼 판정이 흔들린다
40
+ * (2026-09-01: Claude Code 가 놀 때도 상태줄을 매초 갱신해 진행중이 안 풀렸다).
41
+ * 훅을 안 쓰더라도 비용은 유휴 포트 하나와 400ms 폴링뿐이다.
33
42
  */
34
43
  notifyChannel: boolean;
44
+ /**
45
+ * 훅 설치 안내를 다시 띄우지 않는다.
46
+ *
47
+ * 기동할 때 훅이 안 걸려 있으면 한 번 물어보는데, "다시 묻지 않기" 를 고르면 켜진다.
48
+ * 설정 창의 설치 버튼은 이 값과 무관하게 언제든 쓸 수 있다.
49
+ */
50
+ hookPromptDismissed: boolean;
35
51
  /** PTY 출력이 이 시간(ms) 이상 없으면 running -> idle */
36
52
  idleAfterMs: number;
37
53
  /** 출력 패턴으로 상태를 자동 추론할지 */
@@ -47,15 +63,6 @@ export declare class AgentDeckConfigProvider extends ConfigProvider {
47
63
  * (사이드바 DOM 갱신뿐이라 미미하다).
48
64
  */
49
65
  keepRenderingWhenHidden: boolean;
50
- /**
51
- * 사이드바의 제목 줄에 탭 제목 대신 작업 라벨을 쓴다.
52
- * Claude Code 는 세션 시작 때 붙인 콘솔 제목을 계속 들고 있어서 다음 작업으로
53
- * 넘어가도 안 바뀐다(2026-08-28 실측) — 제목만 보여주면 사이드바가 옛 작업 이름에
54
- * 박혀 있게 된다. 라벨이 없는 탭은 그대로 제목을 보여준다.
55
- * Tabby 의 탭 제목 자체(customTitle)는 건드리지 않는다 — 앱이 넣는 제목을
56
- * 가리지 않아야 그게 갱신되는지 계속 관측할 수 있다.
57
- */
58
- labelAsTitle: boolean;
59
66
  /**
60
67
  * Enter 로 프롬프트를 보낼 때 그 원문을 작업 라벨로 삼는다 (claimEnterLabel 주석 참고).
61
68
  * 훅도 콘솔 제목도 못 믿는 배포 환경에서 라벨을 최신으로 유지하는 유일한 경로다.
@@ -119,18 +126,110 @@ export declare class AgentDeckConfigProvider extends ConfigProvider {
119
126
  /** 사이드바 배경 불투명도 (%). 터미널과 같은 이유로 배경 이미지가 비치게 한다 */
120
127
  sidebarOpacity: number;
121
128
  /**
122
- * 새 탭에서 첫 화면이 깨질 때, 세션이 열린 뒤 이 시간(ms)에 pty 크기를 한 번 흔든다.
123
- * 0 이면 끄기. 자세한 이유는 deck.service 의 kickPtySize 주석 참고
129
+ * tabby-background 의 배경 이미지를 **터미널 영역(`.content.main`) 안에만** 그린다.
130
+ *
131
+ * 그 플러그인은 이미지를 `.content-tab-active::before` 에 `position:fixed; 100%x100%`
132
+ * 로 깔아 창 전체를 덮으므로, 사이드바가 반투명이면 이미지가 사이드바 밑까지 비친다
133
+ * (2026-09-02 유저 지적: "배경이 agentdeck 사이드바를 침범한다").
134
+ * true 면 relayout 때마다 터미널 영역 좌표를 CSS 변수(`--ad-bg-*`)로 내려
135
+ * 그 레이어의 위치·크기를 터미널 영역으로 잘라 붙인다. false 면 순정(창 전체).
136
+ */
137
+ clipBackgroundToTerminal: boolean;
138
+ /**
139
+ * 화면 복구(사이드바 `↻` / `agentdeck-repair` 핫키)가 **스크롤백까지 지우고**
140
+ * 터미널 상태를 초기화한다(`xterm.reset()`).
141
+ *
142
+ * true 인 이유: 깨진 화면에는 옛 폭으로 그려진 잔상이 셀에 남아 있는데, 앱이 다시
143
+ * 그려도 자기가 쓰는 자리만 덮으므로 그 바깥은 지워지지 않는다. 지난 대화를 남기고
144
+ * 싶으면 false — 그때는 크기 정합 + 흔들기까지만 한다(그것만으로는 안 고쳐지는
145
+ * 경우가 있다, 2026-09-01 확인).
146
+ */
147
+ /**
148
+ * 화면이 깨졌는지 **주기적으로 직접 보고**, 깨졌으면 조용히 복구한다.
149
+ *
150
+ * 이게 필요한 이유 — 크기 감시(`sizeWatchMs`)는 "우리가 pty 에 보낸 값" 과
151
+ * "xterm 이 가진 값" 을 비교한다. 그런데 세션이 막 열린 직후의 `session.resize()` 는
152
+ * **pty 에 도달하지 못하고 유실된다**(2026-09-01 실측: 진단 로그에
153
+ * `pty resize 254x78 -> 280x78` 을 찍고 `pane.size` 도 280 으로 남겼는데,
154
+ * 같은 탭에서 셸에 물어보니 `PTYW=254 xtermCols=280`). 보낸 값을 낙관적으로
155
+ * 기록하기 때문에 감시는 "이제 맞다" 고 보고 손을 뗀다. 그 뒤에 뜨는 TUI 는
156
+ * 254 폭으로 그리고, 오른쪽 26칸에는 옛 프레임(`Try "how do I …"` 힌트)이 남는다.
157
+ *
158
+ * 그래서 판정 기준을 **화면 자체**로 바꾼다. 입력창 테두리 길이가 화면 폭과 다르거나,
159
+ * 가로선과 입력 텍스트가 한 줄에 섞였거나, 입력창 머리가 둘 보이면 깨진 것이다
160
+ * (`screen.ts` 의 `judgeScreen` — 정상/깨짐 실측 케이스로 유닛 테스트가 걸려 있다).
161
+ * 원인이 무엇이든 결과를 보고 고치므로, 원인 하나를 쫓다 다른 것이 깨지는 일이 없다.
162
+ */
163
+ /**
164
+ * **다시 켬 (2026-09-02).** 껐던 근거가 오진이었다.
165
+ *
166
+ * 껐을 때의 이유는 "Claude Code 는 테두리를 `cols - 1` 로 그리는데 판정이
167
+ * `== cols` 를 정상으로 봐서 멀쩡한 화면을 깨짐으로 친다" 였다. **그 전제가 틀렸다.**
168
+ * 격리 인스턴스에서 실물 claude 를 띄워 xterm 버퍼를 직접 덤프해 보니
169
+ * (2026-09-02, cols=271):
170
+ *
171
+ * 71 len=271 wrap=0 "────…" ← 테두리는 cols 를 꽉 채운다
172
+ * 72 len=2 wrap=1 "❯ " ← 꽉 찼기 때문에 autowrap 으로 넘어온 행이다
173
+ * 73 len=271 wrap=0 "────…"
174
+ *
175
+ * 즉 `len == cols` 가 정상이고, `len == cols - 1` 은 **앱이 폭을 1 작게 알고 있는**
176
+ * 진짜 깨짐이다. 그때는 테두리가 폭을 못 채워 autowrap 이 안 걸리므로 `❯` 가
177
+ * 윗줄에 붙어버리고 **입력행이 통째로 사라진다**(항목 25 의 ④ 증상이 이것이다).
178
+ * 껐던 탓에 실사용에서 깨져도 아무도 고치지 않고 방치됐다.
179
+ *
180
+ * 예전 자동복구가 화면을 흔들어 놓은 진짜 이유는 판정이 아니라 **조건이 없어서**였다.
181
+ * 프레임을 그리는 도중을 한 번 잡아도 바로 복구를 때렸다. 그래서 이번에는
182
+ * `autoRepairMinStreak` 로 "몇 초 넘게 깨진 채로 있을 때만" 손대게 한다.
183
+ */
184
+ /**
185
+ * **다시 끔 (2026-09-02, 실사용 실측 4/4 실패).** 위 판정 근거는 여전히 맞지만
186
+ * **복구 수단이 이 유형에 안 듣는다.** 실사용 로그 06:02:27~06:03:12 —
187
+ * `redraw nudge 277x78` → `pty resize 276x78 -> 277x78` → `Ctrl+L` 을 다 보내고도
188
+ * 네 번 모두 `auto-after 아직 깨짐 rule@73 len=276 != cols=277`.
189
+ * 그동안 10초마다 화면이 흔들려 유저 체감만 나빠졌다("최대화됐다 다시 깨지네").
190
+ *
191
+ * 격리에서 한 번에 복구됐던 건 `session.resize(cols-1)` 로 **pty 를 직접 좁혀** 만든
192
+ * 표본이라, 흔들기가 되돌릴 대상이 실제로 있었다. 실사용은 pty 가 이미 277 로 맞고
193
+ * **앱만 276** 이라 되돌릴 것이 없다 — 항목 27 이 이 유형을 정확히 짚었던 것이고,
194
+ * 항목 28 의 반전은 그 표본에는 해당하지 않았다.
195
+ * 되살리려면 "앱이 pty 폭을 다시 읽게 만드는" 수단부터 찾을 것. 감지·채증(`screenWatch`)은 계속 돈다.
124
196
  */
125
- redrawKickMs: number;
197
+ autoRepairScreen: boolean;
126
198
  /**
127
- * 기동 후 이 시간(ms)에 **창을 복원→최대화로 한 번 흔든다**. 0 이면 끄기.
199
+ * 화면을 **보고 기록만** 한다 (복구는 하지 않는다).
128
200
  *
129
- * pty 크기 흔들기(redrawKickMs)가 놓치는 경우를 위한 최종 보험이다. 사용자가 손으로
130
- * 하던 바로 그 동작이고, OS 가 진짜 SIGWINCH 를 두 번 내므로 TUI 가 반드시 다시 그린다.
131
- * 창이 최대화 상태일 때만 동작한다 — 아니면 창 상태를 멋대로 바꾸게 되므로.
201
+ * `autoRepairScreen` 을 끄면서 감지·채증까지 같이 꺼진 게 문제였다 —
202
+ * 실사용에서 깨져도 `~/.agentdeck-screen.log` 에 한 줄도 안 남아,
203
+ * "고쳤다고 했는데 또 깨진다" 를 확인할 증거가 없었다
204
+ * (2026-09-02 실측: Tabby 재시작 11:21 이후 기록 0줄, 그 사이 화면은 깨져 있었다).
205
+ * 그래서 **관측과 조치를 분리한다.** 이 값이 켜져 있으면 깨짐을 판정해
206
+ * 화면 원문을 남기기만 하고 키는 쏘지 않는다 — 부작용이 없으니 기본으로 켠다.
207
+ */
208
+ screenWatch: boolean;
209
+ /** 자동 복구를 한 번 한 뒤 이만큼은 다시 하지 않는다 (ms) */
210
+ autoRepairCooldownMs: number;
211
+ /**
212
+ * 깨짐이 이만큼 **연속으로** 보여야 복구한다 (화면 검사는 2초 그물이므로 2 면 2초 이상).
213
+ * TUI 는 한 프레임을 여러 번의 write 로 그리므로 그 도중을 읽으면 멀쩡한 화면도
214
+ * 한 번은 깨져 보인다 — 그 중간 프레임에 키를 쏘지 않기 위한 조건이다.
132
215
  */
133
- startupWindowKickMs: number;
216
+ autoRepairMinStreak: number;
217
+ /**
218
+ * 한 탭에서 자동 복구를 최대 몇 번까지 할지. 판정이 오탐이거나 앱이 정말
219
+ * 그렇게 그리는 경우에 무한히 흔드는 것을 막는 안전장치다. 0 이면 무제한.
220
+ */
221
+ autoRepairMaxPerTab: number;
222
+ /**
223
+ * 복구 때 `xterm.reset()` 까지 한다 — **스크롤백(지난 대화)이 날아간다.**
224
+ * 기본은 끔. 잔상은 `clearInputArea`(화면만 비우고 스크롤백은 보존)로 지우므로
225
+ * 여기까지 갈 일이 거의 없다. 그걸로도 안 풀리는 화면을 만나면 그때 켠다.
226
+ */
227
+ repairHard: boolean;
228
+ /**
229
+ * 복구 마지막에 `Ctrl+L`(0x0C)을 보내 TUI 에게 전체 재렌더를 시킨다.
230
+ * 입력 중이던 글자는 유지된다. 이걸 다른 뜻으로 쓰는 앱을 만나면 끈다.
231
+ */
232
+ repairSendRedrawKey: boolean;
134
233
  /**
135
234
  * 작업 루트 전용 프로필을 만들고 기본 프로필로 삼는다.
136
235
  *
@@ -157,10 +256,33 @@ export declare class AgentDeckConfigProvider extends ConfigProvider {
157
256
  COLORTERM: string;
158
257
  FORCE_COLOR: string;
159
258
  };
160
- /** true 면 탭마다 UAC 승격 (Tabby 자체를 관리자로 띄웠다면 불필요) */
161
- rootProfileAdmin: boolean;
162
259
  /** 기본 프로필 지정을 이미 했는지 — 사용자 선택을 매번 덮어쓰지 않기 위한 플래그 */
163
260
  rootProfileClaimed: boolean;
261
+ /**
262
+ * 사이드바가 붙는 방향. 헤더를 잡고 창 가장자리로 끌어다 놓으면 바뀐다.
263
+ * 'left' | 'right' | 'top' | 'bottom'
264
+ */
265
+ sidebarDock: string;
266
+ /**
267
+ * 좌/우 도킹일 때의 사이드바 폭 (px). 0 이면 자동 —
268
+ * 화면비(aspectW:aspectH)로 터미널 폭을 역산하고 남는 만큼을 사이드바가 갖는다.
269
+ * 경계선을 드래그하는 순간 그 폭이 여기 박히고, 그 뒤로는 자동 계산을 쓰지 않는다.
270
+ */
271
+ sidebarWidth: number;
272
+ /** 상/하 도킹일 때의 사이드바 높이 (px). 경계선 드래그로 바뀐다 */
273
+ sidebarHeight: number;
274
+ /** 레이아웃/사이드바 전체 on-off */
275
+ enabled: boolean;
276
+ /** 터미널 뷰포트 가로:세로 비율 */
277
+ aspectW: number;
278
+ aspectH: number;
279
+ /**
280
+ * 사이드바 폭 제한 (px).
281
+ * sidebarMax 를 키우면 사이드바가 그만큼 넓어지고, 그래도 남는 폭은 터미널이 흡수한다
282
+ * (4:3 보다 "창을 꽉 채운다" 가 우선). config.yaml 의 agentDeck 아래에서 조절한다.
283
+ */
284
+ sidebarMin: number;
285
+ sidebarMax: number;
164
286
  };
165
287
  hotkeys: {
166
288
  'agentdeck-toggle': any[];
@@ -32,16 +32,24 @@ export declare class AgentDeckService {
32
32
  /** 직전에 적용한 폭 — 같은 값이면 DOM 을 다시 건드리지 않는다 */
33
33
  private lastTermW;
34
34
  private lastSidebarW;
35
+ /** 직전에 적용한 도킹 방향 — 크기가 같아도 방향이 바뀌면 다시 그려야 한다 */
36
+ private lastDock;
37
+ /** 경계선 드래그(크기)와 헤더 드래그(도킹 방향)를 맡는다 */
38
+ private dock;
35
39
  private refitQueued;
36
40
  /** 이미 감시를 건 래퍼 탭 / pane — 중복 구독 방지 */
37
41
  private watched;
38
42
  private watchedPanes;
39
- /** 첫 화면 복구용 pty 흔들기를 이미 한 pane — 탭당 한 번만 한다 */
40
- private kickedPanes;
41
- /** setup() 시각 — 기동 직후 구간인지 판단한다 (0 이면 아직 setup 전) */
42
- private setupAt;
43
- /** 기동 직후 구간에 잡힌 pane 들 — 폭이 바뀌면 이들만 다시 흔든다. 구간이 지나면 비운다 */
44
- private startupPanes;
43
+ /** 화면 깨짐 검사 예약 타이머 — pane 당 하나만 돈다 */
44
+ private screenCheckTimers;
45
+ /** 빈 래퍼 탭을 다시 훑은 횟수 */
46
+ private emptyTabRetries;
47
+ /** 마지막 화면 판정 결과 — 상태가 바뀔 때만 채증하려고 들고 있는다 */
48
+ private screenObs;
49
+ /** 마지막 자동 복구 시각 — 쿨다운 판정용 */
50
+ private autoRepairAt;
51
+ /** 자동 복구 횟수 — 오탐으로 무한히 흔드는 것을 막는다 */
52
+ private autoRepairCount;
45
53
  /** 진단 기록 — devtools 의 `__agentdeck.kickLog` 와 DIAG_PATH 파일에 함께 남는다 */
46
54
  private kickLog;
47
55
  /** diagOnce 가 이미 남긴 키 */
@@ -66,6 +74,94 @@ export declare class AgentDeckService {
66
74
  /** 진단 한 줄 — 메모리와 파일에 함께 남긴다. 파일이 안 써져도 동작에는 영향이 없다 */
67
75
  private diag;
68
76
  private setup;
77
+ /**
78
+ * 깨진 화면을 사람이 직접 되살린다 — 창을 복원→최대화로 흔드는 것을 코드로 대신한다.
79
+ *
80
+ * 자동 경로(`watchSize`)는 "xterm 과 pty 의 크기가 어긋났다" 를 보고 고치는데, 그 감시는
81
+ * 탭이 열리고 `sizeWatchMs`(기본 6초) 동안만 돈다. 그 뒤에 깨지는 경우가 남는다 —
82
+ * 두 크기는 서로 맞는데 **TUI 가 들고 있는 프레임만** 옛것인 상황이다. 크기가 이미 같으니
83
+ * 커널은 SIGWINCH 를 내지 않고, 앱은 다시 그릴 이유를 영영 못 받는다. 증상은
84
+ * "statusline 이 사라지고 입력창 구분선이 중간에서 끊긴다" (2026-09-01 실측).
85
+ *
86
+ * 그래서 복구는 크기를 맞추는 것으로 끝내지 않고 **항상 한 번 흔든다**(`nudgePtyRedraw`).
87
+ * 어긋남이 없어도 무해하다 — 폭이 한 칸 줄었다 돌아올 뿐이고 xterm 은 건드리지 않는다.
88
+ *
89
+ * @param scope `active` = 지금 보고 있는 탭만, `all` = 열려 있는 모든 탭
90
+ */
91
+ repair(scope?: 'active' | 'all'): void;
92
+ /**
93
+ * 복구 한 pane 분 — **화면을 지우고 앱에게 처음부터 다시 그리게 한다.**
94
+ *
95
+ * 흔들기(`nudgePtyRedraw`)만으로는 안 고쳐지는 경우가 실제로 있다(2026-09-01 유저 확인).
96
+ * 이유는 흔들기가 고치는 대상이 하나뿐이라서다 — 그건 "앱이 폭을 잘못 알고 있다" 를 푼다.
97
+ * 그런데 깨진 화면에는 그 말고도 두 가지가 더 얹혀 있다.
98
+ *
99
+ * - **xterm 쪽 잔상**: 옛 폭으로 그려진 글자가 셀에 그대로 남아 있다. 앱이 다시 그려도
100
+ * 앱은 자기가 쓴 자리만 덮으므로, 그 바깥(옛 프레임의 오른쪽·아래)은 영영 안 지워진다.
101
+ * - **터미널 상태**: 스크롤 영역(DECSTBM)·줄바꿈 모드·속성이 옛 크기 기준으로 잡혀 있으면
102
+ * 새로 그린 것까지 어긋난 자리에 들어간다.
103
+ *
104
+ * 그래서 순서가 있다 — ① 크기를 맞추고 ② `reset()` 으로 터미널 상태와 화면을 비우고
105
+ * ③ 그래도 앱은 자기가 이미 그렸다고 믿고 있으니 흔들어 SIGWINCH 를 주고
106
+ * ④ 마지막으로 `Ctrl+L`(0x0C) 을 보내 "전부 다시 그려라" 를 명시로 시킨다.
107
+ * ③ 만으로 부족했던 이유가 ①②④ 가 빠져서였다.
108
+ *
109
+ * `reset()` 은 스크롤백을 함께 지운다. 지난 대화를 남기고 싶으면 `repairHard: false` —
110
+ * 그때는 예전처럼 크기 정합 + 흔들기까지만 한다.
111
+ */
112
+ private repairPane;
113
+ /**
114
+ * TUI 에 "화면을 전부 다시 그려라" 를 보낸다 — `Ctrl+L`(0x0C).
115
+ *
116
+ * Claude Code·Codex 를 포함해 거의 모든 TUI 와 셸이 이걸 전체 재렌더로 받는다.
117
+ * 입력 중이던 글자는 건드리지 않는다(readline·TUI 모두 입력 버퍼는 유지하고 화면만 다시 그린다).
118
+ */
119
+ private sendRedrawKey;
120
+ /**
121
+ * 입력창 영역에 남은 **옛 프레임 잔상만** 지운다 (대화 = 스크롤백은 그대로 둔다).
122
+ *
123
+ * 복구가 계속 실패하던 진짜 이유가 여기 있었다 (2026-09-02 실측).
124
+ * `nudge` 도 `Ctrl+L` 도 하는 일은 "앱아 다시 그려라" 뿐인데, **앱은 자기가 쓰는 자리만 덮는다.**
125
+ * 그래서 옛 폭으로 그려진 줄이 `❯` 자리를 차지하고 있으면 아무도 그걸 못 지운다 —
126
+ * 채증 원문이 그 모양이었다 (`.agentdeck-screen.log` 06:02:27):
127
+ *
128
+ * 72 len=275 " " ← 위 테두리가 있어야 할 자리, 공백
129
+ * 73 len=276 "────…" ← ❯ 자리에 옛 프레임(276) 잔상
130
+ * 74 len=277 "────…" ← 앱이 새로 그린 아래 테두리(277, 정상)
131
+ *
132
+ * 앱은 이미 277 로 제대로 그리고 있는데 73 행 잔상 때문에 계속 깨짐으로 남았다.
133
+ * `auto-after 아직 깨짐` 이 두 번 연속 찍힌 게 이것이다.
134
+ *
135
+ * 그래서 **터미널 쪽에서 직접 지운다** — 입력창 첫 테두리 행으로 커서를 옮기고
136
+ * `ESC[J`(커서부터 화면 끝까지)로 비운다. 이건 화면만 비우고 스크롤백은 건드리지 않으므로
137
+ * `xterm.reset()`(스크롤백까지 날아감)과 다르다. 커서는 `ESC7`/`ESC8` 로 저장·복원해
138
+ * 앱이 들고 있는 커서 위치와 어긋나지 않게 한다. 지운 뒤 `Ctrl+L` 을 보내면 앱이
139
+ * 자기 UI 를 그 빈 자리에 다시 그린다.
140
+ */
141
+ private clearInputArea;
142
+ /**
143
+ * 출력이 잠잠해지면 화면이 온전한지 본다.
144
+ *
145
+ * TUI 는 한 프레임을 여러 번의 write 로 그리므로, 그리는 도중에 읽으면 멀쩡한 화면도
146
+ * 깨져 보인다. 그래서 마지막 출력에서 `SCREEN_CHECK_DEBOUNCE_MS` 만큼 조용해진 뒤에만 본다.
147
+ */
148
+ private scheduleScreenCheck;
149
+ /** 감지·채증이 켜져 있나 — 복구(`autoRepairScreen`)와는 별개다 */
150
+ private screenObservingEnabled;
151
+ /** 화면을 판정해 채증하고, 복구가 켜져 있으면 조용히 고친다 */
152
+ private checkScreen;
153
+ /** 활성 탭의 pane 들을 한 번씩 본다 (주기 그물) */
154
+ private sweepScreens;
155
+ /** 판정에 넣을 화면 줄과 폭 — 숨겨진 탭이나 아직 안 붙은 프론트엔드는 건너뛴다 */
156
+ private readScreen;
157
+ /**
158
+ * 화면 원문을 통째로 남긴다 — 깨짐 채증용.
159
+ *
160
+ * 판정 이유만 남기면 다음에 또 "왜 그렇게 됐는지" 를 추측하게 된다. 행 번호·wrap 플래그·
161
+ * 길이까지 그대로 적어 두면 `❯` 가 어느 행에 있었는지, 테두리가 몇 칸이었는지가 확정된다.
162
+ * before/after 를 짝으로 남기므로 복구가 실제로 먹혔는지도 이 파일에서 바로 갈린다.
163
+ */
164
+ private snapshot;
69
165
  /**
70
166
  * 탭(그리고 그 안의 모든 pane)의 PTY 출력을 구독한다.
71
167
  *
@@ -76,31 +172,15 @@ export declare class AgentDeckService {
76
172
  private watchTab;
77
173
  private watchPane;
78
174
  /**
79
- * 기동 직후 창을 **복원 -> 최대화**로 한 번 흔든다. 사용자가 손으로 하던 그 동작이다.
175
+ * 탭이 열린 뒤 몇 차례, **지금 xterm 이 쓰는 크기를 pty 에 알려준다.**
80
176
  *
81
- * pty 크기 흔들기(kickPtySize)는 우리가 만든 가짜 SIGWINCH 라, 세션이 늦게 열리거나
82
- * 폭 변경 타이밍이 어긋나면 놓칠 수 있다. 이쪽은 OS 가 진짜로 창 크기를 두 번 바꾸므로
83
- * 그 아래 있는 xterm, pty, TUI 가 예외 없이 다시 그린다. 최종 보험이다.
177
+ * 순정이 유일하게 못 메우는 구멍(새 탭에서 pty 만 좁게 태어남)을 메우는 최소 개입이다.
178
+ * 자세한 근거와 실측은 `TAB_OPEN_ANNOUNCE_MS` 주석에 있다.
84
179
  *
85
- * 최대화 상태일 때만 한다. 아니면 사용자의 창 상태를 멋대로 최대화해버린다.
180
+ * 하지 않는 것 — xterm 은 건드리지 않고(fit 도 resize 도), 폭을 흔들지 않으며,
181
+ * 감시 루프도 돌지 않는다. 그 셋이 예전에 화면을 깨뜨린 장본인이다.
86
182
  */
87
- private scheduleWindowKick;
88
- /**
89
- * 탭이 열린 직후 한 번만 pty 크기를 흔들어 TUI 가 화면 전체를 다시 그리게 한다.
90
- *
91
- * 왜 필요한가 — 탭이 만들어지는 동안 우리가 `.content.main` 폭을 바꾸면 그 사이에
92
- * 이미 뜬 TUI(Claude Code 등)는 **옛 폭 기준으로 첫 화면을 그려 놓은 상태**가 된다.
93
- * 그 뒤 refit/syncPtySize 가 크기를 맞춰도, 맞춘 값이 pty 가 이미 들고 있던 값과 같으면
94
- * 커널은 SIGWINCH 를 내지 않는다 — TUI 는 "크기 안 바뀌었네" 하고 그대로 있고,
95
- * 옛 폭으로 그린 입력창 프레임 잔상이 화면에 남는다 (2026-08-28 실측: 첫 한 번만 재현,
96
- * 창 복원→최대화로 흔들면 즉시 정상 — 그게 SIGWINCH 를 두 번 내기 때문이다).
97
- *
98
- * 그래서 같은 일을 코드로 한다: 한 열 좁혔다가 곧바로 되돌린다. 두 번의 SIGWINCH 가
99
- * 확실히 전달되어 TUI 가 전체 재렌더를 하고 잔상이 지워진다. 탭당 한 번뿐이라
100
- * 정상 동작하던 탭에는 재렌더 1회 외에 영향이 없다.
101
- */
102
- private scheduleRedrawKick;
103
- private kickPtySize;
183
+ private announceSizeOnOpen;
104
184
  /**
105
185
  * Ctrl+V 를 **핫키 테이블에서 걷어낸다** (실제 처리는 claimCtrlVKey 가 한다).
106
186
  *
@@ -251,6 +331,15 @@ export declare class AgentDeckService {
251
331
  private sendNewline;
252
332
  private get enabled();
253
333
  private applyEnabled;
334
+ /**
335
+ * tabby-background 의 이미지 레이어를 터미널 영역(`.content.main`)에 맞춰 자른다.
336
+ *
337
+ * 그 레이어는 `position: fixed` 라 viewport 좌표를 쓰므로 `getBoundingClientRect()` 값을
338
+ * 그대로 CSS 변수로 내리면 된다 (styles.scss 가 `!important` 로 덮어쓴다).
339
+ * relayout 직후에는 인라인 폭이 아직 레이아웃에 반영되지 않았을 수 있어 rAF 한 번 뒤에 잰다.
340
+ * 꺼져 있으면(또는 agentdeck 비활성) 클래스와 변수를 걷어 순정(창 전체)으로 되돌린다.
341
+ */
342
+ private syncBackgroundClip;
254
343
  /**
255
344
  * 터미널/사이드바 배경에 알파를 넣어 tabby-background 의 배경 이미지가 비치게 한다.
256
345
  *
@@ -263,7 +352,16 @@ export declare class AgentDeckService {
263
352
  * ensurePasteHotkey 와 같은 이유로 여기서 코드로 보정한다.
264
353
  */
265
354
  private applyOpacity;
266
- /** 창 높이에서 터미널 폭을 역산해 .content.main 을 고정폭으로 만든다 */
355
+ /** 지금 사이드바가 붙어 있는 방향 (설정값이 망가져 있으면 오른쪽으로 본다) */
356
+ private get dockSide();
357
+ /**
358
+ * 사이드바가 차지할 크기를 px 로 정한다 — 좌/우면 폭, 상/하면 높이.
359
+ *
360
+ * 사용자가 경계선을 한 번이라도 끌었으면 그 값(`sidebarWidth`/`sidebarHeight`)이 진실이다.
361
+ * 아직 안 끌었다면(폭이 0) 예전처럼 화면비로 터미널 폭을 역산해 남는 만큼을 사이드바가 갖는다.
362
+ */
363
+ private sidebarExtent;
364
+ /** 사이드바를 창 한쪽에 붙이고, 남는 자리를 `.content.main` 이 갖게 한다 */
267
365
  private relayout;
268
366
  /**
269
367
  * 폭을 바꿨으면 터미널에 반드시 알려야 한다.
@@ -293,6 +391,19 @@ export declare class AgentDeckService {
293
391
  * 그래서 fit() 뒤에 pty 로 직접 한 번 더 내려보낸다. 같은 값이면 pty 쪽에서 무시되므로 무해하다.
294
392
  */
295
393
  private syncPtySize;
394
+ /**
395
+ * pty 크기만 한 칸 줄였다 되돌려 TUI 가 화면을 다시 그리게 한다. **수동 복구 전용.**
396
+ *
397
+ * 자동 경로에서는 전부 걷어냈다 — 이 흔들기가 순정 수렴과 경쟁하며 깨진 화면을
398
+ * 만들어 왔다(2026-09-02). 남겨 둔 이유는 `repairPane`(사이드바 `↻` / `agentdeck-repair`)
399
+ * 때문이다. 거기서는 사용자가 "지금 깨졌으니 고쳐라" 를 명시로 눌렀고, 크기를 맞추는
400
+ * 것만으로는 앱이 다시 그리지 않는 경우가 실제로 있다 — 폭이 **실제로 바뀌어야**
401
+ * SIGWINCH 가 나가므로 한 칸 흔들어 두 번 내보낸다. 뒤이어 `Ctrl+L` 이 따라간다.
402
+ *
403
+ * 좁힌 값을 `pane.size` 에 그대로 적는다. 되돌리기가 유실됐는데 낙관적으로 `cols` 를
404
+ * 적어 두면 아무도 어긋남을 못 보고 한 열 좁은 채로 굳는다(옛 버그).
405
+ */
406
+ private nudgePtyRedraw;
296
407
  private buildSidebar;
297
408
  /** 설정에 지정된 기본 프로필(기본값 agentdeck:root)로 새 탭을 연다 */
298
409
  private openNewTab;
package/dist/dock.d.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * 사이드바를 마우스로 조절하는 부분 — 경계선 드래그로 크기, 헤더 드래그로 붙는 방향.
3
+ *
4
+ * 설정 창에서 숫자를 넣게 하지 않고 Visual Studio 의 도킹처럼 직접 끌어서 맞추게 한다.
5
+ * DOM 을 직접 만지는 코드라 deck.service 밖으로 빼 두고, 값을 읽고 쓰는 통로만
6
+ * `DockHost` 로 받는다 (deck.service 가 config 와 relayout 을 쥐고 있으므로).
7
+ */
8
+ export type DockSide = 'left' | 'right' | 'top' | 'bottom';
9
+ export declare const DOCK_SIDES: DockSide[];
10
+ export declare function isDockSide(v: any): v is DockSide;
11
+ /** 좌/우 도킹이면 폭, 상/하 도킹이면 높이를 다룬다 */
12
+ export declare function isHorizontalDock(side: DockSide): boolean;
13
+ export interface DockHost {
14
+ /** 사이드바 엘리먼트 */
15
+ sidebar: HTMLElement;
16
+ /** 사이드바가 얹혀 있는 `.window` */
17
+ windowEl: HTMLElement;
18
+ getDock(): DockSide;
19
+ setDock(side: DockSide): void;
20
+ /**
21
+ * 그 방향으로 붙였을 때의 크기 — 좌/우면 폭, 상/하면 높이.
22
+ * 방향마다 저장된 값이 다르므로 미리보기도 물어보는 방향 기준으로 그린다.
23
+ */
24
+ getSize(side: DockSide): number;
25
+ setSize(px: number): void;
26
+ /** 드래그가 끝났을 때 한 번 — config.save() 를 부르라는 뜻 */
27
+ commit(): void;
28
+ /** 값이 바뀌었으니 다시 그리라는 뜻 */
29
+ relayout(): void;
30
+ }
31
+ export declare class DockController {
32
+ private host;
33
+ private handle;
34
+ private overlay;
35
+ private preview;
36
+ private zones;
37
+ private resizing;
38
+ private dragging;
39
+ private dragArmed;
40
+ private dragFrom;
41
+ private hoverSide;
42
+ constructor(host: DockHost);
43
+ /** 사이드바가 만들어진 뒤 한 번 부른다 */
44
+ install(): void;
45
+ /** 도킹 방향이 바뀌면 핸들 위치도 반대편으로 옮겨야 한다 */
46
+ syncHandle(): void;
47
+ private installResizeHandle;
48
+ private installDockDrag;
49
+ private showOverlay;
50
+ /** 존 버튼을 `.window` 네 가장자리 안쪽에 놓는다 (창이 전체화면이 아닐 수도 있으므로 매번 잰다) */
51
+ private positionZones;
52
+ /** 커서 위치로 어느 쪽에 붙일지 정하고 미리보기를 그린다 */
53
+ private updateHover;
54
+ /**
55
+ * 존 버튼 위에 있으면 그쪽, 아니면 커서가 창의 어느 가장자리 띠에 들어와 있는지로 정한다.
56
+ * 가운데에 있으면 null — 놓아도 아무 일이 없다.
57
+ */
58
+ private hitTest;
59
+ /** 놓으면 사이드바가 어디에 얼마만큼 자리잡는지 반투명 박스로 미리 보여준다 */
60
+ private drawPreview;
61
+ private hideOverlay;
62
+ }