tabby-agentdeck 0.3.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,37 @@
1
+ /**
2
+ * 미리보기 패널이 쓰는 최소 마크다운 렌더러.
3
+ *
4
+ * 왜 라이브러리(marked 등)를 안 쓰나 — 이 플러그인은 런타임 의존성이 하나도 없다(package.json
5
+ * 은 devDependencies 뿐이고 webpack 이 전부 번들한다). 그 성질을 지키는 값이 크고, 여기서 필요한
6
+ * 것은 문서 미리보기 수준의 부분집합이다. 무엇보다 **HTML 을 우리가 전부 만들면 새니타이즈가
7
+ * 필요 없다** — Electron 렌더러 안에서 남이 만든 문자열을 innerHTML 에 넣는 것이 이 기능의
8
+ * 유일한 보안 위험인데, 원문을 먼저 이스케이프하고 태그는 이쪽에서만 붙이므로 원문에 들어 있는
9
+ * `<script>` 나 `onerror=` 는 태그가 될 기회가 없다.
10
+ *
11
+ * 지원하는 것: 제목 · 문단 · 강조 · 코드(인라인/펜스) · 목록(중첩) · 표 · 인용 · 구분선 ·
12
+ * 링크 · 이미지 · 체크박스. 지원하지 않는 것: 원문 HTML(그대로 글자로 보인다) · 참조 링크
13
+ * (`[a][1]`) · 각주 · 정의목록.
14
+ */
15
+ export interface MarkdownOptions {
16
+ /**
17
+ * 이미지 경로를 실제로 그릴 수 있는 URL 로 바꾼다.
18
+ * 로컬 파일을 data URL 로 읽는 일은 fs 가 필요해 패널(viewPanel)이 맡고, 여기서는 호출만 한다.
19
+ * null 을 돌려주면 이미지 대신 대체 텍스트를 보여준다.
20
+ */
21
+ resolveImage?: (src: string) => string | null;
22
+ }
23
+ export declare function escapeHtml(s: string): string;
24
+ /**
25
+ * 링크로 써도 되는 주소인지 본다.
26
+ *
27
+ * 스킴이 붙어 있으면 http/https/mailto/file 만 통과시킨다 — `javascript:` 와 `data:` 를 막는
28
+ * 것이 목적이다. 스킴이 없는 것(상대경로 `./a.md`, 절대경로 `/tmp/a`, 앵커 `#절`)은 그대로 둔다.
29
+ * Windows 드라이브 문자(`D:/x`)는 스킴처럼 보이므로 따로 통과시킨다.
30
+ */
31
+ export declare function safeUrl(raw: string): string | null;
32
+ /**
33
+ * 마크다운 원문을 HTML 로 바꾼다.
34
+ *
35
+ * 한 번 훑으면서 블록을 가른다 — 상태는 "지금 펜스 안인가 / 목록 몇 겹인가" 두 개뿐이다.
36
+ */
37
+ export declare function renderMarkdown(src: string, opts?: MarkdownOptions): string;
package/dist/nav.d.ts ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * 사이드바 **키보드 내비게이션**의 순수 규칙 — 보이는 줄 펼치기(`navRowsOf`)와 그 위의
3
+ * 커서 이동(`stepNavIndex`). Angular/Tabby/DOM 에 기대지 않아 단위 테스트가 된다.
4
+ *
5
+ * 이 기능의 계약은 딱 하나다 — **훑는 동안 활성 탭이 바뀌지 않는다.** 지나가는 탭의 출력이
6
+ * 화면을 덮으면 훑어보기 자체가 불가능해지므로, ↑↓ 는 `navFocus`(포커스 링)만 옮기고
7
+ * 선택은 Enter 에서만 일어난다. 그래서 "어느 줄로 가는가" 는 화면을 흔들지 않고 확인할 수
8
+ * 있어야 하는 값이고, 펼치기와 이동을 한 곳에 둔 이유도 그것이다 — 줄 목록의 순서와
9
+ * 커서의 경계 규칙이 같은 계약(화면에 보이는 순서)을 지켜야 한다.
10
+ *
11
+ * **`deck.service.ts` 에서 여기로 옮겨 왔다** — 그 파일은 Angular/Tabby 를 import 해서
12
+ * `package.json` 의 test 스크립트가 컴파일하는 순수 모듈 목록에 들어갈 수 없고, 그래서 이 두
13
+ * 함수엔 유닛 테스트가 한 개도 없었다(사람이 손으로 떼어내 돌려보는 것이 전부였다).
14
+ * `planTabMove` 를 `order.ts` 로 옮긴 것과 같은 이유·같은 방식이며, 구현은 한 줄도 바꾸지
15
+ * 않았다(실경로에서 이미 도는 코드다). 옮기자마자 `test/nav.test.js` 의 전수 검사가 붙었다.
16
+ */
17
+ /**
18
+ * 키보드가 훑는 줄 하나 — **화면에 실제로 보이는 것만** 담는다.
19
+ *
20
+ * 그룹 헤더는 그룹 키로, 탭 줄은 탭 객체로 가리킨다. 인덱스로 들고 있으면 안 된다 —
21
+ * 탭이 하나 닫히거나 검색이 걸리면 같은 인덱스가 **다른 줄**을 가리키게 되고
22
+ * (`dropTargetAt` 이 드래그 중에 겪은 것과 같은 함정), 그 사이 사람은 다른 세션을 열게 된다.
23
+ */
24
+ export type NavRow<T> = {
25
+ kind: 'head';
26
+ key: string | null;
27
+ } | {
28
+ kind: 'tab';
29
+ tab: T;
30
+ };
31
+ /**
32
+ * 렌더 계획을 키보드 이동 순서로 펼친다 — `render()` 의 루프와 **같은 순서**여야 한다.
33
+ *
34
+ * 순서의 출처는 `renderPlan()` 하나뿐이다. 여기서 정렬·필터·그룹 규칙을 다시 적으면 그 사본이
35
+ * 낡는다(`deck.service.ts` 가 `renderPlan` 을 뽑은 이유 자체가 그것이다). 그래서 이 함수는
36
+ * **받은 구획을 그대로 펼치기만** 하고, 무엇이 보이는지는 판단하지 않는다.
37
+ *
38
+ * 접힌 그룹은 헤더만 남고 줄이 없다 — 화면과 같다. 헤더를 안 그리는 화면(`withHeads: false`)은
39
+ * `head: false` 로 들어오므로 평면 목록이 된다.
40
+
41
+ *
42
+ * **전제 — 구획마다 `head`·`collapsed` 가 일관되어야 한다.** 지금 유일한 호출부(`navPlan`)
43
+ * 는 둘을 화면 전체의 `withHeads` 에서 파생시키므로 어긋날 수 없다. 손으로 섞어 넣으면 규칙이
44
+ * 이상해진다 — `head: false` + `collapsed: true` 인 구획은 **통째로 사라지고**(줄 0개), 구획마다
45
+ * `head` 가 섞이면 헤더 없는 구획의 탭이 **앞 구획 헤더 밑에 붙어** 보인다.
46
+ * 그 조합을 함수가 막지 않는 이유는, 막는 분기가 지금 도달 불가라 검증되지 않는 코드로 남기
47
+ * 때문이다. 새 호출부를 만들 사람이 읽을 곳은 여기다. */
48
+ export declare function navRowsOf<T>(sections: ReadonlyArray<{
49
+ key: string | null;
50
+ tabs: readonly T[];
51
+ head: boolean;
52
+ collapsed: boolean;
53
+ }>): Array<NavRow<T>>;
54
+ /**
55
+ * 보이는 줄이 `count` 개일 때 `index` 에서 `step` 칸 움직인 자리. 줄이 없으면 -1.
56
+ *
57
+ * 화면도 DOM 도 모르는 순수 규칙으로 뽑아 둔 이유는 `planTabMove` 와 같다 — 경계에서
58
+ * 무엇이 일어나는지(멈추나 감싸나)를 좌표·포커스 없이 확인할 수 있어야 한다.
59
+ * 규칙 세 가지 —
60
+ * 1) 포커스가 없거나(-1) 목록에서 사라졌으면(닫힘·필터) **방향에 맞는 끝**에서 새로 시작한다.
61
+ * 아래로 눌렀으면 첫 줄, 위로 눌렀으면 마지막 줄이다. 손이 간 방향으로 들어와야
62
+ * "아무 일도 안 일어났다" 가 되지 않는다.
63
+ * 2) `wrap` 이 false 면 끝에서 **제자리**다(기본). 감싸면 "맨 아래에 왔다" 는 신호가 사라진다.
64
+ * 3) `wrap` 이 true 면 반대쪽 끝으로 — 음수 나머지를 쓰지 않게 두 번 접는다.
65
+ *
66
+ * 돌려주는 값은 **줄 인덱스일 뿐 선택이 아니다** — 호출부(`moveNav`)는 이 값으로 `navFocus`
67
+ * 만 옮기고 활성 탭은 건드리지 않는다. 그리고 헤더 줄을 건너뛰지 않는다: 건너뛰면 그룹 A 의
68
+ * 마지막 탭에서 B 의 첫 탭으로 점프해 "보이는 순서대로 훑는다" 는 계약이 깨진다.
69
+ */
70
+ /**
71
+ * `Ctrl+W` 가 닫을 탭 — **키보드가 어디에 있느냐로 갈린다.**
72
+ *
73
+ * 터미널에서 눌렀으면 활성 탭이다(사람이 보고 있는 화면 = 닫으려는 것). 목록이 키보드를
74
+ * 갖고 있을 때(`inList`)는 **포커스 링이 얹힌 줄**이다 — 그때 활성 탭을 닫으면 링을 옮겨
75
+ * 놓고 엉뚱한 탭이 사라진다. ↑↓ 로 훑다가 "이건 버리자" 하는 자리가 곧 이 키의 쓰임새다.
76
+ *
77
+ * 두 경우에 `null`(아무것도 하지 않는다)을 돌려준다 —
78
+ * 1) 목록에서 포커스가 **그룹 헤더**에 있다. 헤더는 탭이 아니고, "이 그룹을 통째로 닫는다"
79
+ * 는 되돌릴 수 없는 조작이라 키 하나에 매달 것이 아니다.
80
+ * 2) 고른 탭이 `tabs` 에 없다 — 이미 닫혔거나(포커스가 낡았다) 다른 창의 것이다.
81
+ * `navFocus` 는 렌더마다 정리되지만(`pruneNavFocus`) 그 사이 키가 들어올 수 있다.
82
+ *
83
+ * 호출부(`claimCtrlWKey`)는 `null` 이면 **키를 막지 않고 흘린다** — 터미널의 `Ctrl+W`
84
+ * (앞 단어 지우기)를 우리가 먹어 놓고 아무 일도 안 하는 것이 가장 나쁘다.
85
+ */
86
+ export declare function pickCloseTarget<T>(opts: {
87
+ inList: boolean;
88
+ focus: NavRow<T> | null;
89
+ active: T | null;
90
+ tabs: readonly T[];
91
+ }): T | null;
92
+ export declare function stepNavIndex(count: number, index: number, step: number, wrap: boolean): number;
@@ -1,5 +1,15 @@
1
- import { AppService, ConfigService, PlatformService } from 'tabby-core';
1
+ import { AppService, BaseTabComponent, ConfigService, PlatformService } from 'tabby-core';
2
2
  import { WorkStatusService } from './status.service';
