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/index.js CHANGED
@@ -1,13 +1,13 @@
1
1
  (function webpackUniversalModuleDefinition(root, factory) {
2
2
  if(typeof exports === 'object' && typeof module === 'object')
3
- module.exports = factory(require("@angular/common"), require("@angular/core"), (function webpackLoadOptionalExternalModule() { try { return require("electron"); } catch(e) {} }()), require("fs"), require("os"), require("path"), require("rxjs"), require("tabby-core"), require("tabby-settings"), require("tabby-terminal"));
3
+ module.exports = factory(require("@angular/common"), require("@angular/core"), require("@angular/forms"), (function webpackLoadOptionalExternalModule() { try { return require("electron"); } catch(e) {} }()), require("fs"), require("os"), require("path"), require("rxjs"), require("tabby-core"), require("tabby-settings"), require("tabby-terminal"));
4
4
  else if(typeof define === 'function' && define.amd)
5
- define(["@angular/common", "@angular/core", "electron", "fs", "os", "path", "rxjs", "tabby-core", "tabby-settings", "tabby-terminal"], factory);
5
+ define(["@angular/common", "@angular/core", "@angular/forms", "electron", "fs", "os", "path", "rxjs", "tabby-core", "tabby-settings", "tabby-terminal"], factory);
6
6
  else {
7
- var a = typeof exports === 'object' ? factory(require("@angular/common"), require("@angular/core"), (function webpackLoadOptionalExternalModule() { try { return require("electron"); } catch(e) {} }()), require("fs"), require("os"), require("path"), require("rxjs"), require("tabby-core"), require("tabby-settings"), require("tabby-terminal")) : factory(root["@angular/common"], root["@angular/core"], root["electron"], root["fs"], root["os"], root["path"], root["rxjs"], root["tabby-core"], root["tabby-settings"], root["tabby-terminal"]);
7
+ var a = typeof exports === 'object' ? factory(require("@angular/common"), require("@angular/core"), require("@angular/forms"), (function webpackLoadOptionalExternalModule() { try { return require("electron"); } catch(e) {} }()), require("fs"), require("os"), require("path"), require("rxjs"), require("tabby-core"), require("tabby-settings"), require("tabby-terminal")) : factory(root["@angular/common"], root["@angular/core"], root["@angular/forms"], root["electron"], root["fs"], root["os"], root["path"], root["rxjs"], root["tabby-core"], root["tabby-settings"], root["tabby-terminal"]);
8
8
  for(var i in a) (typeof exports === 'object' ? exports : root)[i] = a[i];
9
9
  }
10
- })(global, (__WEBPACK_EXTERNAL_MODULE__angular_common__, __WEBPACK_EXTERNAL_MODULE__angular_core__, __WEBPACK_EXTERNAL_MODULE_electron__, __WEBPACK_EXTERNAL_MODULE_fs__, __WEBPACK_EXTERNAL_MODULE_os__, __WEBPACK_EXTERNAL_MODULE_path__, __WEBPACK_EXTERNAL_MODULE_rxjs__, __WEBPACK_EXTERNAL_MODULE_tabby_core__, __WEBPACK_EXTERNAL_MODULE_tabby_settings__, __WEBPACK_EXTERNAL_MODULE_tabby_terminal__) => {
10
+ })(global, (__WEBPACK_EXTERNAL_MODULE__angular_common__, __WEBPACK_EXTERNAL_MODULE__angular_core__, __WEBPACK_EXTERNAL_MODULE__angular_forms__, __WEBPACK_EXTERNAL_MODULE_electron__, __WEBPACK_EXTERNAL_MODULE_fs__, __WEBPACK_EXTERNAL_MODULE_os__, __WEBPACK_EXTERNAL_MODULE_path__, __WEBPACK_EXTERNAL_MODULE_rxjs__, __WEBPACK_EXTERNAL_MODULE_tabby_core__, __WEBPACK_EXTERNAL_MODULE_tabby_settings__, __WEBPACK_EXTERNAL_MODULE_tabby_terminal__) => {
11
11
  return /******/ (() => { // webpackBootstrap
12
12
  /******/ "use strict";
13
13
  /******/ var __webpack_modules__ = ({
@@ -31,19 +31,47 @@ __webpack_require__.r(__webpack_exports__);
31
31
 
32
32
  var ___CSS_LOADER_EXPORT___ = _node_modules_css_loader_dist_runtime_api_js__WEBPACK_IMPORTED_MODULE_1___default()((_node_modules_css_loader_dist_runtime_sourceMaps_js__WEBPACK_IMPORTED_MODULE_0___default()));
33
33
  // Module
34
- ___CSS_LOADER_EXPORT___.push([module.id, `#agentdeck-sidebar {
34
+ ___CSS_LOADER_EXPORT___.push([module.id, `body.agentdeck-active.ad-bg-clip .content-tab-active::before {
35
+ left: var(--ad-bg-left) !important;
36
+ top: var(--ad-bg-top) !important;
37
+ right: auto !important;
38
+ bottom: auto !important;
39
+ width: var(--ad-bg-width) !important;
40
+ height: var(--ad-bg-height) !important;
41
+ }
42
+
43
+ #agentdeck-sidebar {
35
44
  display: flex;
36
45
  flex-direction: column;
37
- flex: 0 0 auto;
38
- height: 100%;
39
- min-width: 180px;
46
+ z-index: 5;
40
47
  overflow: hidden;
41
48
  background: var(--ad-panel-bg, var(--bs-body-bg, #1e2127));
42
- border-left: 1px solid rgba(255, 255, 255, 0.08);
43
49
  font-size: 12px;
44
50
  user-select: none;
45
51
  -webkit-app-region: no-drag;
46
52
  }
53
+ #agentdeck-sidebar.ad-dock-right {
54
+ border-left: 1px solid rgba(255, 255, 255, 0.08);
55
+ }
56
+ #agentdeck-sidebar.ad-dock-left {
57
+ border-right: 1px solid rgba(255, 255, 255, 0.08);
58
+ }
59
+ #agentdeck-sidebar.ad-dock-top {
60
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
61
+ }
62
+ #agentdeck-sidebar.ad-dock-bottom {
63
+ border-top: 1px solid rgba(255, 255, 255, 0.08);
64
+ }
65
+ #agentdeck-sidebar.ad-dock-top .ad-list, #agentdeck-sidebar.ad-dock-bottom .ad-list {
66
+ display: flex;
67
+ flex-wrap: wrap;
68
+ align-content: flex-start;
69
+ gap: 0 6px;
70
+ overflow-y: auto;
71
+ }
72
+ #agentdeck-sidebar.ad-dock-top .ad-tab, #agentdeck-sidebar.ad-dock-bottom .ad-tab {
73
+ flex: 0 0 220px;
74
+ }
47
75
  #agentdeck-sidebar .ad-head {
48
76
  display: flex;
49
77
  align-items: center;
@@ -53,7 +81,46 @@ ___CSS_LOADER_EXPORT___.push([module.id, `#agentdeck-sidebar {
53
81
  font-size: 10px;
54
82
  font-weight: 700;
55
83
  color: rgba(255, 255, 255, 0.4);
56
- -webkit-app-region: drag;
84
+ -webkit-app-region: no-drag;
85
+ cursor: grab;
86
+ }
87
+ #agentdeck-sidebar .ad-head:active {
88
+ cursor: grabbing;
89
+ }
90
+ #agentdeck-sidebar .ad-resize {
91
+ position: absolute;
92
+ z-index: 6;
93
+ background: transparent;
94
+ transition: background 0.12s ease;
95
+ }
96
+ #agentdeck-sidebar .ad-resize:hover, body.ad-resizing #agentdeck-sidebar .ad-resize {
97
+ background: rgba(97, 175, 239, 0.5);
98
+ }
99
+ #agentdeck-sidebar .ad-resize-right,
100
+ #agentdeck-sidebar .ad-resize-left {
101
+ top: 0;
102
+ bottom: 0;
103
+ width: 6px;
104
+ cursor: col-resize;
105
+ }
106
+ #agentdeck-sidebar .ad-resize-right {
107
+ left: 0;
108
+ }
109
+ #agentdeck-sidebar .ad-resize-left {
110
+ right: 0;
111
+ }
112
+ #agentdeck-sidebar .ad-resize-top,
113
+ #agentdeck-sidebar .ad-resize-bottom {
114
+ left: 0;
115
+ right: 0;
116
+ height: 6px;
117
+ cursor: row-resize;
118
+ }
119
+ #agentdeck-sidebar .ad-resize-bottom {
120
+ top: 0;
121
+ }
122
+ #agentdeck-sidebar .ad-resize-top {
123
+ bottom: 0;
57
124
  }
58
125
  #agentdeck-sidebar .ad-head-count {
59
126
  -webkit-app-region: no-drag;
@@ -136,12 +203,24 @@ ___CSS_LOADER_EXPORT___.push([module.id, `#agentdeck-sidebar {
136
203
  text-decoration: underline dotted;
137
204
  text-underline-offset: 2px;
138
205
  }
206
+ #agentdeck-sidebar .ad-prompt {
207
+ display: flex;
208
+ align-items: center;
209
+ margin-top: 2px;
210
+ min-width: 0;
211
+ font-size: 11px;
212
+ line-height: 1.4;
213
+ }
139
214
  #agentdeck-sidebar .ad-label {
215
+ flex: 1 1 auto;
140
216
  overflow: hidden;
141
217
  text-overflow: ellipsis;
142
218
  white-space: nowrap;
143
219
  color: rgba(255, 255, 255, 0.6);
144
220
  }
221
+ #agentdeck-sidebar .ad-label.empty {
222
+ color: rgba(255, 255, 255, 0.25);
223
+ }
145
224
  #agentdeck-sidebar .ad-elapsed {
146
225
  flex: 0 0 auto;
147
226
  margin-left: auto;
@@ -202,6 +281,12 @@ ___CSS_LOADER_EXPORT___.push([module.id, `#agentdeck-sidebar {
202
281
  background: rgba(255, 255, 255, 0.08);
203
282
  color: #fff;
204
283
  }
284
+ #agentdeck-sidebar .ad-repair {
285
+ flex: 0 0 auto;
286
+ min-width: 28px;
287
+ font-size: 13px;
288
+ line-height: 1;
289
+ }
205
290
 
206
291
  .ad-menu {
207
292
  position: fixed;
@@ -226,14 +311,74 @@ ___CSS_LOADER_EXPORT___.push([module.id, `#agentdeck-sidebar {
226
311
  color: #fff;
227
312
  }
228
313
 
314
+ .ad-dock-overlay {
315
+ position: fixed;
316
+ inset: 0;
317
+ z-index: 9998;
318
+ pointer-events: none;
319
+ }
320
+
321
+ .ad-dock-preview {
322
+ position: fixed;
323
+ display: none;
324
+ border: 1px solid rgba(97, 175, 239, 0.9);
325
+ background: rgba(97, 175, 239, 0.22);
326
+ box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.18);
327
+ transition: left 0.08s ease, top 0.08s ease, width 0.08s ease, height 0.08s ease;
328
+ }
329
+
330
+ .ad-dock-zone {
331
+ position: fixed;
332
+ width: 46px;
333
+ height: 46px;
334
+ margin: -23px 0 0 -23px;
335
+ border: 1px solid rgba(255, 255, 255, 0.35);
336
+ border-radius: 6px;
337
+ background: rgba(38, 42, 49, 0.92);
338
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45);
339
+ display: flex;
340
+ align-items: center;
341
+ justify-content: center;
342
+ }
343
+ .ad-dock-zone .ad-dock-mark {
344
+ width: 26px;
345
+ height: 26px;
346
+ border: 1px solid rgba(255, 255, 255, 0.45);
347
+ border-radius: 3px;
348
+ }
349
+ .ad-dock-zone.active {
350
+ border-color: rgba(97, 175, 239, 0.95);
351
+ background: rgba(97, 175, 239, 0.35);
352
+ }
353
+ .ad-dock-zone.ad-dock-zone-left .ad-dock-mark {
354
+ border-left: 4px solid rgba(97, 175, 239, 0.95);
355
+ }
356
+ .ad-dock-zone.ad-dock-zone-right .ad-dock-mark {
357
+ border-right: 4px solid rgba(97, 175, 239, 0.95);
358
+ }
359
+ .ad-dock-zone.ad-dock-zone-top .ad-dock-mark {
360
+ border-top: 4px solid rgba(97, 175, 239, 0.95);
361
+ }
362
+ .ad-dock-zone.ad-dock-zone-bottom .ad-dock-mark {
363
+ border-bottom: 4px solid rgba(97, 175, 239, 0.95);
364
+ }
365
+
366
+ body.ad-resizing {
367
+ cursor: inherit;
368
+ }
369
+ body.ad-resizing .content.main {
370
+ pointer-events: none;
371
+ }
372
+
229
373
  body.agentdeck-active .content.main > .tab-bar {
230
374
  display: none !important;
231
375
  }
232
376
  body.agentdeck-active .window {
233
377
  display: flex;
234
378
  flex-direction: row;
379
+ position: relative;
235
380
  background: var(--ad-panel-bg, var(--bs-body-bg, #1e2127));
236
- }`, "",{"version":3,"sources":["webpack://./src/styles.scss"],"names":[],"mappings":"AAGA;EACI,aAAA;EACA,sBAAA;EACA,cAAA;EACA,YAAA;EACA,gBAAA;EACA,gBAAA;EAEA,0DAAA;EACA,gDAAA;EACA,eAAA;EACA,iBAAA;EACA,2BAAA;AAHJ;AAKI;EACI,aAAA;EACA,mBAAA;EACA,8BAAA;EACA,sBAAA;EACA,sBAAA;EACA,eAAA;EACA,gBAAA;EACA,+BAAA;EAEA,wBAAA;AAJR;AAOI;EACI,2BAAA;EACA,gBAAA;EACA,kBAAA;EACA,qCAAA;EACA,gCAAA;AALR;AASI;EACI,cAAA;EACA,gBAAA;EACA,kBAAA;EACA,oBAAA;AAPR;AASQ;EACI,UAAA;AAPZ;AAUQ;EACI,qCAAA;EACA,kBAAA;AARZ;AAUY;EACI,qCAAA;AARhB;AAaI;EACI,kBAAA;EACA,aAAA;EACA,oBAAA;EACA,QAAA;EACA,kBAAA;EACA,sBAAA;EACA,kBAAA;EACA,eAAA;EACA,gCAAA;AAXR;AAaQ;EACI,qCAAA;AAXZ;AAaY;EACI,aAAA;AAXhB;AAeQ;EACI,oCAAA;AAbZ;AAeY;EACI,WAAA;AAbhB;AAmBI;EACI,aAAA;EACA,kBAAA;EACA,oCAAA;AAjBR;AAoBI;EACI,cAAA;EACA,YAAA;AAlBR;AAqBI;EACI,gBAAA;EACA,uBAAA;EACA,mBAAA;EACA,gCAAA;EACA,iBAAA;EACA,iBAAA;AAnBR;AAsBI;EACI,aAAA;EACA,mBAAA;EACA,QAAA;EACA,eAAA;EACA,iBAAA;EACA,gBAAA;EACA,gCAAA;AApBR;AAuBI;EACI,cAAA;EACA,+BAAA;EACA,gBAAA;AArBR;AAwBQ;EACI,iCAAA;EACA,0BAAA;AAtBZ;AA0BI;EACI,gBAAA;EACA,uBAAA;EACA,mBAAA;EACA,+BAAA;AAxBR;AA2BI;EACI,cAAA;EACA,iBAAA;EACA,kCAAA;EACA,aAAA;AAzBR;AA4BI;EACI,cAAA;EACA,YAAA;EACA,cAAA;EACA,2CAAA;EACA,kBAAA;EACA,+BAAA;EACA,WAAA;EACA,iBAAA;EACA,aAAA;AA1BR;AA4BQ;EACI,sCAAA;AA1BZ;AA8BI;EACI,cAAA;EACA,kBAAA;EACA,WAAA;EACA,YAAA;EACA,UAAA;EACA,SAAA;EACA,kBAAA;EACA,UAAA;EACA,uBAAA;EACA,+BAAA;EACA,eAAA;EACA,cAAA;EACA,eAAA;EACA,mDAAA;AA5BR;AA8BQ;EACI,qBAAA;EACA,mCAAA;AA5BZ;AAgCI;EACI,aAAA;EACA,QAAA;EACA,YAAA;EACA,+CAAA;AA9BR;AAiCI;EACI,cAAA;EACA,gBAAA;EACA,2CAAA;EACA,kBAAA;EACA,uBAAA;EACA,+BAAA;EACA,eAAA;EACA,eAAA;AA/BR;AAiCQ;EACI,qCAAA;EACA,WAAA;AA/BZ;;AAqCA;EACI,eAAA;EACA,cAAA;EACA,gBAAA;EACA,YAAA;EACA,2CAAA;EACA,kBAAA;EACA,mBAAA;EACA,yCAAA;EACA,eAAA;EACA,iBAAA;AAlCJ;AAoCI;EACI,iBAAA;EACA,kBAAA;EACA,gCAAA;EACA,eAAA;AAlCR;AAoCQ;EACI,oCAAA;EACA,WAAA;AAlCZ;;AAyCI;EACI,wBAAA;AAtCR;AAyCI;EACI,aAAA;EACA,mBAAA;EAGA,0DAAA;AAzCR","sourcesContent":["// AgentDeck 사이드바 스타일\n// Tabby 테마 변수(--bs-*)를 우선 쓰되, 없을 때를 대비해 폴백 색을 함께 준다.\n\n#agentdeck-sidebar {\n display: flex;\n flex-direction: column;\n flex: 0 0 auto;\n height: 100%;\n min-width: 180px;\n overflow: hidden;\n // --ad-panel-bg 는 agentdeck 이 sidebarOpacity 로 내려준다 (배경 이미지가 비치게)\n background: var(--ad-panel-bg, var(--bs-body-bg, #1e2127));\n border-left: 1px solid rgba(255, 255, 255, 0.08);\n font-size: 12px;\n user-select: none;\n -webkit-app-region: no-drag;\n\n .ad-head {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 10px 12px 8px;\n letter-spacing: 0.12em;\n font-size: 10px;\n font-weight: 700;\n color: rgba(255, 255, 255, 0.4);\n // 창 상단을 잡아 끌 수 있게 (프레임리스 대비)\n -webkit-app-region: drag;\n }\n\n .ad-head-count {\n -webkit-app-region: no-drag;\n padding: 1px 6px;\n border-radius: 8px;\n background: rgba(255, 255, 255, 0.08);\n color: rgba(255, 255, 255, 0.55);\n }\n\n // 요구 2-1: 탭이 많아지면 이 영역만 스크롤된다\n .ad-list {\n flex: 1 1 auto;\n overflow-y: auto;\n overflow-x: hidden;\n padding: 2px 6px 8px;\n\n &::-webkit-scrollbar {\n width: 8px;\n }\n\n &::-webkit-scrollbar-thumb {\n background: rgba(255, 255, 255, 0.14);\n border-radius: 4px;\n\n &:hover {\n background: rgba(255, 255, 255, 0.24);\n }\n }\n }\n\n .ad-tab {\n position: relative;\n display: flex;\n align-items: stretch;\n gap: 8px;\n margin-bottom: 3px;\n padding: 7px 6px 7px 0;\n border-radius: 5px;\n cursor: pointer;\n transition: background 0.1s ease;\n\n &:hover {\n background: rgba(255, 255, 255, 0.05);\n\n .ad-close {\n opacity: 0.65;\n }\n }\n\n &.active {\n background: rgba(255, 255, 255, 0.1);\n\n .ad-title {\n color: #fff;\n }\n }\n }\n\n // 상태색 표시 바\n .ad-bar {\n flex: 0 0 3px;\n border-radius: 2px;\n background: var(--ad-color, #6b727d);\n }\n\n .ad-body {\n flex: 1 1 auto;\n min-width: 0;\n }\n\n .ad-title {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n color: rgba(255, 255, 255, 0.82);\n font-size: 12.5px;\n line-height: 1.35;\n }\n\n .ad-meta {\n display: flex;\n align-items: center;\n gap: 6px;\n margin-top: 2px;\n font-size: 10.5px;\n line-height: 1.4;\n color: rgba(255, 255, 255, 0.45);\n }\n\n .ad-badge {\n flex: 0 0 auto;\n color: var(--ad-color, #6b727d);\n font-weight: 600;\n\n // 수동/훅으로 고정된 상태는 밑줄로 구분\n &.pinned {\n text-decoration: underline dotted;\n text-underline-offset: 2px;\n }\n }\n\n .ad-label {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n color: rgba(255, 255, 255, 0.6);\n }\n\n .ad-elapsed {\n flex: 0 0 auto;\n margin-left: auto;\n font-variant-numeric: tabular-nums;\n opacity: 0.55;\n }\n\n .ad-label-input {\n flex: 1 1 auto;\n min-width: 0;\n padding: 0 4px;\n border: 1px solid rgba(255, 255, 255, 0.25);\n border-radius: 3px;\n background: rgba(0, 0, 0, 0.35);\n color: #fff;\n font-size: 10.5px;\n outline: none;\n\n &:focus {\n border-color: var(--ad-color, #61afef);\n }\n }\n\n .ad-close {\n flex: 0 0 auto;\n align-self: center;\n width: 18px;\n height: 18px;\n padding: 0;\n border: 0;\n border-radius: 3px;\n opacity: 0;\n background: transparent;\n color: rgba(255, 255, 255, 0.8);\n font-size: 15px;\n line-height: 1;\n cursor: pointer;\n transition: opacity 0.1s ease, background 0.1s ease;\n\n &:hover {\n opacity: 1 !important;\n background: rgba(255, 80, 80, 0.35);\n }\n }\n\n .ad-foot {\n display: flex;\n gap: 6px;\n padding: 8px;\n border-top: 1px solid rgba(255, 255, 255, 0.07);\n }\n\n .ad-btn {\n flex: 1 1 auto;\n padding: 5px 8px;\n border: 1px solid rgba(255, 255, 255, 0.12);\n border-radius: 4px;\n background: transparent;\n color: rgba(255, 255, 255, 0.6);\n font-size: 11px;\n cursor: pointer;\n\n &:hover {\n background: rgba(255, 255, 255, 0.08);\n color: #fff;\n }\n }\n}\n\n// 우클릭 상태 메뉴\n.ad-menu {\n position: fixed;\n z-index: 10000;\n min-width: 130px;\n padding: 4px;\n border: 1px solid rgba(255, 255, 255, 0.12);\n border-radius: 6px;\n background: #262a31;\n box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5);\n font-size: 12px;\n user-select: none;\n\n .ad-menu-item {\n padding: 6px 10px;\n border-radius: 4px;\n color: rgba(255, 255, 255, 0.75);\n cursor: pointer;\n\n &:hover {\n background: rgba(255, 255, 255, 0.1);\n color: #fff;\n }\n }\n}\n\n// AgentDeck 이 켜져 있을 때만 순정 탭바를 감춘다 (사이드바가 그 역할을 대신함)\nbody.agentdeck-active {\n .content.main > .tab-bar {\n display: none !important;\n }\n\n .window {\n display: flex;\n flex-direction: row;\n // 4:3 터미널 왼쪽에 남는 여백이 밝은 기본 배경으로 튀지 않게 한다\n // (--ad-panel-bg 가 있으면 배경 이미지가 이 여백으로도 비친다)\n background: var(--ad-panel-bg, var(--bs-body-bg, #1e2127));\n }\n}\n"],"sourceRoot":""}]);
381
+ }`, "",{"version":3,"sources":["webpack://./src/styles.scss"],"names":[],"mappings":"AAMA;EACI,kCAAA;EACA,gCAAA;EACA,sBAAA;EACA,uBAAA;EACA,oCAAA;EACA,sCAAA;AALJ;;AAQA;EACI,aAAA;EACA,sBAAA;EAEA,UAAA;EACA,gBAAA;EAEA,0DAAA;EACA,eAAA;EACA,iBAAA;EACA,2BAAA;AAPJ;AAUI;EAAkB,gDAAA;AAPtB;AAQI;EAAiB,iDAAA;AALrB;AAMI;EAAgB,kDAAA;AAHpB;AAII;EAAmB,+CAAA;AADvB;AAMQ;EACI,aAAA;EACA,eAAA;EACA,yBAAA;EACA,UAAA;EACA,gBAAA;AAJZ;AAOQ;EACI,eAAA;AALZ;AASI;EACI,aAAA;EACA,mBAAA;EACA,8BAAA;EACA,sBAAA;EACA,sBAAA;EACA,eAAA;EACA,gBAAA;EACA,+BAAA;EAEA,2BAAA;EACA,YAAA;AARR;AAUQ;EACI,gBAAA;AARZ;AAaI;EACI,kBAAA;EACA,UAAA;EACA,uBAAA;EACA,iCAAA;AAXR;AAaQ;EAEI,mCAAA;AAZZ;AAgBI;;EAEI,MAAA;EACA,SAAA;EACA,UAAA;EACA,kBAAA;AAdR;AAiBI;EAAmB,OAAA;AAdvB;AAeI;EAAkB,QAAA;AAZtB;AAcI;;EAEI,OAAA;EACA,QAAA;EACA,WAAA;EACA,kBAAA;AAZR;AAeI;EAAoB,MAAA;AAZxB;AAaI;EAAiB,SAAA;AAVrB;AAYI;EACI,2BAAA;EACA,gBAAA;EACA,kBAAA;EACA,qCAAA;EACA,gCAAA;AAVR;AAcI;EACI,cAAA;EACA,gBAAA;EACA,kBAAA;EACA,oBAAA;AAZR;AAcQ;EACI,UAAA;AAZZ;AAeQ;EACI,qCAAA;EACA,kBAAA;AAbZ;AAeY;EACI,qCAAA;AAbhB;AAkBI;EACI,kBAAA;EACA,aAAA;EACA,oBAAA;EACA,QAAA;EACA,kBAAA;EACA,sBAAA;EACA,kBAAA;EACA,eAAA;EACA,gCAAA;AAhBR;AAkBQ;EACI,qCAAA;AAhBZ;AAkBY;EACI,aAAA;AAhBhB;AAoBQ;EACI,oCAAA;AAlBZ;AAoBY;EACI,WAAA;AAlBhB;AAwBI;EACI,aAAA;EACA,kBAAA;EACA,oCAAA;AAtBR;AAyBI;EACI,cAAA;EACA,YAAA;AAvBR;AA0BI;EACI,gBAAA;EACA,uBAAA;EACA,mBAAA;EACA,gCAAA;EACA,iBAAA;EACA,iBAAA;AAxBR;AA2BI;EACI,aAAA;EACA,mBAAA;EACA,QAAA;EACA,eAAA;EACA,iBAAA;EACA,gBAAA;EACA,gCAAA;AAzBR;AA4BI;EACI,cAAA;EACA,+BAAA;EACA,gBAAA;AA1BR;AA6BQ;EACI,iCAAA;EACA,0BAAA;AA3BZ;AAgCI;EACI,aAAA;EACA,mBAAA;EACA,eAAA;EACA,YAAA;EACA,eAAA;EACA,gBAAA;AA9BR;AAiCI;EACI,cAAA;EACA,gBAAA;EACA,uBAAA;EACA,mBAAA;EACA,+BAAA;AA/BR;AAkCQ;EACI,gCAAA;AAhCZ;AAoCI;EACI,cAAA;EACA,iBAAA;EACA,kCAAA;EACA,aAAA;AAlCR;AAqCI;EACI,cAAA;EACA,YAAA;EACA,cAAA;EACA,2CAAA;EACA,kBAAA;EACA,+BAAA;EACA,WAAA;EACA,iBAAA;EACA,aAAA;AAnCR;AAqCQ;EACI,sCAAA;AAnCZ;AAuCI;EACI,cAAA;EACA,kBAAA;EACA,WAAA;EACA,YAAA;EACA,UAAA;EACA,SAAA;EACA,kBAAA;EACA,UAAA;EACA,uBAAA;EACA,+BAAA;EACA,eAAA;EACA,cAAA;EACA,eAAA;EACA,mDAAA;AArCR;AAuCQ;EACI,qBAAA;EACA,mCAAA;AArCZ;AAyCI;EACI,aAAA;EACA,QAAA;EACA,YAAA;EACA,+CAAA;AAvCR;AA0CI;EACI,cAAA;EACA,gBAAA;EACA,2CAAA;EACA,kBAAA;EACA,uBAAA;EACA,+BAAA;EACA,eAAA;EACA,eAAA;AAxCR;AA0CQ;EACI,qCAAA;EACA,WAAA;AAxCZ;AA6CI;EACI,cAAA;EACA,eAAA;EACA,eAAA;EACA,cAAA;AA3CR;;AAgDA;EACI,eAAA;EACA,cAAA;EACA,gBAAA;EACA,YAAA;EACA,2CAAA;EACA,kBAAA;EACA,mBAAA;EACA,yCAAA;EACA,eAAA;EACA,iBAAA;AA7CJ;AA+CI;EACI,iBAAA;EACA,kBAAA;EACA,gCAAA;EACA,eAAA;AA7CR;AA+CQ;EACI,oCAAA;EACA,WAAA;AA7CZ;;AAmDA;EACI,eAAA;EACA,QAAA;EACA,aAAA;EAEA,oBAAA;AAjDJ;;AAqDA;EACI,eAAA;EACA,aAAA;EACA,yCAAA;EACA,oCAAA;EACA,4CAAA;EACA,gFAAA;AAlDJ;;AAsDA;EACI,eAAA;EACA,WAAA;EACA,YAAA;EACA,uBAAA;EACA,2CAAA;EACA,kBAAA;EACA,kCAAA;EACA,0CAAA;EACA,aAAA;EACA,mBAAA;EACA,uBAAA;AAnDJ;AAqDI;EACI,WAAA;EACA,YAAA;EACA,2CAAA;EACA,kBAAA;AAnDR;AAsDI;EACI,sCAAA;EACA,oCAAA;AApDR;AAwDI;EAAoC,+CAAA;AArDxC;AAsDI;EAAqC,gDAAA;AAnDzC;AAoDI;EAAmC,8CAAA;AAjDvC;AAkDI;EAAsC,iDAAA;AA/C1C;;AAmDA;EACI,eAAA;AAhDJ;AAkDI;EACI,oBAAA;AAhDR;;AAsDI;EACI,wBAAA;AAnDR;AAsDI;EACI,aAAA;EACA,mBAAA;EAEA,kBAAA;EAGA,0DAAA;AAvDR","sourcesContent":["// AgentDeck 사이드바 스타일\r\n// Tabby 테마 변수(--bs-*)를 우선 쓰되, 없을 때를 대비해 폴백 색을 함께 준다.\r\n\r\n// tabby-background 의 배경 이미지 레이어(.content-tab-active::before)를 터미널 영역으로 자른다.\r\n// 좌표 변수(--ad-bg-*)는 deck.service 의 syncBackgroundClip 이 relayout 마다 내려준다.\r\n// 그 플러그인은 런타임에 <style> 을 만들어 head 뒤쪽에 붙이므로 순서로는 못 이긴다 — !important 필수.\r\nbody.agentdeck-active.ad-bg-clip .content-tab-active::before {\r\n left: var(--ad-bg-left) !important;\r\n top: var(--ad-bg-top) !important;\r\n right: auto !important;\r\n bottom: auto !important;\r\n width: var(--ad-bg-width) !important;\r\n height: var(--ad-bg-height) !important;\r\n}\r\n\r\n#agentdeck-sidebar {\r\n display: flex;\r\n flex-direction: column;\r\n // 실제 위치와 크기는 deck.service 의 relayout 이 인라인으로 준다 (도킹 방향에 따라 달라짐)\r\n z-index: 5;\r\n overflow: hidden;\r\n // --ad-panel-bg 는 agentdeck 이 sidebarOpacity 로 내려준다 (배경 이미지가 비치게)\r\n background: var(--ad-panel-bg, var(--bs-body-bg, #1e2127));\r\n font-size: 12px;\r\n user-select: none;\r\n -webkit-app-region: no-drag;\r\n\r\n // 붙어 있는 쪽 반대편에만 경계선을 그린다\r\n &.ad-dock-right { border-left: 1px solid rgba(255, 255, 255, 0.08); }\r\n &.ad-dock-left { border-right: 1px solid rgba(255, 255, 255, 0.08); }\r\n &.ad-dock-top { border-bottom: 1px solid rgba(255, 255, 255, 0.08); }\r\n &.ad-dock-bottom { border-top: 1px solid rgba(255, 255, 255, 0.08); }\r\n\r\n // 상/하 도킹은 세로가 짧으므로 목록을 가로로 흘린다\r\n &.ad-dock-top,\r\n &.ad-dock-bottom {\r\n .ad-list {\r\n display: flex;\r\n flex-wrap: wrap;\r\n align-content: flex-start;\r\n gap: 0 6px;\r\n overflow-y: auto;\r\n }\r\n\r\n .ad-tab {\r\n flex: 0 0 220px;\r\n }\r\n }\r\n\r\n .ad-head {\r\n display: flex;\r\n align-items: center;\r\n justify-content: space-between;\r\n padding: 10px 12px 8px;\r\n letter-spacing: 0.12em;\r\n font-size: 10px;\r\n font-weight: 700;\r\n color: rgba(255, 255, 255, 0.4);\r\n // 헤더를 잡아 끌면 도킹 방향을 바꾼다 (OS 창 이동에 뺏기면 안 되므로 no-drag)\r\n -webkit-app-region: no-drag;\r\n cursor: grab;\r\n\r\n &:active {\r\n cursor: grabbing;\r\n }\r\n }\r\n\r\n // 경계선 — 잡아 끌면 크기가 바뀐다. 붙어 있는 쪽 반대편 가장자리에 놓는다\r\n .ad-resize {\r\n position: absolute;\r\n z-index: 6;\r\n background: transparent;\r\n transition: background 0.12s ease;\r\n\r\n &:hover,\r\n body.ad-resizing & {\r\n background: rgba(97, 175, 239, 0.5);\r\n }\r\n }\r\n\r\n .ad-resize-right,\r\n .ad-resize-left {\r\n top: 0;\r\n bottom: 0;\r\n width: 6px;\r\n cursor: col-resize;\r\n }\r\n\r\n .ad-resize-right { left: 0; }\r\n .ad-resize-left { right: 0; }\r\n\r\n .ad-resize-top,\r\n .ad-resize-bottom {\r\n left: 0;\r\n right: 0;\r\n height: 6px;\r\n cursor: row-resize;\r\n }\r\n\r\n .ad-resize-bottom { top: 0; }\r\n .ad-resize-top { bottom: 0; }\r\n\r\n .ad-head-count {\r\n -webkit-app-region: no-drag;\r\n padding: 1px 6px;\r\n border-radius: 8px;\r\n background: rgba(255, 255, 255, 0.08);\r\n color: rgba(255, 255, 255, 0.55);\r\n }\r\n\r\n // 요구 2-1: 탭이 많아지면 이 영역만 스크롤된다\r\n .ad-list {\r\n flex: 1 1 auto;\r\n overflow-y: auto;\r\n overflow-x: hidden;\r\n padding: 2px 6px 8px;\r\n\r\n &::-webkit-scrollbar {\r\n width: 8px;\r\n }\r\n\r\n &::-webkit-scrollbar-thumb {\r\n background: rgba(255, 255, 255, 0.14);\r\n border-radius: 4px;\r\n\r\n &:hover {\r\n background: rgba(255, 255, 255, 0.24);\r\n }\r\n }\r\n }\r\n\r\n .ad-tab {\r\n position: relative;\r\n display: flex;\r\n align-items: stretch;\r\n gap: 8px;\r\n margin-bottom: 3px;\r\n padding: 7px 6px 7px 0;\r\n border-radius: 5px;\r\n cursor: pointer;\r\n transition: background 0.1s ease;\r\n\r\n &:hover {\r\n background: rgba(255, 255, 255, 0.05);\r\n\r\n .ad-close {\r\n opacity: 0.65;\r\n }\r\n }\r\n\r\n &.active {\r\n background: rgba(255, 255, 255, 0.1);\r\n\r\n .ad-title {\r\n color: #fff;\r\n }\r\n }\r\n }\r\n\r\n // 상태색 표시 바\r\n .ad-bar {\r\n flex: 0 0 3px;\r\n border-radius: 2px;\r\n background: var(--ad-color, #6b727d);\r\n }\r\n\r\n .ad-body {\r\n flex: 1 1 auto;\r\n min-width: 0;\r\n }\r\n\r\n .ad-title {\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n white-space: nowrap;\r\n color: rgba(255, 255, 255, 0.82);\r\n font-size: 12.5px;\r\n line-height: 1.35;\r\n }\r\n\r\n .ad-meta {\r\n display: flex;\r\n align-items: center;\r\n gap: 6px;\r\n margin-top: 2px;\r\n font-size: 10.5px;\r\n line-height: 1.4;\r\n color: rgba(255, 255, 255, 0.45);\r\n }\r\n\r\n .ad-badge {\r\n flex: 0 0 auto;\r\n color: var(--ad-color, #6b727d);\r\n font-weight: 600;\r\n\r\n // 수동/훅으로 고정된 상태는 밑줄로 구분\r\n &.pinned {\r\n text-decoration: underline dotted;\r\n text-underline-offset: 2px;\r\n }\r\n }\r\n\r\n // 두 번째 줄 — 마지막으로 보낸 프롬프트\r\n .ad-prompt {\r\n display: flex;\r\n align-items: center;\r\n margin-top: 2px;\r\n min-width: 0;\r\n font-size: 11px;\r\n line-height: 1.4;\r\n }\r\n\r\n .ad-label {\r\n flex: 1 1 auto;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n white-space: nowrap;\r\n color: rgba(255, 255, 255, 0.6);\r\n\r\n // 아직 프롬프트가 없는 탭 — 자리는 지키되 눈에 덜 걸리게\r\n &.empty {\r\n color: rgba(255, 255, 255, 0.25);\r\n }\r\n }\r\n\r\n .ad-elapsed {\r\n flex: 0 0 auto;\r\n margin-left: auto;\r\n font-variant-numeric: tabular-nums;\r\n opacity: 0.55;\r\n }\r\n\r\n .ad-label-input {\r\n flex: 1 1 auto;\r\n min-width: 0;\r\n padding: 0 4px;\r\n border: 1px solid rgba(255, 255, 255, 0.25);\r\n border-radius: 3px;\r\n background: rgba(0, 0, 0, 0.35);\r\n color: #fff;\r\n font-size: 10.5px;\r\n outline: none;\r\n\r\n &:focus {\r\n border-color: var(--ad-color, #61afef);\r\n }\r\n }\r\n\r\n .ad-close {\r\n flex: 0 0 auto;\r\n align-self: center;\r\n width: 18px;\r\n height: 18px;\r\n padding: 0;\r\n border: 0;\r\n border-radius: 3px;\r\n opacity: 0;\r\n background: transparent;\r\n color: rgba(255, 255, 255, 0.8);\r\n font-size: 15px;\r\n line-height: 1;\r\n cursor: pointer;\r\n transition: opacity 0.1s ease, background 0.1s ease;\r\n\r\n &:hover {\r\n opacity: 1 !important;\r\n background: rgba(255, 80, 80, 0.35);\r\n }\r\n }\r\n\r\n .ad-foot {\r\n display: flex;\r\n gap: 6px;\r\n padding: 8px;\r\n border-top: 1px solid rgba(255, 255, 255, 0.07);\r\n }\r\n\r\n .ad-btn {\r\n flex: 1 1 auto;\r\n padding: 5px 8px;\r\n border: 1px solid rgba(255, 255, 255, 0.12);\r\n border-radius: 4px;\r\n background: transparent;\r\n color: rgba(255, 255, 255, 0.6);\r\n font-size: 11px;\r\n cursor: pointer;\r\n\r\n &:hover {\r\n background: rgba(255, 255, 255, 0.08);\r\n color: #fff;\r\n }\r\n }\r\n\r\n // 복구 버튼은 글자가 한 자뿐이라 나머지 버튼과 같은 비율로 늘어나면 허전하다\r\n .ad-repair {\r\n flex: 0 0 auto;\r\n min-width: 28px;\r\n font-size: 13px;\r\n line-height: 1;\r\n }\r\n}\r\n\r\n// 우클릭 상태 메뉴\r\n.ad-menu {\r\n position: fixed;\r\n z-index: 10000;\r\n min-width: 130px;\r\n padding: 4px;\r\n border: 1px solid rgba(255, 255, 255, 0.12);\r\n border-radius: 6px;\r\n background: #262a31;\r\n box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5);\r\n font-size: 12px;\r\n user-select: none;\r\n\r\n .ad-menu-item {\r\n padding: 6px 10px;\r\n border-radius: 4px;\r\n color: rgba(255, 255, 255, 0.75);\r\n cursor: pointer;\r\n\r\n &:hover {\r\n background: rgba(255, 255, 255, 0.1);\r\n color: #fff;\r\n }\r\n }\r\n}\r\n\r\n// 도킹 가이드 — 헤더를 끌고 있는 동안만 창 위에 뜬다\r\n.ad-dock-overlay {\r\n position: fixed;\r\n inset: 0;\r\n z-index: 9998;\r\n // 커서 판정은 좌표로 직접 하므로 오버레이가 이벤트를 가로채면 안 된다\r\n pointer-events: none;\r\n}\r\n\r\n// 놓으면 사이드바가 자리잡을 영역\r\n.ad-dock-preview {\r\n position: fixed;\r\n display: none;\r\n border: 1px solid rgba(97, 175, 239, 0.9);\r\n background: rgba(97, 175, 239, 0.22);\r\n box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.18);\r\n transition: left 0.08s ease, top 0.08s ease, width 0.08s ease, height 0.08s ease;\r\n}\r\n\r\n// 네 가장자리에 뜨는 도킹 표적\r\n.ad-dock-zone {\r\n position: fixed;\r\n width: 46px;\r\n height: 46px;\r\n margin: -23px 0 0 -23px;\r\n border: 1px solid rgba(255, 255, 255, 0.35);\r\n border-radius: 6px;\r\n background: rgba(38, 42, 49, 0.92);\r\n box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45);\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n\r\n .ad-dock-mark {\r\n width: 26px;\r\n height: 26px;\r\n border: 1px solid rgba(255, 255, 255, 0.45);\r\n border-radius: 3px;\r\n }\r\n\r\n &.active {\r\n border-color: rgba(97, 175, 239, 0.95);\r\n background: rgba(97, 175, 239, 0.35);\r\n }\r\n\r\n // 표적 안의 작은 사각형이 \"어느 쪽 절반에 붙는지\" 를 그대로 보여준다\r\n &.ad-dock-zone-left .ad-dock-mark { border-left: 4px solid rgba(97, 175, 239, 0.95); }\r\n &.ad-dock-zone-right .ad-dock-mark { border-right: 4px solid rgba(97, 175, 239, 0.95); }\r\n &.ad-dock-zone-top .ad-dock-mark { border-top: 4px solid rgba(97, 175, 239, 0.95); }\r\n &.ad-dock-zone-bottom .ad-dock-mark { border-bottom: 4px solid rgba(97, 175, 239, 0.95); }\r\n}\r\n\r\n// 경계선을 끄는 동안에는 터미널이 커서를 뺏지 않게 한다\r\nbody.ad-resizing {\r\n cursor: inherit;\r\n\r\n .content.main {\r\n pointer-events: none;\r\n }\r\n}\r\n\r\n// AgentDeck 이 켜져 있을 때만 순정 탭바를 감춘다 (사이드바가 그 역할을 대신함)\r\nbody.agentdeck-active {\r\n .content.main > .tab-bar {\r\n display: none !important;\r\n }\r\n\r\n .window {\r\n display: flex;\r\n flex-direction: row;\r\n // 사이드바를 창 안에 절대 배치하므로 기준점이 필요하다\r\n position: relative;\r\n // 터미널 옆에 남는 여백이 밝은 기본 배경으로 튀지 않게 한다\r\n // (--ad-panel-bg 가 있으면 배경 이미지가 이 여백으로도 비친다)\r\n background: var(--ad-panel-bg, var(--bs-body-bg, #1e2127));\r\n }\r\n}\r\n"],"sourceRoot":""}]);
237
382
  // Exports
