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.
package/dist/detect.d.ts CHANGED
@@ -1,15 +1,34 @@
1
1
  import { BaseTabComponent } from 'tabby-core';
2
2
  import { WorkStatusService } from './status.service';
3
+ import { AgentProfile } from './agents';
4
+ /**
5
+ * 한도 도달을 사람의 행동으로 풀어 준다 — **사용자가 새 프롬프트를 보낸 순간**이 근거다.
6
+ *
7
+ * `limited` 는 화면 갱신만으로는 안 풀린다(`applyOutput` 의 마지막 갈래 주석). 그래서 풀리는
8
+ * 길을 반드시 하나 더 열어 둔다 — 실패에 영구 lock 을 걸지 않는다는 이 저장소의 원칙이다.
9
+ * `deck.service` 의 Enter 관측 지점(`claimEnterLabel`)에서 부르면, 사용량이 회복된 뒤 사용자가
10
+ * 다시 지시를 넣는 순간 배지가 진행중으로 돌아온다.
11
+ *
12
+ * pinned(훅이 지정한 `limited`)는 건드리지 않는다 — 그 탭은 훅이 풀어 준다
13
+ * (`UserPromptSubmit` → `running`, `claudeHooks.ts` PLAN).
14
+ */
15
+ export declare function releaseLimited(status: WorkStatusService, root: BaseTabComponent): void;
3
16
  /**
4
17
  * PTY 출력 한 조각을 보고 탭 상태를 갱신한다.
5
18
  *
6
- * 우선순위: OSC 통보 > 대기 패턴 > "출력이 있다 = 진행중".
19
+ * 우선순위: OSC 통보 > 대기 패턴 > 한도 패턴 > "출력이 있다 = 진행중".
7
20
  * idle 로의 복귀는 사이드바 타이머(tickIdle)가 담당한다.
8
21
  *
9
22
  * decorator 와 deck 의 세션 워처 양쪽에서 호출된다 — 같은 조각을 두 번 먹어도
10
23
  * 결과가 같도록 상태 설정은 모두 멱등하게 만들어 두었다.
24
+ *
25
+ * @param profile 이 탭에서 도는 에이전트의 프로필. **생략하면 합집합**(`unionProfile()`)이라
26
+ * 0.5.0 까지의 판정과 완전히 같다 — 아직 어떤 에이전트인지 못 알아낸 탭에는 넘기지 않는다.
11
27
  */
12
- export declare function applyOutput(status: WorkStatusService, root: BaseTabComponent, owner: object, data: string, autoDetect: boolean): void;
28
+ export declare function applyOutput(status: WorkStatusService, root: BaseTabComponent, owner: object, data: string, autoDetect: boolean, profile?: AgentProfile): void;
13
29
  export declare function forgetOutputBuffer(owner: object): void;
14
30
  export declare function stripTitleMarker(title: string): string;