3
+ import { SubagentCall } from './subagents';
4
+ /** 사이드바(`deck.service` renderTab)에 넘기는 결론 */
5
+ export interface SubagentView {
6
+ sessionId: string;
7
+ /** 도는 중인 개수 — 0 이면 이 객체를 아예 만들지 않는다(`subagentsOf` 가 null) */
8
+ running: number;
9
+ items: readonly SubagentCall[];
10
+ /** 그대로 `title` 에 넣을 문장. 빈 문자열이면 붙이지 않는다 */
11
+ tooltip: string;
12
+ }
3
13
  /**
4
14
  * 에이전트 훅 -> 사이드바 상태 채널. **기본으로 켜져 있다** (`notifyChannel`).
5
15
  *
@@ -69,6 +79,19 @@ export declare class WorkNotifyService {
69
79
  private pollCount;
70
80
  /** 같은 매칭 실패를 폴링마다 diag 에 찍지 않기 위한 마지막 메시지 */
71
81
  private lastBindLog;
82
+ /**
83
+ * 세션 id -> 대화기록 증분 스캔 상태. 탭에 묶인 세션만 들어 있다(`pruneScans`).
84
+ *
85
+ * 탭이 아니라 **세션** 을 키로 두는 이유: 같은 탭에서 claude 를 다시 띄우면 세션 id 가
86
+ * 새로 생기는데(그게 정상이다) 탭을 키로 두면 옛 세션의 줄 수·호출 목록이 새 세션 것으로
87
+ * 이어져 개수가 틀어진다.
88
+ */
89
+ private scans;
90
+ /**
91
+ * Claude Code 대화기록이 사는 곳. 그 아래 프로젝트 폴더 이름은 **규칙을 흉내내지 않고**
92
+ * 훑어서 찾는다 (`findTranscript`).
93
+ */
94
+ private projectsDir;
72
95
  constructor(app: AppService, config: ConfigService, status: WorkStatusService, platform: PlatformService);