238
383
  /* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (___CSS_LOADER_EXPORT___);
239
384
 
@@ -700,6 +845,198 @@ const STATUS_STYLES = {
700
845
  };
701
846
 
702
847
 
848
+ /***/ },
849
+
850
+ /***/ "./src/bind.ts"
851
+ /*!*********************!*\
852
+ !*** ./src/bind.ts ***!
853
+ \*********************/
854
+ (__unused_webpack_module, __webpack_exports__, __webpack_require__) {
855
+
856
+ __webpack_require__.r(__webpack_exports__);
857
+ /* harmony export */ __webpack_require__.d(__webpack_exports__, {
858
+ /* harmony export */ pickTabByPids: () => (/* binding */ pickTabByPids)
859
+ /* harmony export */ });
860
+ function pickTabByPids(pids, tabPids) {
861
+ const wanted = (pids !== null && pids !== void 0 ? pids : []).filter(n => Number.isInteger(n) && n > 0);
862
+ let anyInfo = false;
863
+ for (const list of tabPids.values()) {
864
+ if (list.length) {
865
+ anyInfo = true;
866
+ break;
867
+ }
868
+ }
869
+ if (!wanted.length || !anyInfo) {
870
+ return { tab: null, mode: 'legacy' };
871
+ }
872
+ const set = new Set(wanted);
873
+ for (const [tab, list] of tabPids) {
874
+ if (list.some(p => set.has(p))) {
875
+ return { tab, mode: 'pid' };
876
+ }
877
+ }
878
+ return { tab: null, mode: 'pid-miss' };
879
+ }
880
+
881
+
882
+ /***/ },
883
+
884
+ /***/ "./src/claudeHooks.ts"
885
+ /*!****************************!*\
886
+ !*** ./src/claudeHooks.ts ***!
887
+ \****************************/
888
+ (__unused_webpack_module, __webpack_exports__, __webpack_require__) {
889
+
890
+ __webpack_require__.r(__webpack_exports__);
891
+ /* harmony export */ __webpack_require__.d(__webpack_exports__, {
892
+ /* harmony export */ claudeSettingsPath: () => (/* binding */ claudeSettingsPath),
893
+ /* harmony export */ hooksInstalled: () => (/* binding */ hooksInstalled),
894
+ /* harmony export */ hooksSupported: () => (/* binding */ hooksSupported),
895
+ /* harmony export */ installHooks: () => (/* binding */ installHooks),
896
+ /* harmony export */ isMerged: () => (/* binding */ isMerged),
897
+ /* harmony export */ mergeHooks: () => (/* binding */ mergeHooks),
898
+ /* harmony export */ notifyScriptPath: () => (/* binding */ notifyScriptPath),
899
+ /* harmony export */ stripHooks: () => (/* binding */ stripHooks),
900
+ /* harmony export */ uninstallHooks: () => (/* binding */ uninstallHooks)
901
+ /* harmony export */ });
902
+ /* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! fs */ "fs");
903
+ /* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(fs__WEBPACK_IMPORTED_MODULE_0__);
904
+ /* harmony import */ var os__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! os */ "os");
905
+ /* harmony import */ var os__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(os__WEBPACK_IMPORTED_MODULE_1__);
906
+ /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! path */ "path");
907
+ /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_2__);
908
+
909
+
910
+
911
+ /**
912
+ * Claude Code 상태 통보 훅을 사용자의 Claude 설정에 등록/해제한다.
913
+ *
914
+ * 왜 플러그인이 남의 설정 파일을 만지나 — 훅은 Claude Code 쪽 설정(`~/.claude/settings.json`)에
915
+ * 있어야 하고, npm 으로 배포되는 Tabby 플러그인은 그 파일에 손댈 방법이 달리 없다.
916
+ * 그래서 "설치할지" 를 사용자가 설정 창에서 직접 고르게 하고, 고른 순간에만 쓴다.
917
+ * 쓰기 전에 항상 백업을 남기고, 남의 훅은 건드리지 않는다.
918
+ *
919
+ * 훅이 있으면 상태 판정이 정확해진다. 에이전트가 "지시를 받았다 / 승인을 기다린다 / 끝났다" 를
920
+ * 직접 알려주기 때문이다. 훅이 없으면 화면에 그려진 글자로 추측하는 폴백(detect.ts)이 도는데,
921
+ * 그건 TUI 가 화면을 어떻게 그리는지에 기대는 만큼 흔들린다.
922
+ */
923
+ /** 우리 훅인지 알아보는 표식 — 파일 이름이 그대로 명령줄에 들어간다 */
924
+ const MARK = 'agentdeck-notify';
925
+ /**
926
+ * 어떤 순간에 무엇을 보고할지.
927
+ *
928
+ * **상태만 보낸다 — 작업 이름은 여기서 보내지 않는다.** 라벨은 플러그인이 Enter 시점에
929
+ * 입력창을 읽어 매번 갱신하는 경로(`claimEnterLabel`)가 전담한다. 예전에는 여기에
930
+ * `-PromptAsLabel` 이 붙어 있었는데, 훅이 상태 파일의 직전 라벨을 도로 써 넣는 바람에
931
+ * 플러그인이 방금 넣은 최신 프롬프트를 옛 값으로 되돌렸다 (2026-09-01 실측).
932
+ */
933
+ const PLAN = [
934
+ // 지시를 받았다
935
+ { event: 'UserPromptSubmit', args: '-Status running' },
936
+ // 승인을 기다린다
937
+ { event: 'Notification', args: '-Status waiting' },
938
+ // 답이 끝났다
939
+ { event: 'Stop', args: '-Status done' },
940
+ ];
941
+ function claudeSettingsPath() {
942
+ return path__WEBPACK_IMPORTED_MODULE_2__.join(os__WEBPACK_IMPORTED_MODULE_1__.homedir(), '.claude', 'settings.json');
943
+ }
944
+ /** 훅 스크립트의 실제 경로. dist/index.js 기준으로 한 단계 위가 패키지 뿌리다 */
945
+ function notifyScriptPath() {
946
+ return path__WEBPACK_IMPORTED_MODULE_2__.join(__dirname, '..', 'hooks', 'agentdeck-notify.ps1');
947
+ }
948
+ /** 이 환경에서 훅을 걸 수 있나 — 통보 스크립트가 PowerShell 이라 Windows 전용이다 */
949
+ function hooksSupported() {
950
+ return process.platform === 'win32' && fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(notifyScriptPath());
951
+ }
952
+ function readSettings() {
953
+ try {
954
+ const raw = fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(claudeSettingsPath(), 'utf8');
955
+ return raw.trim() ? JSON.parse(raw) : {};
956
+ }
957
+ catch {
958
+ // 파일이 없거나 아직 JSON 이 아니다 — 새로 만드는 것으로 본다
959
+ return {};
960
+ }
961
+ }
962
+ /** 이 이벤트에 걸린 훅 중 우리 것이 아닌 것만 남긴다 */
963
+ function withoutOurs(entries) {
964
+ return (Array.isArray(entries) ? entries : [])
965
+ .filter(e => !JSON.stringify(e !== null && e !== void 0 ? e : '').includes(MARK));
966
+ }
967
+ /**
968
+ * 우리 훅이 세 이벤트에 모두 걸려 있나 — 파일을 읽지 않는 순수 판정.
969
+ * 파일 접근과 분리해 둔 이유는 이 판단을 테스트로 묶어 두기 위해서다.
970
+ */
971
+ function isMerged(settings) {
972
+ var _a;
973
+ const hooks = (_a = settings === null || settings === void 0 ? void 0 : settings.hooks) !== null && _a !== void 0 ? _a : {};
974
+ return PLAN.every(p => {
975
+ const entries = Array.isArray(hooks[p.event]) ? hooks[p.event] : [];
976
+ return entries.some((e) => JSON.stringify(e !== null && e !== void 0 ? e : '').includes(MARK));
977
+ });
978
+ }
979
+ /**
980
+ * 설정 객체에 우리 훅을 더한 새 객체를 돌려준다 (원본은 건드리지 않는다).
981
+ * 이미 우리 것이 있으면 명령줄만 지금 경로로 갈아끼우고, 남이 걸어 둔 훅은 그대로 둔다.
982
+ */
983
+ function mergeHooks(settings, script) {
984
+ var _a;
985
+ const next = { ...(settings !== null && settings !== void 0 ? settings : {}) };
986
+ next.hooks = { ...((_a = next.hooks) !== null && _a !== void 0 ? _a : {}) };
987
+ for (const p of PLAN) {
988
+ const command = `powershell -NoProfile -ExecutionPolicy Bypass -File "${script}" ${p.args}`;
989
+ next.hooks[p.event] = [
990
+ ...withoutOurs(next.hooks[p.event]),
991
+ { matcher: '*', hooks: [{ type: 'command', command, timeout: 5 }] },
992
+ ];
993
+ }
994
+ return next;
995
+ }
996
+ /** 우리 훅만 걷어낸 새 객체. 비어 버린 이벤트 키는 지워서 설정을 깨끗하게 남긴다 */
997
+ function stripHooks(settings) {
998
+ const next = { ...(settings !== null && settings !== void 0 ? settings : {}) };
999
+ if (!next.hooks) {
1000
+ return next;
1001
+ }
1002
+ next.hooks = { ...next.hooks };
1003
+ for (const p of PLAN) {
1004
+ const kept = withoutOurs(next.hooks[p.event]);
1005
+ if (kept.length) {
1006
+ next.hooks[p.event] = kept;
1007
+ }
1008
+ else {
1009
+ delete next.hooks[p.event];
1010
+ }
1011
+ }
1012
+ return next;
1013
+ }
1014
+ function hooksInstalled() {
1015
+ return isMerged(readSettings());
1016
+ }
1017
+ /** 훅을 등록한다 */
1018
+ function installHooks() {
1019
+ const script = notifyScriptPath();
1020
+ if (!fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(script)) {
1021
+ throw new Error(`훅 스크립트를 찾지 못했다: ${script}`);
1022
+ }
1023
+ writeSettings(mergeHooks(readSettings(), script));
1024
+ }
1025
+ /** 훅을 뗀다 */
1026
+ function uninstallHooks() {
1027
+ writeSettings(stripHooks(readSettings()));
1028
+ }
1029
+ function writeSettings(settings) {
1030
+ const file = claudeSettingsPath();
1031
+ fs__WEBPACK_IMPORTED_MODULE_0__.mkdirSync(path__WEBPACK_IMPORTED_MODULE_2__.dirname(file), { recursive: true });
1032
+ // 남의 설정을 고치는 일이라 되돌릴 수 있게 한 벌 남긴다
1033
+ if (fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(file)) {
1034
+ fs__WEBPACK_IMPORTED_MODULE_0__.copyFileSync(file, file + '.agentdeck-backup');
1035
+ }
1036
+ fs__WEBPACK_IMPORTED_MODULE_0__.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n', 'utf8');
1037
+ }
1038
+
1039
+
703
1040
  /***/ },
704
1041
 
705
1042
  /***/ "./src/config.ts"
@@ -710,11 +1047,31 @@ const STATUS_STYLES = {
710
1047
 
711
1048
  __webpack_require__.r(__webpack_exports__);
712
1049
  /* harmony export */ __webpack_require__.d(__webpack_exports__, {
713
- /* harmony export */ AgentDeckConfigProvider: () => (/* binding */ AgentDeckConfigProvider)
1050
+ /* harmony export */ AgentDeckConfigProvider: () => (/* binding */ AgentDeckConfigProvider),
1051
+ /* harmony export */ LAYOUT_DEFAULTS: () => (/* binding */ LAYOUT_DEFAULTS)
714
1052
  /* harmony export */ });
715
1053
  /* harmony import */ var tabby_core__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! tabby-core */ "tabby-core");
716
1054
  /* harmony import */ var tabby_core__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(tabby_core__WEBPACK_IMPORTED_MODULE_0__);
717
1055
 
1056
+ /**
1057
+ * 마우스로 만진 배치만 따로 모아 둔다 — 설정 탭의 "배치 초기화" 가 이 값으로 되돌린다.
1058
+ * 기본값 정의와 초기화 대상이 어긋나지 않도록 아래 defaults 도 이걸 펼쳐 쓴다.
1059
+ */
1060
+ const LAYOUT_DEFAULTS = {
1061
+ /**
1062
+ * 사이드바가 붙는 방향. 헤더를 잡고 창 가장자리로 끌어다 놓으면 바뀐다.
1063
+ * 'left' | 'right' | 'top' | 'bottom'
1064
+ */
1065
+ sidebarDock: 'right',
1066
+ /**
1067
+ * 좌/우 도킹일 때의 사이드바 폭 (px). 0 이면 자동 —
1068
+ * 화면비(aspectW:aspectH)로 터미널 폭을 역산하고 남는 만큼을 사이드바가 갖는다.
1069
+ * 경계선을 드래그하는 순간 그 폭이 여기 박히고, 그 뒤로는 자동 계산을 쓰지 않는다.
1070
+ */
1071
+ sidebarWidth: 0,
1072
+ /** 상/하 도킹일 때의 사이드바 높이 (px). 경계선 드래그로 바뀐다 */
1073
+ sidebarHeight: 200,
1074
+ };
718
1075
  /** @hidden */