15
- export declare function applyTitle(status: WorkStatusService, root: BaseTabComponent, title: string, autoDetect: boolean): void;
31
+ /**
32
+ * @param profile 이 탭의 에이전트 프로필. 생략하면 합집합이라 0.5.0 과 같은 판정이다.
33
+ */
34
+ export declare function applyTitle(status: WorkStatusService, root: BaseTabComponent, title: string, autoDetect: boolean, profile?: AgentProfile): void;
package/dist/diag.d.ts ADDED
@@ -0,0 +1,96 @@
1
+ /** 한 줄 요약이 쌓이는 곳 */
2
+ export declare const DIAG_PATH: string;
3
+ /** 직전 분량 — 상한을 넘으면 여기로 밀고 새로 쓴다. 지우지 않는 것이 요점이다 */
4
+ export declare const DIAG_PREV_PATH: string;
5
+ /** 깨진 화면의 원문 (deck.service 의 snapshot) */
6
+ export declare const SCREEN_PATH: string;
7
+ /** 화면 원문의 직전 분량 */
8
+ export declare const SCREEN_PREV_PATH: string;
9
+ /** 세션 스탬프 — 받은 로그가 "어느 빌드/환경의 것인가" 를 답하는 값들 */
10
+ export interface DiagStamp {
11
+ /** Tabby 버전 (`PlatformService.getAppVersion()`) */
12
+ tabby?: string;
13
+ /** OS 릴리스 (`PlatformService.getOSRelease()`) */
14
+ osRelease?: string;
15
+ /** 로그를 읽는 사람이 재현에 필요한 설정만 골라 넣는다 (전체 config 를 넣지 않는다) */
16
+ config?: Record<string, any>;
17
+ }
18
+ /**
19
+ * 세션 머리말·수집 리포트에 넣을 설정만 골라 담는다.
20
+ *
21
+ * 전체 config 를 넣지 않는 이유 — 작업 폴더 같은 사적인 경로까지 들어가는데, 정작 증상을
22
+ * 가르는 데 필요한 값은 몇 개뿐이다. `terminal.useConPTY` 는 Tabby 쪽 값이지만 새 탭이 안
23
+ * 열리는 증상(README 문제해결)의 원인이라 같이 본다.
24
+ */
25
+ export declare function configStamp(store: any): Record<string, any>;
26
+ /**
27
+ * 이 플러그인 버전.
28
+ *
29
+ * `dist/index.js` 기준 `../package.json` 이 npm 설치본과 개발용 junction 양쪽에서 모두 맞는다
30
+ * (`claudeHooks.ts:52` 가 `hooks/` 를 찾는 방식과 같다).
31
+ */
32
+ export declare function pluginVersion(): string;
33
+ /** 진단 한 줄. 시각은 여기서 붙이므로 호출부는 내용만 넘긴다 */
34
+ export declare function diag(line: string): void;
35
+ /**
36
+ * 같은 키로는 한 번만 남기는 진단.
37
+ * relayout 처럼 초당 여러 번 불리는 자리에서 조기 return 사유를 남길 때 쓴다
38
+ */
39
+ export declare function diagOnce(key: string, line: string): void;
40
+ /** `diagOnce` 키를 푼다 — 상황이 바뀌어 다시 한 번 남겨야 할 때 */
41
+ export declare function resetDiagOnce(key: string): void;
42
+ /** 메모리에 남은 진단 줄 (devtools `__agentdeck.kickLog` 가 이 배열을 그대로 참조한다) */
43
+ export declare function diagMemory(): string[];
44
+ /** 최근 N 줄 — 수집 리포트가 쓴다 */
45
+ export declare function recentDiag(limit?: number): string[];
46
+ /**
47
+ * 세션 시작을 기록한다.
48
+ *
49
+ * 옛 구현은 여기서 파일을 **비웠다**(`writeFileSync(path, '')`). 그래서 "어제 이렇게 깨졌어" 를
50
+ * 들고 Tabby 를 껐다 켠 사람에게서는 받을 로그가 없었다. 지금은 비우지 않고, 상한을 넘었을 때만
51
+ * `.1` 로 밀어 두 세대를 남긴다.
52
+ */
53
+ export declare function startDiagSession(stamp?: DiagStamp): void;
54
+ /**
55
+ * 잡히지 않은 예외를 진단 로그로 끌어온다.
56
+ *
57
+ * 세 갈래를 다 덮는다 —
58
+ * ① `window.onerror` / `unhandledrejection`: Angular 밖에서 터지는 비동기 예외
59
+ * ② `console.error` / `console.warn`: Angular 기본 `ErrorHandler` 와 Tabby 의
60
+ * `platform.setErrorHandler` 가 결국 여기로 흘린다(tabby-core `AppModule`).
61
+ * 그래서 `ErrorHandler` 를 우리가 갈아끼우지 않아도 잡힌다 — 코어 동작을 건드리지 않는 쪽을 골랐다.
62
+ * 원래 함수는 항상 그대로 호출하므로 devtools 출력은 달라지지 않는다.
63
+ */
64
+ export declare function installErrorCapture(): void;
65
+ /** 예외 한 건 — 첫 줄에 요약, 그 뒤에 스택을 붙인다 */
66
+ export declare function diagException(tag: string, error: any): void;
67
+ /**
68
+ * `catch` 에서 삼키던 실패를 한 줄로 남긴다.
69
+ *
70
+ * 이 프로젝트는 "동작에는 영향 없다" 는 이유로 빈 `catch {}` 를 많이 쓴다(그 판단 자체는 맞다 —
71
+ * 세션이 막 닫히는 중의 resize 실패 같은 것들이다). 문제는 **정말 이상한 실패도 같은 자리에서
72
+ * 사라진다** 는 것이다. 그래서 같은 (자리, 메시지) 조합은 창(`CATCH_DEDUPE_MS`) 안에서 한 번만,
73
+ * 세션 전체로도 상한(`CATCH_MAX_ENTRIES`)까지만 남긴다 — 흔한 실패로 로그가 밀리지 않게.
74
+ */
75
+ export declare function diagCatch(tag: string, error: any): void;
76
+ /** 화면 원문 한 덩이 — 상한을 넘으면 회전한다 (옛 구현은 상한이 없어 무한히 커졌다) */
77
+ export declare function appendScreenLog(chunk: string): void;
78
+ /** 수집 결과 */
79
+ export interface DiagBundle {
80
+ /** 사용자에게 보여줄 경로 — zip 이면 zip, 실패하면 폴더 */
81
+ path: string;
82
+ /** 압축까지 됐나 */
83
+ zipped: boolean;
84
+ /** 담긴 파일명 */
85
+ files: string[];
86
+ }
87
+ /**
88
+ * 신고용 묶음을 만든다.
89
+ *
90
+ * 화면 원문(`SCREEN_PATH`)은 **기본으로 넣지 않는다** — 터미널에 찍힌 코드·경로가 그대로 들어 있어서,
91
+ * 넣을지는 보내는 사람이 정할 일이다. 압축이 안 되는 환경(zip 도구 없음)에서는 폴더를 그대로 돌려준다.
92
+ */
93
+ export declare function collectDiagBundle(options?: {
94
+ includeScreen?: boolean;
95
+ stamp?: DiagStamp;
96
+ }): DiagBundle;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * `git status` 의 스테이지 상태를 구조로 바꾸고, 스테이지·커밋에 쓸 **명령 인자만** 조립하는
3
+ * 순수 모듈 — 미리보기 패널 `변경` 탭의 스테이지 토글·커밋 버튼이 쓴다.
4
+ *
5
+ * **여기서 git 을 실행하지 않는 이유** — 커밋은 되돌리기 번거로운 실제 부작용이다. 그래서
6
+ * "무엇을 실행할지" 를 정하는 자리(이 파일, 테스트로 고정)와 "실제로 실행하는 자리"
7
+ * (viewPanel.ts, 사람의 클릭에만 반응)를 갈랐다. 이 파일만 읽으면 어떤 명령이 나가는지
8
+ * 인자 배열 단위로 확인할 수 있고, 반대로 이 파일을 불러도 아무 일도 일어나지 않는다.
9
+ *
10
+ * DOM·child_process·fs 를 건드리지 않는다 — gitDiff.ts 와 같은 결이다(테스트로 묶기 위해서).
11
+ *
12
+ * **셸을 거치지 않는다** — viewPanel 은 `execFile('git', args)` 로 부르므로 여기서 만드는
13
+ * 인자는 인용·이스케이프가 필요 없고, 반대로 인용을 붙이면 그 따옴표가 파일명이 된다.
14
+ * 경로 앞에는 항상 `--` 를 넣는다(파일명이 `-f` 나 브랜치명이어도 옵션·리비전으로 안 읽히게).
15
+ */
16
+ /**
17
+ * 한 경로의 스테이지 상태.
18
+ * - `staged` = 스테이지에만 변경이 있다 (X 만 있음)
19
+ * - `unstaged` = 작업트리에만 있다 (Y 만 있음)
20
+ * - `both` = 둘 다 — 올린 뒤에 또 고친 파일이다 (부분 스테이지)
21
+ * - `untracked` = `??`
22
+ * - `conflict` = 머지 충돌 (`UU`·`AA`·`DD`·`AU`·`UA`·`DU`·`UD`)
23
+ */
24
+ export type StageState = 'staged' | 'unstaged' | 'both' | 'untracked' | 'conflict';
25
+ export interface StatusEntry {
26
+ /** 저장소 루트 기준 경로. 이름이 바뀐 경우 **새** 경로 */
27
+ path: string;
28
+ /** 이름이 바뀐(복사된) 경우의 원래 경로 */
29
+ oldPath?: string;
30
+ /** 상태코드 두 글자 중 첫 글자 = 스테이지(index) 쪽 */
31
+ index: string;
32
+ /** 둘째 글자 = 작업트리(worktree) 쪽 */
33
+ work: string;
34
+ state: StageState;
35
+ }
36
+ /**
37
+ * 두 글자 상태코드(XY)를 상태로 읽는다.
38
+ *
39
+ * 규칙은 하나다 — **X 는 스테이지, Y 는 작업트리.** 어느 쪽이 공백이 아니냐로 갈리고,
40
+ * 둘 다 값이 있으면 부분 스테이지(`both`)다. 충돌을 먼저 걸러내는 이유는 `AA`·`UU` 가
41
+ * "양쪽에 값이 있다" 조건도 만족해서 `both` 로 새기 때문이다.
42
+ */
43
+ export declare function stageStateOf(index: string, work: string): StageState;
44
+ /** 목록에 붙일 딱지 — 지금 상태를 그대로 말한다(누르면 무엇이 되는지는 title 이 말한다) */
45
+ export declare function stageLabel(state: StageState): string;
46
+ /**
47
+ * `git status --porcelain=v1` 출력을 구조로 바꾼다. `-z` 인지는 NUL 유무로 스스로 안다 —
48
+ * 부르는 쪽이 어느 형식으로 뽑았는지 두 번 말하지 않게 하기 위해서다.
49
+ *
50
+ * 버리는 것 두 가지: `!!`(무시된 파일 — `--ignored` 없이는 안 나오고 화면에도 안 그린다)과
51
+ * `## ` 머리줄. 나머지는 상태코드를 그대로 보존해서(`index`/`work`) 판정을 되짚을 수 있게 둔다.
52
+ */
53
+ export declare function parseStatus(text: string): StatusEntry[];
54
+ /** 스테이지에 올린다. 경로가 없으면 **빈 배열** — 인자 없는 `git add` 는 저장소 전체를 건드린다 */
55
+ export declare function stageArgs(paths: readonly string[]): string[];
56
+ /**
57
+ * 스테이지에서 내린다. `git reset -q -- <경로>` = 그 경로만 index 를 HEAD 로 되돌린다
58
+ * (작업트리는 건드리지 않는다 — `--hard` 는 절대 쓰지 않는다).
59
+ * 경로가 없으면 빈 배열 — 인자 없는 `git reset` 은 스테이지 전체를 비운다.
60
+ */
61
+ export declare function unstageArgs(paths: readonly string[]): string[];
62
+ /**
63
+ * 커밋이 0개인 저장소용 대체 명령. `reset` 은 HEAD 를 풀어야 하므로 첫 커밋 전에는 실패한다
64
+ * (`변경` 탭이 `diff HEAD` 대신 `diff` 로 폴백해야 했던 것과 같은 사정 — docs/DEVELOPMENT.md).
65
+ * `rm --cached` 는 index 에서만 빼고 작업트리의 파일은 남긴다.
66
+ */
67
+ export declare function unstageFallbackArgs(paths: readonly string[]): string[];
68
+ /**
69
+ * 커밋 메시지 검증. **거부하는 이유를 사람이 읽을 문장으로** 준다 — 버튼이 안 눌리는데
70
+ * 이유가 없으면 사용자는 플러그인이 고장난 줄 안다.
71
+ */
72
+ export declare function validateMessage(msg: string): {
73
+ ok: boolean;
74
+ reason?: string;
75
+ };
76
+ /**
77
+ * 커밋 명령. **메시지는 `-m` 의 값으로 배열 원소 하나로 넘긴다** — 셸을 거치지 않으므로
78
+ * 메시지에 `&&`·`"`·`$(…)` 가 들어 있어도 그냥 글자다(주입 경로가 없다).
79
+ *
80
+ * 검증에 걸리면 **빈 배열**을 돌려준다. 부르는 쪽이 실수로 그대로 실행해도 인자가 없어
81
+ * 커밋이 만들어지지 않는다 — UI 의 버튼 잠금과 별개인 두 번째 안전장치다.
82
+ */
83
+ export declare function commitArgs(msg: string, opts?: {
84
+ amend?: boolean;
85
+ }): string[];
86
+ /** 스테이지에 올라간 것이 하나라도 있나 — 커밋 버튼이 "올린 게 없다" 를 미리 말하는 근거 */
87
+ export declare function hasStaged(entries: readonly StatusEntry[]): boolean;
88
+ /**
89
+ * 커밋 줄에 붙일 요약. 부분 스테이지(`both`)는 **양쪽에 센다** — 그 파일은 올린 부분과
90
+ * 안 올린 부분이 실제로 둘 다 있고, 한쪽만 세면 합이 파일 수와 안 맞아 더 헷갈린다.
91
+ */
92
+ export declare function formatStageSummary(entries: readonly StatusEntry[]): string;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * `git diff` 유니파이드 출력을 구조로 바꾸는 순수 파서 — 미리보기 패널의 `변경` 탭이 쓴다.
3
+ *
4
+ * **왜 근거를 훅이 아니라 git 에 두나 —** 이 패널은 Claude·Codex·Gemini 를 가리지 않아야 한다.
5
+ * "누가 무엇을 고쳤다" 를 에이전트에게 물으면 에이전트마다 규약이 달라지고(훅이 있는 것은
6
+ * Claude Code 뿐이다 — src/claudeHooks.ts), 훅이 없는 에이전트에서는 탭이 텅 빈다.
7
+ * 반면 작업트리는 누가 고쳤든 같은 자리에 같은 모양으로 남는다. 그래서 `git diff` 가 기본이다.
8
+ *
9
+ * DOM 도 child_process 도 건드리지 않는다 — git 실행과 화면 그리기는 viewPanel.ts 가 맡고
10
+ * 여기서는 문자열을 구조로만 바꾼다. viewer.ts / markdown.ts 를 가른 것과 같은 이유(테스트)다.
11
+ */
12
+ export interface DiffLine {
13
+ /** `hunk` = `@@ … @@` 머리줄, 나머지는 본문 한 줄 */
14
+ kind: 'add' | 'del' | 'ctx' | 'hunk';
15
+ /** 본문은 앞머리 기호(`+ - ` )를 뗀 내용. hunk 는 머리줄 원문 그대로 */
16
+ text: string;
17
+ /** 구(舊) 파일 줄번호 — 추가줄에는 없다 */
18
+ oldNo?: number;
19
+ /** 신(新) 파일 줄번호 — 삭제줄에는 없다 */
20
+ newNo?: number;
21
+ }
22
+ export interface DiffFile {
23
+ /** 새 경로 (`b/` 쪽). 삭제된 파일은 지워진 경로 */
24
+ path: string;
25
+ /** 이름이 바뀐 경우의 옛 경로. 그 외에는 없다 */
26
+ oldPath?: string;
27
+ status: 'modified' | 'added' | 'deleted' | 'renamed';
28
+ /** 바이너리는 내용 비교가 없다 — `lines` 가 비어 있다 */
29
+ binary: boolean;
30
+ added: number;
31
+ removed: number;
32
+ lines: DiffLine[];
33
+ }
34
+ /** 경로 한 토큰을 정규화한다 — 따옴표를 벗기고 탭 뒤(타임스탬프)를 버린다 */
35
+ export declare function unquotePath(raw: string): string;
36
+ /**
37
+ * 유니파이드 diff 한 덩이를 파일 목록으로 바꾼다.
38
+ *
39
+ * 훑는 방식: `diff --git` 마다 파일을 하나 열고, `@@` 를 만나면 그 헝크가 **몇 줄인지 세면서**
40
+ * 본문을 먹는다. 줄 수로 끝을 판정하는 이유 — 헝크 본문에는 `+++ b/x` 나 `--- a/x` 같은 글자가
41
+ * 그대로 들어 있을 수 있다(diff 를 담은 파일의 diff). 앞머리 기호만 보고 헤더와 본문을 가르면
42
+ * 그런 줄에서 파싱이 어긋난다.
43
+ */
44
+ export declare function parseUnifiedDiff(text: string): DiffFile[];
45
+ /**
46
+ * 헤더에 쓸 한 줄 요약. 변경이 없으면 그 사실을 그대로 문장으로 돌려준다 —
47
+ * 부르는 쪽에서 빈 목록을 또 갈라 보지 않게 하기 위해서다.
48
+ */
49
+ export declare function formatStat(files: DiffFile[]): string;
50
+ /** 파일 목록에 붙일 상태 딱지 */
51
+ export declare function statusLabel(file: DiffFile): string;
52
+ /**
53
+ * 참조에 쓸 줄번호 — **신(新) 파일 기준**이다.
54
+ *
55
+ * 유니파이드 diff 에는 구/신 번호가 둘 다 있는데, 참조를 받는 에이전트는 **지금 디스크에 있는
56
+ * 파일을 열어 본다.** 그 파일에서 맞는 번호는 신 번호뿐이다(구 번호는 커밋 전 원본의 좌표라
57
+ * 작업트리와 어긋난다). 그래서 추가줄·문맥줄은 `newNo` 를 쓴다.
58
+ *
59
+ * **삭제줄은 `null` 을 돌려준다 = 참조 불가.** 삭제된 줄은 지금 파일에 없으므로 어떤 번호를
60
+ * 붙여도 거짓이 된다. "직전 신 번호" 로 대신하면 `file:42` 가 삭제된 내용이 아니라 **엉뚱하게
61
+ * 남아 있는 42번 줄**을 가리키고, 에이전트는 그 줄을 자신 있게 고친다 — 조용히 틀리는 쪽보다
62
+ * 참조를 못 만들고 이유를 말하는 쪽이 낫다. 헝크 머리줄(`@@`)도 내용이 아니라 머리라 제외한다.
63
+ */
64
+ export declare function refLineNo(line: DiffLine | null | undefined): number | null;
65
+ /**
66
+ * git 이 낸 저장소 루트 기준 경로를 절대경로로 바꾼다 (`/` 형태로).
67
+ *
68
+ * `path.join` 을 쓰지 않는 이유 — 윈도우에서 `\` 로 되돌아오는데, 참조 문자열은 git 이 보여 준
69
+ * 모양(`src/a.ts`)과 같아야 사람이 화면과 대조할 수 있다. `/` 는 윈도우 셸·에이전트 모두 받는다.
70
+ */
71
+ export declare function joinRefPath(root: string | null | undefined, rel: string): string;
72
+ /**
73
+ * 절대경로를 **그 탭의 cwd 기준 상대경로**로 줄인다. cwd 밖이면 절대경로를 그대로 돌려준다.
74
+ *
75
+ * 왜 cwd 기준인가 — 참조를 받는 에이전트는 그 탭의 cwd 에서 돌고 있어서 `src/a.ts` 가 곧바로
76
+ * 열린다. 반대로 저장소 루트 기준을 그대로 넘기면 cwd 가 하위 폴더일 때 어긋난다.
77
+ * cwd 밖(예: cwd 가 하위 폴더인데 파일은 상위에 있음)은 `../` 로 올라가지 않고 절대경로로 준다 —
78
+ * 짧게는 되지만 에이전트가 cwd 밖 파일을 못 여는 경우가 있고, 그때 원인이 보이지 않는다.
79
+ */
80
+ export declare function refPathFrom(abs: string, cwd: string | null | undefined): string;
81
+ /**
82
+ * `파일:라인` 한 토큰을 만든다. 두 줄 이상이면 `파일:시작-끝`.
83
+ *
84
+ * **공백이 든 경로는 따옴표로 감싼다** — 그러지 않으면 셸이 두 인자로 쪼개 읽는다
85
+ * (deck.service 의 `pasteDroppedPath` 와 같은 규칙, 같은 이유). 번호까지 함께 감싸서
86
+ * 한 낱말로 남긴다. 경로에 따옴표 자체가 들어 있는 경우는 다루지 않는다 — 윈도우에서는
87
+ * 파일명에 쓸 수 없는 글자이고, 드롭 붙여넣기도 같은 선에서 멈춰 있다.
88
+ *
89
+ * 번호가 없거나(0·NaN) 뒤집혀 있어도 값을 낸다 — 참조를 못 만들어 침묵하는 것보다 경로만이라도
90
+ * 넣는 쪽이 사람이 이어 쓸 수 있다.
91
+ */
92
+ export declare function formatLineRef(refPath: string, from?: number, to?: number): string;
93
+ /**
94
+ * `git status --porcelain` 에서 추적되지 않는 파일(`??`)의 경로만 뽑는다.
95
+ *
96
+ * 내용 diff 는 하지 않는다 — 새로 만든 파일은 전부 추가줄이라 diff 를 붙여도 정보가 없고,
97
+ * 에이전트가 만든 큰 산출물(로그·이미지)이 그대로 딸려 들어와 패널이 굳는다.
98
+ */
99
+ export declare function parseUntracked(text: string): string[];
@@ -0,0 +1,175 @@
1
+ /**
2
+ * 사이드바를 프로젝트 단위로 묶는 순수 규칙 — DOM·tabby·fs 에 기대지 않아 단위 테스트가 된다.
3
+ *
4
+ * 탭이 열 개를 넘으면 평면 목록에서는 어느 프로젝트의 세션인지 눈으로 가릴 수 없다. 그래서
5
+ * **탭이 태어난 작업 폴더(cwd)** 로 묶는다 — 에이전트 CLI 는 "어느 폴더에서 떴는가" 가 곧
6
+ * 작업 대상이고(`config.ts` rootProfile 주석), `getWorkingDirectory()` 는 셸의 `cd` 를
7
+ * 따라오지 않아 사실상 "탭이 태어난 폴더" 를 준다(docs/DEVELOPMENT.md 0.5.0 실측).
8
+ * 즉 이 값은 세션이 사는 동안 안 바뀌는 = 그룹 키로 쓸 만한 유일한 값이다.
9
+ *
10
+ * 이 모듈은 **git 저장소 루트를 모른다** (fs 접근이 없다). 프로젝트 경계는 호출부가 넘기는
11
+ * `root`(설정 `groupRoot` / `rootProfileCwd`) 로만 정한다 — `groupKeyOf` 주석 참고.
12
+ * 경계를 알아내려고 여기서 `.git` 을 찾기 시작하면 렌더마다 디스크를 때리게 된다
13
+ * (렌더는 초당 여러 번 불린다 — deck.service `scheduleRender`).
14
+ */
15
+ /** cwd 를 모르는 탭들이 모이는 그룹의 이름 (Welcome 탭·조회 실패) */
16
+ export declare const UNGROUPED_LABEL = "\uAE30\uD0C0";
17
+ export interface GroupKeyOptions {
18
+ /**
19
+ * 프로젝트들이 담겨 있는 상위 폴더. cwd 가 이 아래면 **직하 한 단계**까지만 키로 삼는다.
20
+ * 없거나 cwd 가 이 밖이면 cwd 자체가 키다.
21
+ */
22
+ root?: string | null;
23
+ /**
24
+ * `root` 가 안 걸릴 때 키가 가질 최대 경로 조각 수 (드라이브·UNC 접두는 제외).
25
+ * 0 이거나 안 주면 무제한 = cwd 그대로. `root` 아래일 때는 이미 한 단계로 줄었으므로 적용하지 않는다.
26
+ */
27
+ maxDepth?: number;
28
+ }
29
+ export interface GroupedTab<T> {
30
+ tab: T;
31
+ key: string | null;
32
+ }
33
+ export interface TabGroup<T> {
34
+ key: string | null;
35
+ label: string;
36
+ tabs: T[];
37
+ }
38
+ /**
39
+ * 경로를 비교 가능한 꼴로 — 구분자는 `/` 하나, 중복 슬래시 축약, 끝 슬래시 제거.
40
+ *
41
+ * 대소문자는 **일부러 안 건드린다.** 라벨에 원문 대소문자(`Root` 를 `root` 로 만들지 않게)를
42
+ * 살려야 하기 때문이고, 같은 폴더인지 볼 때만 `foldGroupKey` 를 통과시킨다.
43
+ * 값이 없거나 공백뿐이면 null — 호출부에서 "cwd 모름" 과 같게 취급된다.
44
+ */
45
+ export declare function normalizeCwd(cwd: string | null | undefined): string | null;
46
+ /**
47
+ * 그룹 동일성 판정용 접기.
48
+ *
49
+ * Windows 는 경로 대소문자를 무시한다 — `D:\Project` 와 `d:\project` 는 같은 폴더다.
50
+ * 이걸 안 접으면 같은 프로젝트가 두 그룹으로 갈려 기능 자체가 무의미해진다.
51
+ * 설정에 저장한 접힘 목록을 맞출 때도 이걸 통과시켜야 한다 — 셸이 알려주는 경로의
52
+ * 대소문자는 기동마다 다를 수 있는데 접힘은 유지돼야 하기 때문이다.
53
+ */
54
+ export declare function foldGroupKey(key: string | null | undefined): string;
55
+ /**
56
+ * cwd 를 그룹 키로 — 없으면 null(그룹 없음).
57
+ *
58
+ * 깊이 규칙이 이 함수의 핵심이다. cwd 를 그대로 쓰면 `D:\Project\a\sub` 에서 띄운 탭과
59
+ * `D:\Project\a` 에서 띄운 탭이 다른 그룹으로 갈린다(에이전트를 서브폴더에서 띄우는 건 흔하다).
60
+ * 그래서 `root` 를 받으면 그 **직하 한 단계**를 키로 삼는다 — `root = D:/Project` 면
61
+ * `D:/Project/a/sub` 도 `D:/Project/a` 도 키가 `D:/Project/a` 다.
62
+ * `root` 가 없거나 cwd 가 그 밖이면 cwd 자체를 키로 쓴다(그때는 `maxDepth` 로만 줄인다).
63
+ */
64
+ export declare function groupKeyOf(cwd: string | null | undefined, opts?: GroupKeyOptions): string | null;
65
+ /**
66
+ * 그룹에 붙일 짧은 이름 — 경로 마지막 조각. 다른 그룹과 겹치면 상위를 한 단계씩 더 붙인다.
67
+ *
68
+ * 사이드바는 200~560px 라(config `sidebarMin`/`sidebarMax`) 전체 경로를 그리면 말줄임으로
69
+ * 뭉개진다. 그래도 `…/work/agent` 와 `…/temp/agent` 를 둘 다 `agent` 로 적으면 그룹을
70
+ * 구분할 수 없으므로, 겹치는 동안만 위로 올라간다. 끝까지 겹치면 정규화한 전체 경로.
71
+ */
72
+ export declare function groupLabelOf(key: string, allKeys: readonly string[]): string;
73
+ /**
74
+ * 탭을 그룹으로 묶는다.
75
+ *
76
+ * 두 순서를 서로 다르게 정한다 —
77
+ * - **그룹 순서**: 라벨 사전순, cwd 를 모르는 그룹(`key: null`)은 맨 아래. 프로젝트 목록은
78
+ * 자주 안 바뀌므로 눈이 자리를 기억하는 편이 낫고, 상태로 정렬하면 그룹이 매초 뛴다.
79
+ * - **그룹 안의 탭 순서**: 넘어온 순서 그대로. 호출부가 `sortByStatus` 를 이미 적용해
80
+ * 넘길 수 있기 때문이다(`order.ts` 의 `sortTabsByStatus` 도 같은 성질을 지킨다).
81
+ *
82
+ * 반환 길이가 1 이면 호출부는 그룹 헤더를 생략한다 — 단일 프로젝트에서 세로 공간을 낭비하지 않게.
83
+ * 비용은 탭 수만큼의 맵 조작 + 그룹 수²의 라벨 대조뿐이라 렌더마다 불러도 된다.
84
+ */
85
+ export declare function groupTabs<T>(tabs: readonly T[], cwdOf: (t: T) => string | null, opts?: GroupKeyOptions): TabGroup<T>[];
86
+ /**
87
+ * 접힘 상태를 설정(`collapsedGroups`)에 적을 때 쓰는 id.
88
+ *
89
+ * cwd 를 모르는 그룹(`key === null`, 라벨 `기타`)도 접을 수 있어야 하는데 그 그룹에는 경로
90
+ * 키가 없다. 그래서 경로일 수 없는 이 토큰을 대신 쓴다 — 실제 그룹 키는 정규화된 경로뿐이라
91
+ * 괄호로 시작하는 값과는 겹칠 수 없다.
92
+ *
93
+ * `deck.service.ts` 의 private `UNGROUPED_KEY` 와 **같은 값이어야 한다**(deck.service.ts:134).
94
+ * 이미 그 값으로 설정에 적힌 접힘이 있어서, 바꾸면 사용자가 접어 둔 그룹이 조용히 펴진다.
95
+ */
96
+ export declare const UNGROUPED_COLLAPSE_KEY = "(ungrouped)";
97
+ /**
98
+ * 그룹 키 → 설정에 적는 접힘 id. **쓸 때도 읽을 때도** 이걸 통과시켜야 한다.
99
+ *
100
+ * 폴딩(`foldGroupKey`)을 빼먹으면 셸이 기동마다 다른 대소문자로 cwd 를 알려줄 때 접힘이
101
+ * 풀린다(회귀 `GR8`·`GR9` 가 그 성질을 고정한다). `null` 은 `기타` 그룹이라 위 토큰이 된다.
102
+ */
103
+ export declare function collapseIdOf(key: string | null | undefined): string;
104
+ /**
105
+ * 설정에 남은 접힘 목록에 이 그룹이 있나 — **저장된 사용자 의도**를 읽는 유일한 창구.
106
+ *
107
+ * 저장값은 사람이 손으로 고칠 수 있는 config.yaml 이라 무엇이든 들어올 수 있다. 배열이
108
+ * 아니거나 비어 있으면 "접힌 것 없음" 이고, 원소는 문자열로 만든 뒤 폴딩해서 비교한다
109
+ * (`deck.service.ts` `collapsedKeys` 와 같은 처리).
110
+ */
111
+ export declare function isStoredCollapsed(key: string | null | undefined, stored: readonly unknown[] | null | undefined): boolean;
112
+ /**
113
+ * 그룹이 지금 접혀 있는 이유 — 화면(캐럿·title)이 상태만 보고 문구를 정할 수 있게 같이 준다.
114
+ * - `open`: 저장된 접힘이 없다. 그냥 펴진 그룹.
115
+ * - `stored`: 저장된 접힘 그대로 접혀 있다.
116
+ * - `filter-open`: **저장은 접힘인데** 지금 걸린 필터에 걸린 탭이 이 그룹에 있어 임시로 펴 보인다.
117
+ * 저장값은 그대로다 — 필터를 풀면 다시 접힌다.
118
+ */
119
+ export type GroupCollapseReason = 'open' | 'stored' | 'filter-open';
120
+ export interface GroupCollapseState {
121
+ collapsed: boolean;
122
+ reason: GroupCollapseReason;
123
+ }
124
+ export interface GroupCollapseInput {
125
+ /** 설정에 이 그룹이 접힘으로 적혀 있나 (`isStoredCollapsed`) */
126
+ stored: boolean;
127
+ /** 검색어나 상태 필터가 하나라도 걸려 있나 (`renderPlan().filtering`) */
128
+ filtering: boolean;
129
+ /** 이 그룹에서 필터를 통과한 탭 수. 없거나 0 이하면 0 (필터 중이 아니면 보지 않는다) */
130
+ matches?: number;
131
+ }
132
+ /**
133
+ * 접힘의 **최종 판정** — 저장된 접힘 + 필터 상태 + 그 그룹의 매칭 수로 정한다.
134
+ *
135
+ * 왜 이 함수가 있나 — 그룹이 여럿이고 그중 접힌 그룹에만 검색어가 걸리면, 줄은 안 그려지고
136
+ * 헤더의 개수 칩만 남는다. 사용자는 "검색했는데 아무것도 안 나온다" 로 읽는데 실제로는
137
+ * 접힌 그룹 안에 있다. 이 규칙을 렌더에 직접 적으면 진단구(`groups()`)와 사본이 갈리므로
138
+ * (그 사본 문제가 `renderPlan()` 을 뽑은 이유다) 판정은 여기 하나만 둔다.
139
+ *
140
+ * **세 안을 비교하고 ②를 골랐다.**
141
+ * ① 검색 중에는 접힘을 통째로 무시(다 펴 보인다) — 결과는 지금 배선에서 ②와 같다
142
+ * (`renderPlan` 이 그룹을 `visible` 로 만들므로 매칭 0인 그룹은 애초에 목록에 없다).
143
+ * 그래도 규칙으로는 버렸다: "매칭이 없는데도 펴진다" 를 허용하는 문장이라, 나중에 빈
144
+ * 그룹까지 헤더로 보여주는 화면이 생기면 아무 이유 없이 다 펴진다.
145
+ * ② **매칭이 있는 접힌 그룹만 임시로 펴진다** ← 채택. 좁아진 이유가 화면에 보이고
146
+ * (`renderEmpty` 가 0건을 한 줄로 알리는 것과 같은 계열), 저장값은 건드리지 않으므로
147
+ * 필터를 지우면 저장된 접힘으로 **정확히** 돌아온다. 판정에 매칭 수가 들어가 있어
148
+ * "왜 펴졌나" 를 `reason` 으로 되읽을 수 있다.
149
+ * ③ 접힌 채 두고 헤더에 "안에 N개 걸렸다" 를 표시 — 버렸다. 개수 칩은 **전체 기준**이라는
150
+ * 성질을 회귀 `GR11` 이 고정하고 있어서(접어도 총계가 줄지 않는다) 같은 자리에 매칭 수를
151
+ * 넣으면 그 성질과 부딪히고, 칩을 하나 더 두더라도 200px 사이드바(`sidebarMin`)에
152
+ * 자리가 없다. 무엇보다 사용자가 헤더를 눌러야 결과를 보게 되는데, 검색은 "치면 바로
153
+ * 보이는 것" 이라 왕복이 하나 늘면 검색창의 의미가 준다.
154
+ *
155
+ * **이 함수는 설정을 바꾸지 않는다** — 임시로 펴 보이는 것과 접힘을 푸는 것은 다른 일이다.
156
+ * 접힘을 실제로 푸는 길은 헤더 클릭(`toggleGroup`) 하나뿐이어야 한다. 그래서 반환값은
157
+ * 매번 입력만으로 계산되고, 임시 상태를 어디에도 남기지 않는다(필터를 지우면 같은 입력이
158
+ * 되어 저장된 접힘이 그대로 나온다).
159
+ *
160
+ * 상태 필터만 걸린 경우(검색어는 비었다)도 **같게 다룬다.** 칩 `⏸ 2` 를 눌렀는데 그 두 탭이
161
+ * 접힌 그룹에 있으면 "대기 중인 게 없다" 로 읽히는 것은 검색어일 때와 똑같은 오독이다.
162
+ * 그래서 입력은 검색어가 아니라 `filtering`(검색어 OR 상태 필터) 하나로 받는다.
163
+ */
164
+ export declare function resolveGroupCollapse(input: GroupCollapseInput): GroupCollapseState;
165
+ /**
166
+ * `resolveGroupCollapse` 를 그룹 하나에 바로 먹인다 — 렌더가 매칭 수를 다시 세지 않게.
167
+ *
168
+ * 필터가 걸린 화면에서 `group.tabs` 는 **이미 필터를 통과한 탭들**이다
169
+ * (`deck.service.ts renderPlan()` 이 `groupsFor(visible)` 로 그룹을 만든다). 그래서
170
+ * `tabs.length` 가 곧 이 그룹의 매칭 수다. 필터가 없으면 그 값은 쓰이지 않는다.
171
+ *
172
+ * 헤더 자체를 안 그리는 화면(그룹 1개 = `withHeads === false`)에서는 접힘을 적용하지 않는다는
173
+ * 규칙은 그대로 호출부에 있다 — `withHeads && collapsed` 조합을 유지할 것.
174
+ */
175
+ export declare function resolveGroupCollapseFor<T>(group: TabGroup<T>, stored: readonly unknown[] | null | undefined, filtering: boolean): GroupCollapseState;
package/dist/ime.d.ts ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * 한글 조합 중에 우리가 PTY 로 바이트를 써도 되는 시점인지 가른다.
3
+ *
4
+ * 증상: 조합 중인 마지막 음절이 **커서를 따라간다.** Home/End 를 누르면 그 음절이 옮겨간
5
+ * 자리에 찍히고, Shift+Enter 를 누르면 새 줄로 따라간다 (2026-09-08 사용자 재보고).
6
+ * 방향키(←→)만 멀쩡한데, 그 차이가 곧 원인이다.
7
+ *
8
+ * xterm 의 조합 확정에는 **동기·비동기 두 경로**가 있다
9
+ * (`node_modules/tabby-terminal/dist/index.js:44087~44121`, CompositionHelper):
10
+ *
11
+ * keydown(t) → 조합 중이면 `_finalizeComposition(false)` = **지금 바로** triggerDataEvent
12
+ * compositionend() → `_finalizeComposition(true)` = `setTimeout(…, 0)` 에서 triggerDataEvent
13
+ *
14
+ * 방향키는 xterm 의 키 경로를 타므로 `keydown()` 이 먼저 동기 확정을 하고 그 뒤에 키를 보낸다 —
15
+ * 글자 먼저, 이동 나중이라 순서가 맞는다. 반대로 **xterm 을 거치지 않고 pty 에 직접 쓰는 경로**
16
+ * (Tabby 핫키의 `sendInput('\x1b[H')`, 우리 `sendNewline()` 의 `sendInput('\n')`)는 동기 확정을
17
+ * 트리거하지 않는다. 확정이 `compositionend` 의 setTimeout 으로 밀리는 동안
18
+ * (`_isSendingComposition === true`) 우리 바이트가 먼저 나가고 음절이 한 틱 뒤에 붙는다.
19
+ *
20
+ * 0.3.0 에서 Home/End 를 핫키에서 놓아준 것(`releaseHomeEndHotkey`)만으로는 부족했다 —
21
+ * 실사용 `%APPDATA%\tabby\config.yaml:71-72` 가 `home: []` / `end: []` 인데도 재발했다.
22
+ * 그래서 한 겹 더 내려가, **바이트를 쓰기 직전에 조합 상태를 보고 순서를 맞춘다.**
23
+ *
24
+ * 여기는 순수 로직만 둔다(테스트로 고정). DOM·xterm 접근은 인자로 받은 객체로만 한다 —
25
+ * `screen.ts` / `prompt.ts` 와 같은 관례다.
26
+ */
27
+ /**
28
+ * 조합 상태 — 지금 pty 에 바이트를 써도 되는지 가른다.
29
+ *
30
+ * - `composing` : 조합이 진행 중이다 (동기 확정을 시킬 수 있다)
31
+ * - `pending` : 확정 텍스트 전송이 `setTimeout(…, 0)` 에 예약돼 있다 (우리가 끼어들면 안 된다)
32
+ * - `idle` : 조합과 무관하다
33
+ * - `unknown` : 헬퍼를 못 찾았거나 내부 구조가 바뀌었다 (지금까지의 동작으로 떨어진다)
34
+ */
35
+ export type CompositionState = 'idle' | 'composing' | 'pending' | 'unknown';
36
+ /**
37
+ * 우리 바이트를 언제 보낼지.
38
+ *
39
+ * - `now` : 지금 바로
40
+ * - `after-composition` : **조합이 끝난 뒤**에 (조합 중일 때)
41
+ * - `defer` : 한 틱 뒤에 (확정 전송이 이미 예약돼 있을 때)
42
+ * - `flush-then-now` : 동기 확정시키고 바로 (조합이 끝나지 않는 비상 경로에서만)
43
+ */
44
+ export type SendPlan = 'now' | 'after-composition' | 'defer' | 'flush-then-now';
45
+ /**
46
+ * xterm 의 CompositionHelper 에서 **우리가 쓰는 부분만** 추린 최소 형태.
47
+ *
48
+ * 최소화된 빌드의 내부 객체라 필드가 언제든 사라질 수 있으므로 전부 옵셔널로 둔다.
49
+ * 테스트에서는 이 모양의 가짜 객체를 넣는다.
50
+ */
51
+ export interface CompositionHelperLike {
52
+ isComposing?: boolean;
53
+ _isSendingComposition?: boolean;
54
+ _compositionPosition?: {
55
+ start: number;
56
+ end: number;
57
+ };
58
+ keydown?(e: {
59
+ keyCode: number;
60
+ }): boolean;
61
+ }
62
+ /**
63
+ * 조합 헬퍼의 **살아있는 상태**를 읽는다.
64
+ *
65
+ * 키보드 이벤트의 `isComposing` / 수식키 필드를 믿으면 안 된다 — 한글 IME 가 조합을 확정하며
66
+ * 이벤트를 한 번 삼키고 뒤이어 내보내는 이벤트에는 상태를 싣지 않는다
67
+ * (2026-09-08 실측: Shift+Enter 의 Enter 가 `shiftKey=false` 로 도착, `claimShiftEnterKey` 주석).
68
+ * 그래서 판정 근거는 이벤트가 아니라 헬퍼가 지금 들고 있는 값이다.
69
+ */
70
+ export declare function readCompositionState(helper: CompositionHelperLike | null | undefined): CompositionState;
71
+ /**
72
+ * 그 상태에서 우리 바이트를 언제 보내야 하나.
73
+ *
74
+ * `unknown` 이 `now` 인 것이 중요하다 — 이 훅은 순서를 맞추려고 얹은 것이고, 상태를 못 읽었을 때
75
+ * 정상 입력을 막아서는 절대 안 된다. 못 읽으면 0.4.0 까지의 동작 그대로 간다.
76
+ */
77
+ export declare function planSend(state: CompositionState): SendPlan;
78
+ /**
79
+ * 조합을 **동기 확정**시킨다. 성공하면 true.
80
+ *
81
+ * 왜 `keydown()` 인가 — 실제로 확정하는 것은 `_finalizeComposition(false)` 지만 그건 private 이고
82
+ * 최소화 빌드에서 이름이 바뀔 수 있다. `keydown` 은 xterm 코어가 밖에서 부르는 공개 표면이라
83
+ * (`tabby-terminal/dist/index.js:50225` `this._compositionHelper.keydown(e)`) 최소화를 넘어 살아남는다.
84
+ * 조합 중에 아무 키나 들어오면 동기 확정한다는 성질이 곧 방향키가 멀쩡한 이유이므로,
85
+ * **방향키가 하는 일을 그대로 흉내내는 것**이 가장 안전하다.
86
+ *
87
+ * `keyCode: 0` 을 쓰는 이유 — 20(한/영)·229(IME)·16/17/18(Shift/Ctrl/Alt)은 헬퍼가 확정 없이
88
+ * 빠져나가는 값이고, 229 는 `_handleAnyTextareaChanges()` 까지 부른다. 0 은 그 어느 것도 아니다.
89
+ *
90
+ * 낡은 `end` 보정 — 동기 경로는
91
+ * `value.substring(_compositionPosition.start, _compositionPosition.end)` 를 보내는데, `end` 는
92
+ * `compositionupdate` 의 `setTimeout(…, 0)` 에서만 갱신된다(`index.js:44092`). 첫 음절을 치고
93
+ * 바로 Home 을 누르면 `end` 가 아직 0(또는 start 이하)이라 **빈 문자열이 나가 음절이 통째로
94
+ * 유실된다.** 그래서 확정 직전에 `textarea.value.length` 로 메워 준다.
95
+ *
96
+ * @param textLength `xterm.textarea.value.length` — 낡은 `end` 를 메울 값
97
+ */
98
+ export declare function flushComposition(helper: CompositionHelperLike | null | undefined, textLength: number): boolean;