73
96
  /**
74
97
  * 훅이 안 걸려 있으면 기동할 때 한 번 물어본다.
@@ -186,4 +209,69 @@ export declare class WorkNotifyService {
186
209
  private bind;
187
210
  /** 매칭 실패는 폴링마다 반복되므로 같은 내용은 한 번만 남긴다 */
188
211
  private bindLog;
212
+ /** 서브에이전트 개수를 세고 보일지 (`agentDeck.subagentCount`) */
213
+ private get subagentEnabled();
214
+ /**
215
+ * 이 탭에서 도는 서브에이전트 — **사이드바가 쓰는 유일한 창구**. 없으면 null.
216
+ *
217
+ * 개수를 여기서 다시 세지 않는다(`subagents.ts` 가 센 것을 되읽는다). 툴팁만 부를 때마다
218
+ * 만드는데, 거기에 **경과 시간**이 들어가기 때문이다 — 캐시하면 시계가 멈춘다.
219
+ * 세션이 안 묶인 탭·꺼진 상태·0 개는 전부 null 로 떨어져 호출부가 조건을 따로 적을 일이 없다.
220
+ */
221
+ subagentsOf(tab: BaseTabComponent | null | undefined): SubagentView | null;
222
+ /**
223
+ * 툴팁 문장 = `formatSubagentTooltip()`(목록) + **가장 오래된 것의 경과 시간** 한 줄.
224
+ *
225
+ * **유령 카운트를 감추지 않는다** (2026-09-09 판단 확정). 사용자가 Esc 로 취소하거나 CLI 가
226
+ * 죽으면 종료 알림이 안 남아 "영구 1개" 가 될 수 있는데, 임의 임계(예: 2시간 넘으면 제외)나
227
+ * 세션 상태로 숨기는 처리를 넣지 않는다 —
228
+ * ⓐ 백그라운드 에이전트가 도는 동안 **메인 세션 상태가 `대기` 인 것은 정상**이다. 그걸로
229
+ * 숨기면 진짜로 도는 것을 지운다.
230
+ * ⓑ 이 저장소의 원칙은 잘못된 상태를 조용한 폴백으로 덮지 않는 것이다. 숨겨 버리면 개수가
231
+ * 맞는지 아무도 알 수 없고, 정작 사용자가 "왜 안 보이나" 를 물을 때 근거가 없다.
232
+ * 대신 **경과 시간을 보여** 사람이 스스로 유령을 알아보게 한다 — 5분 된 3개와 이틀 된 1개는
233
+ * 눈으로 갈린다. 시각의 원천은 호출 줄의 `timestamp`(`SubagentCall.startedAt`)다.
234
+ */
235
+ private subagentTooltip;
236
+ /**
237
+ * 탭에 묶인 세션들의 대화기록을 **자란 만큼만** 읽어 개수를 갱신한다 (2초마다).
238
+ *
239
+ * 매 주기에 하는 일은 `statSync().size` 비교뿐이다 — 안 자랐으면 파일을 아예 열지 않는다.
240
+ * 자란 경우에만 그 구간을 스트림으로 읽고, 그 읽기는 **비동기**다: 실측 대화기록이
241
+ * 13MB / 6,570줄이라 첫 스캔을 동기로 하면 그동안 Tabby UI 가 통째로 멈춘다.
242
+ *
243
+ * 상태 파일을 다시 읽지 않는다 — 세션 ↔ 탭 은 이 서비스가 이미 들고 있는 `sessionTab` 이
244
+ * 정본이고(훅 보고로 채워진다), 같은 것을 두 곳에서 만들면 어긋난다.
245
+ */
246
+ private scanSubagents;
247
+ /**
248
+ * 탭이 닫히거나 세션이 다른 탭으로 넘어가면 그 스캔 상태를 버린다.
249
+ *
250
+ * `tabClosed$` 만 믿지 않고 매 주기에 표를 대조하는 이유 — 세션이 다른 탭에서 되살아나면
251
+ * (`claude --resume`) 닫힘 이벤트 없이 `bind()` 가 이전 주인을 조용히 풀고, 그러면
252
+ * `sessionTab` 에서 빠진 세션의 스캔만 남는다.
253
+ */
254
+ private pruneScans;
255
+ /** 상태·decoder·오프셋을 한 세트로 새로 만든다 (셋이 따로 놀면 글자가 깨지거나 개수가 틀어진다) */
256
+ private resetScan;
257
+ /**
258
+ * `~/.claude/projects/<프로젝트폴더>/<세션id>.jsonl` 을 찾는다.
259
+ *
260
+ * **폴더 이름 규칙을 흉내내지 않는다.** 그 이름은 작업 경로를 `-` 로 뭉갠 것인데 드라이브
261
+ * 문자·대소문자·특수문자에서 어긋난다. 하위 폴더를 훑어 파일명을 세션 id 로 맞추면 폴더를
262
+ * 몰라도 정확히 하나가 잡힌다 — 판정은 `subagents.ts` 의 `matchesSessionTranscript` 하나뿐이고
263
+ * 여기에 사본을 두지 않는다.
264
+ *
265
+ * 비동기인 이유는 읽기와 같다(프로젝트가 수십 개면 readdir 도 공짜가 아니다). 못 찾으면
266
+ * `TRANSCRIPT_LOOKUP_RETRY_MS` 뒤에 다시 본다 — 파일이 아직 안 생겼을 수 있다.
267
+ */
268
+ private findTranscript;
269
+ /**
270
+ * `lastSize` 부터 `size` 까지만 읽어 먹인다.
271
+ *
272
+ * 실패하면 `lastSize` 를 **올리지 않는다** — 다음 주기에 같은 구간을 다시 읽게 되는데, 같은
273
+ * 줄을 두 번 먹여도 결과는 같다(`subagents.ts`: 호출은 이미 본 id 를 무시하고 종료는 Set).
274
+ * 중간에 끊긴 조각이 앞에 붙어 생기는 깨진 줄은 그쪽에서 `skipped` 로 버린다.
275
+ */
276
+ private readGrowth;
189
277
  }