719
1076
  class AgentDeckConfigProvider extends tabby_core__WEBPACK_IMPORTED_MODULE_0__.ConfigProvider {
720
1077
  constructor() {
@@ -733,6 +1090,8 @@ class AgentDeckConfigProvider extends tabby_core__WEBPACK_IMPORTED_MODULE_0__.Co
733
1090
  */
734
1091
  sidebarMin: 200,
735
1092
  sidebarMax: 560,
1093
+ // 마우스로 만지는 배치 (도킹 방향 / 폭 / 높이) — 정의는 LAYOUT_DEFAULTS 에 있다
1094
+ ...LAYOUT_DEFAULTS,
736
1095
  /**
737
1096
  * 훅이 상태를 밀어 넣는 TCP 채널의 포트 (127.0.0.1 전용).
738
1097
  *
@@ -745,11 +1104,20 @@ class AgentDeckConfigProvider extends tabby_core__WEBPACK_IMPORTED_MODULE_0__.Co
745
1104
  /**
746
1105
  * 외부 통보 채널(TCP + 상태 파일 폴링)을 열지.
747
1106
  *
748
- * 기본은 꺼 둔다 — 라벨은 Enter 시점 입력창에서(`enterAsLabel`), 상태는 출력 패턴에서
749
- * 플러그인이 스스로 얻으므로 바깥에서 밀어 넣어 줄 것이 없다. 켜 두면 쓰지도 않는
750
- * 400ms 폴링과 포트 점유만 남는다. 외부 도구가 상태를 직접 보고하고 싶을 때만 켠다.
1107
+ * 기본으로 켠다. 에이전트 훅이 보내 주는 상태가 가장 정확한 원천이기 때문이다 —
1108
+ * 출력 패턴으로 추측하는 경로(detect.ts)는 훅이 없는 환경을 위한 폴백일 뿐이고,
1109
+ * TUI 가 화면을 어떻게 그리는지에 기대는 만큼 판정이 흔들린다
1110
+ * (2026-09-01: Claude Code 가 놀 때도 상태줄을 매초 갱신해 진행중이 안 풀렸다).
1111
+ * 훅을 안 쓰더라도 비용은 유휴 포트 하나와 400ms 폴링뿐이다.
1112
+ */
1113
+ notifyChannel: true,
1114
+ /**
1115
+ * 훅 설치 안내를 다시 띄우지 않는다.
1116
+ *
1117
+ * 기동할 때 훅이 안 걸려 있으면 한 번 물어보는데, "다시 묻지 않기" 를 고르면 켜진다.
1118
+ * 설정 창의 설치 버튼은 이 값과 무관하게 언제든 쓸 수 있다.
751
1119
  */
752
- notifyChannel: false,
1120
+ hookPromptDismissed: false,
753
1121
  /** PTY 출력이 이 시간(ms) 이상 없으면 running -> idle */
754
1122
  idleAfterMs: 2500,
755
1123
  /** 출력 패턴으로 상태를 자동 추론할지 */
@@ -765,15 +1133,8 @@ class AgentDeckConfigProvider extends tabby_core__WEBPACK_IMPORTED_MODULE_0__.Co
765
1133
  * (사이드바 DOM 갱신뿐이라 미미하다).
766
1134
  */
767
1135
  keepRenderingWhenHidden: true,
768
- /**
769
- * 사이드바의 제목 줄에 탭 제목 대신 작업 라벨을 쓴다.
770
- * Claude Code 는 세션 시작 때 붙인 콘솔 제목을 계속 들고 있어서 다음 작업으로
771
- * 넘어가도 안 바뀐다(2026-08-28 실측) — 제목만 보여주면 사이드바가 옛 작업 이름에
772
- * 박혀 있게 된다. 라벨이 없는 탭은 그대로 제목을 보여준다.
773
- * Tabby 의 탭 제목 자체(customTitle)는 건드리지 않는다 — 앱이 넣는 제목을
774
- * 가리지 않아야 그게 갱신되는지 계속 관측할 수 있다.
775
- */
776
- labelAsTitle: true,
1136
+ // labelAsTitle 은 없앴다 — 사이드바가 제목/프롬프트/상태를 각자 줄에 보여주게 되면서
1137
+ // (deck.service renderTab) 둘 중 하나를 고를 이유가 사라졌다.
777
1138
  /**
778
1139
  * Enter 로 프롬프트를 보낼 때 그 원문을 작업 라벨로 삼는다 (claimEnterLabel 주석 참고).
779
1140
  * 훅도 콘솔 제목도 못 믿는 배포 환경에서 라벨을 최신으로 유지하는 유일한 경로다.
@@ -837,18 +1198,115 @@ class AgentDeckConfigProvider extends tabby_core__WEBPACK_IMPORTED_MODULE_0__.Co
837
1198
  /** 사이드바 배경 불투명도 (%). 터미널과 같은 이유로 배경 이미지가 비치게 한다 */
838
1199
  sidebarOpacity: 75,
839
1200
  /**
840
- * 새 탭에서 첫 화면이 깨질 때, 세션이 열린 뒤 이 시간(ms)에 pty 크기를 한 번 흔든다.
841
- * 0 이면 끄기. 자세한 이유는 deck.service 의 kickPtySize 주석 참고
1201
+ * tabby-background 의 배경 이미지를 **터미널 영역(`.content.main`) 안에만** 그린다.
1202
+ *
1203
+ * 그 플러그인은 이미지를 `.content-tab-active::before` 에 `position:fixed; 100%x100%`
1204
+ * 로 깔아 창 전체를 덮으므로, 사이드바가 반투명이면 이미지가 사이드바 밑까지 비친다
1205
+ * (2026-09-02 유저 지적: "배경이 agentdeck 사이드바를 침범한다").
1206
+ * true 면 relayout 때마다 터미널 영역 좌표를 CSS 변수(`--ad-bg-*`)로 내려
1207
+ * 그 레이어의 위치·크기를 터미널 영역으로 잘라 붙인다. false 면 순정(창 전체).
1208
+ */
1209
+ clipBackgroundToTerminal: true,
1210
+ // 크기 보정 설정(`sizeWatchMs` / `sizeWatchIntervalMs` / `redrawKickMs` /
1211
+ // `startupWindowKickMs`)은 그 장치들과 함께 폐지했다 (2026-09-02).
1212
+ // 줄맞춤은 Tabby 순정이 한다 — xterm `ResizeObserver` → `fitAddon.fit()` →
1213
+ // `session.resize()`. agentdeck 은 폭만 정해 `.content.main` 에 적는다.
1214
+ // 자세한 경위는 `deck.service.ts` 의 relayout 주석 참고.
1215
+ /**
1216
+ * 화면 복구(사이드바 `↻` / `agentdeck-repair` 핫키)가 **스크롤백까지 지우고**
1217
+ * 터미널 상태를 초기화한다(`xterm.reset()`).
1218
+ *
1219
+ * true 인 이유: 깨진 화면에는 옛 폭으로 그려진 잔상이 셀에 남아 있는데, 앱이 다시
1220
+ * 그려도 자기가 쓰는 자리만 덮으므로 그 바깥은 지워지지 않는다. 지난 대화를 남기고
1221
+ * 싶으면 false — 그때는 크기 정합 + 흔들기까지만 한다(그것만으로는 안 고쳐지는
1222
+ * 경우가 있다, 2026-09-01 확인).
1223
+ */
1224
+ /**
1225
+ * 화면이 깨졌는지 **주기적으로 직접 보고**, 깨졌으면 조용히 복구한다.
1226
+ *
1227
+ * 이게 필요한 이유 — 크기 감시(`sizeWatchMs`)는 "우리가 pty 에 보낸 값" 과
1228
+ * "xterm 이 가진 값" 을 비교한다. 그런데 세션이 막 열린 직후의 `session.resize()` 는
1229
+ * **pty 에 도달하지 못하고 유실된다**(2026-09-01 실측: 진단 로그에
1230
+ * `pty resize 254x78 -> 280x78` 을 찍고 `pane.size` 도 280 으로 남겼는데,
1231
+ * 같은 탭에서 셸에 물어보니 `PTYW=254 xtermCols=280`). 보낸 값을 낙관적으로
1232
+ * 기록하기 때문에 감시는 "이제 맞다" 고 보고 손을 뗀다. 그 뒤에 뜨는 TUI 는
1233
+ * 254 폭으로 그리고, 오른쪽 26칸에는 옛 프레임(`Try "how do I …"` 힌트)이 남는다.
1234
+ *
1235
+ * 그래서 판정 기준을 **화면 자체**로 바꾼다. 입력창 테두리 길이가 화면 폭과 다르거나,
1236
+ * 가로선과 입력 텍스트가 한 줄에 섞였거나, 입력창 머리가 둘 보이면 깨진 것이다
1237
+ * (`screen.ts` 의 `judgeScreen` — 정상/깨짐 실측 케이스로 유닛 테스트가 걸려 있다).
1238
+ * 원인이 무엇이든 결과를 보고 고치므로, 원인 하나를 쫓다 다른 것이 깨지는 일이 없다.
1239
+ */
1240
+ /**
1241
+ * **다시 켬 (2026-09-02).** 껐던 근거가 오진이었다.
1242
+ *
1243
+ * 껐을 때의 이유는 "Claude Code 는 테두리를 `cols - 1` 로 그리는데 판정이
1244
+ * `== cols` 를 정상으로 봐서 멀쩡한 화면을 깨짐으로 친다" 였다. **그 전제가 틀렸다.**
1245
+ * 격리 인스턴스에서 실물 claude 를 띄워 xterm 버퍼를 직접 덤프해 보니
1246
+ * (2026-09-02, cols=271):
1247
+ *
1248
+ * 71 len=271 wrap=0 "────…" ← 테두리는 cols 를 꽉 채운다
1249
+ * 72 len=2 wrap=1 "❯ " ← 꽉 찼기 때문에 autowrap 으로 넘어온 행이다
1250
+ * 73 len=271 wrap=0 "────…"
1251
+ *
1252
+ * 즉 `len == cols` 가 정상이고, `len == cols - 1` 은 **앱이 폭을 1 작게 알고 있는**
1253
+ * 진짜 깨짐이다. 그때는 테두리가 폭을 못 채워 autowrap 이 안 걸리므로 `❯` 가
1254
+ * 윗줄에 붙어버리고 **입력행이 통째로 사라진다**(항목 25 의 ④ 증상이 이것이다).
1255
+ * 껐던 탓에 실사용에서 깨져도 아무도 고치지 않고 방치됐다.
1256
+ *
1257
+ * 예전 자동복구가 화면을 흔들어 놓은 진짜 이유는 판정이 아니라 **조건이 없어서**였다.
1258
+ * 프레임을 그리는 도중을 한 번 잡아도 바로 복구를 때렸다. 그래서 이번에는
1259
+ * `autoRepairMinStreak` 로 "몇 초 넘게 깨진 채로 있을 때만" 손대게 한다.
1260
+ */
1261
+ /**
1262
+ * **다시 끔 (2026-09-02, 실사용 실측 4/4 실패).** 위 판정 근거는 여전히 맞지만
1263
+ * **복구 수단이 이 유형에 안 듣는다.** 실사용 로그 06:02:27~06:03:12 —
1264
+ * `redraw nudge 277x78` → `pty resize 276x78 -> 277x78` → `Ctrl+L` 을 다 보내고도
1265
+ * 네 번 모두 `auto-after 아직 깨짐 rule@73 len=276 != cols=277`.
1266
+ * 그동안 10초마다 화면이 흔들려 유저 체감만 나빠졌다("최대화됐다 다시 깨지네").
1267
+ *
1268
+ * 격리에서 한 번에 복구됐던 건 `session.resize(cols-1)` 로 **pty 를 직접 좁혀** 만든
1269
+ * 표본이라, 흔들기가 되돌릴 대상이 실제로 있었다. 실사용은 pty 가 이미 277 로 맞고
1270
+ * **앱만 276** 이라 되돌릴 것이 없다 — 항목 27 이 이 유형을 정확히 짚었던 것이고,
1271
+ * 항목 28 의 반전은 그 표본에는 해당하지 않았다.
1272
+ * 되살리려면 "앱이 pty 폭을 다시 읽게 만드는" 수단부터 찾을 것. 감지·채증(`screenWatch`)은 계속 돈다.
842
1273
  */
843
- redrawKickMs: 900,
1274
+ autoRepairScreen: false,
844
1275
  /**
845
- * 기동 후 이 시간(ms)에 **창을 복원→최대화로 한 번 흔든다**. 0 이면 끄기.
1276
+ * 화면을 **보고 기록만** 한다 (복구는 하지 않는다).
846
1277
  *
847
- * pty 크기 흔들기(redrawKickMs)가 놓치는 경우를 위한 최종 보험이다. 사용자가 손으로
848
- * 하던 바로 그 동작이고, OS 가 진짜 SIGWINCH 를 두 번 내므로 TUI 가 반드시 다시 그린다.
849
- * 창이 최대화 상태일 때만 동작한다 — 아니면 창 상태를 멋대로 바꾸게 되므로.
1278
+ * `autoRepairScreen` 을 끄면서 감지·채증까지 같이 꺼진 게 문제였다 —
1279
+ * 실사용에서 깨져도 `~/.agentdeck-screen.log` 에 한 줄도 안 남아,
1280
+ * "고쳤다고 했는데 또 깨진다" 를 확인할 증거가 없었다
1281
+ * (2026-09-02 실측: Tabby 재시작 11:21 이후 기록 0줄, 그 사이 화면은 깨져 있었다).
1282
+ * 그래서 **관측과 조치를 분리한다.** 이 값이 켜져 있으면 깨짐을 판정해
1283
+ * 화면 원문을 남기기만 하고 키는 쏘지 않는다 — 부작용이 없으니 기본으로 켠다.
1284
+ */
1285
+ screenWatch: true,
1286
+ /** 자동 복구를 한 번 한 뒤 이만큼은 다시 하지 않는다 (ms) */
1287
+ autoRepairCooldownMs: 10000,
1288
+ /**
1289
+ * 깨짐이 이만큼 **연속으로** 보여야 복구한다 (화면 검사는 2초 그물이므로 2 면 2초 이상).
1290
+ * TUI 는 한 프레임을 여러 번의 write 로 그리므로 그 도중을 읽으면 멀쩡한 화면도
1291
+ * 한 번은 깨져 보인다 — 그 중간 프레임에 키를 쏘지 않기 위한 조건이다.
1292
+ */
1293
+ autoRepairMinStreak: 2,
1294
+ /**
1295
+ * 한 탭에서 자동 복구를 최대 몇 번까지 할지. 판정이 오탐이거나 앱이 정말
1296
+ * 그렇게 그리는 경우에 무한히 흔드는 것을 막는 안전장치다. 0 이면 무제한.
850
1297
  */
851
- startupWindowKickMs: 1500,
1298
+ autoRepairMaxPerTab: 3,
1299
+ /**
1300
+ * 복구 때 `xterm.reset()` 까지 한다 — **스크롤백(지난 대화)이 날아간다.**
1301
+ * 기본은 끔. 잔상은 `clearInputArea`(화면만 비우고 스크롤백은 보존)로 지우므로
1302
+ * 여기까지 갈 일이 거의 없다. 그걸로도 안 풀리는 화면을 만나면 그때 켠다.
1303
+ */
1304
+ repairHard: false,
1305
+ /**
1306
+ * 복구 마지막에 `Ctrl+L`(0x0C)을 보내 TUI 에게 전체 재렌더를 시킨다.
1307
+ * 입력 중이던 글자는 유지된다. 이걸 다른 뜻으로 쓰는 앱을 만나면 끈다.
1308
+ */
1309
+ repairSendRedrawKey: true,
852
1310
  /**
853
1311
  * 작업 루트 전용 프로필을 만들고 기본 프로필로 삼는다.
854
1312
  *
@@ -877,8 +1335,6 @@ class AgentDeckConfigProvider extends tabby_core__WEBPACK_IMPORTED_MODULE_0__.Co
877
1335
  COLORTERM: 'truecolor',
878
1336
  FORCE_COLOR: '3',
879
1337
  },
880
- /** true 면 탭마다 UAC 승격 (Tabby 자체를 관리자로 띄웠다면 불필요) */
881
- rootProfileAdmin: false,
882
1338
  /** 기본 프로필 지정을 이미 했는지 — 사용자 선택을 매번 덮어쓰지 않기 위한 플래그 */
883
1339
  rootProfileClaimed: false,
884
1340
  },
@@ -928,6 +1384,8 @@ __webpack_require__.r(__webpack_exports__);
928
1384
  /* harmony import */ var _detect__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(/*! ./detect */ "./src/detect.ts");
929
1385
  /* harmony import */ var _api__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(/*! ./api */ "./src/api.ts");
930
1386
  /* harmony import */ var _prompt__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(/*! ./prompt */ "./src/prompt.ts");
1387
+ /* harmony import */ var _screen__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(/*! ./screen */ "./src/screen.ts");
1388
+ /* harmony import */ var _dock__WEBPACK_IMPORTED_MODULE_11__ = __webpack_require__(/*! ./dock */ "./src/dock.ts");
931
1389
  var __decorate = (undefined && undefined.__decorate) || function (decorators, target, key, desc) {
932
1390
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
933
1391
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
@@ -947,16 +1405,18 @@ var __metadata = (undefined && undefined.__metadata) || function (k, v) {
947
1405
 
948
1406
 
949
1407
 
1408
+
1409
+
950
1410
  const SIDEBAR_ID = 'agentdeck-sidebar';
951
1411
  const BODY_CLASS = 'agentdeck-active';
952
- /** pty 흔들기 재시도 간격/횟수 — 첫 기동 때 셸이 늦게 열리는 구간을 덮는다 (최대 약 6초) */
953
- const KICK_RETRY_MS = 400;
954
- const KICK_RETRY_MAX = 15;
955
- /**
956
- * 기동 직후 이 시간(ms) 안에 `.content.main` 폭이 실제로 바뀌면 그때 열려 있던 pane 을 다시 흔든다.
957
- * 첫 흔들기가 폭 변경보다 먼저 끝나버리면 TUI 는 다시 옛 폭 화면으로 남기 때문이다.
958
- */
959
- const KICK_REARM_MS = 8000;
1412
+ /** 배경 이미지 레이어를 터미널 영역으로 자르는 중일 때 body 에 붙는 클래스 (styles.scss 참조) */
1413
+ const BG_CLIP_CLASS = 'ad-bg-clip';
1414
+ /** 터미널이 이보다 좁아지거나 낮아지지 않게 막는다 — 드래그로 사이드바를 끝까지 밀어도 */
1415
+ const MIN_TERM_W = 320;
1416
+ const MIN_TERM_H = 160;
1417
+ /** 사이드바가 이보다 작아지면 목록을 읽을 수 없다 — 드래그로도 이 밑으로는 안 내려간다 */
1418
+ const MIN_SIDEBAR_W = 140;
1419
+ const MIN_SIDEBAR_H = 90;
960
1420
  /** Ctrl+V 를 눌렀을 때 xterm 의 네이티브 paste 가 오는지 기다려보는 시간 */
961
1421
  const PASTE_NATIVE_WAIT_MS = 60;
962
1422
  /**
@@ -971,8 +1431,56 @@ const PASTE_DEDUPE_MS = 80;
971
1431
  const PASTE_PROBE_MS = 5000;
972
1432
  /** 입력창을 찾으려고 화면 아래에서 훑어 올라갈 줄 수 — 여러 줄 프롬프트를 덮을 만큼만 */
973
1433
  const PROMPT_SCAN_ROWS = 14;
1434
+ /**
1435
+ * 복구할 때 Ctrl+L 을 보내기까지 기다리는 시간.
1436
+ * 앞선 흔들기(`nudgePtyRedraw`)가 60ms 뒤에 폭을 되돌리므로, 앱이 그 두 번째 SIGWINCH 로
1437
+ * 새 폭을 받아들인 다음에 "다시 그려라" 가 도착해야 한다. 순서가 뒤집히면 옛 폭으로 다시 그린다.
1438
+ */
1439
+ const REPAIR_REDRAW_DELAY_MS = 180;
1440
+ /** 복구 뒤 화면을 다시 재는 시각 — 앱이 새 프레임을 다 그릴 만큼은 기다린다 */
1441
+ const SNAP_AFTER_MS = 1500;
974
1442
  /** 진단 로그 — devtools 를 못 여는 상황에서도 밖에서 읽을 수 있게 파일로 남긴다 */
1443
+ /**
1444
+ * 화면 깨짐 검사를 출력이 멈춘 뒤 이만큼 기다렸다 한다.
1445
+ * TUI 가 한 프레임을 여러 번의 write 로 그리므로, 그리는 도중에 읽으면 멀쩡한 화면도 깨져 보인다.
1446
+ */
1447
+ const SCREEN_CHECK_DEBOUNCE_MS = 700;
1448
+ /**
1449
+ * pty 크기를 고친 뒤 같은 값을 다시 보내는 시점 (ms).
1450
+ * 세션 초기화 직후의 resize 는 유실되므로(syncPtySize 주석) 시간을 벌려 몇 번 더 던진다.
1451
+ */
1452
+ const PTY_RESIZE_RETRY_MS = [150, 400, 900, 1800, 3000];
1453
+ /**
1454
+ * 탭이 열린 뒤 "지금 xterm 이 쓰는 크기" 를 pty 에 알려 주는 시점 (ms).
1455
+ *
1456
+ * 순정이 못 메우는 구멍이 딱 하나 남아 있다 — **새 탭.** Tabby 는 프론트엔드의 첫 `resize$`
1457
+ * 값으로 세션을 스폰하므로(tabby-local `initializeSession(this.size…)`), 그 스냅샷이
1458
+ * 레이아웃 확정보다 이르면 pty 만 좁게 태어난다. 그 뒤 xterm 이 넓어져도 **크기 변화
1459
+ * 이벤트가 나지 않으므로** ResizeObserver 도 fit() 도 아무것도 보내지 않는다(fit 은 열/행이
1460
+ * 이미 맞으면 no-op). 아무도 pty 에 알려주지 않아 그대로 굳는다.
1461
+ *
1462
+ * 실측(2026-09-02, 격리 인스턴스): 평범하게 연 새 탭 3개가 전부 `xterm=88 / pane.size=88`
1463
+ * 인데 pty 는 72 였고, TUI 는 72 폭으로 그려 테두리 두 줄이 73·88 로 어긋났다.
1464
+ * 같은 값(`session.resize(88, 39)`)을 **한 번** 보내자 즉시 정상으로 돌아왔다.
1465
+ *
1466
+ * 그래서 되살린 개입은 이것 하나뿐이다 — "현재 크기를 몇 번 알려준다". 흔들기(cols-1 ↔ cols)도,
1467
+ * 250ms 감시 루프도, fit 강제도 아니다. 값이 이미 맞으면 SIGWINCH 가 나지 않아 무해하고,
1468
+ * xterm 을 건드리지 않으므로 그리는 중인 프레임과 경쟁하지도 않는다.
1469
+ */
1470
+ const TAB_OPEN_ANNOUNCE_MS = [250, 800, 1800];
1471
+ /** 화면 깨짐 주기 검사 간격 (ms) — 출력 구독이 없는 탭도 잡기 위한 그물 */
1472
+ const SCREEN_SWEEP_MS = 2000;
1473
+ /** 자식이 아직 없는 래퍼 탭을 다시 훑는 간격과 횟수 */
1474
+ const EMPTY_TAB_RETRY_MS = 400;
1475
+ const EMPTY_TAB_RETRY_MAX = 20;
975
1476
  const DIAG_PATH = path__WEBPACK_IMPORTED_MODULE_5__.join(os__WEBPACK_IMPORTED_MODULE_4__.homedir(), '.agentdeck-diag.log');
1477
+ /**
1478
+ * 깨진 화면의 **원문**을 남기는 곳. 진단 로그(한 줄 요약)와 따로 두는 이유 —
1479
+ * 원인을 못 찾는 이유가 늘 "깨졌을 때의 화면을 아무도 안 봐 뒀다" 여서다.
1480
+ * 감지 직후(before)와 복구 뒤(after)를 같이 남기므로, 다음에 깨지면 추측 없이
1481
+ * "무엇이 깨졌나 / 복구가 실제로 고쳤나" 를 이 파일 하나로 가른다.
1482
+ */
1483
+ const SNAP_PATH = path__WEBPACK_IMPORTED_MODULE_5__.join(os__WEBPACK_IMPORTED_MODULE_4__.homedir(), '.agentdeck-screen.log');
976
1484
  let cachedClipboard;
977
1485
  /**
978
1486
  * Electron 의 clipboard 를 런타임에 가져온다.
@@ -1059,16 +1567,24 @@ let AgentDeckService = class AgentDeckService {
1059
1567
  /** 직전에 적용한 폭 — 같은 값이면 DOM 을 다시 건드리지 않는다 */
1060
1568
  this.lastTermW = -1;
1061
1569
  this.lastSidebarW = -1;
1570
+ /** 직전에 적용한 도킹 방향 — 크기가 같아도 방향이 바뀌면 다시 그려야 한다 */
1571
+ this.lastDock = null;
1572
+ /** 경계선 드래그(크기)와 헤더 드래그(도킹 방향)를 맡는다 */
1573
+ this.dock = null;
1062
1574
  this.refitQueued = false;
1063
1575
  /** 이미 감시를 건 래퍼 탭 / pane — 중복 구독 방지 */
1064
1576
  this.watched = new WeakSet();
1065
1577
  this.watchedPanes = new WeakSet();
1066
- /** 첫 화면 복구용 pty 흔들기를 이미 한 pane — 탭당 한 번만 한다 */
1067
- this.kickedPanes = new WeakSet();
1068
- /** setup() 시각 — 기동 직후 구간인지 판단한다 (0 이면 아직 setup 전) */
1069
- this.setupAt = 0;
1070
- /** 기동 직후 구간에 잡힌 pane 들 — 폭이 바뀌면 이들만 다시 흔든다. 구간이 지나면 비운다 */
1071
- this.startupPanes = [];
1578
+ /** 화면 깨짐 검사 예약 타이머 — pane 당 하나만 돈다 */
1579
+ this.screenCheckTimers = new WeakMap();
1580
+ /** 빈 래퍼 탭을 다시 훑은 횟수 */
1581
+ this.emptyTabRetries = new WeakMap();
1582
+ /** 마지막 화면 판정 결과 — 상태가 바뀔 때만 채증하려고 들고 있는다 */
1583
+ this.screenObs = new WeakMap();
1584
+ /** 마지막 자동 복구 시각 — 쿨다운 판정용 */
1585
+ this.autoRepairAt = new WeakMap();
1586
+ /** 자동 복구 횟수 — 오탐으로 무한히 흔드는 것을 막는다 */
1587
+ this.autoRepairCount = new WeakMap();
1072
1588
  /** 진단 기록 — devtools 의 `__agentdeck.kickLog` 와 DIAG_PATH 파일에 함께 남는다 */
1073
1589
  this.kickLog = [];
1074
1590
  /** diagOnce 가 이미 남긴 키 */
@@ -1132,7 +1648,6 @@ let AgentDeckService = class AgentDeckService {
1132
1648
  catch {
1133
1649
  // 진단 파일을 못 열어도 그냥 간다
1134
1650
  }
1135
- this.setupAt = Date.now();
1136
1651
  this.diag(`${new Date().toISOString()} setup start tabs=${this.app.tabs.length}`);
1137
1652
  this.buildSidebar();
1138
1653
  this.applyEnabled();
@@ -1181,6 +1696,10 @@ let AgentDeckService = class AgentDeckService {
1181
1696
  this.relayout();
1182
1697
  this.scheduleRender();
1183
1698
  });
1699
+ // 화면 깨짐 주기 검사. 출력 구독(scheduleScreenCheck)이 더 정밀하지만, 구독이 붙지
1700
+ // 못한 탭에서는 아무도 보지 않게 되므로 그물을 하나 더 둔다. 활성 탭만 본다 —
1701
+ // 숨은 탭은 폭이 0 이라 판정할 근거가 없다.
1702
+ setInterval(() => this.sweepScreens(), SCREEN_SWEEP_MS);
1184
1703
  // 창 크기 변화 -> 4:3 폭 재계산
1185
1704
  this.observer = new ResizeObserver(() => this.relayout());
1186
1705
  this.observer.observe(this.windowEl);
@@ -1197,10 +1716,12 @@ let AgentDeckService = class AgentDeckService {
1197
1716
  if (hotkey === 'agentdeck-paste') {
1198
1717
  this.doPaste('hotkey');
1199
1718
  }
1719
+ if (hotkey === 'agentdeck-repair') {
1720
+ this.repair('all');
1721
+ }
1200
1722
  });
1201
1723
  // 경과 시간 갱신 + running -> idle 자동 복귀
1202
1724
  setInterval(() => this.tick(), 1000);
1203
- this.scheduleWindowKick();
1204
1725
  this.relayout();
1205
1726
  this.render();
1206
1727
  this.diag(`${new Date().toISOString()} setup done tabs=${this.app.tabs.length} enabled=${this.enabled} mainEl=${!!this.mainEl}`);
@@ -1210,205 +1731,529 @@ let AgentDeckService = class AgentDeckService {
1210
1731
  status: this.status,
1211
1732
  relayout: () => this.relayout(),
1212
1733
  render: () => this.render(),
1734
+ repair: (scope = 'all') => this.repair(scope),
1213
1735
  kickLog: this.kickLog,
1214
1736
  };
1215
1737
  }
1216
- // ---------- 세션 감시 ----------
1738
+ // ---------- 화면 복구 ----------
1217
1739
  /**
1218
- * 탭(그리고 그 안의 모든 pane)의 PTY 출력을 구독한다.
1740
+ * 깨진 화면을 사람이 직접 되살린다 — 창을 복원→최대화로 흔드는 것을 코드로 대신한다.
1219
1741
  *
1220
- * TerminalDecorator 는 프론트엔드 첫 리사이즈 시점에만 붙어서 복원된 탭 등을
1221
- * 놓치는 경우가 있다. 이쪽은 탭이 목록에 들어오는 즉시 붙으므로 그 빈틈을 메운다.
1222
- * 같은 출력을 decorator 와 둘 다 먹어도 상태 설정이 멱등이라 결과는 같다.
1742
+ * 자동 경로(`watchSize`)는 "xterm 과 pty 의 크기가 어긋났다" 를 보고 고치는데, 그 감시는
1743
+ * 탭이 열리고 `sizeWatchMs`(기본 6초) 동안만 돈다. 그 뒤에 깨지는 경우가 남는다 —
1744
+ * 두 크기는 서로 맞는데 **TUI 가 들고 있는 프레임만** 옛것인 상황이다. 크기가 이미 같으니
1745
+ * 커널은 SIGWINCH 를 내지 않고, 앱은 다시 그릴 이유를 영영 못 받는다. 증상은
1746
+ * "statusline 이 사라지고 입력창 구분선이 중간에서 끊긴다" (2026-09-01 실측).
1747
+ *
1748
+ * 그래서 복구는 크기를 맞추는 것으로 끝내지 않고 **항상 한 번 흔든다**(`nudgePtyRedraw`).
1749
+ * 어긋남이 없어도 무해하다 — 폭이 한 칸 줄었다 돌아올 뿐이고 xterm 은 건드리지 않는다.
1750
+ *
1751
+ * @param scope `active` = 지금 보고 있는 탭만, `all` = 열려 있는 모든 탭
1223
1752
  */
1224
- watchTab(root) {
1225
- const anyRoot = root;
1226
- if (!this.watched.has(root)) {
1227
- this.watched.add(root);
1228
- // 제목은 래퍼 탭이 자식 것을 위임받으므로 여기서 받는 게 가장 확실하다
1229
- if (anyRoot.titleChange$) {
1230
- anyRoot.titleChange$.subscribe((title) => {
1231
- (0,_detect__WEBPACK_IMPORTED_MODULE_7__.applyTitle)(this.status, root, title, this.config.store.agentDeck.autoDetect);
1232
- });
1233
- }
1234
- if (anyRoot.title) {
1235
- (0,_detect__WEBPACK_IMPORTED_MODULE_7__.applyTitle)(this.status, root, anyRoot.title, this.config.store.agentDeck.autoDetect);
1236
- }
1237
- // 나중에 분할로 추가되는 pane 도 따라간다
1238
- if (anyRoot.tabAdded$) {
1239
- anyRoot.tabAdded$.subscribe((pane) => this.watchPane(root, pane));
1240
- }
1241
- }
1242
- // pane 은 래퍼보다 늦게 붙는 경우가 있으므로 호출될 때마다 다시 훑는다
1243
- // (래퍼만 보고 한 번에 끝내면 목록에 먼저 들어온 빈 SplitTab 을 잡고 끝나버린다)
1244
- const panes = typeof anyRoot.getAllTabs === 'function'
1245
- ? anyRoot.getAllTabs()
1246
- : [root];
1247
- this.diag(`${new Date().toISOString()} watchTab panes=${panes.length}`);
1248
- for (const pane of panes) {
1249
- this.watchPane(root, pane);
1250
- }
1251
- }
1252
- watchPane(root, pane) {
1253
- var _a;
1254
- if (this.watchedPanes.has(pane)) {
1255
- return;
1256
- }
1257
- this.watchedPanes.add(pane);
1258
- this.diag(`${new Date().toISOString()} watchPane ${(_a = pane.constructor) === null || _a === void 0 ? void 0 : _a.name}`);
1259
- if (this.setupAt && Date.now() - this.setupAt < KICK_REARM_MS) {
1260
- this.startupPanes.push(pane);
1261
- }
1262
- const anyPane = pane;
1263
- const bind = (session) => {
1264
- if (!session || !session.output$) {
1265
- return;
1753
+ repair(scope = 'all') {
1754
+ const targets = scope === 'active' && this.app.activeTab
1755
+ ? [this.app.activeTab]
1756
+ : this.app.tabs;
1757
+ this.diag(`${new Date().toISOString()} repair scope=${scope} tabs=${targets.length}`);
1758
+ // 1) 사이드바 폭부터 다시 잰다 — 어긋남의 출발점이 대개 여기다
1759
+ this.relayout();
1760
+ // 2) 레이아웃이 실제로 반영된 다음에 재야 한다 (scheduleRefit 과 같은 이유로 두 프레임)
1761
+ requestAnimationFrame(() => requestAnimationFrame(() => {
1762
+ for (const tab of targets) {
1763
+ const anyTab = tab;
1764
+ const panes = typeof anyTab.getAllTabs === 'function'
1765
+ ? anyTab.getAllTabs()
1766
+ : [tab];
1767
+ for (const pane of panes) {
1768
+ this.repairPane(pane);
1769
+ }
1266
1770
  }
1267
- session.output$.subscribe((data) => {
1268
- ;(0,_detect__WEBPACK_IMPORTED_MODULE_7__.applyOutput)(this.status, root, pane, data, this.config.store.agentDeck.autoDetect);
1269
- });
1270
- };
1271
- bind(anyPane.session);
1272
- if (anyPane.sessionChanged$) {
1273
- anyPane.sessionChanged$.subscribe((session) => bind(session));
1274
- }
1275
- // 프론트엔드가 붙는 도중에 우리가 폭을 바꿨으면 그 변경은 유실된다
1276
- // (Tabby 는 attach() 맨 끝에서야 ResizeObserver 를 건다) — 준비되면 한 번 맞춰준다
1277
- if (anyPane.frontendReady$) {
1278
- anyPane.frontendReady$.subscribe(() => {
1279
- this.scheduleRefit();
1280
- this.scheduleRedrawKick(pane);
1281
- });
1282
- }
1283
- // frontendReady 는 replay 가 없는 맨 Subject 다 (tabby-terminal 의 `frontendReady: Subject<void>`).
1284
- // Tabby 를 처음 띄우면 복원된 탭의 프론트엔드가 우리 setup() 보다 먼저 붙어버려서
1285
- // 위 구독은 이미 지나간 이벤트를 영영 못 받는다 — 그래서 첫 기동에서만 흔들기가 빠졌다.
1286
- // (새 탭은 tabOpened$ 로 먼저 구독이 걸리니 정상 수신 → 새 탭만 되던 이유)
1287
- // 이벤트와 무관하게 항상 한 번 예약하고, 아직 준비 전이면 kickPtySize 가 재시도한다.
1288
- // kickedPanes WeakSet 이 있어 구독 경로와 겹쳐도 흔들기는 pane 당 한 번뿐이다.
1289
- this.scheduleRedrawKick(pane);
1771
+ }));
1290
1772
  }
1291
1773
  /**
1292
- * 기동 직후 창을 **복원 -> 최대화**로 한 번 흔든다. 사용자가 손으로 하던 그 동작이다.
1774
+ * 복구 한 pane 분 — **화면을 지우고 앱에게 처음부터 다시 그리게 한다.**
1775
+ *
1776
+ * 흔들기(`nudgePtyRedraw`)만으로는 안 고쳐지는 경우가 실제로 있다(2026-09-01 유저 확인).
1777
+ * 이유는 흔들기가 고치는 대상이 하나뿐이라서다 — 그건 "앱이 폭을 잘못 알고 있다" 를 푼다.
1778
+ * 그런데 깨진 화면에는 그 말고도 두 가지가 더 얹혀 있다.
1293
1779
  *
1294
- * pty 크기 흔들기(kickPtySize)는 우리가 만든 가짜 SIGWINCH 라, 세션이 늦게 열리거나
1295
- * 폭 변경 타이밍이 어긋나면 놓칠 수 있다. 이쪽은 OS 가 진짜로 창 크기를 두 번 바꾸므로
1296
- * 그 아래 있는 xterm, pty, TUI 가 예외 없이 다시 그린다. 최종 보험이다.
1780
+ * - **xterm 쪽 잔상**: 옛 폭으로 그려진 글자가 셀에 그대로 남아 있다. 앱이 다시 그려도
1781
+ * 앱은 자기가 쓴 자리만 덮으므로, 그 바깥(옛 프레임의 오른쪽·아래)은 영영 안 지워진다.
1782
+ * - **터미널 상태**: 스크롤 영역(DECSTBM)·줄바꿈 모드·속성이 옛 크기 기준으로 잡혀 있으면
1783
+ * 새로 그린 것까지 어긋난 자리에 들어간다.
1297
1784
  *
1298
- * 최대화 상태일 때만 한다. 아니면 사용자의 창 상태를 멋대로 최대화해버린다.
1785
+ * 그래서 순서가 있다 — ① 크기를 맞추고 ② `reset()` 으로 터미널 상태와 화면을 비우고
1786
+ * ③ 그래도 앱은 자기가 이미 그렸다고 믿고 있으니 흔들어 SIGWINCH 를 주고
1787
+ * ④ 마지막으로 `Ctrl+L`(0x0C) 을 보내 "전부 다시 그려라" 를 명시로 시킨다.
1788
+ * ③ 만으로 부족했던 이유가 ①②④ 가 빠져서였다.
1789
+ *
1790
+ * `reset()` 은 스크롤백을 함께 지운다. 지난 대화를 남기고 싶으면 `repairHard: false` —
1791
+ * 그때는 예전처럼 크기 정합 + 흔들기까지만 한다.
1299
1792
  */
1300
- scheduleWindowKick() {
1301
- const delay = this.config.store.agentDeck.startupWindowKickMs;
1302
- if (!delay) {
1303
- this.diag(`${new Date().toISOString()} window-kick disabled startupWindowKickMs=${JSON.stringify(delay)}`);
1793
+ repairPane(pane) {
1794
+ var _a, _b, _c, _d, _e, _f;
1795
+ const cfg = this.config.store.agentDeck;
1796
+ const anyPane = pane;
1797
+ const frontend = anyPane.frontend;
1798
+ const xterm = frontend === null || frontend === void 0 ? void 0 : frontend.xterm;
1799
+ // 숨겨진 탭은 컨테이너 폭이 0 이라 fit 하면 1열로 줄어든다 — 크기는 건드리지 않고
1800
+ // 다시 선택될 때 Tabby 가 재측정하게 둔다
1801
+ const host = (_a = xterm === null || xterm === void 0 ? void 0 : xterm.element) === null || _a === void 0 ? void 0 : _a.parentElement;
1802
+ if (!(host === null || host === void 0 ? void 0 : host.clientWidth) || !(host === null || host === void 0 ? void 0 : host.clientHeight)) {
1304
1803
  return;
1305
1804
  }
1306
- this.diag(`${new Date().toISOString()} window-kick scheduled +${delay}ms`);
1307
- setTimeout(() => {
1308
- let maximized = false;
1805
+ const before = this.readScreen(pane);
1806
+ const beforeVerdict = before ? (0,_screen__WEBPACK_IMPORTED_MODULE_10__.judgeScreen)(before.lines, before.cols) : null;
1807
+ this.snapshot(pane, `manual-before ${beforeVerdict
1808
+ ? (beforeVerdict.broken ? beforeVerdict.reasons.join(' / ') : '판정=정상')
1809
+ : '읽을 수 없음'}`);
1810
+ // ① 크기 정합 — 여기가 어긋나 있으면 뒤 단계가 전부 헛돈다
1811
+ try {
1812
+ (_c = (_b = frontend === null || frontend === void 0 ? void 0 : frontend.fitAddon) === null || _b === void 0 ? void 0 : _b.fit) === null || _c === void 0 ? void 0 : _c.call(_b);
1813
+ }
1814
+ catch {
1815
+ // 아직 붙는 중이다 — 아래 단계만이라도 태운다
1816
+ }
1817
+ this.syncPtySize(pane, frontend);
1818
+ // ② 잔상 지우기 — 입력창 영역만 비운다 (대화는 남는다)
1819
+ if (before) {
1820
+ this.clearInputArea(pane, before);
1821
+ }
1822
+ // ②-b 그래도 안 되면 터미널 상태까지 초기화 (스크롤백이 날아가므로 기본은 끔)
1823
+ if (cfg.repairHard === true) {
1309
1824
  try {
1310
- maximized = this.hostWindow.isMaximized();
1825
+ (_d = xterm === null || xterm === void 0 ? void 0 : xterm.reset) === null || _d === void 0 ? void 0 : _d.call(xterm);
1311
1826
  }
1312
1827
  catch {
1313
- // 창 정보를 못 읽는 호스트(웹 빌드 등)라면 그냥 넘어간다
1828
+ // 프론트엔드가 막 떨어졌다
1314
1829
  }
1315
- if (!maximized) {
1316
- this.diag(`${new Date().toISOString()} window-kick skipped (not maximized)`);
1317
- return;
1318
- }
1319
- this.diag(`${new Date().toISOString()} window-kick restore`);
1320
- this.hostWindow.toggleMaximize();
1321
- setTimeout(() => {
1322
- this.diag(`${new Date().toISOString()} window-kick maximize`);
1323
- this.hostWindow.toggleMaximize();
1324
- }, 220);
1325
- }, delay);
1830
+ }
1831
+ try {
1832
+ (_e = xterm === null || xterm === void 0 ? void 0 : xterm.refresh) === null || _e === void 0 ? void 0 : _e.call(xterm, 0, ((_f = xterm.rows) !== null && _f !== void 0 ? _f : 1) - 1);
1833
+ }
1834
+ catch { }
1835
+ // ③ 크기가 이미 맞아도 무조건 흔든다 — 이 함수를 부르는 이유 자체가 "맞는데 깨졌다" 다
1836
+ this.nudgePtyRedraw(pane);
1837
+ // ④ 앱에게 직접 "다시 그려라". 흔들기가 도착해 앱이 새 폭을 받은 뒤에 보내야 하므로
1838
+ // nudge 의 되돌리기(60ms)보다 확실히 뒤에 둔다.
1839
+ if (cfg.repairSendRedrawKey !== false) {
1840
+ setTimeout(() => this.sendRedrawKey(pane), REPAIR_REDRAW_DELAY_MS);
1841
+ }
1842
+ // ⑤ 채증 — "↻ 를 눌렀는데 안 고쳐진다" 를 감이 아니라 파일로 가른다.
1843
+ // 누른 직후와 다시 그려진 뒤를 짝으로 남긴다.
1844
+ setTimeout(() => {
1845
+ const after = this.readScreen(pane);
1846
+ const still = after ? (0,_screen__WEBPACK_IMPORTED_MODULE_10__.judgeScreen)(after.lines, after.cols) : null;
1847
+ this.snapshot(pane, `manual-after ${still ? (still.broken ? '아직 깨짐: ' + still.reasons.join(' / ') : '복구됨') : '읽을 수 없음'}`);
1848
+ }, SNAP_AFTER_MS);
1326
1849
  }
1327
1850
  /**
1328
- * 탭이 열린 직후 한 번만 pty 크기를 흔들어 TUI 가 화면 전체를 다시 그리게 한다.
1851
+ * TUI 에 "화면을 전부 다시 그려라" 를 보낸다 — `Ctrl+L`(0x0C).
1329
1852
  *
1330
- * 왜 필요한가 — 탭이 만들어지는 동안 우리가 `.content.main` 폭을 바꾸면 그 사이에
1331
- * 이미 뜬 TUI(Claude Code 등)는 **옛 폭 기준으로 첫 화면을 그려 놓은 상태**가 된다.
1332
- * 그 뒤 refit/syncPtySize 가 크기를 맞춰도, 맞춘 값이 pty 가 이미 들고 있던 값과 같으면
1333
- * 커널은 SIGWINCH 를 내지 않는다 — TUI 는 "크기 안 바뀌었네" 하고 그대로 있고,
1334
- * 옛 폭으로 그린 입력창 프레임 잔상이 화면에 남는다 (2026-08-28 실측: 첫 한 번만 재현,
1335
- * 창 복원→최대화로 흔들면 즉시 정상 — 그게 SIGWINCH 를 두 번 내기 때문이다).
1336
- *
1337
- * 그래서 같은 일을 코드로 한다: 한 열 좁혔다가 곧바로 되돌린다. 두 번의 SIGWINCH 가
1338
- * 확실히 전달되어 TUI 가 전체 재렌더를 하고 잔상이 지워진다. 탭당 한 번뿐이라
1339
- * 정상 동작하던 탭에는 재렌더 1회 외에 영향이 없다.
1853
+ * Claude Code·Codex 를 포함해 거의 모든 TUI 와 셸이 이걸 전체 재렌더로 받는다.
1854
+ * 입력 중이던 글자는 건드리지 않는다(readline·TUI 모두 입력 버퍼는 유지하고 화면만 다시 그린다).
1340
1855
  */
1341
- scheduleRedrawKick(pane, force = false) {
1342
- if (!force && this.kickedPanes.has(pane)) {
1856
+ sendRedrawKey(pane) {
1857
+ var _a;
1858
+ const anyPane = pane;
1859
+ if (typeof anyPane.sendInput !== 'function' || !((_a = anyPane.session) === null || _a === void 0 ? void 0 : _a.open)) {
1343
1860
  return;
1344
1861
  }
1345
- const delay = this.config.store.agentDeck.redrawKickMs;
1346
- if (!delay) {
1347
- this.diag(`${new Date().toISOString()} kick disabled redrawKickMs=${JSON.stringify(delay)}`);
1862
+ this.diag(`${new Date().toISOString()} repair redraw-key ctrl-l`);
1863
+ try {
1864
+ anyPane.sendInput('\x0c');
1865
+ }
1866
+ catch {
1867
+ // 세션이 막 닫혔다
1868
+ }
1869
+ }
1870
+ /**
1871
+ * 입력창 영역에 남은 **옛 프레임 잔상만** 지운다 (대화 = 스크롤백은 그대로 둔다).
1872
+ *
1873
+ * 복구가 계속 실패하던 진짜 이유가 여기 있었다 (2026-09-02 실측).
1874
+ * `nudge` 도 `Ctrl+L` 도 하는 일은 "앱아 다시 그려라" 뿐인데, **앱은 자기가 쓰는 자리만 덮는다.**
1875
+ * 그래서 옛 폭으로 그려진 줄이 `❯` 자리를 차지하고 있으면 아무도 그걸 못 지운다 —
1876
+ * 채증 원문이 그 모양이었다 (`.agentdeck-screen.log` 06:02:27):
1877
+ *
1878
+ * 72 len=275 " " ← 위 테두리가 있어야 할 자리, 공백
1879
+ * 73 len=276 "────…" ← ❯ 자리에 옛 프레임(276) 잔상
1880
+ * 74 len=277 "────…" ← 앱이 새로 그린 아래 테두리(277, 정상)
1881
+ *
1882
+ * 앱은 이미 277 로 제대로 그리고 있는데 73 행 잔상 때문에 계속 깨짐으로 남았다.
1883
+ * `auto-after 아직 깨짐` 이 두 번 연속 찍힌 게 이것이다.
1884
+ *
1885
+ * 그래서 **터미널 쪽에서 직접 지운다** — 입력창 첫 테두리 행으로 커서를 옮기고
1886
+ * `ESC[J`(커서부터 화면 끝까지)로 비운다. 이건 화면만 비우고 스크롤백은 건드리지 않으므로
1887
+ * `xterm.reset()`(스크롤백까지 날아감)과 다르다. 커서는 `ESC7`/`ESC8` 로 저장·복원해
1888
+ * 앱이 들고 있는 커서 위치와 어긋나지 않게 한다. 지운 뒤 `Ctrl+L` 을 보내면 앱이
1889
+ * 자기 UI 를 그 빈 자리에 다시 그린다.
1890
+ */
1891
+ clearInputArea(pane, read) {
1892
+ var _a;
1893
+ const xterm = (_a = pane.frontend) === null || _a === void 0 ? void 0 : _a.xterm;
1894
+ if (!xterm || typeof xterm.write !== 'function') {
1895
+ return false;
1896
+ }
1897
+ const lines = read.lines;
1898
+ // 하단 16행 안에서만 본다 — 대화 이력에 남아 있는 옛 입력창까지 지우면 안 된다
1899
+ const from = Math.max(0, lines.length - 16);
1900
+ let last = -1;
1901
+ for (let i = lines.length - 1; i >= from; i--) {
1902
+ if (/^[─━═]{2,}$/.test(lines[i].text.trim())) {
1903
+ last = i;
1904
+ break;
1905
+ }
1906
+ }
1907
+ if (last < 0) {
1908
+ return false;
1909
+ }
1910
+ // 마지막 테두리에서 위로 최대 4행까지만 같은 입력창 블록으로 본다
1911
+ let start = last;
1912
+ for (let i = last - 1; i >= Math.max(from, last - 4); i--) {
1913
+ if (/^[─━═]{2,}$/.test(lines[i].text.trim())) {
1914
+ start = i;
1915
+ }
1916
+ }
1917
+ const row = start + 1;
1918
+ this.diag(`${new Date().toISOString()} clear input area row=${row}/${lines.length}`);
1919
+ try {
1920
+ xterm.write(`7[${row};1H8`);
1921
+ }
1922
+ catch {
1923
+ return false;
1924
+ }
1925
+ return true;
1926
+ }
1927
+ // ---------- 화면 깨짐 자동 감지 ----------
1928
+ /**
1929
+ * 출력이 잠잠해지면 화면이 온전한지 본다.
1930
+ *
1931
+ * TUI 는 한 프레임을 여러 번의 write 로 그리므로, 그리는 도중에 읽으면 멀쩡한 화면도
1932
+ * 깨져 보인다. 그래서 마지막 출력에서 `SCREEN_CHECK_DEBOUNCE_MS` 만큼 조용해진 뒤에만 본다.
1933
+ */
1934
+ scheduleScreenCheck(pane) {
1935
+ // 감지·채증은 복구와 분리한다 — `autoRepairScreen` 이 꺼져 있어도 관측은 계속한다.
1936
+ // (2026-09-02: 이 게이트가 채증까지 막아 실사용 재현이 통째로 증거 없이 지나갔다)
1937
+ if (!this.screenObservingEnabled()) {
1348
1938
  return;
1349
1939
  }
1350
- this.kickedPanes.add(pane);
1351
- this.diag(`${new Date().toISOString()} scheduled +${delay}ms`);
1352
- setTimeout(() => this.kickPtySize(pane), delay);
1940
+ const prev = this.screenCheckTimers.get(pane);
1941
+ if (prev) {
1942
+ clearTimeout(prev);
1943
+ }
1944
+ this.screenCheckTimers.set(pane, setTimeout(() => {
1945
+ this.screenCheckTimers.delete(pane);
1946
+ this.checkScreen(pane);
1947
+ }, SCREEN_CHECK_DEBOUNCE_MS));
1353
1948
  }
1354
- kickPtySize(pane, attempt = 0) {
1355
- var _a, _b, _c, _d, _e;
1949
+ /** 감지·채증이 켜져 있나 — 복구(`autoRepairScreen`)와는 별개다 */
1950
+ screenObservingEnabled() {
1951
+ const cfg = this.config.store.agentDeck;
1952
+ return cfg.screenWatch !== false || !!cfg.autoRepairScreen;
1953
+ }
1954
+ /** 화면을 판정해 채증하고, 복구가 켜져 있으면 조용히 고친다 */
1955
+ checkScreen(pane) {
1956
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p;
1957
+ const cfg = this.config.store.agentDeck;
1958
+ const read = this.readScreen(pane);
1959
+ if (!read) {
1960
+ return;
1961
+ }
1962
+ const verdict = (0,_screen__WEBPACK_IMPORTED_MODULE_10__.judgeScreen)(read.lines, read.cols);
1963
+ // ---- 관측 (복구와 무관하게 항상) ----
1964
+ // 상태가 바뀔 때만 남긴다 — 2초마다 도는 그물이라 매번 찍으면 파일이 같은 줄로 찬다.
1965
+ const prevObs = this.screenObs.get(pane);
1966
+ const reason = verdict.reasons.join(' / ');
1967
+ // 같은 깨짐이 몇 번 연속으로 보였나 — TUI 가 한 프레임을 여러 write 로 그리는 도중을
1968
+ // 잡으면 멀쩡한 화면도 한 번은 깨져 보인다. 그 중간 프레임에 손대지 않으려고 센다.
1969
+ const streak = verdict.broken ? (((prevObs === null || prevObs === void 0 ? void 0 : prevObs.broken) ? ((_a = prevObs.streak) !== null && _a !== void 0 ? _a : 0) : 0) + 1) : 0;
1970
+ const changed = !prevObs || prevObs.broken !== verdict.broken || prevObs.reason !== reason;
1971
+ this.screenObs.set(pane, { broken: verdict.broken, reason, streak });
1972
+ if (changed) {
1973
+ if (verdict.broken) {
1974
+ this.snapshot(pane, `detect 깨짐 ${reason}`);
1975
+ }
1976
+ else if (prevObs === null || prevObs === void 0 ? void 0 : prevObs.broken) {
1977
+ // 우리가 아무것도 안 했는데 스스로 돌아왔다 — 트리거를 가르는 중요한 단서다
1978
+ this.snapshot(pane, 'detect 스스로 복구됨');
1979
+ }
1980
+ }
1981
+ if (!verdict.broken) {
1982
+ return;
1983
+ }
1984
+ if (!cfg.autoRepairScreen) {
1985
+ return;
1986
+ }
1987
+ // 한 번 스친 중간 프레임은 건드리지 않는다 — 2초 그물이므로 기본값 2 는 "2초 넘게
1988
+ // 깨진 채로 있다" 는 뜻이다. 예전에 자동복구가 타이핑 중에 튀어나와 체감을 망친 것이
1989
+ // 이 조건이 없어서였다 (2026-09-02 항목 27).
1990
+ if (streak < ((_b = cfg.autoRepairMinStreak) !== null && _b !== void 0 ? _b : 2)) {
1991
+ return;
1992
+ }
1356
1993
  const anyPane = pane;
1994
+ const now = Date.now();
1995
+ const last = (_c = this.autoRepairAt.get(pane)) !== null && _c !== void 0 ? _c : 0;
1996
+ const count = (_d = this.autoRepairCount.get(pane)) !== null && _d !== void 0 ? _d : 0;
1997
+ const max = (_e = cfg.autoRepairMaxPerTab) !== null && _e !== void 0 ? _e : 5;
1998
+ const cooldown = (_f = cfg.autoRepairCooldownMs) !== null && _f !== void 0 ? _f : 4000;
1999
+ // 쿨다운/한도에 걸려 아무것도 하지 않을 때는 로그도 남기지 않는다 —
2000
+ // 주기 검사가 2초마다 돌므로 판정이 오탐이면 진단 파일이 같은 줄로 가득 찬다
2001
+ if (now - last < cooldown) {
2002
+ return;
2003
+ }
2004
+ if (max > 0 && count >= max) {
2005
+ this.diagOnce(`auto-repair-cap`, `screen auto-repair 한도 도달 (${count}/${max}) — 더 고치지 않는다.`
2006
+ + ` 사이드바 ↻ 로 직접 복구할 것. 마지막 판정: ${verdict.reasons.join(' / ')}`);
2007
+ return;
2008
+ }
2009
+ this.diag(`${new Date().toISOString()} screen BROKEN cols=${read.cols}`
2010
+ + ` sent=${(_g = anyPane.size) === null || _g === void 0 ? void 0 : _g.columns}x${(_h = anyPane.size) === null || _h === void 0 ? void 0 : _h.rows} ${verdict.reasons.join(' / ')}`);
2011
+ // 고치기 **전에** 화면 원문을 남긴다 — 복구가 덮어쓰면 증거가 사라진다
2012
+ this.snapshot(pane, `auto-before ${verdict.reasons.join(' / ')}`);
2013
+ this.autoRepairAt.set(pane, now);
2014
+ this.autoRepairCount.set(pane, count + 1);
2015
+ this.diag(`${new Date().toISOString()} screen auto-repair #${count + 1}`);
2016
+ // 스크롤백을 지우는 hard 복구는 하지 않는다 — 자동으로 도는 경로에서 오탐 한 번에
2017
+ // 지난 대화가 날아가면 손해가 크다. 크기 재동기 + pty 흔들기 + Ctrl+L 까지만 한다.
2018
+ // 실측(2026-09-01): 세션이 열린 직후 유실됐던 resize 도 이 흔들기로는 pty 에 도달한다.
1357
2019
  const frontend = anyPane.frontend;
1358
- const cols = (_a = frontend === null || frontend === void 0 ? void 0 : frontend.xterm) === null || _a === void 0 ? void 0 : _a.cols;
1359
- const rows = (_b = frontend === null || frontend === void 0 ? void 0 : frontend.xterm) === null || _b === void 0 ? void 0 : _b.rows;
1360
- const session = anyPane.session;
1361
- if (!cols || !rows || cols < 2 || !(session === null || session === void 0 ? void 0 : session.open) || typeof session.resize !== 'function') {
1362
- // Tabby 첫 기동은 셸이 뜨는 데 시간이 걸려 예약 시점엔 세션이 아직 안 열려 있다.
1363
- // 여기서 그냥 포기하면 kickedPanes 에 이미 올라가 있어 두 번 다시 시도하지 않는다.
1364
- this.diag(`${new Date().toISOString()} not-ready attempt=${attempt} cols=${cols} open=${session === null || session === void 0 ? void 0 : session.open}`);
1365
- if (attempt < KICK_RETRY_MAX) {
1366
- setTimeout(() => this.kickPtySize(pane, attempt + 1), KICK_RETRY_MS);
2020
+ // 크기를 건드리는 건 **실제로 어긋났을 때만.** 이번에 실사용에서 잡힌 유형은
2021
+ // xterm 도 pty 도 277 로 맞는데 화면만 깨져 있었다 — 거기에 흔들기를 때리면
2022
+ // 고치지도 못하면서 화면만 흔든다(`auto-after 아직 깨짐` 2연속이 그 결과다).
2023
+ const xcols = (_j = frontend === null || frontend === void 0 ? void 0 : frontend.xterm) === null || _j === void 0 ? void 0 : _j.cols;
2024
+ const xrows = (_k = frontend === null || frontend === void 0 ? void 0 : frontend.xterm) === null || _k === void 0 ? void 0 : _k.rows;
2025
+ if (xcols > 1 && (((_l = anyPane.size) === null || _l === void 0 ? void 0 : _l.columns) !== xcols || ((_m = anyPane.size) === null || _m === void 0 ? void 0 : _m.rows) !== xrows)) {
2026
+ try {
2027
+ (_p = (_o = frontend === null || frontend === void 0 ? void 0 : frontend.fitAddon) === null || _o === void 0 ? void 0 : _o.fit) === null || _p === void 0 ? void 0 : _p.call(_o);
1367
2028
  }
2029
+ catch {
2030
+ // 붙는 중이다 — 아래 단계만이라도 태운다
2031
+ }
2032
+ this.syncPtySize(pane, frontend);
2033
+ this.nudgePtyRedraw(pane);
2034
+ }
2035
+ // 잔상을 **먼저** 지우고 그 다음에 앱에게 다시 그리라고 한다.
2036
+ // 앱은 자기가 쓰는 자리만 덮으므로, 지우지 않으면 옛 줄이 `❯` 자리를 계속 차지한다.
2037
+ this.clearInputArea(pane, read);
2038
+ if (cfg.repairSendRedrawKey !== false) {
2039
+ setTimeout(() => this.sendRedrawKey(pane), REPAIR_REDRAW_DELAY_MS);
2040
+ }
2041
+ // 복구가 실제로 먹혔는지 같은 파일에 이어 남긴다 — 앱이 다시 그릴 시간을 준다
2042
+ setTimeout(() => {
2043
+ const after = this.readScreen(pane);
2044
+ const still = after ? (0,_screen__WEBPACK_IMPORTED_MODULE_10__.judgeScreen)(after.lines, after.cols) : null;
2045
+ this.snapshot(pane, `auto-after ${still ? (still.broken ? '아직 깨짐: ' + still.reasons.join(' / ') : '복구됨') : '읽을 수 없음'}`);
2046
+ }, SNAP_AFTER_MS);
2047
+ }
2048
+ /** 활성 탭의 pane 들을 한 번씩 본다 (주기 그물) */
2049
+ sweepScreens() {
2050
+ if (!this.enabled || !this.screenObservingEnabled()) {
2051
+ return;
2052
+ }
2053
+ const tab = this.app.activeTab;
2054
+ if (!tab) {
2055
+ return;
2056
+ }
2057
+ const anyTab = tab;
2058
+ const panes = typeof anyTab.getAllTabs === 'function'
2059
+ ? anyTab.getAllTabs()
2060
+ : [tab];
2061
+ for (const pane of panes) {
2062
+ this.checkScreen(pane);
2063
+ }
2064
+ }
2065
+ /** 판정에 넣을 화면 줄과 폭 — 숨겨진 탭이나 아직 안 붙은 프론트엔드는 건너뛴다 */
2066
+ readScreen(pane) {
2067
+ var _a, _b, _c, _d;
2068
+ const anyPane = pane;
2069
+ const xterm = (_a = anyPane.frontend) === null || _a === void 0 ? void 0 : _a.xterm;
2070
+ const buf = (_b = xterm === null || xterm === void 0 ? void 0 : xterm.buffer) === null || _b === void 0 ? void 0 : _b.active;
2071
+ if (!buf || typeof buf.getLine !== 'function' || !(xterm.cols > 1)) {
2072
+ return null;
2073
+ }
2074
+ // 숨겨진 탭은 컨테이너 폭이 0 이라 xterm 이 1열로 줄어 있다 — 판정 대상이 아니다
2075
+ const host = (_c = xterm.element) === null || _c === void 0 ? void 0 : _c.parentElement;
2076
+ if (!(host === null || host === void 0 ? void 0 : host.clientWidth)) {
2077
+ return null;
2078
+ }
2079
+ const lines = [];
2080
+ for (let y = buf.baseY; y <= buf.baseY + xterm.rows - 1; y++) {
2081
+ const line = buf.getLine(y);
2082
+ lines.push({
2083
+ text: (_d = line === null || line === void 0 ? void 0 : line.translateToString(false)) !== null && _d !== void 0 ? _d : '',
2084
+ wrapped: !!(line === null || line === void 0 ? void 0 : line.isWrapped),
2085
+ });
2086
+ }
2087
+ return { lines, cols: xterm.cols };
2088
+ }
2089
+ /**
2090
+ * 화면 원문을 통째로 남긴다 — 깨짐 채증용.
2091
+ *
2092
+ * 판정 이유만 남기면 다음에 또 "왜 그렇게 됐는지" 를 추측하게 된다. 행 번호·wrap 플래그·
2093
+ * 길이까지 그대로 적어 두면 `❯` 가 어느 행에 있었는지, 테두리가 몇 칸이었는지가 확정된다.
2094
+ * before/after 를 짝으로 남기므로 복구가 실제로 먹혔는지도 이 파일에서 바로 갈린다.
2095
+ */
2096
+ snapshot(pane, tag) {
2097
+ var _a, _b, _c, _d;
2098
+ const read = this.readScreen(pane);
2099
+ if (!read) {
1368
2100
  return;
1369
2101
  }
1370
- this.diag(`${new Date().toISOString()} kick cols=${cols} rows=${rows} attempt=${attempt} `
1371
- + `fit=${typeof ((_c = frontend === null || frontend === void 0 ? void 0 : frontend.fitAddon) === null || _c === void 0 ? void 0 : _c.fit)}`);
2102
+ const anyPane = pane;
2103
+ const xterm = (_a = anyPane.frontend) === null || _a === void 0 ? void 0 : _a.xterm;
2104
+ const head = `\n===== ${new Date().toISOString()} ${tag}`
2105
+ + ` cols=${read.cols} rows=${xterm === null || xterm === void 0 ? void 0 : xterm.rows} sent=${(_b = anyPane.size) === null || _b === void 0 ? void 0 : _b.columns}x${(_c = anyPane.size) === null || _c === void 0 ? void 0 : _c.rows}`
2106
+ + ` title=${JSON.stringify((_d = pane.title) !== null && _d !== void 0 ? _d : '')} =====`;
2107
+ const body = read.lines
2108
+ .map((l, i) => ({ i, t: l.text.replace(/\s+$/, ''), w: l.wrapped }))
2109
+ .filter(l => l.t)
2110
+ .map(l => `${String(l.i).padStart(3)} w=${l.w ? 1 : 0} len=${l.t.length} ${JSON.stringify(l.t)}`);
1372
2111
  try {
1373
- // PTY 만 흔들면 첫 기동은 고쳐지는데 새 탭은 안 고쳐진다.
1374
- // 첫 기동은 창 복원→최대화(scheduleWindowKick)가 창 자체를 리사이즈해서
1375
- // xterm 이 스스로 다시 계산하지만, 새 탭은 창 크기가 그대로라 그 계산이 일어나지 않는다.
1376
- // TUI 가 옛 폭 화면으로 남는 건 xterm 쪽 상태이므로 xterm 도 같이 흔든다.
1377
- (_e = (_d = frontend === null || frontend === void 0 ? void 0 : frontend.xterm) === null || _d === void 0 ? void 0 : _d.resize) === null || _e === void 0 ? void 0 : _e.call(_d, cols - 1, rows);
1378
- session.resize(cols - 1, rows);
2112
+ fs__WEBPACK_IMPORTED_MODULE_3__.appendFileSync(SNAP_PATH, [head, ...body, ''].join('\n'));
2113
+ }
2114
+ catch {
2115
+ // 진단일 뿐이라 실패해도 동작에는 영향이 없다
2116
+ }
2117
+ }
2118
+ // ---------- 세션 감시 ----------
2119
+ /**
2120
+ * 탭(그리고 그 안의 모든 pane)의 PTY 출력을 구독한다.
2121
+ *
2122
+ * TerminalDecorator 는 프론트엔드 첫 리사이즈 시점에만 붙어서 복원된 탭 등을
2123
+ * 놓치는 경우가 있다. 이쪽은 탭이 목록에 들어오는 즉시 붙으므로 그 빈틈을 메운다.
2124
+ * 같은 출력을 decorator 와 둘 다 먹어도 상태 설정이 멱등이라 결과는 같다.
2125
+ */
2126
+ watchTab(root) {
2127
+ var _a;
2128
+ const anyRoot = root;
2129
+ if (!this.watched.has(root)) {
2130
+ this.watched.add(root);
2131
+ // 제목은 래퍼 탭이 자식 것을 위임받으므로 여기서 받는 게 가장 확실하다
2132
+ if (anyRoot.titleChange$) {
2133
+ anyRoot.titleChange$.subscribe((title) => {
2134
+ (0,_detect__WEBPACK_IMPORTED_MODULE_7__.applyTitle)(this.status, root, title, this.config.store.agentDeck.autoDetect);
2135
+ });
2136
+ }
2137
+ if (anyRoot.title) {
2138
+ (0,_detect__WEBPACK_IMPORTED_MODULE_7__.applyTitle)(this.status, root, anyRoot.title, this.config.store.agentDeck.autoDetect);
2139
+ }
2140
+ // 나중에 분할로 추가되는 pane 도 따라간다
2141
+ if (anyRoot.tabAdded$) {
2142
+ anyRoot.tabAdded$.subscribe((pane) => this.watchPane(root, pane));
2143
+ }
2144
+ }
2145
+ // pane 은 래퍼보다 늦게 붙는 경우가 있으므로 호출될 때마다 다시 훑는다
2146
+ // (래퍼만 보고 한 번에 끝내면 목록에 먼저 들어온 빈 SplitTab 을 잡고 끝나버린다)
2147
+ const panes = typeof anyRoot.getAllTabs === 'function'
2148
+ ? anyRoot.getAllTabs()
2149
+ : [root];
2150
+ this.diag(`${new Date().toISOString()} watchTab panes=${panes.length}`);
2151
+ for (const pane of panes) {
2152
+ this.watchPane(root, pane);
2153
+ }
2154
+ // 래퍼(SplitTabComponent)가 자식보다 먼저 목록에 들어오는 경우가 있다. 그때 여기서
2155
+ // panes=0 을 보고 물러나면, 자식이 붙어도 다시 훑을 계기가 없어 그 탭은 영영
2156
+ // 감시 밖에 남는다 — 상태 감지·Enter 라벨·화면 검사가 전부 안 돈다
2157
+ // (2026-09-01 실측: 기동 직후 첫 탭에서 `watchTab panes=0` 만 두 번 찍히고
2158
+ // `session bind` 가 한 번도 없었다). 그래서 비어 있으면 잠시 뒤 다시 본다.
2159
+ if (!panes.length) {
2160
+ const tries = ((_a = this.emptyTabRetries.get(root)) !== null && _a !== void 0 ? _a : 0) + 1;
2161
+ this.emptyTabRetries.set(root, tries);
2162
+ if (tries <= EMPTY_TAB_RETRY_MAX) {
2163
+ setTimeout(() => {
2164
+ if (this.app.tabs.includes(root)) {
2165
+ this.watchTab(root);
2166
+ }
2167
+ }, EMPTY_TAB_RETRY_MS);
2168
+ }
2169
+ }
2170
+ }
2171
+ watchPane(root, pane) {
2172
+ var _a;
2173
+ if (this.watchedPanes.has(pane)) {
2174
+ return;
2175
+ }
2176
+ this.watchedPanes.add(pane);
2177
+ this.diag(`${new Date().toISOString()} watchPane ${(_a = pane.constructor) === null || _a === void 0 ? void 0 : _a.name}`);
2178
+ const anyPane = pane;
2179
+ const bind = (session) => {
2180
+ var _a, _b, _c, _d, _e, _f;
2181
+ if (!session || !session.output$) {
2182
+ return;
2183
+ }
2184
+ // pty 가 어떤 폭으로 태어났는지 남긴다. Tabby 는 프론트엔드의 **첫** resize$ 값으로
2185
+ // 세션을 스폰하므로(tabby-local: initializeSession(this.size...) -> spawn cols),
2186
+ // 그 스냅샷이 우리 레이아웃보다 이르면 pty 만 옛 폭으로 굳는다 — 그 순간이 여기 찍힌다.
2187
+ this.diag(`${new Date().toISOString()} session bind`
2188
+ + ` xterm=${(_b = (_a = anyPane.frontend) === null || _a === void 0 ? void 0 : _a.xterm) === null || _b === void 0 ? void 0 : _b.cols}x${(_d = (_c = anyPane.frontend) === null || _c === void 0 ? void 0 : _c.xterm) === null || _d === void 0 ? void 0 : _d.rows}`
2189
+ + ` paneSize=${(_e = anyPane.size) === null || _e === void 0 ? void 0 : _e.columns}x${(_f = anyPane.size) === null || _f === void 0 ? void 0 : _f.rows}`);
2190
+ session.output$.subscribe((data) => {
2191
+ (0,_detect__WEBPACK_IMPORTED_MODULE_7__.applyOutput)(this.status, root, pane, data, this.config.store.agentDeck.autoDetect);
2192
+ // 출력이 멈춘 뒤 화면이 온전한지 본다 (그리는 도중에 읽으면 멀쩡한 화면도 깨져 보인다)
2193
+ this.scheduleScreenCheck(pane);
2194
+ });
2195
+ };
2196
+ bind(anyPane.session);
2197
+ if (anyPane.sessionChanged$) {
2198
+ anyPane.sessionChanged$.subscribe((session) => bind(session));
2199
+ }
2200
+ // 프론트엔드가 붙는 도중에 우리가 폭을 바꿨으면 그 변경은 유실될 수 있다
2201
+ // (Tabby 는 attach() 맨 끝에서야 ResizeObserver 를 건다) — 준비되면 fit 한 번으로 깨워 준다.
2202
+ // 여기서 하는 건 `fitAddon.fit()` 뿐이다. 그 뒤는 순정 경로가 이어받는다
2203
+ // (fit → xterm onResize → Tabby 가 session.resize). 우리가 pty 에 직접 쏘지 않는다.
2204
+ if (anyPane.frontendReady$) {
2205
+ anyPane.frontendReady$.subscribe(() => {
2206
+ this.scheduleRefit();
2207
+ this.announceSizeOnOpen(pane);
2208
+ });
2209
+ }
2210
+ // frontendReady 는 replay 가 없는 맨 Subject 라, 복원된 탭의 프론트엔드가 우리보다
2211
+ // 먼저 붙어버리면 위 구독은 영영 못 받는다. 이벤트와 무관하게 한 번 더 예약한다 —
2212
+ // 같은 크기를 두 번 알려도 SIGWINCH 가 안 나므로 무해하다.
2213
+ this.announceSizeOnOpen(pane);
2214
+ }
2215
+ /**
2216
+ * 탭이 열린 뒤 몇 차례, **지금 xterm 이 쓰는 크기를 pty 에 알려준다.**
2217
+ *
2218
+ * 순정이 유일하게 못 메우는 구멍(새 탭에서 pty 만 좁게 태어남)을 메우는 최소 개입이다.
2219
+ * 자세한 근거와 실측은 `TAB_OPEN_ANNOUNCE_MS` 주석에 있다.
2220
+ *
2221
+ * 하지 않는 것 — xterm 은 건드리지 않고(fit 도 resize 도), 폭을 흔들지 않으며,
2222
+ * 감시 루프도 돌지 않는다. 그 셋이 예전에 화면을 깨뜨린 장본인이다.
2223
+ */
2224
+ announceSizeOnOpen(pane) {
2225
+ const anyPane = pane;
2226
+ for (const delay of TAB_OPEN_ANNOUNCE_MS) {
1379
2227
  setTimeout(() => {
1380
- var _a, _b, _c, _d, _e, _f, _g;
2228
+ var _a;
2229
+ const x = (_a = anyPane.frontend) === null || _a === void 0 ? void 0 : _a.xterm;
2230
+ const session = anyPane.session;
2231
+ if (!(session === null || session === void 0 ? void 0 : session.open) || typeof session.resize !== 'function' || !((x === null || x === void 0 ? void 0 : x.cols) > 1)) {
2232
+ return;
2233
+ }
2234
+ const prev = anyPane.size;
2235
+ if ((prev === null || prev === void 0 ? void 0 : prev.columns) !== x.cols || (prev === null || prev === void 0 ? void 0 : prev.rows) !== x.rows) {
2236
+ this.diag(`${new Date().toISOString()} announce size ${prev === null || prev === void 0 ? void 0 : prev.columns}x${prev === null || prev === void 0 ? void 0 : prev.rows}`
2237
+ + ` -> ${x.cols}x${x.rows} (+${delay}ms)`);
2238
+ }
1381
2239
  try {
1382
- // fit() 이 뷰포트 실제 폭으로 되돌리면서 resize$ 를 흘려 session 까지 맞춘다.
1383
- // fitAddon 이 없는 프론트엔드(웹 빌드 등)면 xterm 을 직접 되돌린다.
1384
- if (typeof ((_a = frontend === null || frontend === void 0 ? void 0 : frontend.fitAddon) === null || _a === void 0 ? void 0 : _a.fit) === 'function') {
1385
- frontend.fitAddon.fit();
1386
- }
1387
- else {
1388
- (_c = (_b = frontend === null || frontend === void 0 ? void 0 : frontend.xterm) === null || _b === void 0 ? void 0 : _b.resize) === null || _c === void 0 ? void 0 : _c.call(_b, cols, rows);
1389
- }
1390
- // 되돌릴 값은 fit() 이 실제로 잡은 폭이다. 흔들기 시작 시점의 cols 를
1391
- // 그대로 pty 에 다시 박으면 그 사이 사이드바 폭이 바뀌었거나 fit 결과가
1392
- // 달라진 경우 pty(옛 값) > xterm(실제) 로 어긋난 채 고정된다 —
1393
- // TUI 는 넓은 폭으로 그리는데 화면은 좁아 입력창이 오른쪽으로 밀려 잘린다
1394
- // (2026-08-28 실측: 새 탭에서 줄바꿈하는 순간 드러남).
1395
- const fitCols = (_e = (_d = frontend === null || frontend === void 0 ? void 0 : frontend.xterm) === null || _d === void 0 ? void 0 : _d.cols) !== null && _e !== void 0 ? _e : cols;
1396
- const fitRows = (_g = (_f = frontend === null || frontend === void 0 ? void 0 : frontend.xterm) === null || _f === void 0 ? void 0 : _f.rows) !== null && _g !== void 0 ? _g : rows;
1397
- if (fitCols !== cols || fitRows !== rows) {
1398
- this.diag(`${new Date().toISOString()} kick refit ${cols}x${rows} -> ${fitCols}x${fitRows}`);
1399
- }
1400
- session.resize(fitCols, fitRows);
1401
- anyPane.size = { columns: fitCols, rows: fitRows };
2240
+ session.resize(x.cols, x.rows);
2241
+ anyPane.size = { columns: x.cols, rows: x.rows };
1402
2242
  }
1403
2243
  catch {
1404
- // 세션이 닫혔다 — 되돌릴 대상이 없다
2244
+ // 세션이 막 닫혔다
1405
2245
  }
1406
- }, 80);
1407
- }
1408
- catch {
1409
- // 세션이 막 닫혔다
2246
+ }, delay);
1410
2247
  }
1411
2248
  }
2249
+ // 흔들기 장치는 전부 폐지했다 (2026-09-02).
2250
+ //
2251
+ // 여기에 있던 것들 — `scheduleWindowKick`(기동 시 창 복원→최대화), `scheduleRedrawKick`,
2252
+ // `kickPtySize`(탭당 한 번 cols-1 ↔ cols) — 는 모두 "순정이 크기를 못 맞춘다" 는 전제에서
2253
+ // 나온 우회책이었다. 전제가 틀렸다: Tabby 는 xterm `ResizeObserver` → `fitAddon.fit()` →
2254
+ // `session.resize()` 로 스스로 맞춘다. 우리가 폭만 정해 주면 된다.
2255
+ // 이 흔들기들은 순정 수렴과 경쟁하며 프레임을 반쯤 그린 상태를 남겨 화면을 깨뜨렸다.
2256
+ // 수동 복구(`repairPane`)는 여전히 흔든다 — 거기는 사용자가 명시로 요청한 경로다.
1412
2257
  /**
1413
2258
  * Ctrl+V 를 **핫키 테이블에서 걷어낸다** (실제 처리는 claimCtrlVKey 가 한다).
1414
2259
  *
@@ -1929,15 +2774,53 @@ let AgentDeckService = class AgentDeckService {
1929
2774
  this.sidebar.style.display = this.enabled ? '' : 'none';
1930
2775
  }
1931
2776
  if (!this.enabled && this.mainEl) {
1932
- this.mainEl.style.removeProperty('flex');
1933
- this.mainEl.style.removeProperty('width');
1934
- this.mainEl.style.removeProperty('max-width');
1935
- this.mainEl.style.removeProperty('margin-left');
2777
+ for (const prop of ['flex', 'width', 'max-width', 'height',
2778
+ 'margin-left', 'margin-right', 'margin-top', 'margin-bottom']) {
2779
+ this.mainEl.style.removeProperty(prop);
2780
+ }
1936
2781
  // 폭을 되돌렸으니 메모도 무효화하고 터미널에 새 폭을 알린다
1937
2782
  this.lastTermW = -1;
1938
2783
  this.lastSidebarW = -1;
2784
+ this.lastDock = null;
1939
2785
  this.scheduleRefit();
1940
2786
  }
2787
+ this.syncBackgroundClip();
2788
+ }
2789
+ /**
2790
+ * tabby-background 의 이미지 레이어를 터미널 영역(`.content.main`)에 맞춰 자른다.
2791
+ *
2792
+ * 그 레이어는 `position: fixed` 라 viewport 좌표를 쓰므로 `getBoundingClientRect()` 값을
2793
+ * 그대로 CSS 변수로 내리면 된다 (styles.scss 가 `!important` 로 덮어쓴다).
2794
+ * relayout 직후에는 인라인 폭이 아직 레이아웃에 반영되지 않았을 수 있어 rAF 한 번 뒤에 잰다.
2795
+ * 꺼져 있으면(또는 agentdeck 비활성) 클래스와 변수를 걷어 순정(창 전체)으로 되돌린다.
2796
+ */
2797
+ syncBackgroundClip() {
2798
+ const root = document.documentElement;
2799
+ const clear = () => {
2800
+ document.body.classList.remove(BG_CLIP_CLASS);
2801
+ for (const v of ['--ad-bg-left', '--ad-bg-top', '--ad-bg-width', '--ad-bg-height']) {
2802
+ root.style.removeProperty(v);
2803
+ }
2804
+ };
2805
+ if (!this.enabled || !this.config.store.agentDeck.clipBackgroundToTerminal || !this.mainEl) {
2806
+ clear();
2807
+ return;
2808
+ }
2809
+ requestAnimationFrame(() => {
2810
+ if (!this.enabled || !this.mainEl) {
2811
+ clear();
2812
+ return;
2813
+ }
2814
+ const r = this.mainEl.getBoundingClientRect();
2815
+ if (r.width <= 0 || r.height <= 0) {
2816
+ return;
2817
+ }
2818
+ root.style.setProperty('--ad-bg-left', Math.round(r.left) + 'px');
2819
+ root.style.setProperty('--ad-bg-top', Math.round(r.top) + 'px');
2820
+ root.style.setProperty('--ad-bg-width', Math.round(r.width) + 'px');
2821
+ root.style.setProperty('--ad-bg-height', Math.round(r.height) + 'px');
2822
+ document.body.classList.add(BG_CLIP_CLASS);
2823
+ });
1941
2824
  }
1942
2825
  /**
1943
2826
  * 터미널/사이드바 배경에 알파를 넣어 tabby-background 의 배경 이미지가 비치게 한다.
@@ -1979,8 +2862,46 @@ let AgentDeckService = class AgentDeckService {
1979
2862
  this.config.store.terminal.background = wantMode;
1980
2863
  this.config.save();
1981
2864
  }
1982
- /** 창 높이에서 터미널 폭을 역산해 .content.main 을 고정폭으로 만든다 */
2865
+ /** 지금 사이드바가 붙어 있는 방향 (설정값이 망가져 있으면 오른쪽으로 본다) */
2866
+ get dockSide() {
2867
+ const v = this.config.store.agentDeck.sidebarDock;
2868
+ return (0,_dock__WEBPACK_IMPORTED_MODULE_11__.isDockSide)(v) ? v : 'right';
2869
+ }
2870
+ /**
2871
+ * 사이드바가 차지할 크기를 px 로 정한다 — 좌/우면 폭, 상/하면 높이.
2872
+ *
2873
+ * 사용자가 경계선을 한 번이라도 끌었으면 그 값(`sidebarWidth`/`sidebarHeight`)이 진실이다.
2874
+ * 아직 안 끌었다면(폭이 0) 예전처럼 화면비로 터미널 폭을 역산해 남는 만큼을 사이드바가 갖는다.
2875
+ */
2876
+ sidebarExtent(availW, availH, winH) {
2877
+ const cfg = this.config.store.agentDeck;
2878
+ if ((0,_dock__WEBPACK_IMPORTED_MODULE_11__.isHorizontalDock)(this.dockSide)) {
2879
+ const minSidebar = cfg.sidebarMin || 200;
2880
+ const maxSidebar = Math.max(cfg.sidebarMax || 560, minSidebar);
2881
+ let want = cfg.sidebarWidth || 0;
2882
+ if (!want) {
2883
+ // 화면비를 지킨다는 가정에서 터미널 폭을 높이로 역산하고 남는 폭을 사이드바에 준다
2884
+ const ratio = (cfg.aspectW || 4) / (cfg.aspectH || 3);
2885
+ let termW = Math.round(availH * ratio);
2886
+ termW = Math.min(termW, availW - minSidebar);
2887
+ termW = Math.max(termW, MIN_TERM_W);
2888
+ want = availW - termW;
2889
+ }
2890
+ // 손으로 끈 값은 설정의 min/max 를 넘어설 수 있다 — 마우스가 우선이므로
2891
+ // 목록이 읽히는 최소 폭과 터미널이 남을 최대 폭까지만 막는다
2892
+ const hardMin = cfg.sidebarWidth ? MIN_SIDEBAR_W : minSidebar;
2893
+ const hardMax = Math.max(availW - MIN_TERM_W, hardMin);
2894
+ const softMax = cfg.sidebarWidth ? Math.max(maxSidebar, cfg.sidebarWidth) : maxSidebar;
2895
+ return Math.min(Math.max(want, hardMin), softMax, hardMax);
2896
+ }
2897
+ // 세로 도킹은 창 높이를 기준으로 잡는다 — 터미널 높이(availH)는 사이드바를 뺀 값이라
2898
+ // 그걸로 클램프하면 잴 때마다 값이 줄어드는 되먹임이 생긴다
2899
+ const want = cfg.sidebarHeight || 200;
2900
+ return Math.min(Math.max(want, MIN_SIDEBAR_H), Math.max(winH - MIN_TERM_H, MIN_SIDEBAR_H));
2901
+ }
2902
+ /** 사이드바를 창 한쪽에 붙이고, 남는 자리를 `.content.main` 이 갖게 한다 */
1983
2903
  relayout() {
2904
+ var _a;
1984
2905
  if (!this.enabled || !this.windowEl) {
1985
2906
  this.diagOnce('relayout-skip-enabled', `relayout skipped enabled=${this.enabled} windowEl=${!!this.windowEl}`);
1986
2907
  return;
@@ -1993,53 +2914,114 @@ let AgentDeckService = class AgentDeckService {
1993
2914
  this.diagOnce('relayout-skip-mainel', 'relayout skipped mainEl=null');
1994
2915
  return;
1995
2916
  }
1996
- const cfg = this.config.store.agentDeck;
1997
2917
  const availW = this.windowEl.clientWidth;
2918
+ // 화면비로 폭을 역산할 때 쓰는 높이는 터미널이 실제로 쓰는 높이여야 한다.
2919
+ // 창 높이를 쓰면 탭바 등을 뺀 만큼 폭이 과대 계산돼 사이드바가 좁아진다
1998
2920
  const availH = this.mainEl.clientHeight || this.windowEl.clientHeight;
1999
2921
  if (!availW || !availH) {
2000
2922
  return;
2001
2923
  }
2002
- const ratio = (cfg.aspectW || 4) / (cfg.aspectH || 3);
2003
- const minSidebar = cfg.sidebarMin || 200;
2004
- const maxSidebar = Math.max(cfg.sidebarMax || 560, minSidebar);
2005
- // 1) 4:3 을 지킨다는 가정에서 터미널 폭을 높이로 역산한다
2006
- let termW = Math.round(availH * ratio);
2007
- termW = Math.min(termW, availW - minSidebar);
2008
- termW = Math.max(termW, 320);
2009
- // 2) 사이드바는 min~max 로 클램프한다 (max 는 config agentDeck.sidebarMax 로 사용자가 올릴 수 있다)
2010
- const sidebarW = Math.min(Math.max(availW - termW, minSidebar), maxSidebar);
2011
- // 3) 그래도 폭이 남으면 4:3 을 포기하고 터미널이 흡수한다 — 오른쪽에 빈 띠를 남기지 않는다
2012
- // (2026-08-28 실측: 창2000 - 터미널1460 - 사이드바430 = 110px 잔여가 빈 띠로 보였다)
2013
- termW = Math.max(availW - sidebarW, 320);
2924
+ const side = this.dockSide;
2925
+ const horizontal = (0,_dock__WEBPACK_IMPORTED_MODULE_11__.isHorizontalDock)(side);
2926
+ let extent = Math.round(this.sidebarExtent(availW, availH, this.windowEl.clientHeight));
2927
+ // 터미널이 갖는 폭 — 가로 도킹일 때만 의미가 있다 (세로 도킹이면 폭 전체를 쓴다)
2928
+ let termW = horizontal ? availW - extent : availW;
2929
+ // 터미널 폭을 문자 셀의 정수배로 깎는다.
2930
+ //
2931
+ // 셀 폭으로 나누어떨어지지 않으면 오른쪽에 셀 하나가 안 되는 자투리가 남는데,
2932
+ // fit() 은 그 자투리를 두고 창이 몇 px 만 흔들려도 열 수가 오르내린다. 열 수가 바뀔 때마다
2933
+ // pty 에 새 크기가 나가고 TUI 는 전체를 다시 그린다 — 화면이 깜빡이고 어긋날 틈이 생긴다.
2934
+ // 정수배로 맞춰 두면 자투리가 사라져 열 수가 흔들리지 않는다. 깎아 낸 픽셀은 사이드바가 갖는다.
2935
+ // 한때 이 자리에서 터미널 폭을 문자 셀의 정수배로 깎았다. 자투리 픽셀을 없애려던 건데
2936
+ // 오히려 화면이 계속 밀리는 원인이 됐다 — 셀 폭은 폰트가 자리를 잡는 동안 조금씩 다르게
2937
+ // 잡히고(2026-09-01 실측: 같은 창에서 termW 가 1977 -> 1970 -> 1974 로 진동),
2938
+ // 폭이 바뀔 때마다 pty 를 다시 맞추고 TUI 를 다시 그리게 만들었다.
2939
+ // 폭은 사용자가 정한 사이드바 크기에서 곧장 나오는 값 하나로 고정하는 편이 안정적이다.
2940
+ // 남는 자투리는 어차피 배경색이라 눈에 띄지 않는다.
2941
+ extent = Math.round(extent);
2942
+ termW = Math.round(termW);
2014
2943
  // 값이 그대로면 DOM 을 건드리지 않는다 — relayout 은 탭/상태가 바뀔 때마다 불리므로
2015
2944
  // 매번 스타일을 다시 쓰면 xterm 이 불필요하게 재측정한다
2016
- if (termW === this.lastTermW && sidebarW === this.lastSidebarW) {
2945
+ if (termW === this.lastTermW && extent === this.lastSidebarW && side === this.lastDock) {
2017
2946
  return;
2018
2947
  }
2019
2948
  const prevTermW = this.lastTermW;
2020
2949
  this.lastTermW = termW;
2021
- this.lastSidebarW = sidebarW;
2022
- // 기동 직후에 폭이 실제로 바뀌었다면, 그 전에 그려진 TUI 첫 화면은 옛 폭 기준이다.
2023
- // 예약해둔 흔들기가 이미 지나갔을 수도 있으므로 여기서 다시 한 번 흔들어준다
2024
- // (창 복원→최대화를 손으로 하던 것과 같은 효과 — kickPtySize 주석 참고).
2025
- if (this.setupAt && Date.now() - this.setupAt < KICK_REARM_MS) {
2026
- this.diag(`${new Date().toISOString()} rearm kick termW ${prevTermW}->${termW} panes=${this.startupPanes.length}`);
2027
- for (const pane of this.startupPanes) {
2028
- this.kickedPanes.delete(pane);
2029
- this.scheduleRedrawKick(pane, true);
2030
- }
2031
- }
2032
- else if (this.startupPanes.length) {
2033
- // 기동 구간이 끝났다 — 닫힌 pane 을 붙들고 있지 않도록 목록을 비운다
2034
- this.startupPanes = [];
2035
- }
2036
- this.mainEl.style.flex = '0 0 ' + termW + 'px';
2037
- this.mainEl.style.width = termW + 'px';
2038
- this.mainEl.style.maxWidth = termW + 'px';
2950
+ this.lastSidebarW = extent;
2951
+ this.lastDock = side;
2952
+ // 기동 직후 흔들기(rearm kick)는 폐지했다 — 아래 relayout 주석 참고.
2953
+ // 사이드바는 창 위에 절대 배치한다. flex 자식으로 두면 도킹 방향을 바꿀 때마다
2954
+ // `.window` 의 flex-direction 을 뒤집어야 하고, 그러면 같은 줄에 있는 profile-tree 까지
2955
+ // 따라 움직인다. 절대 배치면 우리 것만 옮기면 된다.
2039
2956
  if (this.sidebar) {
2040
- this.sidebar.style.width = sidebarW + 'px';
2957
+ const s = this.sidebar.style;
2958
+ s.position = 'absolute';
2959
+ s.left = side === 'right' ? 'auto' : '0';
2960
+ s.right = side === 'left' ? 'auto' : '0';
2961
+ s.top = side === 'bottom' ? 'auto' : '0';
2962
+ s.bottom = side === 'top' ? 'auto' : '0';
2963
+ s.width = horizontal ? extent + 'px' : 'auto';
2964
+ s.height = horizontal ? 'auto' : extent + 'px';
2965
+ for (const d of _dock__WEBPACK_IMPORTED_MODULE_11__.DOCK_SIDES) {
2966
+ this.sidebar.classList.toggle('ad-dock-' + d, d === side);
2967
+ }
2041
2968
  }
2969
+ // 터미널이 차지할 자리.
2970
+ //
2971
+ // 가로 도킹에서는 폭을 px 로 못 박는다. margin 만 주고 flex 에게 남는 폭을 맡겨 봤더니
2972
+ // (2026-09-01) xterm 이 fit 할 때 잡는 폭과 TUI 가 아는 폭이 어긋나 입력창이 깨졌다 —
2973
+ // 가로선과 입력 텍스트가 한 줄에 섞여 wrap 되는 그 증상이다. 폭을 직접 주면 xterm host 가
2974
+ // 재는 값이 곧 우리가 정한 값이라 어긋날 여지가 없다.
2975
+ // 세로 도킹은 폭을 건드릴 필요가 없고, `h-100`(height:100%) 때문에 margin 만으로는
2976
+ // 그만큼 창 밖으로 넘치므로 높이를 대신 깎는다.
2977
+ const m = this.mainEl.style;
2978
+ if (horizontal) {
2979
+ m.flex = '0 0 ' + termW + 'px';
2980
+ m.width = termW + 'px';
2981
+ m.maxWidth = termW + 'px';
2982
+ m.removeProperty('height');
2983
+ m.marginTop = '0';
2984
+ m.marginBottom = '0';
2985
+ // 왼쪽 도킹일 때만 앞자리를 비운다 (오른쪽 도킹은 고정폭만으로 자리가 남는다)
2986
+ m.marginLeft = side === 'left' ? extent + 'px' : '0';
2987
+ m.marginRight = '0';
2988
+ }
2989
+ else {
2990
+ m.removeProperty('flex');
2991
+ m.removeProperty('width');
2992
+ m.removeProperty('max-width');
2993
+ m.marginLeft = '0';
2994
+ m.marginRight = '0';
2995
+ m.marginTop = side === 'top' ? extent + 'px' : '0';
2996
+ m.marginBottom = side === 'bottom' ? extent + 'px' : '0';
2997
+ // **`important` 가 필요하다.** `.content.main` 에는 부트스트랩 `h-100` 클래스가 붙어
2998
+ // 있고 그 규칙이 `height: 100% !important` 라, 평범한 인라인 스타일은 진다.
2999
+ // 그래서 상하 도킹에서 터미널 높이가 전혀 줄지 않았다 — bottom 이면 아래쪽이
3000
+ // 사이드바에 가리고, top 이면 그만큼 창 밖으로 넘쳤다
3001
+ // (2026-09-02 실측, 창 1100x700 / 사이드바 200: 인라인은 `calc(100% - 200px)` 인데
3002
+ // computed 는 `700px`, host 는 top 에서 `1070x670@y=215` 로 하단 185px 가 잘렸다).
3003
+ m.setProperty('height', 'calc(100% - ' + extent + 'px)', 'important');
3004
+ }
3005
+ (_a = this.dock) === null || _a === void 0 ? void 0 : _a.syncHandle();
3006
+ this.syncBackgroundClip();
3007
+ // 우리가 하는 일은 여기까지다 — **폭을 정해 `.content.main` 에 적고 fit 한 번.**
3008
+ //
3009
+ // 2026-09-02 방향 전환(유저 지적): "Tabby 는 창 크기가 어떻든 알아서 줄맞춤 되잖아?" 맞다.
3010
+ // 순정 경로는 xterm `ResizeObserver` → `fitAddon.fit()` → `session.resize()` 이고,
3011
+ // 우리가 폭만 정해 주면 그 뒤는 Tabby 가 스스로 계산한다.
3012
+ // 예전에는 "순정이 우리 CSS 변경을 못 따라온다"고 보고 보정을 4겹 쌓았다 —
3013
+ // relayout 시 fit / `watchSize`(250ms 마다 fit+resize, 6초) / `syncPtySize` 재전송 사슬 /
3014
+ // `nudgePtyRedraw`(cols-1↔cols 흔들기). 그 겹침이 순정 수렴과 경쟁하면서
3015
+ // 프레임을 반쯤 그린 상태를 남겼고, 그게 실사용 깨짐의 정체였다(항목 24~28 은 전부 그 부작용).
3016
+ // 전제도 로그로 반박됐다 — 폭은 탭이 생기기 전에 이미 확정돼 있는데도
3017
+ // (`rearm kick termW -1->2000 panes=0`) 새 탭 xterm 이 254 로 뜬다. 그건 레이아웃 확정 전의
3018
+ // 초기 측정값일 뿐이고 순정 옵저버가 어차피 수렴시킨다.
3019
+ // 그래서 자동 보정은 전부 걷어냈다. 남은 건 이 fit 하나(순정 경로를 깨우는 용도)다.
2042
3020
  this.scheduleRefit();
3021
+ // 폭이 어긋나는 사고를 다시 만나면 이 줄이 실제 값을 알려 준다 —
3022
+ // 우리가 정한 값(termW)과 브라우저가 실제로 준 폭(clientWidth)이 다르면 그게 원인이다
3023
+ this.diag(`${new Date().toISOString()} relayout dock=${side} avail=${availW}x${availH}`
3024
+ + ` extent=${extent} termW=${termW} mainW=${this.mainEl.clientWidth}`);
2043
3025
  }
2044
3026
  /**
2045
3027
  * 폭을 바꿨으면 터미널에 반드시 알려야 한다.
@@ -2094,7 +3076,9 @@ let AgentDeckService = class AgentDeckService {
2094
3076
  catch {
2095
3077
  // 아직 붙지 않은 프론트엔드 — 다음 relayout 때 다시 잡힌다
2096
3078
  }
2097
- this.syncPtySize(pane, frontend);
3079
+ // 여기서 끝낸다. 예전에는 뒤이어 `syncPtySize` 로 pty 에 직접 크기를 쐈는데,
3080
+ // fit() 이 이미 순정 경로(onResize → session.resize)를 태우므로 중복이고,
3081
+ // 그 중복이 순정 수렴과 경쟁해 화면을 깨뜨렸다 (2026-09-02).
2098
3082
  }
2099
3083
  }
2100
3084
  }
@@ -2121,14 +3105,97 @@ let AgentDeckService = class AgentDeckService {
2121
3105
  if (!session || !session.open || typeof session.resize !== 'function') {
2122
3106
  return;
2123
3107
  }
3108
+ // 이 경로는 지금까지 조용했다. 그래서 "언제 pty 폭이 바뀌었나" 가 로그에 안 남아
3109
+ // 어긋남 사고를 만나도 발생 지점을 못 짚었다 (watchSize 의 `size fix` 만 남았다).
3110
+ // 값이 실제로 달라질 때만 남긴다 — 같은 값 재전송은 매 refit 마다 일어나므로 시끄럽다.
3111
+ const prev = anyPane.size;
3112
+ if ((prev === null || prev === void 0 ? void 0 : prev.columns) !== cols || (prev === null || prev === void 0 ? void 0 : prev.rows) !== rows) {
3113
+ this.diag(`${new Date().toISOString()} pty resize ${prev === null || prev === void 0 ? void 0 : prev.columns}x${prev === null || prev === void 0 ? void 0 : prev.rows}`
3114
+ + ` -> ${cols}x${rows}`);
3115
+ }
2124
3116
  try {
2125
3117
  session.resize(cols, rows);
2126
3118
  anyPane.size = { columns: cols, rows };
2127
3119
  }
2128
3120
  catch {
2129
3121
  // 세션이 막 닫혔다 — 다음 refit 때 다시 잡힌다
3122
+ return;
3123
+ }
3124
+ // **보냈다고 도착한 것이 아니다.**
3125
+ //
3126
+ // 세션이 막 열린 직후의 `session.resize()` 는 pty 에 닿지 못하고 유실된다
3127
+ // (2026-09-01 실측: `size fix 254x78 -> 280x78` + `pty resize` + `redraw nudge` 가
3128
+ // 모두 돌고 `pane.size` 도 280 으로 남았는데, 같은 탭 셸에 물어보니 `PTYW=254`.
3129
+ // 같은 호출을 몇 초 뒤에 CDP 로 하면 그때는 정상 반영된다 — 초기화 경합이다).
3130
+ // 그런데 `pane.size` 에 낙관적으로 280 을 적어 두므로, 크기 감시(watchSize)는
3131
+ // "이제 맞다" 고 보고 손을 뗀다. 그 뒤에 뜨는 TUI 는 254 폭으로 첫 화면을 그린다.
3132
+ //
3133
+ // 그래서 값이 실제로 바뀐 경우에만 같은 크기를 몇 번 더 보낸다. pty 크기가 이미
3134
+ // 그 값이면 아무 일도 일어나지 않으므로(SIGWINCH 도 안 난다) 재전송은 무해하다.
3135
+ if ((prev === null || prev === void 0 ? void 0 : prev.columns) !== cols || (prev === null || prev === void 0 ? void 0 : prev.rows) !== rows) {
3136
+ for (const delay of PTY_RESIZE_RETRY_MS) {
3137
+ setTimeout(() => {
3138
+ var _a, _b;
3139
+ const now = (_a = anyPane.frontend) === null || _a === void 0 ? void 0 : _a.xterm;
3140
+ // 그동안 폭이 또 바뀌었으면 그쪽 경로가 새 값을 보낸다 — 옛 값을 덮어쓰지 않는다
3141
+ if (!((_b = anyPane.session) === null || _b === void 0 ? void 0 : _b.open) || (now === null || now === void 0 ? void 0 : now.cols) !== cols || (now === null || now === void 0 ? void 0 : now.rows) !== rows) {
3142
+ return;
3143
+ }
3144
+ try {
3145
+ anyPane.session.resize(cols, rows);
3146
+ }
3147
+ catch {
3148
+ // 세션이 닫혔다
3149
+ }
3150
+ }, delay);
3151
+ }
3152
+ }
3153
+ }
3154
+ /**
3155
+ * pty 크기만 한 칸 줄였다 되돌려 TUI 가 화면을 다시 그리게 한다. **수동 복구 전용.**
3156
+ *
3157
+ * 자동 경로에서는 전부 걷어냈다 — 이 흔들기가 순정 수렴과 경쟁하며 깨진 화면을
3158
+ * 만들어 왔다(2026-09-02). 남겨 둔 이유는 `repairPane`(사이드바 `↻` / `agentdeck-repair`)
3159
+ * 때문이다. 거기서는 사용자가 "지금 깨졌으니 고쳐라" 를 명시로 눌렀고, 크기를 맞추는
3160
+ * 것만으로는 앱이 다시 그리지 않는 경우가 실제로 있다 — 폭이 **실제로 바뀌어야**
3161
+ * SIGWINCH 가 나가므로 한 칸 흔들어 두 번 내보낸다. 뒤이어 `Ctrl+L` 이 따라간다.
3162
+ *
3163
+ * 좁힌 값을 `pane.size` 에 그대로 적는다. 되돌리기가 유실됐는데 낙관적으로 `cols` 를
3164
+ * 적어 두면 아무도 어긋남을 못 보고 한 열 좁은 채로 굳는다(옛 버그).
3165
+ */
3166
+ nudgePtyRedraw(pane) {
3167
+ var _a, _b, _c, _d;
3168
+ const anyPane = pane;
3169
+ const session = anyPane.session;
3170
+ const cols = (_b = (_a = anyPane.frontend) === null || _a === void 0 ? void 0 : _a.xterm) === null || _b === void 0 ? void 0 : _b.cols;
3171
+ const rows = (_d = (_c = anyPane.frontend) === null || _c === void 0 ? void 0 : _c.xterm) === null || _d === void 0 ? void 0 : _d.rows;
3172
+ if (!(session === null || session === void 0 ? void 0 : session.open) || typeof session.resize !== 'function' || !(cols > 1) || !(rows > 1)) {
3173
+ return;
3174
+ }
3175
+ this.diag(`${new Date().toISOString()} redraw nudge ${cols}x${rows}`);
3176
+ try {
3177
+ session.resize(cols - 1, rows);
3178
+ anyPane.size = { columns: cols - 1, rows };
3179
+ setTimeout(() => {
3180
+ var _a;
3181
+ if (!((_a = anyPane.session) === null || _a === void 0 ? void 0 : _a.open)) {
3182
+ return;
3183
+ }
3184
+ // 되돌릴 값은 "지금" 의 xterm 폭이다 — 흔드는 사이 사이드바가 움직였을 수 있다
3185
+ this.syncPtySize(pane, anyPane.frontend);
3186
+ }, 60);
3187
+ }
3188
+ catch {
3189
+ // 세션이 막 닫혔다
2130
3190
  }
2131
3191
  }
3192
+ // 크기 감시(`watchSize` / `watchAllSizes`)도 폐지했다 (2026-09-02).
3193
+ //
3194
+ // 250ms 마다 `fitAddon.fit()` + `session.resize()` 를 6초간 쏘던 장치다. 목적은
3195
+ // "xterm 은 넓어졌는데 pty 는 옛 폭" 을 잡는 것이었는데, 그 어긋남은 순정
3196
+ // ResizeObserver 가 이미 수렴시킨다. 우리 감시는 그 수렴 도중의 중간값을 보고
3197
+ // 계속 끼어들었고, 끝에 `nudgePtyRedraw` 흔들기까지 붙여 화면을 깨뜨렸다.
3198
+ // 순정이 정말 못 맞추는 경우가 나오면 `screenWatch` 채증에 남는다 — 그때 근거를 갖고 되켠다.
2132
3199
  // ---------- 사이드바 DOM ----------
2133
3200
  buildSidebar() {
2134
3201
  const el = document.createElement('div');
@@ -2142,6 +3209,9 @@ let AgentDeckService = class AgentDeckService {
2142
3209
  '<div class="ad-foot">',
2143
3210
  ' <button class="ad-btn ad-new" title="새 탭">+ 새 탭</button>',
2144
3211
  ' <button class="ad-btn ad-settings" title="설정">설정</button>',
3212
+ // 화면이 깨졌을 때 누르는 자리 — 창을 복원→최대화로 흔들던 것을 대신한다.
3213
+ // 자주 쓰는 두 버튼(새 탭·설정) 뒤에 둔다 (2026-09-01 유저 요청)
3214
+ ' <button class="ad-btn ad-repair" title="화면 복구 (모든 탭 다시 그리기)">↻</button>',
2145
3215
  '</div>',
2146
3216
  ].join('\n');
2147
3217
  // 순정 탭바보다 뒤(오른쪽)에 놓는다 — 왼쪽에 두면 세로로 길어 허전하다는 피드백(2026-08-28)
@@ -2151,9 +3221,45 @@ let AgentDeckService = class AgentDeckService {
2151
3221
  el.querySelector('.ad-new').addEventListener('click', () => {
2152
3222
  this.zone.run(() => { void this.openNewTab(); });
2153
3223
  });
3224
+ el.querySelector('.ad-repair').addEventListener('click', () => {
3225
+ this.zone.run(() => this.repair('all'));
3226
+ });
2154
3227
  el.querySelector('.ad-settings').addEventListener('click', () => {
2155
3228
  this.zone.run(() => this.openSettings());
2156
3229
  });
3230
+ // 크기/도킹은 설정 창이 아니라 마우스로 맞춘다.
3231
+ // 드래그 중에는 relayout 이 초당 수십 번 불리므로 config.save() 는 손을 뗄 때만 부른다.
3232
+ this.dock = new _dock__WEBPACK_IMPORTED_MODULE_11__.DockController({
3233
+ sidebar: el,
3234
+ windowEl: this.windowEl,
3235
+ getDock: () => this.dockSide,
3236
+ setDock: side => {
3237
+ this.config.store.agentDeck.sidebarDock = side;
3238
+ },
3239
+ getSize: side => {
3240
+ const cfg = this.config.store.agentDeck;
3241
+ if (!(0,_dock__WEBPACK_IMPORTED_MODULE_11__.isHorizontalDock)(side)) {
3242
+ return cfg.sidebarHeight || 0;
3243
+ }
3244
+ // 지금 좌/우로 붙어 있다면 화면에 실제로 적용된 폭이 가장 정확하다
3245
+ // (자동 계산 상태면 sidebarWidth 가 0 이라 물어봐야 소용이 없다)
3246
+ return (0,_dock__WEBPACK_IMPORTED_MODULE_11__.isHorizontalDock)(this.dockSide) && this.lastSidebarW > 0
3247
+ ? this.lastSidebarW
3248
+ : (cfg.sidebarWidth || 0);
3249
+ },
3250
+ setSize: px => {
3251
+ const cfg = this.config.store.agentDeck;
3252
+ if ((0,_dock__WEBPACK_IMPORTED_MODULE_11__.isHorizontalDock)(this.dockSide)) {
3253
+ cfg.sidebarWidth = px;
3254
+ }
3255
+ else {
3256
+ cfg.sidebarHeight = px;
3257
+ }
3258
+ },
3259
+ commit: () => this.zone.run(() => this.config.save()),
3260
+ relayout: () => this.relayout(),
3261
+ });
3262
+ this.dock.install();
2157
3263
  }
2158
3264
  /** 설정에 지정된 기본 프로필(기본값 agentdeck:root)로 새 탭을 연다 */
2159
3265
  async openNewTab() {
@@ -2259,12 +3365,13 @@ let AgentDeckService = class AgentDeckService {
2259
3365
  row.style.setProperty('--ad-color', style.color);
2260
3366
  // [AD:...] 표식은 상태로 이미 반영했으니 제목에서는 지운다
2261
3367
  const tabTitle = (0,_detect__WEBPACK_IMPORTED_MODULE_7__.stripTitleMarker)(tab.customTitle || tab.title || '');
2262
- // 제목 줄은 라벨(= 지금 무슨 작업 중인지)이 있으면 그쪽을 쓴다.
2263
- // Claude Code 는 세션 시작 때 붙인 콘솔 제목을 계속 들고 있어 다음 작업으로 넘어가도
2264
- // 안 바뀐다(2026-08-28 실측 — 이 판정 때문에 제목을 라벨 원천으로 쓰려던 계획을 접었다).
2265
- // 제목만 보여주면 사이드바가 옛 작업 이름에, 최악엔 셸이 붙인 엉뚱한 문자열에 박혀 있게 된다.
2266
- const useLabel = this.config.store.agentDeck.labelAsTitle && !!st.label;
2267
- const title = (useLabel ? st.label : tabTitle) || tabTitle || '(제목 없음)';
3368
+ // 세 줄은 서로 다른 것을 말한다 —
3369
+ // 1) 제목: 에이전트가 세션에 붙인 이름. 세션이 사는 동안 거의 안 바뀐다
3370
+ // (Claude Code 는 시작 때 붙인 콘솔 제목을 계속 들고 있다, 2026-08-28 실측)
3371
+ // 2) 프롬프트: 마지막으로 보낸 지시. 작업이 넘어갈 때마다 바뀐다
3372
+ // 3) 상태: 지금 돌고 있는지 / 얼마나 됐는지
3373
+ // 예전에는 1과 2를 한 줄에 겹쳐 놓느라 둘 중 하나를 못 봤다.
3374
+ const title = tabTitle || '(제목 없음)';
2268
3375
  const elapsed = this.config.store.agentDeck.showElapsed
2269
3376
  ? this.formatElapsed(Date.now() - st.since)
2270
3377
  : '';
@@ -2272,9 +3379,11 @@ let AgentDeckService = class AgentDeckService {
2272
3379
  '<div class="ad-bar"></div>',
2273
3380
  '<div class="ad-body">',
2274
3381
  ' <div class="ad-title"></div>',
3382
+ // 라벨은 더블클릭하면 입력창으로 바뀐다(startLabelEdit) — 그때 줄이 무너지지 않게
3383
+ // 감싸는 줄을 따로 둔다
3384
+ ' <div class="ad-prompt"><span class="ad-label"></span></div>',
2275
3385
  ' <div class="ad-meta">',
2276
3386
  ' <span class="ad-badge"></span>',
2277
- ' <span class="ad-label"></span>',
2278
3387
  ' <span class="ad-elapsed"></span>',
2279
3388
  ' </div>',
2280
3389
  '</div>',
@@ -2282,8 +3391,11 @@ let AgentDeckService = class AgentDeckService {
2282
3391
  ].join('\n');
2283
3392
  // textContent 로만 넣는다 — 탭 제목에 어떤 문자가 와도 마크업으로 해석되지 않게
2284
3393
  row.querySelector('.ad-title').textContent = title;
2285
- // 제목 줄로 승격된 라벨을 아래에 한 번 더 쓰지 않는다
2286
- row.querySelector('.ad-label').textContent = st.label === title ? '' : st.label;
3394
+ const labelEl = row.querySelector('.ad-label');
3395
+ // 아직 프롬프트가 없어도 줄은 남겨 둔다 — 줄 수가 들쭉날쭉하면 목록이 어지럽고,
3396
+ // 더블클릭으로 이름을 붙일 자리도 사라진다
3397
+ labelEl.textContent = st.label || '—';
3398
+ labelEl.classList.toggle('empty', !st.label);
2287
3399
  row.querySelector('.ad-elapsed').textContent = elapsed;
2288
3400
  const badge = row.querySelector('.ad-badge');
2289
3401
  badge.textContent = style.icon + ' ' + style.label;
@@ -2358,6 +3470,11 @@ let AgentDeckService = class AgentDeckService {
2358
3470
  return [label, action];
2359
3471
  });
2360
3472
  items.push(['↺ 자동 감지로', () => this.status.unpin(tab)]);
3473
+ // 이 탭만 다시 그리기 — 사이드바 아래 ↻ 는 모든 탭이 대상이라 무거울 때가 있다
3474
+ items.push(['↻ 화면 복구', () => {
3475
+ this.app.selectTab(tab);
3476
+ this.repair('active');
3477
+ }]);
2361
3478
  for (const [label, action] of items) {
2362
3479
  const item = document.createElement('div');
2363
3480
  item.className = 'ad-menu-item';
@@ -2524,6 +3641,17 @@ const WAITING_RE = [
2524
3641
  /\[y\/N\]/i,
2525
3642
  /Press Enter to continue/i,
2526
3643
  ];
3644
+ /**
3645
+ * 에이전트가 작업 중일 때만 화면에 내보내는 문구.
3646
+ *
3647
+ * 상태줄 갱신 같은 평시 출력과 구분하는 근거다 — 이 문구는 일을 붙잡고 있는 동안에만 뜨고
3648
+ * 끝나면 사라지므로, 사라진 시점부터 시간을 재면 진행중에서 정확히 내려올 수 있다.
3649
+ */
3650
+ const BUSY_RE = [
3651
+ /esc to interrupt/i,
3652
+ /ctrl\+c to (?:interrupt|cancel|stop)/i,
3653
+ /\btokens?\b.{0,20}\besc\b/i,
3654
+ ];
2527
3655
  const VALID = ['idle', 'running', 'waiting', 'done', 'error'];
2528
3656
  /** OSC 가 청크 경계에 걸리는 것을 막기 위한 잔여 버퍼 */
2529
3657
  const tails = new WeakMap();
@@ -2543,8 +3671,11 @@ function applyOutput(status, root, owner, data, autoDetect) {
2543
3671
  const buf = ((_a = tails.get(owner)) !== null && _a !== void 0 ? _a : '') + data;
2544
3672
  let matched = false;
2545
3673
  let m;
3674
+ // 이미 처리한 OSC 가 어디까지인지 — 그 뒤만 다음 조각으로 넘긴다
3675
+ let consumed = 0;
2546
3676
  OSC_RE.lastIndex = 0;
2547
3677
  while ((m = OSC_RE.exec(buf)) !== null) {
3678
+ consumed = OSC_RE.lastIndex;
2548
3679
  const s = m[1].toLowerCase();
2549
3680
  if (VALID.includes(s)) {
2550
3681
  status.setManual(root, s, m[2]);
@@ -2552,8 +3683,11 @@ function applyOutput(status, root, owner, data, autoDetect) {
2552
3683
  matched = true;
2553
3684
  }
2554
3685
  }
2555
- // 미완성 OSC 가 남아있을 수 있으므로 꼬리만 보관
2556
- tails.set(owner, buf.slice(-256));
3686
+ // 미완성 OSC 가 남아있을 수 있으므로 꼬리만 보관한다.
3687
+ // 단 이미 처리한 부분은 반드시 버린다 — 완결된 OSC 가 짧으면 꼬리 안에 통째로 남고,
3688
+ // 그러면 다음 출력마다 같은 시퀀스가 또 매치돼 상태가 그 값에 박혀 버린다
3689
+ // (2026-09-01, detect 테스트에서 드러남).
3690
+ tails.set(owner, buf.slice(Math.max(consumed, buf.length - 256)));
2557
3691
  if (matched || !autoDetect) {
2558
3692
  return;
2559
3693
  }
@@ -2561,6 +3695,19 @@ function applyOutput(status, root, owner, data, autoDetect) {
2561
3695
  status.setAuto(root, 'waiting');
2562
3696
  return;
2563
3697
  }
3698
+ // 에이전트가 "지금 일하는 중" 이라고 스스로 밝히는 표시를 찾는다
3699
+ if (BUSY_MARKS.some(mark => data.includes(mark)) || BUSY_RE.some(re => re.test(data))) {
3700
+ status.markBusy(root);
3701
+ status.setAuto(root, 'running');
3702
+ return;
3703
+ }
3704
+ // 그 표시를 내는 탭(에이전트 TUI)이라면, 표시가 없는 출력은 진행중의 근거가 아니다.
3705
+ // Claude Code 는 놀고 있을 때도 하단 상태줄을 매초 다시 그리기 때문에, 이 갈래가 없으면
3706
+ // 사이드바가 영원히 "진행중" 에 박혀 있게 된다 (2026-09-01 실측 — 세 탭 모두 진행중).
3707
+ if (status.isBusyMode(root)) {
3708
+ return;
3709
+ }
3710
+ // 신호를 낼 줄 모르는 보통 셸 — 예전처럼 "출력이 있다 = 진행중" 으로 본다
2564
3711
  status.setAuto(root, 'running');
2565
3712
  }
2566
3713
  function forgetOutputBuffer(owner) {
@@ -2601,15 +3748,311 @@ function applyTitle(status, root, title, autoDetect) {
2601
3748
 
2602
3749
  /***/ },
2603
3750
 
2604
- /***/ "./src/hotkeys.ts"
2605
- /*!************************!*\
2606
- !*** ./src/hotkeys.ts ***!
2607
- \************************/
3751
+ /***/ "./src/dock.ts"
3752
+ /*!*********************!*\
3753
+ !*** ./src/dock.ts ***!
3754
+ \*********************/
2608
3755
  (__unused_webpack_module, __webpack_exports__, __webpack_require__) {
2609
3756
 
2610
3757
  __webpack_require__.r(__webpack_exports__);
2611
3758
  /* harmony export */ __webpack_require__.d(__webpack_exports__, {
2612
- /* harmony export */ AgentDeckHotkeyProvider: () => (/* binding */ AgentDeckHotkeyProvider)
3759
+ /* harmony export */ DOCK_SIDES: () => (/* binding */ DOCK_SIDES),
3760
+ /* harmony export */ DockController: () => (/* binding */ DockController),
3761
+ /* harmony export */ isDockSide: () => (/* binding */ isDockSide),
3762
+ /* harmony export */ isHorizontalDock: () => (/* binding */ isHorizontalDock)
3763
+ /* harmony export */ });
3764
+ /**
3765
+ * 사이드바를 마우스로 조절하는 부분 — 경계선 드래그로 크기, 헤더 드래그로 붙는 방향.
3766
+ *
3767
+ * 설정 창에서 숫자를 넣게 하지 않고 Visual Studio 의 도킹처럼 직접 끌어서 맞추게 한다.
3768
+ * DOM 을 직접 만지는 코드라 deck.service 밖으로 빼 두고, 값을 읽고 쓰는 통로만
3769
+ * `DockHost` 로 받는다 (deck.service 가 config 와 relayout 을 쥐고 있으므로).
3770
+ */
3771
+ const DOCK_SIDES = ['left', 'right', 'top', 'bottom'];
3772
+ function isDockSide(v) {
3773
+ return DOCK_SIDES.includes(v);
3774
+ }
3775
+ /** 좌/우 도킹이면 폭, 상/하 도킹이면 높이를 다룬다 */
3776
+ function isHorizontalDock(side) {
3777
+ return side === 'left' || side === 'right';
3778
+ }
3779
+ /** 드래그로 오해하지 않을 최소 이동 거리 (px) */
3780
+ const DRAG_THRESHOLD = 5;
3781
+ /** 존 버튼 밖에서 놓았을 때 가장자리로 인정하는 여백 비율 */
3782
+ const EDGE_RATIO = 0.25;
3783
+ class DockController {
3784
+ constructor(host) {
3785
+ this.host = host;
3786
+ this.handle = null;
3787
+ this.overlay = null;
3788
+ this.preview = null;
3789
+ this.zones = new Map();
3790
+ this.resizing = false;
3791
+ this.dragging = false;
3792
+ this.dragArmed = false;
3793
+ this.dragFrom = { x: 0, y: 0 };
3794
+ this.hoverSide = null;
3795
+ }
3796
+ /** 사이드바가 만들어진 뒤 한 번 부른다 */
3797
+ install() {
3798
+ this.installResizeHandle();
3799
+ this.installDockDrag();
3800
+ }
3801
+ /** 도킹 방향이 바뀌면 핸들 위치도 반대편으로 옮겨야 한다 */
3802
+ syncHandle() {
3803
+ if (!this.handle) {
3804
+ return;
3805
+ }
3806
+ const side = this.host.getDock();
3807
+ this.handle.className = 'ad-resize ad-resize-' + side;
3808
+ }
3809
+ // ---------- 경계선 드래그 (크기) ----------
3810
+ installResizeHandle() {
3811
+ const handle = document.createElement('div');
3812
+ handle.className = 'ad-resize';
3813
+ this.host.sidebar.appendChild(handle);
3814
+ this.handle = handle;
3815
+ this.syncHandle();
3816
+ handle.addEventListener('pointerdown', (ev) => {
3817
+ // 왼쪽 버튼만
3818
+ if (ev.button !== 0) {
3819
+ return;
3820
+ }
3821
+ ev.preventDefault();
3822
+ ev.stopPropagation();
3823
+ this.resizing = true;
3824
+ handle.setPointerCapture(ev.pointerId);
3825
+ document.body.classList.add('ad-resizing');
3826
+ });
3827
+ handle.addEventListener('pointermove', (ev) => {
3828
+ if (!this.resizing) {
3829
+ return;
3830
+ }
3831
+ const side = this.host.getDock();
3832
+ const rect = this.host.windowEl.getBoundingClientRect();
3833
+ // 커서와 창 가장자리 사이의 거리가 곧 사이드바 크기다
3834
+ let size;
3835
+ if (side === 'right') {
3836
+ size = rect.right - ev.clientX;
3837
+ }
3838
+ else if (side === 'left') {
3839
+ size = ev.clientX - rect.left;
3840
+ }
3841
+ else if (side === 'bottom') {
3842
+ size = rect.bottom - ev.clientY;
3843
+ }
3844
+ else {
3845
+ size = ev.clientY - rect.top;
3846
+ }
3847
+ this.host.setSize(Math.round(size));
3848
+ this.host.relayout();
3849
+ });
3850
+ const end = (ev) => {
3851
+ if (!this.resizing) {
3852
+ return;
3853
+ }
3854
+ this.resizing = false;
3855
+ try {
3856
+ handle.releasePointerCapture(ev.pointerId);
3857
+ }
3858
+ catch {
3859
+ // 이미 놓쳤다 — 무시해도 상태는 위에서 정리됐다
3860
+ }
3861
+ document.body.classList.remove('ad-resizing');
3862
+ this.host.commit();
3863
+ };
3864
+ handle.addEventListener('pointerup', end);
3865
+ handle.addEventListener('pointercancel', end);
3866
+ }
3867
+ // ---------- 헤더 드래그 (도킹 방향) ----------
3868
+ installDockDrag() {
3869
+ const head = this.host.sidebar.querySelector('.ad-head');
3870
+ if (!head) {
3871
+ return;
3872
+ }
3873
+ head.addEventListener('pointerdown', (ev) => {
3874
+ if (ev.button !== 0) {
3875
+ return;
3876
+ }
3877
+ // 헤더 안의 버튼류를 누른 것이면 건드리지 않는다
3878
+ if (ev.target.closest('button, input')) {
3879
+ return;
3880
+ }
3881
+ this.dragArmed = true;
3882
+ this.dragFrom = { x: ev.clientX, y: ev.clientY };
3883
+ head.setPointerCapture(ev.pointerId);
3884
+ });
3885
+ head.addEventListener('pointermove', (ev) => {
3886
+ if (!this.dragArmed) {
3887
+ return;
3888
+ }
3889
+ if (!this.dragging) {
3890
+ const dx = ev.clientX - this.dragFrom.x;
3891
+ const dy = ev.clientY - this.dragFrom.y;
3892
+ if (Math.hypot(dx, dy) < DRAG_THRESHOLD) {
3893
+ return;
3894
+ }
3895
+ // 여기서부터 도킹 드래그다 — 가이드를 띄운다
3896
+ this.dragging = true;
3897
+ this.showOverlay();
3898
+ }
3899
+ this.updateHover(ev.clientX, ev.clientY);
3900
+ });
3901
+ const end = (ev) => {
3902
+ if (!this.dragArmed) {
3903
+ return;
3904
+ }
3905
+ this.dragArmed = false;
3906
+ try {
3907
+ head.releasePointerCapture(ev.pointerId);
3908
+ }
3909
+ catch {
3910
+ // 무시
3911
+ }
3912
+ if (!this.dragging) {
3913
+ return;
3914
+ }
3915
+ this.dragging = false;
3916
+ const side = this.hoverSide;
3917
+ this.hideOverlay();
3918
+ if (side && side !== this.host.getDock()) {
3919
+ this.host.setDock(side);
3920
+ this.host.relayout();
3921
+ this.syncHandle();
3922
+ this.host.commit();
3923
+ }
3924
+ };
3925
+ head.addEventListener('pointerup', end);
3926
+ head.addEventListener('pointercancel', end);
3927
+ }
3928
+ // ---------- 드롭 가이드 ----------
3929
+ showOverlay() {
3930
+ if (this.overlay) {
3931
+ return;
3932
+ }
3933
+ const overlay = document.createElement('div');
3934
+ overlay.className = 'ad-dock-overlay';
3935
+ const preview = document.createElement('div');
3936
+ preview.className = 'ad-dock-preview';
3937
+ overlay.appendChild(preview);
3938
+ for (const side of DOCK_SIDES) {
3939
+ const zone = document.createElement('div');
3940
+ zone.className = 'ad-dock-zone ad-dock-zone-' + side;
3941
+ zone.innerHTML = '<span class="ad-dock-mark"></span>';
3942
+ overlay.appendChild(zone);
3943
+ this.zones.set(side, zone);
3944
+ }
3945
+ document.body.appendChild(overlay);
3946
+ this.overlay = overlay;
3947
+ this.preview = preview;
3948
+ this.positionZones();
3949
+ }
3950
+ /** 존 버튼을 `.window` 네 가장자리 안쪽에 놓는다 (창이 전체화면이 아닐 수도 있으므로 매번 잰다) */
3951
+ positionZones() {
3952
+ const rect = this.host.windowEl.getBoundingClientRect();
3953
+ const cx = rect.left + rect.width / 2;
3954
+ const cy = rect.top + rect.height / 2;
3955
+ const inset = 56;
3956
+ const place = {
3957
+ left: [rect.left + inset, cy],
3958
+ right: [rect.right - inset, cy],
3959
+ top: [cx, rect.top + inset],
3960
+ bottom: [cx, rect.bottom - inset],
3961
+ };
3962
+ for (const side of DOCK_SIDES) {
3963
+ const zone = this.zones.get(side);
3964
+ if (!zone) {
3965
+ continue;
3966
+ }
3967
+ const [x, y] = place[side];
3968
+ zone.style.left = x + 'px';
3969
+ zone.style.top = y + 'px';
3970
+ }
3971
+ }
3972
+ /** 커서 위치로 어느 쪽에 붙일지 정하고 미리보기를 그린다 */
3973
+ updateHover(x, y) {
3974
+ const side = this.hitTest(x, y);
3975
+ if (side !== this.hoverSide) {
3976
+ this.hoverSide = side;
3977
+ for (const [s, zone] of this.zones) {
3978
+ zone.classList.toggle('active', s === side);
3979
+ }
3980
+ }
3981
+ this.drawPreview(side);
3982
+ }
3983
+ /**
3984
+ * 존 버튼 위에 있으면 그쪽, 아니면 커서가 창의 어느 가장자리 띠에 들어와 있는지로 정한다.
3985
+ * 가운데에 있으면 null — 놓아도 아무 일이 없다.
3986
+ */
3987
+ hitTest(x, y) {
3988
+ for (const [side, zone] of this.zones) {
3989
+ const r = zone.getBoundingClientRect();
3990
+ if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) {
3991
+ return side;
3992
+ }
3993
+ }
3994
+ const rect = this.host.windowEl.getBoundingClientRect();
3995
+ const dx = (x - rect.left) / rect.width;
3996
+ const dy = (y - rect.top) / rect.height;
3997
+ if (dx < EDGE_RATIO && dx <= dy && dx <= 1 - dy) {
3998
+ return 'left';
3999
+ }
4000
+ if (dx > 1 - EDGE_RATIO && 1 - dx <= dy && 1 - dx <= 1 - dy) {
4001
+ return 'right';
4002
+ }
4003
+ if (dy < EDGE_RATIO) {
4004
+ return 'top';
4005
+ }
4006
+ if (dy > 1 - EDGE_RATIO) {
4007
+ return 'bottom';
4008
+ }
4009
+ return null;
4010
+ }
4011
+ /** 놓으면 사이드바가 어디에 얼마만큼 자리잡는지 반투명 박스로 미리 보여준다 */
4012
+ drawPreview(side) {
4013
+ if (!this.preview) {
4014
+ return;
4015
+ }
4016
+ if (!side) {
4017
+ this.preview.style.display = 'none';
4018
+ return;
4019
+ }
4020
+ const rect = this.host.windowEl.getBoundingClientRect();
4021
+ const horizontal = isHorizontalDock(side);
4022
+ // 방향마다 기억된 크기가 다르다 — 지금 붙어 있는 쪽이 아니라 놓으려는 쪽 값을 물어본다
4023
+ const current = this.host.getSize(side);
4024
+ const size = horizontal
4025
+ ? Math.min(Math.max(current || 320, 180), rect.width * 0.6)
4026
+ : Math.min(Math.max(current || 200, 120), rect.height * 0.6);
4027
+ const s = this.preview.style;
4028
+ s.display = 'block';
4029
+ s.left = (side === 'right' ? rect.right - size : rect.left) + 'px';
4030
+ s.top = (side === 'bottom' ? rect.bottom - size : rect.top) + 'px';
4031
+ s.width = (horizontal ? size : rect.width) + 'px';
4032
+ s.height = (horizontal ? rect.height : size) + 'px';
4033
+ }
4034
+ hideOverlay() {
4035
+ var _a;
4036
+ (_a = this.overlay) === null || _a === void 0 ? void 0 : _a.remove();
4037
+ this.overlay = null;
4038
+ this.preview = null;
4039
+ this.zones.clear();
4040
+ this.hoverSide = null;
4041
+ }
4042
+ }
4043
+
4044
+
4045
+ /***/ },
4046
+
4047
+ /***/ "./src/hotkeys.ts"
4048
+ /*!************************!*\
4049
+ !*** ./src/hotkeys.ts ***!
4050
+ \************************/
4051
+ (__unused_webpack_module, __webpack_exports__, __webpack_require__) {
4052
+
4053
+ __webpack_require__.r(__webpack_exports__);
4054
+ /* harmony export */ __webpack_require__.d(__webpack_exports__, {
4055
+ /* harmony export */ AgentDeckHotkeyProvider: () => (/* binding */ AgentDeckHotkeyProvider)
2613
4056
  /* harmony export */ });
2614
4057
  /* harmony import */ var _angular_core__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! @angular/core */ "@angular/core");
2615
4058
  /* harmony import */ var _angular_core__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_angular_core__WEBPACK_IMPORTED_MODULE_0__);
@@ -2646,6 +4089,10 @@ let AgentDeckHotkeyProvider = class AgentDeckHotkeyProvider extends tabby_core__
2646
4089
  id: 'agentdeck-paste',
2647
4090
  name: 'AgentDeck 붙여넣기 (텍스트는 그대로, 이미지는 Alt+V 로 위임)',
2648
4091
  },
4092
+ {
4093
+ id: 'agentdeck-repair',
4094
+ name: 'AgentDeck 화면 복구 (깨진 TUI 다시 그리기)',
4095
+ },
2649
4096
  ];
2650
4097
  }
2651
4098
  };
@@ -2680,6 +4127,8 @@ __webpack_require__.r(__webpack_exports__);
2680
4127
  /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! path */ "path");
2681
4128
  /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_5___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_5__);
2682
4129
  /* harmony import */ var _status_service__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(/*! ./status.service */ "./src/status.service.ts");
4130
+ /* harmony import */ var _claudeHooks__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(/*! ./claudeHooks */ "./src/claudeHooks.ts");
4131
+ /* harmony import */ var _bind__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(/*! ./bind */ "./src/bind.ts");
2683
4132
  var __decorate = (undefined && undefined.__decorate) || function (decorators, target, key, desc) {
2684
4133
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
2685
4134
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
@@ -2696,12 +4145,19 @@ var __metadata = (undefined && undefined.__metadata) || function (k, v) {
2696
4145
 
2697
4146
 
2698
4147
 
4148
+
4149
+
4150
+ const DIAG_PATH = path__WEBPACK_IMPORTED_MODULE_5__.join(os__WEBPACK_IMPORTED_MODULE_4__.homedir(), '.agentdeck-diag.log');
2699
4151
  /**
2700
- * 외부 도구 -> 사이드바 상태 채널. **기본은 꺼져 있다** (`notifyChannel`).
4152
+ * 에이전트 훅 -> 사이드바 상태 채널. **기본으로 켜져 있다** (`notifyChannel`).
2701
4153
  *
2702
- * 라벨은 Enter 시점 입력창에서, 상태는 출력 패턴에서 플러그인이 스스로 얻는 것이 기본 경로다
2703
- * (claimEnterLabel / detect.ts). 설정 파일을 손봐야 도는 훅은 배포판에서 기대할 수 없어서
2704
- * 라벨 원천에서 내렸다. 이 채널은 외부 도구가 상태를 직접 보고하고 싶을 때만 켠다.
4154
+ * 이쪽이 상태의 정확한 원천이다 — 에이전트가 "지시를 받았다 / 승인을 기다린다 / 끝났다" 를
4155
+ * 직접 알려준다. 출력 패턴으로 추측하는 경로(detect.ts)는 훅이 없는 환경을 위한 폴백이고,
4156
+ * TUI 가 화면을 어떻게 그리는지에 기대는 만큼 흔들린다 (2026-09-01: Claude Code 가 놀 때도
4157
+ * 상태줄을 매초 갱신해 진행중이 안 풀렸다).
4158
+ *
4159
+ * 훅은 Claude Code 쪽 설정에 있어야 해서 플러그인 설치만으로는 걸리지 않는다. 그래서
4160
+ * 기동할 때 한 번 물어보고(promptHooksOnce), 설정 창에도 설치 버튼을 둔다(claudeHooks.ts).
2705
4161
  *
2706
4162
  * 왜 파일인가 — 콘솔 제목 방식(`hooks/agentdeck-status.ps1`)은 **훅에서는 통하지 않는다**.
2707
4163
  * Claude Code 는 훅을 파이프 stdio 로 띄우기 때문에 그 자식 프로세스는 터미널의 콘솔에
@@ -2725,10 +4181,11 @@ var __metadata = (undefined && undefined.__metadata) || function (k, v) {
2725
4181
  * 한 번 묶으면 그 뒤로는 활성 탭이 바뀌어도 같은 탭을 따라간다.
2726
4182
  */
2727
4183
  let WorkNotifyService = class WorkNotifyService {
2728
- constructor(app, config, status) {
4184
+ constructor(app, config, status, platform) {
2729
4185
  this.app = app;
2730
4186
  this.config = config;
2731
4187
  this.status = status;
4188
+ this.platform = platform;
2732
4189
  this.root = path__WEBPACK_IMPORTED_MODULE_5__.join(process.env.LOCALAPPDATA || path__WEBPACK_IMPORTED_MODULE_5__.join(os__WEBPACK_IMPORTED_MODULE_4__.homedir(), 'AppData', 'Local'), 'tabby-agentdeck');
2733
4190
  this.dir = path__WEBPACK_IMPORTED_MODULE_5__.join(this.root, 'status');
2734
4191
  /** 훅이 접속할 포트를 알려주는 파일 */
@@ -2742,21 +4199,100 @@ let WorkNotifyService = class WorkNotifyService {
2742
4199
  this.timer = null;
2743
4200
  /** 폴링을 시작한 시각 — 이보다 오래된 파일은 지난 실행의 잔재다 */
2744
4201
  this.startedAt = 0;
4202
+ /**
4203
+ * 탭(루트) -> 그 안 패널들의 셸 PID. 훅이 보낸 조상 PID 와 맞춰 어느 탭의 보고인지 확정한다.
4204
+ * pty 에 물어보는 비동기 작업이라 표로 들고 있고, 탭이 열릴 때와 2초마다 새로 채운다
4205
+ */
4206
+ this.tabPids = new Map();
4207
+ this.pidRefreshing = false;
4208
+ this.pollCount = 0;
4209
+ /** 같은 매칭 실패를 폴링마다 diag 에 찍지 않기 위한 마지막 메시지 */
4210
+ this.lastBindLog = new Map();
4211
+ }
4212
+ /**
4213
+ * 훅이 안 걸려 있으면 기동할 때 한 번 물어본다.
4214
+ *
4215
+ * 훅은 Claude Code 쪽 설정(`~/.claude/settings.json`)에 있어야 해서 플러그인을 설치하는 것만으로는
4216
+ * 걸리지 않는다. 그렇다고 말없이 남의 설정을 고칠 수는 없으니, 한 번 묻고 고른 대로 한다.
4217
+ * "다시 묻지 않기" 를 고르면 그 뒤로는 조용하고, 설정 창의 설치 버튼은 언제든 그대로 쓸 수 있다.
4218
+ */
4219
+ async promptHooksOnce() {
4220
+ var _a;
4221
+ const cfg = this.config.store.agentDeck;
4222
+ if (cfg.hookPromptDismissed || !(0,_claudeHooks__WEBPACK_IMPORTED_MODULE_7__.hooksSupported)()) {
4223
+ return;
4224
+ }
4225
+ try {
4226
+ if ((0,_claudeHooks__WEBPACK_IMPORTED_MODULE_7__.hooksInstalled)()) {
4227
+ return;
4228
+ }
4229
+ }
4230
+ catch {
4231
+ // 설정을 못 읽었다 — 그 상태로 남의 파일을 고치자고 권할 수는 없다
4232
+ return;
4233
+ }
4234
+ const r = await this.platform.showMessageBox({
4235
+ // 이 타입에는 'question' 이 없다 (tabby-core MessageBoxOptions) — 경고로 띄우면
4236
+ // 필요 이상으로 겁을 주므로 'warning' 대신 형만 맞추고 문구로 성격을 드러낸다
4237
+ type: 'warning',
4238
+ message: 'AgentDeck — Claude Code 상태 통보를 연결할까요?',
4239
+ detail: [
4240
+ '연결하면 에이전트가 "지시를 받았다 / 승인을 기다린다 / 끝났다" 를 사이드바에 직접 알려줍니다.',
4241
+ '연결하지 않아도 동작은 하지만, 화면에 그려진 글자를 보고 추측하는 방식이라 상태가 부정확할 수 있습니다.',
4242
+ '',
4243
+ `설치 위치: ${(0,_claudeHooks__WEBPACK_IMPORTED_MODULE_7__.claudeSettingsPath)()}`,
4244
+ '기존 훅은 건드리지 않고, 쓰기 전에 같은 폴더에 백업을 남깁니다. 설정 창에서 언제든 제거할 수 있습니다.',
4245
+ ].join('\n'),
4246
+ buttons: ['연결한다', '나중에', '다시 묻지 않기'],
4247
+ defaultId: 0,
4248
+ cancelId: 1,
4249
+ });
4250
+ if (r.response === 0) {
4251
+ try {
4252
+ (0,_claudeHooks__WEBPACK_IMPORTED_MODULE_7__.installHooks)();
4253
+ if (!cfg.notifyChannel) {
4254
+ cfg.notifyChannel = true;
4255
+ }
4256
+ this.config.save();
4257
+ }
4258
+ catch (e) {
4259
+ await this.platform.showMessageBox({
4260
+ type: 'error',
4261
+ message: 'AgentDeck — 훅을 설치하지 못했습니다.',
4262
+ detail: `${(_a = e === null || e === void 0 ? void 0 : e.message) !== null && _a !== void 0 ? _a : e}`,
4263
+ buttons: ['확인'],
4264
+ });
4265
+ }
4266
+ return;
4267
+ }
4268
+ if (r.response === 2) {
4269
+ cfg.hookPromptDismissed = true;
4270
+ this.config.save();
4271
+ }
2745
4272
  }
2746
4273
  init() {
2747
4274
  this.app.ready$.subscribe(() => {
2748
- // 기본은 꺼져 있다 — 라벨은 Enter 시점 입력창에서, 상태는 출력 패턴에서 코드가 스스로
2749
- // 얻는다(claimEnterLabel / detect.ts). 바깥에서 밀어 넣을 것이 없는데 채널만 열어 두면
2750
- // 400ms 폴링과 포트 점유가 헛돈다. 외부 도구가 직접 보고하고 싶을 때만 켠다
4275
+ // 훅이 안 걸려 있으면 기동할 때 한 번 물어본다. 통로만 열려 있고 보고자가 없으면
4276
+ // 아무 일도 일어나지 않는데, 사용자 눈에는 그냥 "상태가 이상하다" 로만 보인다.
4277
+ // 기동 직후는 화면이 정신없으니 조금 뒤에 띄운다
4278
+ setTimeout(() => { void this.promptHooksOnce(); }, 3000);
4279
+ // 채널이 닫혀 있으면 훅이 보낸 보고가 어디에도 닿지 않는다.
4280
+ // 기본은 열려 있고, 비용은 유휴 포트 하나와 400ms 폴링뿐이다
2751
4281
  if (!this.config.store.agentDeck.notifyChannel) {
2752
4282
  return;
2753
4283
  }
2754
4284
  this.startedAt = Date.now();
2755
4285
  this.listen();
2756
4286
  this.timer = setInterval(() => this.poll(), 400);
4287
+ void this.refreshPids();
4288
+ });
4289
+ // 새 탭의 셸 PID 는 세션이 붙은 뒤에야 나온다 — 조금 뒤에 한 번 더 채운다 (2초 주기 폴링도 있다)
4290
+ this.app.tabOpened$.subscribe(() => {
4291
+ setTimeout(() => { void this.refreshPids(); }, 1500);
2757
4292
  });
2758
4293
  // 탭이 닫히면 점유를 풀어야 그 자리를 다음 세션이 쓸 수 있다
2759
4294
  this.app.tabClosed$.subscribe(tab => {
4295
+ this.tabPids.delete(tab);
2760
4296
  const sid = this.tabSession.get(tab);
2761
4297
  if (sid) {
2762
4298
  this.tabSession.delete(tab);
@@ -2764,6 +4300,60 @@ let WorkNotifyService = class WorkNotifyService {
2764
4300
  }
2765
4301
  });
2766
4302
  }
4303
+ /** 진단 한 줄 — deck.service 와 같은 파일(`~/.agentdeck-diag.log`)에 남긴다 */
4304
+ diag(line) {
4305
+ try {
4306
+ fs__WEBPACK_IMPORTED_MODULE_2__.appendFileSync(DIAG_PATH, `${new Date().toISOString()} ${line}\n`);
4307
+ }
4308
+ catch {
4309
+ // 진단일 뿐이다
4310
+ }
4311
+ }
4312
+ tabName(tab) {
4313
+ const anyTab = tab;
4314
+ return JSON.stringify(String(anyTab.customTitle || anyTab.title || '').slice(0, 40));
4315
+ }
4316
+ /**
4317
+ * 탭별 셸 PID 표를 새로 채운다. 로컬 pty 는 `session.pty.getPID()` 로 셸 PID 를 준다
4318
+ * (tabby-local Session — gracefullyKillProcess 가 쓰는 그 경로). SSH 등 PID 가 없는 탭은 빈 목록.
4319
+ */
4320
+ async refreshPids() {
4321
+ var _a;
4322
+ if (this.pidRefreshing) {
4323
+ return;
4324
+ }
4325
+ this.pidRefreshing = true;
4326
+ try {
4327
+ for (const root of [...this.app.tabs]) {
4328
+ const anyRoot = root;
4329
+ const panes = typeof anyRoot.getAllTabs === 'function' ? anyRoot.getAllTabs() : [root];
4330
+ const pids = [];
4331
+ for (const pane of panes) {
4332
+ const pty = (_a = pane === null || pane === void 0 ? void 0 : pane.session) === null || _a === void 0 ? void 0 : _a.pty;
4333
+ if (!pty || typeof pty.getPID !== 'function') {
4334
+ continue;
4335
+ }
4336
+ try {
4337
+ const pid = await pty.getPID();
4338
+ if (Number.isInteger(pid) && pid > 0) {
4339
+ pids.push(pid);
4340
+ }
4341
+ }
4342
+ catch {
4343
+ // 세션이 닫히는 중이다
4344
+ }
4345
+ }
4346
+ const prev = this.tabPids.get(root);
4347
+ if (!prev || prev.join(',') !== pids.join(',')) {
4348
+ this.tabPids.set(root, pids);
4349
+ this.diag(`pid-map tab=${this.tabName(root)} pids=[${pids.join(',')}]`);
4350
+ }
4351
+ }
4352
+ }
4353
+ finally {
4354
+ this.pidRefreshing = false;
4355
+ }
4356
+ }
2767
4357
  /** 진단용 */
2768
4358
  get debugDir() { return this.dir; }
2769
4359
  /** 진단용 — 듣고 있는 포트 (0 = TCP 채널이 안 떴다) */
@@ -2845,6 +4435,10 @@ let WorkNotifyService = class WorkNotifyService {
2845
4435
  }
2846
4436
  }
2847
4437
  poll() {
4438
+ // 셸 PID 표는 2초마다 — 탭 안에서 셸을 다시 띄우거나 분할하면 바뀐다
4439
+ if (++this.pollCount % 5 === 0) {
4440
+ void this.refreshPids();
4441
+ }
2848
4442
  let names;
2849
4443
  try {
2850
4444
  names = fs__WEBPACK_IMPORTED_MODULE_2__.readdirSync(this.dir);
@@ -2890,6 +4484,7 @@ let WorkNotifyService = class WorkNotifyService {
2890
4484
  * 들고 있는 이쪽이 한다 — 그래야 어느 경로로 들어오든 결과가 같다.
2891
4485
  */
2892
4486
  apply(data) {
4487
+ var _a;
2893
4488
  if (!data || !data.sessionId) {
2894
4489
  return;
2895
4490
  }
@@ -2901,8 +4496,16 @@ let WorkNotifyService = class WorkNotifyService {
2901
4496
  if (this.applied.get(data.sessionId) === data.ts) {
2902
4497
  return;
2903
4498
  }
2904
- const tab = this.resolveTab(data.sessionId);
4499
+ const tab = this.resolveTab(data.sessionId, data.pids);
2905
4500
  if (!tab) {
4501
+ // pids 없는 보고(옛 훅·수동 호출)는 다시 봐도 결과가 같다. 그런데 폴링이 400ms 마다
4502
+ // 되풀이하면 "활성 탭에 묶는다" 규칙이 다음에 열리는 빈 탭을 붙잡는다 — 끝난 세션의
4503
+ // done 이 새 탭에 박히고, 그 탭의 진짜 세션은 주인이 있다며 거절됐다 (2026-09-02 실측).
4504
+ // 적용한 것으로 쳐서 멈춘다. 다음 보고(새 ts)가 오면 그때 다시 시도한다.
4505
+ // pids 가 있는 보고는 탭 PID 표가 채워지면 맞을 수 있으니 다음 폴링에 다시 본다.
4506
+ if (!((_a = data.pids) === null || _a === void 0 ? void 0 : _a.length)) {
4507
+ this.applied.set(data.sessionId, data.ts);
4508
+ }
2906
4509
  return;
2907
4510
  }
2908
4511
  this.applied.set(data.sessionId, data.ts);
@@ -2919,20 +4522,43 @@ let WorkNotifyService = class WorkNotifyService {
2919
4522
  // 제목을 라벨 원천으로 쓸 수 있는지 보려고 한동안 비워 뒀고, 판정은 났다 —
2920
4523
  // Claude Code 는 세션 첫 작업을 요약해 제목에 한 번 박고 그 뒤로 갱신하지 않는다.
2921
4524
  // 그래서 라벨은 Enter 시점 입력창에서 직접 읽고(claimEnterLabel), 사이드바는
2922
- // 제목 대신 그 라벨을 제목 줄에 그린다(labelAsTitle). 여기서 제목을 덮어쓸 이유가 없다.
4525
+ // 제목과 그 라벨을 각자 줄에 나눠 그린다(renderTab). 여기서 제목을 덮어쓸 이유가 없다.
2923
4526
  }
2924
4527
  /**
2925
- * 세션에 묶인 탭을 찾는다. 처음 보는 세션이면 지금 활성 탭에 묶는다.
4528
+ * 세션에 묶인 탭을 찾는다.
2926
4529
  *
2927
- * 단 그 탭을 이미 다른 세션이 점유했으면 **묶지 않는다**. 예전엔 이 검사가 없어서
2928
- * 여러 세션이 한 탭에 겹쳐 붙었고, 서로의 상태를 덮어써 "작업 중인 탭이 완료로,
2929
- * 끝난 탭이 진행 중으로" 뒤바뀌어 보였다. 못 묶은 세션은 사용자가 그 탭을 활성화하는
2930
- * 순간(= 다음 보고 때) 제 짝을 찾는다.
4530
+ * 1순위는 **프로세스 계보**다 — 훅이 보낸 조상 PID(claude.exe → 탭 셸 → Tabby) 에 어떤 탭의
4531
+ * 셸 PID 가 들어 있으면 그 탭이다(bind.ts). 이미 다른 세션이 그 탭을 갖고 있어도 넘겨받는다:
4532
+ * 같은 탭에서 claude 를 다시 띄우면 세션 id 가 바뀌는 게 정상이고, 이전 주인은 이미 죽은 세션이다.
4533
+ * 알던 탭과 다른 탭이 맞으면 옮긴다 (`claude --resume` 을 다른 탭에서 띄운 경우).
4534
+ *
4535
+ * pids 가 왔는데 맞는 탭이 없으면 **추측하지 않고** null 이다 — 탭 PID 표가 아직 안 채워진 것일 수
4536
+ * 있고, 그 경우 다음 폴링(400ms)에 다시 온다. 활성 탭으로 떨어지던 예전 규칙이 낸 사고가
4537
+ * "끝난 세션의 done 이 새 탭에 박힘" 이었다 (2026-09-02 실측).
4538
+ *
4539
+ * pids 가 없는 보고(옛 훅·셸에서 사람이 부른 것)만 예전 규칙을 탄다 — 처음 보는 세션이면 지금 활성 탭.
4540
+ * 단 그 탭을 이미 다른 세션이 점유했으면 묶지 않는다 (여러 세션이 한 탭에 겹쳐 붙어 서로의
4541
+ * 상태를 덮어쓰던 2026-08-28 사고의 방지책).
2931
4542
  */
2932
- resolveTab(sessionId) {
4543
+ resolveTab(sessionId, pids) {
2933
4544
  const known = this.sessionTab.get(sessionId);
2934
- if (known && this.app.tabs.includes(known)) {
2935
- return known;
4545
+ const alive = known && this.app.tabs.includes(known) ? known : null;
4546
+ const pick = (0,_bind__WEBPACK_IMPORTED_MODULE_8__.pickTabByPids)(pids, this.tabPids);
4547
+ if (pick.mode === 'pid' && pick.tab) {
4548
+ if (pick.tab !== alive) {
4549
+ this.bind(sessionId, pick.tab, alive ? 'pid-move' : 'pid');
4550
+ }
4551
+ return pick.tab;
4552
+ }
4553
+ if (alive) {
4554
+ return alive;
4555
+ }
4556
+ if (pick.mode === 'pid-miss') {
4557
+ const table = [...this.tabPids.entries()]
4558
+ .map(([tab, list]) => `${this.tabName(tab)}:[${list.join(',')}]`)
4559
+ .join(' ');
4560
+ this.bindLog(sessionId, `unbound (pid-miss) pids=[${(pids !== null && pids !== void 0 ? pids : []).join(',')}] tabs=${table}`);
4561
+ return null;
2936
4562
  }
2937
4563
  const active = this.app.activeTab;
2938
4564
  if (!active) {
@@ -2940,18 +4566,43 @@ let WorkNotifyService = class WorkNotifyService {
2940
4566
  }
2941
4567
  const owner = this.tabSession.get(active);
2942
4568
  if (owner && owner !== sessionId) {
4569
+ this.bindLog(sessionId, `unbound (legacy: active tab owned by ${owner.slice(0, 8)})`);
2943
4570
  return null;
2944
4571
  }
2945
- this.sessionTab.set(sessionId, active);
2946
- this.tabSession.set(active, sessionId);
4572
+ this.bind(sessionId, active, 'legacy');
2947
4573
  return active;
2948
4574
  }
4575
+ /** 세션 <-> 탭 을 묶고, 그 탭의 이전 주인과 이 세션의 이전 탭은 풀어 준다 */
4576
+ bind(sessionId, tab, how) {
4577
+ const prevOwner = this.tabSession.get(tab);
4578
+ if (prevOwner && prevOwner !== sessionId) {
4579
+ this.sessionTab.delete(prevOwner);
4580
+ }
4581
+ const prevTab = this.sessionTab.get(sessionId);
4582
+ if (prevTab && prevTab !== tab && this.tabSession.get(prevTab) === sessionId) {
4583
+ this.tabSession.delete(prevTab);
4584
+ }
4585
+ this.sessionTab.set(sessionId, tab);
4586
+ this.tabSession.set(tab, sessionId);
4587
+ this.lastBindLog.delete(sessionId);
4588
+ this.diag(`bind sid=${sessionId.slice(0, 8)} via=${how} tab=${this.tabName(tab)}`
4589
+ + (prevOwner && prevOwner !== sessionId ? ` (took over from ${prevOwner.slice(0, 8)})` : ''));
4590
+ }
4591
+ /** 매칭 실패는 폴링마다 반복되므로 같은 내용은 한 번만 남긴다 */
4592
+ bindLog(sessionId, msg) {
4593
+ if (this.lastBindLog.get(sessionId) === msg) {
4594
+ return;
4595
+ }
4596
+ this.lastBindLog.set(sessionId, msg);
4597
+ this.diag(`bind sid=${sessionId.slice(0, 8)} ${msg}`);
4598
+ }
2949
4599
  };
2950
4600
  WorkNotifyService = __decorate([
2951
4601
  (0,_angular_core__WEBPACK_IMPORTED_MODULE_0__.Injectable)({ providedIn: 'root' }),
2952
4602
  __metadata("design:paramtypes", [tabby_core__WEBPACK_IMPORTED_MODULE_1__.AppService,
2953
4603
  tabby_core__WEBPACK_IMPORTED_MODULE_1__.ConfigService,
2954
- _status_service__WEBPACK_IMPORTED_MODULE_6__.WorkStatusService])
4604
+ _status_service__WEBPACK_IMPORTED_MODULE_6__.WorkStatusService,
4605
+ tabby_core__WEBPACK_IMPORTED_MODULE_1__.PlatformService])
2955
4606
  ], WorkNotifyService);
2956
4607
 
2957
4608
 
@@ -3017,9 +4668,9 @@ let AgentDeckProfileService = class AgentDeckProfileService {
3017
4668
  args: [...(cfg.rootProfileArgs || [])],
3018
4669
  cwd: cfg.rootProfileCwd,
3019
4670
  env: { ...(cfg.rootProfileEnv || {}) },
3020
- // Tabby 를 이미 관리자로 띄웠다면 자식 프로세스가 권한을 물려받으므로
3021
- // 이 값은 꺼두는 편이 낫다 (켜면 탭마다 UAC 창이 뜬다)
3022
- runAsAdministrator: !!cfg.rootProfileAdmin,
4671
+ // 권한 승격(runAsAdministrator)은 이 플러그인이 다루지 않는다 — Tabby 를 어떤 권한으로
4672
+ // 띄울지는 사용자의 몫이다. 단 프로필에 runAsAdministrator 를 켜면 Tabby 가 셸을
4673
+ // UAC.exe 릴레이로 감싸고 그 릴레이가 폭 1칸을 잃어 TUI 입력창이 깨진다(README 알려진 문제).
3023
4674
  },
3024
4675
  };
3025
4676
  if (existing) {
@@ -3161,6 +4812,477 @@ function extractPrompt(lines) {
3161
4812
  }
3162
4813
 
3163
4814
 
4815
+ /***/ },
4816
+
4817
+ /***/ "./src/screen.ts"
4818
+ /*!***********************!*\
4819
+ !*** ./src/screen.ts ***!
4820
+ \***********************/
4821
+ (__unused_webpack_module, __webpack_exports__, __webpack_require__) {
4822
+
4823
+ __webpack_require__.r(__webpack_exports__);
4824
+ /* harmony export */ __webpack_require__.d(__webpack_exports__, {
4825
+ /* harmony export */ judgeScreen: () => (/* binding */ judgeScreen)
4826
+ /* harmony export */ });
4827
+ /**
4828
+ * 터미널 화면이 깨졌는지 판정한다.
4829
+ *
4830
+ * "깨졌다" 의 정체는 하나다 — **TUI 가 아는 폭과 xterm 이 가진 폭이 다르다.**
4831
+ * 그러면 TUI 가 그린 한 행이 xterm 에서 두 행으로 접히거나(폭이 더 크다),
4832
+ * 행 오른쪽에 옛 프레임이 남는다(폭이 더 작다). 눈으로는 "입력창 구분선이 중간에서 끊긴다",
4833
+ * "가로선과 입력 텍스트가 한 줄에 섞인다" 로 보인다.
4834
+ *
4835
+ * 정상 화면의 모양은 실측해서 확정했다 (2026-09-01, Claude Code 2.1.x, CDP 로 xterm 버퍼 원문).
4836
+ * 사이드바를 켠 창(cols=280)과 순정 창(cols=360) 양쪽에서 완전히 같은 구조가 나왔다.
4837
+ *
4838
+ * 71 wrapped=0 len=278 " … ● high · /effort" ← 오른쪽 정렬 힌트
4839
+ * 72 wrapped=0 len=280 "──────────────────────────────────────────────────" ← len == cols
4840
+ * 73 wrapped=1 len= 1 "❯" ← wrapped=1 이 정상
4841
+ * 74 wrapped=0 len=280 "──────────────────────────────────────────────────" ← len == cols
4842
+ * 75 wrapped=0 len=155 " ⚠ Transcript saving is off — …"
4843
+ * 76 wrapped=0 len= 58 " ⏱ 1s 컨텍스트 사용량: …"
4844
+ * 77 wrapped=0 len= 38 " ⏵⏵ auto mode on (shift+tab to cycle)"
4845
+ *
4846
+ * 여기서 놓치기 쉬운 세 가지 —
4847
+ * - 가로선은 **cols 를 정확히 꽉 채운다**. 그래서 그 다음 줄에 `wrapped=1` 이 붙는 것은
4848
+ * xterm 의 pending-wrap 이고 **정상이다**. `wrapped` 만 보고 깨졌다고 판정하면 안 된다.
4849
+ * - 프롬프트 오른쪽에 오는 힌트(`● high · /effort`, `Try "how does …"`)는 **별도 줄**이다.
4850
+ * 프롬프트 줄과 같은 줄에 두 개의 머리(`❯`/`>`)가 보이면 그것이 깨진 화면이다.
4851
+ * - **폭이 다 맞아도 깨진 화면이 있다.** 테두리 두 줄이 맞붙어 `❯` 가 들어갈 행이
4852
+ * 사라지는 모양인데, 두 테두리 모두 cols 를 꽉 채우므로 폭 기준 판정은 전부 통과한다
4853
+ * (2026-09-02: 사람 눈으로도 이 도구로도 "정상" 이라고 오판했다). 그래서 폭만이 아니라
4854
+ * **입력할 자리가 있는가**(테두리 사이 간격 ≥ 2)를 같이 본다.
4855
+ *
4856
+ * xterm 없이 검증할 수 있도록 줄 배열만 받는 순수 함수로 둔다.
4857
+ */
4858
+ /**
4859
+ * 가로선만으로 된 줄 (입력창 위/아래 테두리).
4860
+ * 두 글자까지 내려 잡는 이유 — 테두리가 화면 폭을 넘치면 다음 줄에 한두 칸짜리
4861
+ * 자투리로 떨어진다 (실측: cols=280 화면에 280칸 + `──` 2칸).
4862
+ */
4863
+ const RULE_RE = /^[─━═]{2,}$/;
4864
+ /** 입력창 머리글자 — prompt.ts 와 같은 집합. 뒤가 줄 끝이어도(빈 입력창) 머리다 */
4865
+ const HEAD_RE = /^[>❯›❭➜](\s|$)/;
4866
+ /** 줄 어딘가에 있는 머리글자 — 가로선과 섞였는지 볼 때 쓴다 */
4867
+ const HEAD_ANYWHERE_RE = /[>❯›❭➜](\s|$)/;
4868
+ /** 선택지 커서(`❯ 1. Yes`)는 입력창 머리가 아니다 */
4869
+ const CHOICE_RE = /^[>❯›❭➜]\s*(\d+[.)]\s|(?:Yes|No)\b)/i;
4870
+ /** 가로선 문자 */
4871
+ const RULE_CHAR_RE = /[─━═]/;
4872
+ function judgeScreen(lines, cols) {
4873
+ const reasons = [];
4874
+ if (!(cols > 1) || !lines.length) {
4875
+ return { broken: false, reasons: [] };
4876
+ }
4877
+ const rules = [];
4878
+ const heads = [];
4879
+ for (let i = 0; i < lines.length; i++) {
4880
+ const line = lines[i];
4881
+ const body = line.text.replace(/\s+$/, '');
4882
+ if (!body) {
4883
+ continue;
4884
+ }
4885
+ if (RULE_RE.test(body)) {
4886
+ rules.push(i);
4887
+ // 테두리는 화면 폭을 꽉 채운다. 짧으면 TUI 가 더 좁은 폭으로 그린 것이고,
4888
+ // 그보다 짧은 자투리 줄이면 넘쳐 접힌 꼬리다 — 어느 쪽이든 폭이 어긋났다.
4889
+ //
4890
+ // 2026-09-02: `cols - 1` 도 정상으로 봐주도록 한 번 완화했다가 되돌렸다.
4891
+ // 근거로 삼은 `len=279` 스냅샷이 **전부 깨짐으로 판정된 화면**이었고,
4892
+ // 같은 로그의 정상 표본(`auto-after 복구됨`)은 `len == cols == 280` 이었다.
4893
+ // 병상 표본을 정상 기준으로 삼으면 안 된다.
4894
+ if (body.length !== cols) {
4895
+ reasons.push(`rule@${i} len=${body.length} != cols=${cols}`);
4896
+ }
4897
+ continue;
4898
+ }
4899
+ // 가로선과 다른 글자가 한 줄에 섞였다.
4900
+ // 상자형 입력창(`╭──╮` / `│ > … │`)을 쓰는 앱도 있으므로, 머리글자가 같은 줄에
4901
+ // 함께 있을 때만 깨진 것으로 본다 — 그것이 "가로선과 입력 텍스트가 섞인" 그 증상이다.
4902
+ if (RULE_CHAR_RE.test(body) && HEAD_ANYWHERE_RE.test(body)) {
4903
+ reasons.push(`mixed@${i} ${JSON.stringify(body.slice(0, 40))}`);
4904
+ }
4905
+ if (HEAD_RE.test(body) && !CHOICE_RE.test(body)) {
4906
+ heads.push(i);
4907
+ }
4908
+ }
4909
+ // 입력창 **안에** 머리가 두 개 이상 보인다 = 옛 프레임이 지워지지 않았다.
4910
+ //
4911
+ // 화면 전체의 머리 수를 세면 안 된다 — Claude Code 는 지난 사용자 프롬프트를
4912
+ // `❯ 내가 보낸 말` 로 대화 이력에 남기고, 큐에 쌓인 메시지도 같은 모양으로 나열한다.
4913
+ // 그래서 정상 화면에도 머리가 예사로 일곱 개 보인다 (2026-09-01 실측: `heads=7` 오탐으로
4914
+ // 자동 복구가 2초마다 헛돌았다). 판정은 입력창 테두리(가로선) 사이 구간에서만 한다.
4915
+ //
4916
+ // 판정은 **맨 아래 두 테두리** 사이에서만 한다. 위쪽 테두리를 `rules[0]` 으로 잡으면
4917
+ // 대화 이력에 스크롤되어 남은 옛 입력창의 테두리를 물어, 그 사이의 지난 프롬프트가
4918
+ // 전부 머리로 세어진다. 입력창은 언제나 화면 맨 아래에 있다.
4919
+ // 자투리 테두리(폭이 안 맞는 줄)는 위치 기준이 못 되므로 **화면 폭을 꽉 채운 것**만 쓴다.
4920
+ const fullRules = rules.filter(i => lines[i].text.replace(/\s+$/, '').length === cols);
4921
+ if (fullRules.length >= 2) {
4922
+ const bottom = fullRules[fullRules.length - 1];
4923
+ const top = fullRules[fullRules.length - 2];
4924
+ // **입력할 자리가 없다** — 테두리 두 줄이 맞붙어 `❯` 가 들어갈 행이 사라졌다.
4925
+ //
4926
+ // 이것이 2026-09-02 에 사람 눈으로도 도구로도 놓친 그 증상이다. 폭은 멀쩡해서
4927
+ // (테두리 둘 다 cols 를 꽉 채운다) 폭 기준 판정은 전부 통과해 버린다. 실측 픽셀:
4928
+ // 가로선 y=59 와 y=76 이 행높이(17px) 1행 간격, 그 사이 커서 글리프가 아래 선을 관통.
4929
+ // 정상이라면 두 테두리는 최소 한 행(입력줄)을 사이에 두므로 간격이 2 이상이다.
4930
+ if (bottom - top < 2) {
4931
+ reasons.push(`입력창에 자리가 없다 (테두리 ${top}·${bottom} 이 맞붙었다)`);
4932
+ }
4933
+ else {
4934
+ const inside = heads.filter(i => i > top && i < bottom).length;
4935
+ // 머리가 0 개인 것은 깨짐으로 보지 않는다 — 선택지 커서(`❯ 1. Yes`)만 있는
4936
+ // 정상 화면이 그렇게 보인다 (CHOICE_RE 로 빼고 나면 머리가 0 이 된다).
4937
+ if (inside > 1) {
4938
+ reasons.push(`입력창 안 머리=${inside} (기대 1)`);
4939
+ }
4940
+ }
4941
+ }
4942
+ // **아래 테두리가 통째로 사라졌다.**
4943
+ //
4944
+ // 2026-09-02 실사용 실측(창 캡처 픽셀 스캔, 행높이 17px): 가로선이 `y=1295` 하나뿐이고
4945
+ // 프롬프트 글리프(y≈1304~1320) 아래 `y≈1329` 에 있어야 할 두 번째 가로선이 없었다.
4946
+ // 이 모양은 위 규칙들이 전부 통과한다 — 남은 테두리 하나는 폭을 꽉 채우고(폭 규칙 통과),
4947
+ // 테두리가 하나뿐이라 `fullRules.length >= 2` 가 거짓이라 자리 규칙도 건너뛰고,
4948
+ // `rules.length > 1` 도 거짓이라 섞임 규칙도 안 본다. 판정의 구멍이었다.
4949
+ //
4950
+ // 머리 **바로 위** 가 폭을 꽉 채운 테두리일 때만 본다. 그래야 3줄 입력창(`──/❯/──`)을
4951
+ // 쓰는 화면에만 적용되고, 테두리가 없는 일반 셸이나 상자형 입력창은 건드리지 않는다.
4952
+ // 대화 이력에 남은 옛 `❯ 내가 보낸 말` 도 안전하다 — 그 아래에는 현재 입력창의
4953
+ // 테두리가 있으므로 "아래에 테두리가 없다" 조건에 걸리지 않는다.
4954
+ if (heads.length && fullRules.length) {
4955
+ const head = heads[heads.length - 1];
4956
+ const topAttached = fullRules.includes(head - 1);
4957
+ const belowHead = fullRules.some(i => i > head);
4958
+ if (topAttached && !belowHead) {
4959
+ reasons.push(`입력창 아래 테두리가 없다 (머리@${head}, 위 테두리@${head - 1})`);
4960
+ }
4961
+ }
4962
+ // 테두리 길이가 서로 다르면 두 폭이 화면에 공존한다
4963
+ if (rules.length > 1) {
4964
+ const lens = new Set(rules.map(i => lines[i].text.replace(/\s+$/, '').length));
4965
+ if (lens.size > 1) {
4966
+ reasons.push(`rule lens=${[...lens].join(',')} (섞임)`);
4967
+ }
4968
+ }
4969
+ return { broken: reasons.length > 0, reasons };
4970
+ }
4971
+
4972
+
4973
+ /***/ },
4974
+
4975
+ /***/ "./src/settings.component.ts"
4976
+ /*!***********************************!*\
4977
+ !*** ./src/settings.component.ts ***!
4978
+ \***********************************/
4979
+ (__unused_webpack_module, __webpack_exports__, __webpack_require__) {
4980
+
4981
+ __webpack_require__.r(__webpack_exports__);
4982
+ /* harmony export */ __webpack_require__.d(__webpack_exports__, {
4983
+ /* harmony export */ AgentDeckSettingsTabComponent: () => (/* binding */ AgentDeckSettingsTabComponent)
4984
+ /* harmony export */ });
4985
+ /* harmony import */ var _angular_core__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! @angular/core */ "@angular/core");
4986
+ /* harmony import */ var _angular_core__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_angular_core__WEBPACK_IMPORTED_MODULE_0__);
4987
+ /* harmony import */ var tabby_core__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! tabby-core */ "tabby-core");
4988
+ /* harmony import */ var tabby_core__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(tabby_core__WEBPACK_IMPORTED_MODULE_1__);
4989
+ /* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./config */ "./src/config.ts");
4990
+ /* harmony import */ var _claudeHooks__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./claudeHooks */ "./src/claudeHooks.ts");
4991
+ var __decorate = (undefined && undefined.__decorate) || function (decorators, target, key, desc) {
4992
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4993
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4994
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
4995
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
4996
+ };
4997
+ var __metadata = (undefined && undefined.__metadata) || function (k, v) {
4998
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
4999
+ };
5000
+
5001
+
5002
+
5003
+
5004
+ const DOCK_LABELS = {
5005
+ left: '왼쪽',
5006
+ right: '오른쪽',
5007
+ top: '위쪽',
5008
+ bottom: '아래쪽',
5009
+ };
5010
+ /**
5011
+ * 설정 > AgentDeck 탭.
5012
+ *
5013
+ * 사용자가 실제로 바꿀 일이 있는 두 가지만 노출한다 — 레이아웃 on/off 와 작업 루트 프로필.
5014
+ * 나머지(화면비, 사이드바 폭, 투명도, 상태 감지, 입력 가로채기, 렌더 보정, 통보 채널)는
5015
+ * 손댈 이유가 거의 없고 잘못 만지면 화면이 깨지는 값이라 `config.ts` 의 기본값으로 고정하고
5016
+ * 설정 창에서는 뺐다. 정말 필요하면 설정 파일의 `agentDeck.*` 를 직접 고친다.
5017
+ *
5018
+ * `enabled` 는 deck.service 가 `config.changed$` 를 구독해 즉시 반영한다(deck.service.ts:256).
5019
+ * 루트 프로필은 기동 시 한 번만 읽으므로 재시작이 필요하다고 적어 둔다.
5020
+ */
5021
+ let AgentDeckSettingsTabComponent = class AgentDeckSettingsTabComponent {
5022
+ constructor(config, platform) {
5023
+ this.config = config;
5024
+ this.platform = platform;
5025
+ /** 이 환경에서 훅을 걸 수 있나 (통보 스크립트가 PowerShell 이라 Windows 전용) */
5026
+ this.hooksSupported = (0,_claudeHooks__WEBPACK_IMPORTED_MODULE_3__.hooksSupported)();
5027
+ /** 지금 걸려 있나 — 파일을 매번 읽지 않도록 화면용으로 들고 있는다 */
5028
+ this.hooksOn = false;
5029
+ /** 훅을 쓰다 실패한 이유 — 조용히 삼키면 사용자는 버튼이 먹통인 줄 안다 */
5030
+ this.hookError = '';
5031
+ this.refreshHooks();
5032
+ }
5033
+ refreshHooks() {
5034
+ var _a;
5035
+ try {
5036
+ this.hooksOn = this.hooksSupported && (0,_claudeHooks__WEBPACK_IMPORTED_MODULE_3__.hooksInstalled)();
5037
+ }
5038
+ catch (e) {
5039
+ this.hooksOn = false;
5040
+ this.hookError = `설정을 읽지 못했다: ${(_a = e === null || e === void 0 ? void 0 : e.message) !== null && _a !== void 0 ? _a : e}`;
5041
+ }
5042
+ }
5043
+ /**
5044
+ * 훅을 걸거나 뗀다.
5045
+ *
5046
+ * 걸 때는 통보 채널도 같이 켠다 — 훅만 걸고 채널이 닫혀 있으면 보고가 어디에도 닿지 않는데,
5047
+ * 사용자 입장에서는 "설치했는데 아무 일도 안 일어난다" 로만 보인다.
5048
+ */
5049
+ toggleHooks() {
5050
+ var _a;
5051
+ this.hookError = '';
5052
+ try {
5053
+ if (this.hooksOn) {
5054
+ (0,_claudeHooks__WEBPACK_IMPORTED_MODULE_3__.uninstallHooks)();
5055
+ }
5056
+ else {
5057
+ (0,_claudeHooks__WEBPACK_IMPORTED_MODULE_3__.installHooks)();
5058
+ if (!this.config.store.agentDeck.notifyChannel) {
5059
+ this.config.store.agentDeck.notifyChannel = true;
5060
+ this.config.save();
5061
+ }
5062
+ }
5063
+ }
5064
+ catch (e) {
5065
+ this.hookError = `${(_a = e === null || e === void 0 ? void 0 : e.message) !== null && _a !== void 0 ? _a : e}`;
5066
+ }
5067
+ this.refreshHooks();
5068
+ }
5069
+ /**
5070
+ * OS 폴더 선택 창을 띄워 작업 폴더를 고른다.
5071
+ *
5072
+ * `pickDirectory` 는 취소하면 null/undefined 를 주므로 그때는 기존 값을 건드리지 않는다.
5073
+ * 다이얼로그 제목은 Tabby 버전에 따라 인자를 받기도 하고 안 받기도 해서 any 로 넘긴다 —
5074
+ * 안 받는 쪽은 그냥 무시하므로 어느 쪽에서도 깨지지 않는다.
5075
+ * 돌려받은 경로는 백슬래시를 슬래시로 바꿔 저장한다. 설정 파일에 그대로 적히는 값이라
5076
+ * 손으로 고칠 때 이스케이프 사고가 나지 않게 하려는 것이고, Windows 도 슬래시 경로를 받는다.
5077
+ */
5078
+ /** 배치가 기본값에서 벗어나 있나 — 벗어나 있을 때만 초기화 버튼이 살아 있다 */
5079
+ layoutChanged() {
5080
+ const cfg = this.config.store.agentDeck;
5081
+ return Object.keys(_config__WEBPACK_IMPORTED_MODULE_2__.LAYOUT_DEFAULTS).some(k => cfg[k] !== _config__WEBPACK_IMPORTED_MODULE_2__.LAYOUT_DEFAULTS[k]);
5082
+ }
5083
+ dockLabel() {
5084
+ var _a;
5085
+ return (_a = DOCK_LABELS[this.config.store.agentDeck.sidebarDock]) !== null && _a !== void 0 ? _a : '오른쪽';
5086
+ }
5087
+ sizeLabel() {
5088
+ const cfg = this.config.store.agentDeck;
5089
+ if (cfg.sidebarDock === 'top' || cfg.sidebarDock === 'bottom') {
5090
+ return '높이 ' + cfg.sidebarHeight + 'px';
5091
+ }
5092
+ return cfg.sidebarWidth ? '폭 ' + cfg.sidebarWidth + 'px' : '폭 자동';
5093
+ }
5094
+ /**
5095
+ * 도킹 방향과 크기만 기본값으로 되돌린다.
5096
+ *
5097
+ * 되돌리기 쉬운 조작이라 확인 창은 두지 않는다. `config.save()` 가 `changed$` 를 발화시키고
5098
+ * deck.service 가 그걸 받아 곧바로 다시 배치하므로(deck.service.ts:256) 재시작도 필요 없다.
5099
+ */
5100
+ resetLayout() {
5101
+ Object.assign(this.config.store.agentDeck, _config__WEBPACK_IMPORTED_MODULE_2__.LAYOUT_DEFAULTS);
5102
+ this.config.save();
5103
+ }
5104
+ async pickCwd() {
5105
+ const picked = await this.platform.pickDirectory('작업 폴더 선택');
5106
+ if (!picked) {
5107
+ return;
5108
+ }
5109
+ this.config.store.agentDeck.rootProfileCwd = String(picked).replace(/\\/g, '/');
5110
+ this.config.save();
5111
+ }
5112
+ };
5113
+ AgentDeckSettingsTabComponent = __decorate([
5114
+ (0,_angular_core__WEBPACK_IMPORTED_MODULE_0__.Component)({
5115
+ selector: 'agentdeck-settings-tab',
5116
+ template: `
5117
+ <h3 class="mb-3">AgentDeck</h3>
5118
+
5119
+ <div class="form-line">
5120
+ <div class="header">
5121
+ <div class="title">레이아웃 사용</div>
5122
+ <div class="description">끄면 사이드바와 고정폭 레이아웃이 사라지고 Tabby 순정 화면으로 돌아간다.</div>
5123
+ </div>
5124
+ <toggle [(ngModel)]="config.store.agentDeck.enabled" (ngModelChange)="config.save()"></toggle>
5125
+ </div>
5126
+
5127
+ <div class="form-line">
5128
+ <div class="header">
5129
+ <div class="title">UI 배치 초기화</div>
5130
+ <div class="description">
5131
+ 마우스로 바꾼 사이드바 위치와 크기를 처음 상태로 되돌린다 —
5132
+ 오른쪽 도킹 + 화면비로 자동 계산한 폭.
5133
+ <span *ngIf="layoutChanged()">지금: {{dockLabel()}} 도킹, {{sizeLabel()}}.</span>
5134
+ <span *ngIf="!layoutChanged()">지금은 기본 상태다.</span>
5135
+ </div>
5136
+ </div>
5137
+ <button class="btn btn-secondary" type="button" [disabled]="!layoutChanged()" (click)="resetLayout()">
5138
+ <i class="fas fa-undo me-2"></i>초기화
5139
+ </button>
5140
+ </div>
5141
+
5142
+ <h3 class="mt-4 mb-3">Claude Code 연동</h3>
5143
+ <div class="text-muted mb-3">
5144
+ 에이전트가 "지시를 받았다 / 승인을 기다린다 / 끝났다" 를 직접 알려주게 한다.
5145
+ 이게 사이드바 상태의 가장 정확한 원천이다 — 걸지 않으면 화면에 그려진 글자를 보고
5146
+ 추측하는 방식으로 동작하고, 그만큼 판정이 흔들린다.
5147
+ </div>
5148
+
5149
+ <div class="form-line" *ngIf="!hooksSupported">
5150
+ <div class="header">
5151
+ <div class="title">이 환경에서는 걸 수 없다</div>
5152
+ <div class="description">통보 스크립트가 PowerShell 이라 Windows 에서만 동작한다. 상태는 출력 패턴으로 추측한다.</div>
5153
+ </div>
5154
+ </div>
5155
+
5156
+ <div class="form-line" *ngIf="hooksSupported">
5157
+ <div class="header">
5158
+ <div class="title">상태 통보 훅</div>
5159
+ <div class="description">
5160
+ {{hooksOn ? '설치됨' : '설치 안 됨'}} — <code>~/.claude/settings.json</code> 의
5161
+ UserPromptSubmit / Notification / Stop 에 걸린다. 다른 훅은 건드리지 않고,
5162
+ 쓰기 전에 같은 폴더에 백업을 남긴다.
5163
+ <span *ngIf="hookError" class="text-danger d-block mt-1">{{hookError}}</span>
5164
+ </div>
5165
+ </div>
5166
+ <button class="btn" type="button"
5167
+ [class.btn-secondary]="!hooksOn" [class.btn-outline-danger]="hooksOn"
5168
+ (click)="toggleHooks()">
5169
+ <i class="fas me-2" [class.fa-plug]="!hooksOn" [class.fa-trash]="hooksOn"></i>
5170
+ {{hooksOn ? '제거' : '설치'}}
5171
+ </button>
5172
+ </div>
5173
+
5174
+ <div class="form-line" *ngIf="hooksSupported && hooksOn">
5175
+ <div class="header">
5176
+ <div class="title">통보 채널</div>
5177
+ <div class="description">훅이 보낸 상태를 받는 통로. 꺼져 있으면 훅을 걸어도 전달되지 않는다. 변경은 Tabby 재시작 후 적용된다.</div>
5178
+ </div>
5179
+ <toggle [(ngModel)]="config.store.agentDeck.notifyChannel" (ngModelChange)="config.save()"></toggle>
5180
+ </div>
5181
+
5182
+ <h3 class="mt-4 mb-3">작업 루트 프로필</h3>
5183
+ <div class="text-muted mb-3">새 탭이 항상 같은 작업 폴더에서 뜨도록 전용 프로필을 만든다. 변경은 Tabby 재시작 후 적용된다.</div>
5184
+
5185
+ <div class="form-line">
5186
+ <div class="header">
5187
+ <div class="title">루트 프로필 사용</div>
5188
+ <div class="description">켜고 작업 폴더를 채우면 해당 프로필을 만들어 기본 프로필로 삼는다.</div>
5189
+ </div>
5190
+ <toggle [(ngModel)]="config.store.agentDeck.rootProfile" (ngModelChange)="config.save()"></toggle>
5191
+ </div>
5192
+
5193
+ <ng-container *ngIf="config.store.agentDeck.rootProfile">
5194
+ <div class="form-line">
5195
+ <div class="header">
5196
+ <div class="title">프로필 이름</div>
5197
+ </div>
5198
+ <input class="form-control w-50" type="text"
5199
+ [(ngModel)]="config.store.agentDeck.rootProfileName" (change)="config.save()">
5200
+ </div>
5201
+
5202
+ <div class="form-line">
5203
+ <div class="header">
5204
+ <div class="title">작업 폴더</div>
5205
+ <div class="description">비우면 프로필을 만들지 않는다. 직접 적을 때는 백슬래시 이스케이프 함정을 피하려고 슬래시로 적는다 (예: D:/Project).</div>
5206
+ </div>
5207
+ <div class="input-group w-50">
5208
+ <input class="form-control" type="text" placeholder="D:/Project"
5209
+ [(ngModel)]="config.store.agentDeck.rootProfileCwd" (change)="config.save()">
5210
+ <button class="btn btn-secondary" type="button" (click)="pickCwd()">
5211
+ <i class="fas fa-folder-open me-2"></i>찾아보기
5212
+ </button>
5213
+ </div>
5214
+ </div>
5215
+
5216
+ <div class="form-line">
5217
+ <div class="header">
5218
+ <div class="title">실행 명령</div>
5219
+ </div>
5220
+ <input class="form-control w-50" type="text"
5221
+ [(ngModel)]="config.store.agentDeck.rootProfileCommand" (change)="config.save()">
5222
+ </div>
5223
+
5224
+ </ng-container>
5225
+
5226
+ <div class="text-muted mt-4">
5227
+ 화면비·사이드바 폭·투명도·상태 감지·입력 처리 등 나머지 값은 기본값으로 고정돼 있다.
5228
+ 바꿔야 한다면 설정 파일의 <code>agentDeck.*</code> 를 직접 고친다.
5229
+ 단축키는 <b>단축키</b> 탭의 <code>agentdeck-*</code> 항목에서 바꾼다.
5230
+ </div>
5231
+ `,
5232
+ }),
5233
+ __metadata("design:paramtypes", [tabby_core__WEBPACK_IMPORTED_MODULE_1__.ConfigService,
5234
+ tabby_core__WEBPACK_IMPORTED_MODULE_1__.PlatformService])
5235
+ ], AgentDeckSettingsTabComponent);
5236
+
5237
+
5238
+
5239
+ /***/ },
5240
+
5241
+ /***/ "./src/settings.ts"
5242
+ /*!*************************!*\
5243
+ !*** ./src/settings.ts ***!
5244
+ \*************************/
5245
+ (__unused_webpack_module, __webpack_exports__, __webpack_require__) {
5246
+
5247
+ __webpack_require__.r(__webpack_exports__);
5248
+ /* harmony export */ __webpack_require__.d(__webpack_exports__, {
5249
+ /* harmony export */ AgentDeckSettingsTabProvider: () => (/* binding */ AgentDeckSettingsTabProvider)
5250
+ /* harmony export */ });
5251
+ /* harmony import */ var _angular_core__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! @angular/core */ "@angular/core");
5252
+ /* harmony import */ var _angular_core__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_angular_core__WEBPACK_IMPORTED_MODULE_0__);
5253
+ /* harmony import */ var tabby_settings__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! tabby-settings */ "tabby-settings");
5254
+ /* harmony import */ var tabby_settings__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(tabby_settings__WEBPACK_IMPORTED_MODULE_1__);
5255
+ /* harmony import */ var _settings_component__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./settings.component */ "./src/settings.component.ts");
5256
+ var __decorate = (undefined && undefined.__decorate) || function (decorators, target, key, desc) {
5257
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
5258
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5259
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5260
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
5261
+ };
5262
+
5263
+
5264
+
5265
+ /**
5266
+ * 설정 창 왼쪽 목록에 AgentDeck 탭을 끼워 넣는다.
5267
+ * `weight` 를 주지 않으면 등록 순서 뒤쪽에 붙는다 — 순정 탭들 아래로 가도록 그대로 둔다.
5268
+ */
5269
+ let AgentDeckSettingsTabProvider = class AgentDeckSettingsTabProvider extends tabby_settings__WEBPACK_IMPORTED_MODULE_1__.SettingsTabProvider {
5270
+ constructor() {
5271
+ super(...arguments);
5272
+ this.id = 'agentdeck';
5273
+ this.icon = 'layer-group';
5274
+ this.title = 'AgentDeck';
5275
+ }
5276
+ getComponentType() {
5277
+ return _settings_component__WEBPACK_IMPORTED_MODULE_2__.AgentDeckSettingsTabComponent;
5278
+ }
5279
+ };
5280
+ AgentDeckSettingsTabProvider = __decorate([
5281
+ (0,_angular_core__WEBPACK_IMPORTED_MODULE_0__.Injectable)()
5282
+ ], AgentDeckSettingsTabProvider);
5283
+
5284
+
5285
+
3164
5286
  /***/ },
3165
5287
 
3166
5288
  /***/ "./src/status.service.ts"
@@ -3206,7 +5328,10 @@ let WorkStatusService = class WorkStatusService {
3206
5328
  get(tab) {
3207
5329
  let s = this.states.get(tab);
3208
5330
  if (!s) {
3209
- s = { status: 'idle', label: '', since: Date.now(), pinned: false, lastOutput: 0 };
5331
+ s = {
5332
+ status: 'idle', label: '', since: Date.now(), pinned: false,
5333
+ lastOutput: 0, busyMode: false, lastBusy: 0,
5334
+ };
3210
5335
  this.states.set(tab, s);
3211
5336
  }
3212
5337
  return s;
@@ -3247,6 +5372,21 @@ let WorkStatusService = class WorkStatusService {
3247
5372
  markOutput(tab) {
3248
5373
  this.get(tab).lastOutput = Date.now();
3249
5374
  }
5375
+ /**
5376
+ * 에이전트가 "작업 중" 이라고 스스로 밝힌 순간 — 스피너나 "esc to interrupt" 같은 표시.
5377
+ *
5378
+ * 이 신호를 한 번이라도 본 탭은 그 뒤로 신호만 보고 판단한다(busyMode).
5379
+ * 상태줄을 매초 다시 그리는 TUI 를 "계속 일하는 중" 으로 오해하지 않기 위해서다.
5380
+ */
5381
+ markBusy(tab) {
5382
+ const s = this.get(tab);
5383
+ s.busyMode = true;
5384
+ s.lastBusy = Date.now();
5385
+ }
5386
+ /** 이 탭을 에이전트 TUI 로 볼지 — 아니면 예전처럼 출력 유무로 판단한다 */
5387
+ isBusyMode(tab) {
5388
+ return this.get(tab).busyMode;
5389
+ }
3250
5390
  forget(tab) {
3251
5391
  this.states.delete(tab);
3252
5392
  this.changed.next();
@@ -3257,7 +5397,10 @@ let WorkStatusService = class WorkStatusService {
3257
5397
  if (s.pinned || s.status !== 'running') {
3258
5398
  return false;
3259
5399
  }
3260
- if (s.lastOutput && Date.now() - s.lastOutput > idleAfterMs) {
5400
+ // 에이전트 탭은 "작업 중" 신호가 끊긴 시점부터 잰다.
5401
+ // 일반 셸은 예전대로 출력이 끊긴 시점부터 잰다 (신호를 낼 줄 모르므로)
5402
+ const from = s.busyMode ? s.lastBusy : s.lastOutput;
5403
+ if (from && Date.now() - from > idleAfterMs) {
3261
5404
  s.status = 'idle';
3262
5405
  s.since = Date.now();
3263
5406
  return true;
@@ -3303,6 +5446,16 @@ module.exports = __WEBPACK_EXTERNAL_MODULE__angular_core__;
3303
5446
 
3304
5447
  /***/ },
3305
5448
 
5449
+ /***/ "@angular/forms"
5450
+ /*!*********************************!*\
5451
+ !*** external "@angular/forms" ***!
5452
+ \*********************************/
5453
+ (module) {
5454
+
5455
+ module.exports = __WEBPACK_EXTERNAL_MODULE__angular_forms__;
5456
+
5457
+ /***/ },
5458
+
3306
5459
  /***/ "electron"
3307
5460
  /*!***************************!*\
3308
5461
  !*** external "electron" ***!
@@ -3460,25 +5613,31 @@ let __webpack_exports__ = {};
3460
5613
  \**********************/
3461
5614
  __webpack_require__.r(__webpack_exports__);
3462
5615
  /* harmony export */ __webpack_require__.d(__webpack_exports__, {
3463
- /* harmony export */ STATUS_STYLES: () => (/* reexport safe */ _api__WEBPACK_IMPORTED_MODULE_11__.STATUS_STYLES),
5616
+ /* harmony export */ STATUS_STYLES: () => (/* reexport safe */ _api__WEBPACK_IMPORTED_MODULE_15__.STATUS_STYLES),
3464
5617
  /* harmony export */ "default": () => (__WEBPACK_DEFAULT_EXPORT__)
3465
5618
  /* harmony export */ });
3466
5619
  /* harmony import */ var _angular_core__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! @angular/core */ "@angular/core");
3467
5620
  /* harmony import */ var _angular_core__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_angular_core__WEBPACK_IMPORTED_MODULE_0__);
3468
5621
  /* harmony import */ var _angular_common__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! @angular/common */ "@angular/common");
3469
5622
  /* harmony import */ var _angular_common__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(_angular_common__WEBPACK_IMPORTED_MODULE_1__);
3470
- /* harmony import */ var tabby_core__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! tabby-core */ "tabby-core");
3471
- /* harmony import */ var tabby_core__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(tabby_core__WEBPACK_IMPORTED_MODULE_2__);
3472
- /* harmony import */ var tabby_terminal__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! tabby-terminal */ "tabby-terminal");
3473
- /* harmony import */ var tabby_terminal__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(tabby_terminal__WEBPACK_IMPORTED_MODULE_3__);
3474
- /* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ./config */ "./src/config.ts");
3475
- /* harmony import */ var _hotkeys__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! ./hotkeys */ "./src/hotkeys.ts");
3476
- /* harmony import */ var _decorator__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(/*! ./decorator */ "./src/decorator.ts");
3477
- /* harmony import */ var _deck_service__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(/*! ./deck.service */ "./src/deck.service.ts");
3478
- /* harmony import */ var _profile_service__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(/*! ./profile.service */ "./src/profile.service.ts");
3479
- /* harmony import */ var _notify_service__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(/*! ./notify.service */ "./src/notify.service.ts");
3480
- /* harmony import */ var _styles_scss__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(/*! ./styles.scss */ "./src/styles.scss");
3481
- /* harmony import */ var _api__WEBPACK_IMPORTED_MODULE_11__ = __webpack_require__(/*! ./api */ "./src/api.ts");
5623
+ /* harmony import */ var _angular_forms__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! @angular/forms */ "@angular/forms");
5624
+ /* harmony import */ var _angular_forms__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(_angular_forms__WEBPACK_IMPORTED_MODULE_2__);
5625
+ /* harmony import */ var tabby_core__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! tabby-core */ "tabby-core");
5626
+ /* harmony import */ var tabby_core__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(tabby_core__WEBPACK_IMPORTED_MODULE_3__);
5627
+ /* harmony import */ var tabby_settings__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! tabby-settings */ "tabby-settings");
5628
+ /* harmony import */ var tabby_settings__WEBPACK_IMPORTED_MODULE_4___default = /*#__PURE__*/__webpack_require__.n(tabby_settings__WEBPACK_IMPORTED_MODULE_4__);
5629
+ /* harmony import */ var tabby_terminal__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! tabby-terminal */ "tabby-terminal");
5630
+ /* harmony import */ var tabby_terminal__WEBPACK_IMPORTED_MODULE_5___default = /*#__PURE__*/__webpack_require__.n(tabby_terminal__WEBPACK_IMPORTED_MODULE_5__);
5631
+ /* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(/*! ./config */ "./src/config.ts");
5632
+ /* harmony import */ var _hotkeys__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(/*! ./hotkeys */ "./src/hotkeys.ts");
5633
+ /* harmony import */ var _decorator__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(/*! ./decorator */ "./src/decorator.ts");
5634
+ /* harmony import */ var _deck_service__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(/*! ./deck.service */ "./src/deck.service.ts");
5635
+ /* harmony import */ var _profile_service__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(/*! ./profile.service */ "./src/profile.service.ts");
5636
+ /* harmony import */ var _notify_service__WEBPACK_IMPORTED_MODULE_11__ = __webpack_require__(/*! ./notify.service */ "./src/notify.service.ts");
5637
+ /* harmony import */ var _settings__WEBPACK_IMPORTED_MODULE_12__ = __webpack_require__(/*! ./settings */ "./src/settings.ts");
5638
+ /* harmony import */ var _settings_component__WEBPACK_IMPORTED_MODULE_13__ = __webpack_require__(/*! ./settings.component */ "./src/settings.component.ts");
5639
+ /* harmony import */ var _styles_scss__WEBPACK_IMPORTED_MODULE_14__ = __webpack_require__(/*! ./styles.scss */ "./src/styles.scss");
5640
+ /* harmony import */ var _api__WEBPACK_IMPORTED_MODULE_15__ = __webpack_require__(/*! ./api */ "./src/api.ts");
3482
5641
  var __decorate = (undefined && undefined.__decorate) || function (decorators, target, key, desc) {
3483
5642
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3484
5643
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
@@ -3500,6 +5659,10 @@ var __metadata = (undefined && undefined.__metadata) || function (k, v) {
3500
5659
 
3501
5660
 
3502
5661
 
5662
+
5663
+
5664
+
5665
+
3503
5666
  let AgentDeckModule = class AgentDeckModule {
3504
5667
  constructor(deck, profiles, notify) {
3505
5668
  // 세 서비스 모두 app.ready$ 를 기다렸다가 스스로 붙는다
@@ -3512,17 +5675,23 @@ AgentDeckModule = __decorate([
3512
5675
  (0,_angular_core__WEBPACK_IMPORTED_MODULE_0__.NgModule)({
3513
5676
  imports: [
3514
5677
  _angular_common__WEBPACK_IMPORTED_MODULE_1__.CommonModule,
3515
- (tabby_core__WEBPACK_IMPORTED_MODULE_2___default()),
5678
+ // 설정 탭이 [(ngModel)] 로 config.store 를 직접 물고 있어서 필요하다
5679
+ _angular_forms__WEBPACK_IMPORTED_MODULE_2__.FormsModule,
5680
+ (tabby_core__WEBPACK_IMPORTED_MODULE_3___default()),
3516
5681
  ],
3517
5682
  providers: [
3518
- { provide: tabby_core__WEBPACK_IMPORTED_MODULE_2__.ConfigProvider, useClass: _config__WEBPACK_IMPORTED_MODULE_4__.AgentDeckConfigProvider, multi: true },
3519
- { provide: tabby_core__WEBPACK_IMPORTED_MODULE_2__.HotkeyProvider, useClass: _hotkeys__WEBPACK_IMPORTED_MODULE_5__.AgentDeckHotkeyProvider, multi: true },
3520
- { provide: tabby_terminal__WEBPACK_IMPORTED_MODULE_3__.TerminalDecorator, useClass: _decorator__WEBPACK_IMPORTED_MODULE_6__.AgentDeckDecorator, multi: true },
5683
+ { provide: tabby_core__WEBPACK_IMPORTED_MODULE_3__.ConfigProvider, useClass: _config__WEBPACK_IMPORTED_MODULE_6__.AgentDeckConfigProvider, multi: true },
5684
+ { provide: tabby_core__WEBPACK_IMPORTED_MODULE_3__.HotkeyProvider, useClass: _hotkeys__WEBPACK_IMPORTED_MODULE_7__.AgentDeckHotkeyProvider, multi: true },
5685
+ { provide: tabby_terminal__WEBPACK_IMPORTED_MODULE_5__.TerminalDecorator, useClass: _decorator__WEBPACK_IMPORTED_MODULE_8__.AgentDeckDecorator, multi: true },
5686
+ { provide: tabby_settings__WEBPACK_IMPORTED_MODULE_4__.SettingsTabProvider, useClass: _settings__WEBPACK_IMPORTED_MODULE_12__.AgentDeckSettingsTabProvider, multi: true },
5687
+ ],
5688
+ declarations: [
5689
+ _settings_component__WEBPACK_IMPORTED_MODULE_13__.AgentDeckSettingsTabComponent,
3521
5690
  ],
3522
5691
  }),
3523
- __metadata("design:paramtypes", [_deck_service__WEBPACK_IMPORTED_MODULE_7__.AgentDeckService,
3524
- _profile_service__WEBPACK_IMPORTED_MODULE_8__.AgentDeckProfileService,
3525
- _notify_service__WEBPACK_IMPORTED_MODULE_9__.WorkNotifyService])
5692
+ __metadata("design:paramtypes", [_deck_service__WEBPACK_IMPORTED_MODULE_9__.AgentDeckService,
5693
+ _profile_service__WEBPACK_IMPORTED_MODULE_10__.AgentDeckProfileService,
5694
+ _notify_service__WEBPACK_IMPORTED_MODULE_11__.WorkNotifyService])
3526
5695
  ], AgentDeckModule);
3527
5696
  /* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (AgentDeckModule);
3528
5697