package/dist/order.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 사이드바 정렬·집계의 순수 규칙 — Angular/Tabby 에 기대지 않아 단위 테스트가 된다.
2
+ * 사이드바 **순서**의 순수 규칙 — 상태 정렬·집계, 드래그 재배열(`planTabMove`), 그리고 드래그 중 가장자리 자동 스크롤 속도(`planEdgeScroll`) — Angular/Tabby 에 기대지 않아 단위 테스트가 된다.
3
3
  *
4
4
  * 헤더 집계와 `sortByStatus` 정렬이 같은 우선순위를 써야 하므로 한 곳에 둔다.
5
5
  * 순서는 "사람이 지금 손대야 하는 것" 부터다 —
@@ -24,3 +24,71 @@ export declare function countByStatus<T>(tabs: readonly T[], statusOf: (tab: T)
24
24
  * 원본 배열(`app.tabs`)은 건드리지 않는다 — Tabby 의 탭 순서는 사용자의 것이다.
25
25
  */
26
26
  export declare function sortTabsByStatus<T>(tabs: readonly T[], statusOf: (tab: T) => WorkStatus): T[];
27
+ /** 드롭이 대상 줄의 앞에 붙나 뒤에 붙나 */
28
+ export type DropPlace = 'before' | 'after';
29
+ /**
30
+ * `from` 번째를 `to` 번째의 앞/뒤로 옮긴 **인덱스 순열**. 옮길 이유가 없으면 null.
31
+ *
32
+ * 배열도 탭도 모르는 순수 규칙으로 뽑아 둔 이유 둘 —
33
+ * 1) `app.tabs` 를 흔드는 것은 되돌릴 수 없는 조작이다. "어디로 가는가" 를 DOM·포인터 없이
34
+ * 확인할 수 있어야 한다.
35
+ * 2) **제자리로 놓는 드래그를 걸러내는 곳**이 여기 하나여야 한다. 자기 바로 앞 줄의 뒤에
36
+ * 놓는 것은 순열이 항등이므로 null 이 되고, 그러면 호출부가 `emitTabsChanged()` 를
37
+ * 부르지 않는다 — 아무것도 안 바뀐 재배열로 순정 탭바를 다시 그리게 하지 않는다.
38
+ *
39
+ * **`deck.service.ts` 에서 여기로 옮겨 왔다** — 그 파일은 Angular/Tabby 를 import 해서
40
+ * `package.json` 의 test 스크립트가 컴파일하는 순수 모듈 목록에 들어갈 수 없고, 그래서 이 함수엔
41
+ * 유닛 테스트가 한 개도 없었다(손으로 떼어내 돌려보는 것이 전부였다). 순서를 다루는 순수 규칙이라
42
+ * 정렬 규칙과 같은 자리가 맞고, 옮기자마자 위 1) 이 말한 "DOM·포인터 없이 확인" 이
43
+ * `test/order.test.js` 의 전수 검사가 됐다. 구현은 한 줄도 바꾸지 않았다(실경로 R58 통과 상태).
44
+ */
45
+ export declare function planTabMove(count: number, from: number, to: number, place: DropPlace): number[] | null;
46
+ /** 드래그 자동 스크롤 한 프레임의 입력 — 좌표와 숫자만. DOM 은 호출부가 읽는다 */
47
+ export type EdgeScrollInput = {
48
+ /** 포인터의 축 좌표 (viewport px) */
49
+ pos: number;
50
+ /** 목록의 앞쪽 경계 — 세로면 `top`, 가로면 `left` */
51
+ near: number;
52
+ /** 목록의 뒤쪽 경계 — 세로면 `bottom`, 가로면 `right` */
53
+ far: number;
54
+ /** 지금 스크롤 위치 (`scrollTop`/`scrollLeft`) */
55
+ cur: number;
56
+ /** 스크롤할 수 있는 최대값 (`scrollHeight - clientHeight` 등) */
57
+ maxScroll: number;
58
+ /** 가장자리 띠 두께의 **상한** (px) — 짧은 목록에서는 이보다 얇아진다 */
59
+ bandPx: number;
60
+ /** 띠의 안쪽 경계에서의 속도 (px/s) */
61
+ minPps: number;
62
+ /** 가장자리에 완전히 붙였을 때의 속도 (px/s) */
63
+ maxPps: number;
64
+ };
65
+ /**
66
+ * 드래그 중 목록 가장자리 **자동 스크롤 속도** (px/s). 0 이면 스크롤할 이유가 없다.
67
+ * 음수 = 목록 앞쪽(위/왼쪽), 양수 = 뒤쪽(아래/오른쪽).
68
+ *
69
+ * 0 을 주는 갈래 넷 —
70
+ * 1) **목록 밖**: 목록 밖에서 손을 떼는 것은 취소 손짓이다(`dropTargetAt` 의
71
+ * `outside-list`). 취소하려고 커서를 빼는 사람의 목록을 흘려 보내면 안 된다.
72
+ * 2) 띠 밖(목록 가운데): 평범한 드롭을 방해하지 않는다.
73
+ * 3) 그 방향으로 **더 갈 데가 없다**: 루프를 살려 두면 아무 일도 없이 매 프레임 돈다.
74
+ * 4) 목록이 너무 짧아 띠가 성립하지 않는다.
75
+ *
76
+ * 띠를 `bandPx` 그대로 쓰지 않고 목록 길이의 1/3 로 깎는 것이 이 규칙의 핵심이다 —
77
+ * 그래서 앞·뒤 띠는 **어떤 길이에서도 겹치지 않고**(각 1/3, 가운데 1/3 은 조용하다) 짧은
78
+ * 목록에서 "어디에 놓아도 화면이 흘러 놓기 자체가 불가능" 해지는 일이 생기지 않는다.
79
+ * 목록이 12px 보다 짧으면 띠가 4px 아래로 내려가 아예 성립하지 않는다(갈래 4).
80
+ *
81
+ * 상수(32 / 150 / 800)를 안으로 들이지 않고 **인자로 받는다** — 그 세 값의 근거는
82
+ * 줄 높이(`.ad-tab`)와 모니터 주사율이라 `deck.service.ts` 의 상수 주석에 적혀 있고,
83
+ * 사본을 여기 두면 둘 중 하나가 낡는다. 인자로 받으면 테스트가 띠·속도 조합을 직접
84
+ * 만들어 경계 대칭·단조·상하한을 전수로 훑을 수 있다는 이득도 같이 온다.
85
+ *
86
+ * **`deck.service.ts` 의 `dragScrollSpeed` 에서 여기로 옮겨 왔다** — 그 파일은 Angular/Tabby
87
+ * 를 import 해서 `package.json` 의 test 스크립트가 컴파일하는 순수 모듈 목록에 들어갈 수 없고,
88
+ * 그래서 이 속도 램프는 실경로 프로브(RO9·RO13 의 `speed` 진단구)로만 검증됐다.
89
+ * `planTabMove`·`stepNavIndex` 를 옮긴 것과 같은 이유·같은 방식이며, 구현은 이름만 인자로
90
+ * 바꿨을 뿐 산술은 한 줄도 손대지 않았다(실경로 R73 통과 상태). 남은 `dragScrollSpeed` 는
91
+ * rect·`scrollTop` 을 읽어 이 함수에 넘기는 껍데기다 — 경계를 그렇게 그은 이유는 DOM 을
92
+ * 읽는 줄과 속도를 정하는 줄이 한 함수에 있으면 후자를 검사할 방법이 없어서다.
93
+ */
94
+ export declare function planEdgeScroll(input: EdgeScrollInput): number;
package/dist/prompt.d.ts CHANGED
@@ -20,7 +20,41 @@
20
20
  * 라벨을 그대로 두는 편이 낫다.
21
21
  *
22
22
  * 화면 버퍼를 직접 받지 않고 줄 배열만 받는 순수 함수로 둔다 (xterm 없이 검증할 수 있게).
23
+ *
24
+ * **모양은 밖에서 주입한다** (`PromptShape`). 위 실측은 전부 Claude Code 화면이고,
25
+ * Codex·Gemini 는 머리글자도 테두리도 다를 수 있다. 상수를 파일에 박아 두면 에이전트가
26
+ * 늘 때마다 이 순수 함수를 고쳐야 한다 — 대신 글자 묶음을 인자로 받아 두면 에이전트별
27
+ * 프로필이 그 자리에 값을 꽂는다. 인자를 안 주면 `DEFAULT_PROMPT_SHAPE`(= 여태 쓰던 값)다.
23
28
  */
29
+ /**
30
+ * 입력창을 찾는 데 쓰는 글자 묶음.
31
+ *
32
+ * 정규식이 아니라 **문자/단어 배열**로 받는 이유 — 프로필이 정규식을 들고 있으면
33
+ * 이스케이프·플래그 실수가 프로필 쪽으로 번지고 사람이 읽기도 어렵다. 데이터만 받고
34
+ * 조립은 여기서 한 번만 한다 (그리고 캐시한다 — 아래 `promptCache`).
35
+ */
36
+ export interface PromptShape {
37
+ /** 입력창 머리글자 후보. 여러 글자짜리 머리(`>>>` 등)도 그대로 받는다 */
38
+ heads: string[];
39
+ /** 머리글자를 쓰지만 프롬프트가 아닌 줄 — 선택지 커서(`❯ Yes`)의 그 단어들 */
40
+ choiceWords: string[];
41
+ /** 입력창 아래 테두리로 볼 문자 */
42
+ ruleChars: string[];
43
+ }
44
+ /**
45
+ * 여태 쓰던 값 그대로 — Claude Code 2.1.x 실측 기준 (2026-09-01 화면 캡처).
46
+ *
47
+ * - `heads`: ASCII `>` 와 꺾쇠 계열(❯ › ❭ ➜). Claude Code 2.1.x 는 `❯`(U+276F).
48
+ * - `choiceWords`: 같은 꺾쇠를 선택지 커서로도 쓴다 (`❯ 1. Yes`). 번호 목록은 아래
49
+ * 조립부에서 항상 배제하고, `Yes`/`No` 같은 무번호 선택지는 이 단어 목록으로 배제한다.
50
+ * - `ruleChars`: 입력창 아래 테두리에 쓰이는 가로선 문자. ASCII `-` 도 넣어 둔다 —
51
+ * 상자형 입력창을 ASCII 로 그리는 앱(`+------+`)이 있다.
52
+ */
53
+ export declare const DEFAULT_PROMPT_SHAPE: PromptShape;
24
54
  /** 사이드바에 들어가는 라벨 길이 */
25
55
  export declare const MAX_LABEL_LEN = 40;
26
- export declare function extractPrompt(lines: string[]): string;
56
+ /**
57
+ * @param lines 화면에 그려진 줄 배열 (위에서 아래로)
58
+ * @param shape 입력창 모양. 생략하면 `DEFAULT_PROMPT_SHAPE`(Claude Code 2.1.x 실측)
59
+ */
60
+ export declare function extractPrompt(lines: string[], shape?: Partial<PromptShape> | null): string;
package/dist/reason.d.ts CHANGED
@@ -4,9 +4,13 @@
4
4
  * 훅(hooks/agentdeck-notify.ps1)은 원문을 그대로 보내고, 줄이는 규칙은 여기 한 곳에만 둔다.
5
5
  * Claude Code 가 문구를 바꾸면 이 파일만 고치면 되고, 훅을 다시 설치할 필요가 없다.
6
6
  *
7
+ * **훅이 없는 CLI 는 화면에서 온다** — `reasonFromScreen` 이 그 입구다(ANSI·테두리를 벗기고
8
+ * 같은 축약 표를 태운다). 규칙을 두 곳에 두면 한쪽만 고쳐지므로 사본을 만들지 않는다.
9
+ *
7
10
  * 알려진 문구 (2026-09 기준):
8
11
  * "Claude needs your permission to use Bash" -> "Bash 권한"
9
12
  * "Claude is waiting for your input" -> "입력 대기"
13
+ * "Allow execution of [Shell]?" (Gemini CLI 화면) -> "Shell 권한"
10
14
  * 플랜 승인 / 질문 응답도 같은 요령으로 잡는다.
11
15
  * 한도 도달(StopFailure error=rate_limit 의 last_assistant_message):
12
16
  * "You've hit your session limit · resets 12pm (Asia/Seoul)" -> "12pm 리셋"
@@ -14,3 +18,15 @@
14
18
  * 모르는 문구는 앞 24자만 남긴다 — 배지 한 줄을 넘기지 않기 위해서다.
15
19
  */
16
20
  export declare function shortenReason(msg: string): string;
21
+ /**
22
+ * **화면에서 뽑은 줄**을 배지에 붙일 이유로 바꾼다 — 훅 문구용 `shortenReason` 을 그대로 태우고,
23
+ * 화면에만 있는 잡음(ANSI 제어열 · TUI 테두리)을 먼저 벗기는 것만 다르다.
24
+ *
25
+ * 왜 여기 두나 — 축약 규칙의 사본을 만들면 한쪽만 고쳐진다. 훅이 있는 탭과 없는 탭의 배지 문구가
26
+ * 같아야 사이드바를 훑어 어느 세션을 먼저 볼지 정할 수 있다(그게 이유를 붙이는 목적이다).
27
+ *
28
+ * 벗기는 것: CSI(색·커서 이동) · OSC/DCS · 남은 ESC 한 글자 · 박스드로잉(U+2500~257F)과
29
+ * 블록(U+2580~259F) 테두리. **`●`(U+25CF)·`■`(U+25A0)·`❯`(U+276F) 는 남긴다** — 선택지 커서와
30
+ * 오류 표식이라 위 규칙들이 그것을 보고 판정한다(선택지 줄 → 빈 이유).
31
+ */
32
+ export declare function reasonFromScreen(line: string): string;
package/dist/screen.d.ts CHANGED
@@ -28,6 +28,13 @@
28
28
  * **입력할 자리가 있는가**(테두리 사이 간격 ≥ 2)를 같이 본다.
29
29
  *
30
30
  * xterm 없이 검증할 수 있도록 줄 배열만 받는 순수 함수로 둔다.
31
+ *
32
+ * **글자 묶음은 밖에서 주입한다** (`ScreenShape`). 위 실측은 전부 Claude Code 화면이라
33
+ * 테두리 문자·머리글자를 파일에 박아 두면 다른 에이전트가 늘 때 이 순수 함수를 고쳐야 한다.
34
+ * 인자를 안 주면 `DEFAULT_SCREEN_SHAPE`(= 여태 쓰던 값)로 지금과 똑같이 판정한다.
35
+ * **판정 규칙 자체(테두리가 cols 를 꽉 채운다, 테두리 사이 간격 ≥ 2)는 shape 로 빼지 않았다** —
36
+ * 그건 글자 모양이 아니라 "가로선 2줄 + 입력줄 1줄" 이라는 배치 가정이고, 실측 근거가
37
+ * Claude Code 화면 하나뿐이다. 다른 앱 실측이 생기면 그때 규칙을 손댈 자리다.
31
38
  */
32
39
  /** 판정에 필요한 한 줄 — 오른쪽 공백을 떼어낸 본문과 xterm 의 wrap 플래그 */
33
40
  export interface ScreenLine {
@@ -39,4 +46,80 @@ export interface ScreenVerdict {
39
46
  /** 왜 깨졌다고 보는지 — 진단 로그에 그대로 남긴다 */
40
47
  reasons: string[];
41
48
  }
42
- export declare function judgeScreen(lines: ScreenLine[], cols: number): ScreenVerdict;
49
+ /**
50
+ * 화면에서 입력창을 알아보는 데 쓰는 글자 묶음.
51
+ *
52
+ * 정규식이 아니라 문자 배열로 받는 이유는 `prompt.ts` 의 `PromptShape` 와 같다 —
53
+ * 프로필이 이스케이프를 신경쓰지 않아도 되게. 조립·캐시는 이 파일이 한다.
54
+ *
55
+ * `prompt.ts` 와 타입을 공유하지 않고 각자 두는 이유 — 두 판정이 보는 것이 다르다.
56
+ * 프롬프트 추출은 "입력창 아래 테두리" 하나만 알면 되고(그래서 ASCII `-` 도 받는다),
57
+ * 화면 판정은 화면 전체를 훑으므로 테두리 문자를 더 좁게 잡아야 한다(아래 주석 참조).
58
+ */
59
+ export interface ScreenShape {
60
+ /** 가로선(입력창 위/아래 테두리)으로 볼 문자 */
61
+ ruleChars: string[];
62
+ /** 입력창 머리글자 후보 */
63
+ heads: string[];
64
+ /**
65
+ * 선택지 커서로 쓰이는 단어 — 머리로 세지 않는다.
66
+ * 선택(옵셔널)이라 프로필이 `ruleChars`·`heads` 만 줘도 된다.
67
+ */
68
+ choiceWords?: string[];
69
+ }
70
+ /**
71
+ * 여태 쓰던 값 그대로 — Claude Code 2.1.x 실측 기준.
72
+ *
73
+ * `ruleChars` 에 ASCII `-` 가 없는 것은 의도다(원래 값 그대로 옮겼다). 이 판정은 화면
74
+ * 전체를 훑기 때문에, `-` 를 넣으면 코드·마크다운 출력의 `-----` 줄이 입력창 테두리로
75
+ * 세어지고 "폭이 안 맞는 테두리" 오탐이 쏟아진다. 실측 골든의 테두리는 전부 U+2500 계열이다.
76
+ *
77
+ * `choiceWords` 의 `Yes`/`No` 는 Claude Code 확인창 문구다 — 다른 에이전트·다른 언어에서는
78
+ * 다른 단어이므로 상수로 박아 두지 않고 여기 기본값으로만 둔다.
79
+ */
80
+ export declare const DEFAULT_SCREEN_SHAPE: ScreenShape;
81
+ /**
82
+ * 입력창이 있는 구간 — 화면 맨 아래 이만큼만 본다.
83
+ *
84
+ * 입력창은 언제나 화면 맨 아래에 있고, 대화 이력에 남은 옛 입력창까지 같은 규칙으로 보면
85
+ * 오탐이 쏟아진다. `deck.service.ts` 의 `clearInputArea` 가 잔상을 지울 때 쓰는 창과 같은 값이다.
86
+ */
87
+ export declare const INPUT_TAIL = 16;
88
+ /**
89
+ * xterm 이 가진 크기와 pty 에 알려 둔 크기가 **맞나**.
90
+ *
91
+ * 자동복구를 켤 수 있느냐가 이 하나에 달려 있다. 2026-09-09 실사용 채증 8건이 정확히 둘로 갈렸다 —
92
+ *
93
+ * xterm != pty : 00:46:54 (280/283) · 01:13:20 (281/279) · 01:59:08 (221/281) · 02:34:21 (282/280)
94
+ * → 전부 **리사이즈 중간 프레임**. 화면 덤프는 앱 기준 정상이고 0~3초 만에
95
+ * 스스로 돌아왔다. Tabby 순정이 pty 까지 맞춘다 — 우리가 손대면 화면만 흔들린다.
96
+ * xterm == pty : 02:07:45 ~ 02:08:01 (281/281, 4샘플)
97
+ * → 자식 프로세스 stdout 이 TUI 를 덮어쓴 **진짜 깨짐**. 26초를 버텼고
98
+ * `clearInputArea` + `Ctrl+L` 로 고쳐진다.
99
+ *
100
+ * 2026-09-02 에 자동복구를 껐던 "실사용 4/4 실패" 가 앞 유형에 손댄 것이었다.
101
+ * 그래서 판정은 `judgeScreen`(화면 모양)과 **따로** 둔다 — 깨진 것은 맞는데 우리 일이 아닌 경우다.
102
+ *
103
+ * pty 크기를 아직 못 읽은 경우(`undefined`)는 **맞다고 본다** — 모르는 것을 이유로 복구를
104
+ * 막으면 크기 정보가 없는 프론트엔드에서 기능이 통째로 죽는다.
105
+ */
106
+ export declare function sizeInSync(xcols?: number, xrows?: number, sentCols?: number, sentRows?: number): boolean;
107
+ /** 가로선 문자만으로 된 줄인가 (입력창 위/아래 테두리) */
108
+ export declare function isRuleRow(text: string, shape?: Partial<ScreenShape> | null): boolean;
109
+ /**
110
+ * 잔상이 얹힌 테두리 행인가 — 양끝은 가로선인데 가운데에 옛 프레임 글자가 남았다.
111
+ *
112
+ * `judgeScreen` 의 같은 규칙을 밖에서도 쓰려고 뺐다. 복구(`clearInputArea`)가 입력창의
113
+ * 시작 행을 찾을 때 이 행을 **테두리로 쳐 줘야** 잔상 위에서부터 지우기가 시작된다 —
114
+ * 순수 가로선만 찾으면 이 행을 못 보고 입력창 아래를 지워 아무것도 안 고쳐진다.
115
+ *
116
+ * **양끝이 가로선인 줄만** 본다 — 상자형 입력창(`╭──╮`)은 모서리로 시작·끝나므로 안 걸린다.
117
+ * 대화 출력의 짧은 구분선(`── 요약 ──`)은 가로선 비율에서 걸러진다.
118
+ */
119
+ export declare function isGhostRuleRow(text: string, cols: number, shape?: Partial<ScreenShape> | null): boolean;
120
+ /**
121
+ * @param lines 화면에 그려진 줄 배열 (위에서 아래로)
122
+ * @param cols xterm 이 가진 화면 폭
123
+ * @param shape 입력창 글자 묶음. 생략하면 `DEFAULT_SCREEN_SHAPE`(Claude Code 2.1.x 실측)
124
+ */
125
+ export declare function judgeScreen(lines: ScreenLine[], cols: number, shape?: Partial<ScreenShape> | null): ScreenVerdict;
@@ -3,13 +3,28 @@ import { AgentDeckRecoveryService } from './recovery.service';
3
3
  /**
4
4
  * 설정 > AgentDeck 탭.
5
5
  *
6
- * 사용자가 실제로 바꿀 일이 있는 것만 노출한다 — 레이아웃 on/off, 기동 시 탭 복원 끄기, 작업 루트 프로필.
7
- * 나머지(화면비, 사이드바 폭, 투명도, 상태 감지, 입력 가로채기, 렌더 보정, 통보 채널)는
6
+ * 사용자가 실제로 바꿀 일이 있는 것만 노출한다 — 레이아웃 on/off, 기동 시 탭 복원 끄기,
7
+ * 사이드바 목록(키보드 조작 · 서브에이전트 개수), 세션 그룹, 미리보기 패널, 작업 루트 프로필.
8
+ * 나머지(화면비, 사이드바 폭, 투명도, 상태 감지, 붙여넣기/줄바꿈 가로채기, 렌더 보정)는
8
9
  * 손댈 이유가 거의 없고 잘못 만지면 화면이 깨지는 값이라 `config.ts` 의 기본값으로 고정하고
9
10
  * 설정 창에서는 뺐다. 정말 필요하면 설정 파일의 `agentDeck.*` 를 직접 고친다.
10
11
  *
11
12
  * `enabled` 는 deck.service 가 `config.changed$` 를 구독해 즉시 반영한다(deck.service.ts:256).
12
13
  * 루트 프로필은 기동 시 한 번만 읽으므로 재시작이 필요하다고 적어 둔다.
14
+ *
15
+ * `사이드바 목록` 절을 `세션 그룹` **앞**에 둔 이유 — 그룹도 결국 이 목록의 세부 규칙이라
16
+ * 목록 전체에 걸리는 것(키보드 조작 · 줄에 무엇을 보일지)을 먼저 읽는 편이 순서가 맞는다.
17
+ * 0.14.0 의 `dragAutoScroll` 도 같은 절에 뒀다 — 정하는 것이 "목록을 다루는 방법" 이라
18
+ * 키보드 조작과 성질이 같고, 따로 절을 만들면 항목 하나뿐인 절이 생긴다.
19
+ * 0.13.0 에서 생긴 세 값(`keyboardNav` / `keyboardNavWrap` / `subagentCount`)은
20
+ * 설정 창에 자리가 없어 `config.yaml` 을 손으로 고쳐야만 바꿀 수 있었다.
21
+ *
22
+ * `keyboardNavWrap` 과 `keyboardCloseTab`(0.19.0) 은 `keyboardNav` 를 끄면 아무 뜻이 없으므로 그때는 줄을 아예 감춘다 —
23
+ * `groupRoot`·`collapsedGroups` 를 `groupByProject` 에 매달아 둔 것과 같은 방식이고,
24
+ * 꺼진 값을 회색으로 남겨 두는 것보다 "지금 만질 수 있는 것" 이 짧아진다.
25
+ * 이 세 값은 deck.service/notify.service 가 쓸 때마다 store 를 다시 읽으므로
26
+ * (deck.service.ts `navCanFocus`/`moveNav`, notify.service `subagentEnabled`)
27
+ * 토글하면 바로 듣는다 — 그래서 재시작 안내를 붙이지 않았다.
13
28
  */
14
29
  export declare class AgentDeckSettingsTabComponent {
15
30
  config: ConfigService;
@@ -21,6 +36,16 @@ export declare class AgentDeckSettingsTabComponent {
21
36
  hooksOn: boolean;
22
37
  /** 훅을 쓰다 실패한 이유 — 조용히 삼키면 사용자는 버튼이 먹통인 줄 안다 */
23
38
  hookError: string;
39
+ /** 신고 안내에 그대로 보여줄 진단 로그 경로 */
40
+ diagPath: string;
41
+ /** 신고 본문에 적을 버전 — 사용자가 어느 버전을 쓰는지 스스로 알 방법이 없었다 */
42
+ diagVersion: string;
43
+ /** 묶음에 화면 원문까지 넣나 (기본 제외 — 터미널에 찍힌 내용이 그대로 들어간다) */
44
+ diagIncludeScreen: boolean;
45
+ /** 마지막으로 만든 묶음 경로 */
46
+ diagBundlePath: string;
47
+ /** 묶음을 만들다 실패한 이유 */
48
+ diagError: string;
24
49
  constructor(config: ConfigService, recovery: AgentDeckRecoveryService, platform: PlatformService);
25
50
  private refreshHooks;
26
51
  /**
@@ -39,6 +64,13 @@ export declare class AgentDeckSettingsTabComponent {
39
64
  * 돌려받은 경로는 백슬래시를 슬래시로 바꿔 저장한다. 설정 파일에 그대로 적히는 값이라
40
65
  * 손으로 고칠 때 이스케이프 사고가 나지 않게 하려는 것이고, Windows 도 슬래시 경로를 받는다.
41
66
  */
67
+ /**
68
+ * 진단 묶음을 만들고 그 파일이 있는 폴더를 연다.
69
+ *
70
+ * 폴더를 여는 것까지가 한 동작인 이유 — 경로만 알려 주면 "그 폴더가 어디냐" 가 다시 질문으로
71
+ * 돌아온다. 압축이 안 되는 환경에서는 폴더 경로가 나오므로 그대로 열어 주면 된다.
72
+ */
73
+ collectDiag(): void;
42
74
  /** 배치가 기본값에서 벗어나 있나 — 벗어나 있을 때만 초기화 버튼이 살아 있다 */
43
75
  layoutChanged(): boolean;
44
76
  dockLabel(): string;
@@ -50,5 +82,17 @@ export declare class AgentDeckSettingsTabComponent {
50
82
  * deck.service 가 그걸 받아 곧바로 다시 배치하므로(deck.service.ts:256) 재시작도 필요 없다.
51
83
  */
52
84
  resetLayout(): void;
85
+ /** 접혀 있는 그룹 수 — 버튼을 살릴지와 안내 문장에 쓴다 */
86
+ collapsedCount(): number;
87
+ /**
88
+ * 접어 둔 그룹을 모두 펼친다.
89
+ *
90
+ * 왜 버튼이 필요한가 — 접힌 그룹의 탭을 다 닫아도 키는 남는다(그 프로젝트를 다시 열었을 때
91
+ * 접힘이 유지되는 것이 의도다). 그래서 "예전에 접었는데 지금은 그 그룹이 안 보이는" 상태가
92
+ * 쌓일 수 있고, 그때 되돌릴 방법이 config 파일 편집뿐이었다.
93
+ */
94
+ expandGroups(): void;
95
+ /** 프로젝트 기준 폴더를 OS 다이얼로그로 고른다 (작업 폴더와 같은 방식) */
96
+ pickGroupRoot(): Promise<void>;
53
97
  pickCwd(): Promise<void>;
54
98
  }
@@ -48,6 +48,20 @@ export declare class WorkStatusService {
48
48
  /** 이 탭을 에이전트 TUI 로 볼지 — 아니면 예전처럼 출력 유무로 판단한다 */
49
49
  isBusyMode(tab: BaseTabComponent): boolean;
50
50
  forget(tab: BaseTabComponent): void;
51
+ /**
52
+ * 살아있는 탭만 남긴다 — **닫힌 탭이 `states` 에 쌓이는 것을 막는 유일한 경로다.**
53
+ *
54
+ * `states` 는 WeakMap 이 아니라 Map 이라(틱마다 전 탭을 훑어야 해서 열거가 필요하다)
55
+ * 참조를 우리가 직접 지워야 한다. 지우는 곳은 원래 `decorator.detach` 하나뿐이었는데,
56
+ * 거기 조건이 `root === tab || !app.tabs.includes(root)` 라서 **닫히는 순서에 걸린다** —
57
+ * 분할 탭(`SplitTabComponent`)의 자식 터미널이 detach 될 때 부모가 아직 `app.tabs` 에
58
+ * 남아 있으면 그냥 지나가고, 그 뒤 부모가 사라지면 아무도 지우지 않는다.
59
+ * 2026-09-09 실측(PF9): 탭 11개를 닫았는데 `states` 가 37→37 로 그대로였다.
60
+ *
61
+ * 그래서 탭 목록이 바뀔 때마다 여기서 한 번 훑는다. 비용은 Map 크기만큼의 `includes` 이고,
62
+ * 탭 목록 변화는 사람이 만드는 빈도(초당 몇 번이 최대)라 문제가 되지 않는다.
63
+ */
64
+ sweep(alive: readonly BaseTabComponent[]): number;
51
65
  /** 사이드바가 매 틱 호출 — idle 전환만 담당하고 화면 갱신 필요 여부를 돌려준다 */
52
66
  tickIdle(tab: BaseTabComponent, idleAfterMs: number): boolean;
53
67
  }