@sejuneoh/aitop 0.0.0-stage → 0.21.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/src/doctor.mjs ADDED
@@ -0,0 +1,218 @@
1
+ // 도구별 연결 점검: 기록 폴더를 찾았는지, 기록이 몇 개이고 언제 마지막으로 쓰였는지, 지금 몇 개가 실행 중인지,
2
+ // 가장 최근 기록을 읽을 수 있는지. aitop --doctor, 빈 화면, --json 의 tools 가 같은 결과를 쓴다.
3
+ // 기록 내용은 읽어 내보내지 않는다 (Gemini 는 형식 확인용으로 JSON 키 이름만 보인다).
4
+ import { readdirSync, statSync, existsSync, openSync, readSync, closeSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { homedir, platform } from 'node:os';
7
+ import { findProcesses, isCodex, isGemini, isAgy, canListProcesses, isAlive } from './procs.mjs';
8
+ import { AGY_ROOT } from './sources/antigravity.mjs';
9
+ import { loadSqlite, openReadOnly } from './sqlite.mjs';
10
+ import { fmtAgo, pad, stripControl } from './text.mjs';
11
+ import { BRAND } from './brand.mjs';
12
+ import { findNotifier, isWsl } from './notify.mjs';
13
+
14
+ const UNIT = { s: '초', m: '분', h: '시간', d: '일' };
15
+ const ago = (ms) => fmtAgo(ms).replace(/[smhd]$/, (u) => UNIT[u]);
16
+
17
+ const HOME = homedir();
18
+ const CLAUDE = process.env.CLAUDE_CONFIG_DIR ?? join(HOME, '.claude');
19
+ const CODEX = join(process.env.CODEX_HOME ?? join(HOME, '.codex'), 'sessions');
20
+ const GEMINI = join(process.env.GEMINI_HOME ?? join(HOME, '.gemini'), 'tmp');
21
+
22
+ const tilde = (p) => (p.startsWith(HOME + '/') ? '~' + p.slice(HOME.length) : p);
23
+ const ls = (dir) => {
24
+ try {
25
+ return readdirSync(dir, { withFileTypes: true });
26
+ } catch {
27
+ return [];
28
+ }
29
+ };
30
+
31
+ // 폴더 아래 depth 단계까지 내려가며 조건에 맞는 파일을 모은다.
32
+ function walk(dir, depth, match, out = []) {
33
+ for (const e of ls(dir)) {
34
+ const p = join(dir, e.name);
35
+ if (e.isDirectory() && depth > 0) walk(p, depth - 1, match, out);
36
+ else if (e.isFile() && match(e.name)) out.push(p);
37
+ }
38
+ return out;
39
+ }
40
+
41
+ function newest(files) {
42
+ let best = null;
43
+ for (const path of files) {
44
+ try {
45
+ const m = statSync(path).mtimeMs;
46
+ if (!best || m > best.mtime) best = { path, mtime: m };
47
+ } catch {}
48
+ }
49
+ return best;
50
+ }
51
+
52
+ // 파일 첫 줄이 JSON 으로 읽히면 true, 아니면 false. 비어 있거나 첫 줄이 64KB 를 넘으면 판정하지 않는다(null).
53
+ function firstLineIsJson(path) {
54
+ let fd;
55
+ try {
56
+ fd = openSync(path, 'r');
57
+ const buf = Buffer.alloc(65536);
58
+ const n = readSync(fd, buf, 0, buf.length, 0);
59
+ const text = buf.subarray(0, n).toString('utf8');
60
+ const end = text.indexOf('\n');
61
+ if (!n || (end < 0 && n === buf.length)) return null;
62
+ JSON.parse(end < 0 ? text : text.slice(0, end));
63
+ return true;
64
+ } catch {
65
+ return false;
66
+ } finally {
67
+ if (fd != null) closeSync(fd);
68
+ }
69
+ }
70
+
71
+ // 키 이름도 사용자 글이나 경로일 수 있으므로 식별자처럼 생긴 것만 보인다.
72
+ const KEY_NAME = /^[A-Za-z_][\w.-]{0,40}$/;
73
+
74
+ // JSON 파일 맨 위 키 이름만 (값은 보지 않는다). 앞 64KB 로 읽지 못하면(큰 파일) 맨 위 수준 들여쓰기의 "키": 줄에서 찾는다.
75
+ function topKeys(path) {
76
+ let fd;
77
+ try {
78
+ fd = openSync(path, 'r');
79
+ const buf = Buffer.alloc(65536);
80
+ const n = readSync(fd, buf, 0, buf.length, 0);
81
+ const text = buf.subarray(0, n).toString('utf8');
82
+ let keys;
83
+ try {
84
+ let obj = JSON.parse(text);
85
+ if (Array.isArray(obj)) obj = obj[0];
86
+ keys = obj && typeof obj === 'object' ? Object.keys(obj) : [];
87
+ } catch {
88
+ try {
89
+ keys = Object.keys(JSON.parse(text.split('\n')[0]));
90
+ } catch {
91
+ keys = [...text.matchAll(/^ {0,2}\t?"([^"\n]{1,60})"\s*:/gm)].map((m) => m[1]);
92
+ }
93
+ }
94
+ return [...new Set(keys)].filter((k) => KEY_NAME.test(k)).slice(0, 12);
95
+ } catch {
96
+ return [];
97
+ } finally {
98
+ if (fd != null) closeSync(fd);
99
+ }
100
+ }
101
+
102
+ function claude() {
103
+ const dir = join(CLAUDE, 'projects');
104
+ const files = existsSync(dir) ? walk(dir, 1, (n) => n.endsWith('.jsonl')) : null;
105
+ const last = files && newest(files);
106
+ const running = canListProcesses()
107
+ ? ls(join(CLAUDE, 'sessions')).filter((e) => /^\d+\.json$/.test(e.name) && isAlive(Number(e.name.split('.')[0]))).length
108
+ : null;
109
+ if (!files) return { tool: 'claude', state: 'missing', path: tilde(dir), records: 0, lastAt: null, running, readable: null, hint: 'claude 를 한 번 실행하면 생깁니다' };
110
+ return { tool: 'claude', state: 'ok', path: tilde(dir), records: files.length, lastAt: last?.mtime ?? null, running, readable: last ? firstLineIsJson(last.path) : null, hint: null };
111
+ }
112
+
113
+ function codex() {
114
+ const files = existsSync(CODEX) ? walk(CODEX, 3, (n) => n.startsWith('rollout-') && n.endsWith('.jsonl')) : null;
115
+ const last = files && newest(files);
116
+ const running = canListProcesses() ? findProcesses(isCodex).length : null;
117
+ if (!files) return { tool: 'codex', state: 'missing', path: tilde(CODEX), records: 0, lastAt: null, running, readable: null, hint: 'codex 를 한 번 실행하면 생깁니다' };
118
+ return { tool: 'codex', state: 'ok', path: tilde(CODEX), records: files.length, lastAt: last?.mtime ?? null, running, readable: last ? firstLineIsJson(last.path) : null, hint: null };
119
+ }
120
+
121
+ // Gemini 는 세션 기록 형식을 아직 모른다. 프로세스만 세고, 기록 폴더가 있으면 형식 확인용 키 이름을 남긴다.
122
+ function gemini() {
123
+ const files = existsSync(GEMINI) ? walk(GEMINI, 3, (n) => n.endsWith('.json') || n.endsWith('.jsonl')) : null;
124
+ const last = files && newest(files);
125
+ return {
126
+ tool: 'gemini',
127
+ state: 'limited',
128
+ path: tilde(GEMINI),
129
+ found: Boolean(files),
130
+ records: files?.length ?? 0,
131
+ lastAt: last?.mtime ?? null,
132
+ running: canListProcesses() ? findProcesses(isGemini).length : null,
133
+ readable: null,
134
+ keys: last ? topKeys(last.path) : [],
135
+ hint: '프로세스만 감지합니다 (세션·토큰은 아직 지원하지 않음)',
136
+ };
137
+ }
138
+
139
+ // Antigravity CLI: 요약 DB 를 열어 대화 목록을 읽을 수 있는지 본다. node:sqlite 가 없으면 프로세스만 센다.
140
+ function agy() {
141
+ const dir = join(AGY_ROOT, 'conversations');
142
+ const running = canListProcesses() ? findProcesses(isAgy).length : null;
143
+ const files = existsSync(dir) ? walk(dir, 0, (n) => n.endsWith('.db')) : null;
144
+ if (!files) return { tool: 'agy', state: 'missing', path: tilde(AGY_ROOT), records: 0, lastAt: null, running, readable: null, hint: 'agy 를 한 번 실행하면 생깁니다 (Gemini 탭에 보입니다)' };
145
+ const last = newest(files.flatMap((f) => [f, `${f}-wal`]).filter((f) => existsSync(f)));
146
+ if (!loadSqlite()) return { tool: 'agy', state: 'limited', path: tilde(AGY_ROOT), records: files.length, lastAt: last?.mtime ?? null, running, readable: null, hint: `Node ${process.versions.node} 에서는 기록을 읽기 전용으로 열 수 없어 프로세스만 감지합니다 (Node 22.15 이상 필요)` };
147
+ let readable = false;
148
+ let db;
149
+ try {
150
+ db = openReadOnly(join(AGY_ROOT, 'conversation_summaries.db'));
151
+ db.prepare('select count(*) n from conversation_summaries').get();
152
+ readable = true;
153
+ } catch {
154
+ } finally {
155
+ db?.close();
156
+ }
157
+ return { tool: 'agy', state: 'ok', path: tilde(AGY_ROOT), records: files.length, lastAt: last?.mtime ?? null, running, readable, hint: null };
158
+ }
159
+
160
+ export function checkTools() {
161
+ const tools = {};
162
+ for (const fn of [claude, codex, gemini, agy]) {
163
+ const r = fn();
164
+ tools[r.tool] = r;
165
+ }
166
+ const n = findNotifier();
167
+ return { platform: platform(), processes: canListProcesses(), tools, notifier: n ? { kind: n.kind, path: tilde(n.path) } : null };
168
+ }
169
+
170
+ // --notify 에 쓸 데스크톱 알림 도구.
171
+ export function notifierText(n, plat = platform()) {
172
+ if (n) return `✓ 알림 ${n.kind} (${n.path}) · --notify 로 켬`;
173
+ const want = plat === 'darwin' ? 'osascript' : 'notify-send';
174
+ const wsl = plat === 'linux' && isWsl() ? ' (WSL 은 notify-send 만 쓴다)' : '';
175
+ return `○ 알림 ${want} 없음 · --notify 를 켜도 알림을 건너뜀${wsl}`;
176
+ }
177
+
178
+ // JSON 용: 시각은 ISO 문자열로.
179
+ export function toolsJson(tools) {
180
+ if (!tools) return null;
181
+ return Object.fromEntries(Object.entries(tools).map(([k, t]) => [k, { ...t, lastAt: t.lastAt ? new Date(t.lastAt).toISOString() : null }]));
182
+ }
183
+
184
+ // 점검 결과를 사람이 읽는 조각으로 바꾼다. 빈 화면과 --doctor 가 같은 문구를 쓴다.
185
+ export const MARK = { ok: '✓', missing: '✗', limited: '○' };
186
+ export const STATE_TEXT = { ok: '찾음', missing: '없음', limited: '일부 지원' };
187
+
188
+ export function describe(t, now = Date.now()) {
189
+ const parts = [];
190
+ if (t.found === false && t.state === 'limited') parts.push('기록 폴더 없음');
191
+ else if (t.state !== 'missing') parts.push(`기록 ${t.records.toLocaleString('en-US')}개`);
192
+ if (t.lastAt) parts.push(`마지막 ${ago(now - t.lastAt)} 전`);
193
+ parts.push(t.running == null ? '실행 수 확인 불가 (Linux·WSL·macOS 만 지원)' : `실행 ${t.running}`);
194
+ if (t.readable === false) parts.push('최근 기록 읽기 실패');
195
+ if (t.keys?.length) parts.push(`키: ${t.keys.join(', ')}`);
196
+ return { mark: MARK[t.state], state: STATE_TEXT[t.state], path: t.path, detail: parts.join(' · '), hint: t.hint };
197
+ }
198
+
199
+ // aitop --doctor 의 사람용 출력.
200
+ export function doctorText(result, version, now = Date.now()) {
201
+ const os = `${result.platform}, 프로세스 ${result.processes ? '확인 가능' : '확인 불가'}`;
202
+ const out = [`${BRAND.logo} aitop ${version} 연결 점검 (${os})`, ''];
203
+ for (const t of Object.values(result.tools)) {
204
+ const d = describe(t, now);
205
+ out.push(`${d.mark} ${pad(t.tool, 7)}${pad(d.state, 10)}${d.path}`);
206
+ out.push(` ${d.detail}`);
207
+ if (d.hint) out.push(` → ${d.hint}`);
208
+ }
209
+ if ('notifier' in result) out.push('', notifierText(result.notifier, result.platform));
210
+ // 설정 파일: 무시한 키와 값, 받지 않은 onWaiting 을 알린다.
211
+ const cfg = result.config;
212
+ if (cfg) {
213
+ out.push('', `${cfg.problems.length ? '!' : '✓'} 설정 ${pad(cfg.exists ? '있음' : '없음 (기본값)', 10)}${tilde(cfg.path)}`);
214
+ for (const p of cfg.problems) out.push(` ! ${p}`);
215
+ }
216
+ if (result.log) out.push('', `${result.log.on ? '✓' : '○'} 디버그 ${pad(result.log.on ? '켬' : '꺼짐', 10)}${tilde(result.log.path)}${result.log.on ? '' : ' · --debug 로 켬'}`);
217
+ return stripControl(out.join('\n'));
218
+ }
package/src/frames.mjs ADDED
@@ -0,0 +1,24 @@
1
+ // 실시간 화면의 애니메이션 타이머. 움직이는 표시가 있을 때만 ms 마다 onFrame 을 부르고, 없으면 멈춘다.
2
+ // sync() 는 화면을 그릴 때마다 불러 상태에 맞춰 타이머를 켜고 끈다. timers 는 테스트에서 바꿔 끼운다.
3
+ export function frameTimer({ animating, onFrame, ms = 120, timers = globalThis }) {
4
+ let id = null;
5
+ const stop = () => {
6
+ if (id == null) return;
7
+ timers.clearInterval(id);
8
+ id = null;
9
+ };
10
+ const fire = () => {
11
+ if (!animating()) return stop();
12
+ onFrame();
13
+ };
14
+ return {
15
+ sync() {
16
+ if (!animating()) stop();
17
+ else if (id == null) id = timers.setInterval(fire, ms);
18
+ },
19
+ stop,
20
+ get running() {
21
+ return id != null;
22
+ },
23
+ };
24
+ }
package/src/help.mjs ADDED
@@ -0,0 +1,111 @@
1
+ // 키 안내의 한 곳: 화면 안 도움말(ui/chrome.mjs 의 help)과 aitop --help 의 키 줄이 이 표에서 만들어진다.
2
+ // 화면 모듈을 불러오지 않는다 (bin 이 색·테마를 정하기 전에 불러도 되게).
3
+
4
+ // 화면별 키. [키, 설명, 짧은 이름, --help 에 쓸 키(없으면 첫 글자 묶음)]
5
+ export const VIEWS = {
6
+ table: {
7
+ title: '세션 표',
8
+ keys: [
9
+ ['/', '세션 검색 (이름·폴더·모델). Enter 적용, Esc 지우기', '검색'],
10
+ ['↑↓ j k', '세션 선택', '선택', '↑↓/jk'],
11
+ ['n', '다음 볼 세션으로 (응답 대기 → 완료 · 확인 전)', '다음 볼 세션'],
12
+ ['s', '정렬: 기본 → 비용순 → 컨텍스트순 → 최근순. 응답 대기는 늘 맨 위', '정렬'],
13
+ ['g', '묶기: 없음 → 프로젝트 → 도구. 묶음 머리에 세션 수·비용', '묶기'],
14
+ ['c', '비용 기준: API 환산 ↔ 캐시 제외. 표·헤더·상세가 같이 바뀐다 (예산은 늘 API 환산)', '비용 기준'],
15
+ ['p', '고른 세션을 맨 위(응답 대기 아래)에 고정·해제. 여러 개는 고정한 순서, 이번 실행에만', '고정'],
16
+ ['f Tab', '고른 세션의 변경 파일 목록으로', '변경 파일'],
17
+ ['d', '상세 패널 열기·닫기', '상세'],
18
+ ['e', '종료된 세션(24시간) 보이기·숨기기', '종료 세션 토글'],
19
+ ['Esc', '한 단계 뒤로 (도움말·검색 닫기, diff → 파일 목록 → 세션). 종료하지 않음', '뒤로'],
20
+ ],
21
+ },
22
+ detail: {
23
+ title: '상세 패널',
24
+ keys: [
25
+ ['↑↓ j k', '다른 세션을 고르면 상세도 따라 바뀐다', '세션 바꾸기', '↑↓/jk'],
26
+ ['f Tab', '변경 파일 목록으로', '변경 파일'],
27
+ ['d', '상세 패널 닫기', '닫기'],
28
+ ],
29
+ },
30
+ files: {
31
+ title: '변경 파일 목록',
32
+ keys: [
33
+ ['↑↓ j k', '파일 고르기', '고르기', '↑↓/jk'],
34
+ ['←→', '지시 탭: 이번 지시 · 편집이 있던 이전 지시 T번호 · 전체', '지시 탭'],
35
+ ['Enter l', '고른 파일의 diff 보기', 'diff'],
36
+ ['Esc h f Tab', '세션 표로', '뒤로'],
37
+ ],
38
+ },
39
+ diff: {
40
+ title: 'diff',
41
+ keys: [
42
+ ['↑↓ j k', '한 줄씩 스크롤', '스크롤', '↑↓/jk'],
43
+ ['space PgDn PgUp', '한 페이지씩', '페이지', 'space'],
44
+ ['[ ]', '이전·다음 파일', '이전·다음 파일', '[ ]'],
45
+ ['Esc h ← f Tab', '파일 목록으로', '뒤로'],
46
+ ],
47
+ },
48
+ filtered: {
49
+ title: '검색어가 걸린 표',
50
+ keys: [
51
+ ['↑↓ j k', '검색 결과 안에서 세션 선택', '선택', '↑↓/jk'],
52
+ ['Esc', '검색어 지우기 (표 전체로)', '검색어 지우기'],
53
+ ['/', '지금 검색어를 지우고 새로 입력', '새 검색'],
54
+ ],
55
+ },
56
+ // 검색어를 입력하는 동안은 ? 도 검색어로 들어가므로 도움말에서는 '다른 화면' 과 --help 에만 나온다.
57
+ search: {
58
+ title: '검색 입력 중',
59
+ keys: [
60
+ ['글자', '검색어 입력 (이름·폴더·모델, 대소문자 무시)', '입력'],
61
+ ['Backspace', '한 글자 지우기', '지우기'],
62
+ ['Enter', '검색어를 남기고 표로', '적용'],
63
+ ['Esc', '검색 취소 (검색어 지우기)', '취소'],
64
+ ],
65
+ },
66
+ };
67
+
68
+ // 어느 화면에서나 듣는 키. 화면마다 되풀이하지 않고 도움말에서는 지금 화면 키 바로 아래 한 번만 보인다.
69
+ export const COMMON = {
70
+ title: '어디서나',
71
+ keys: [
72
+ ['?', '이 도움말 열기·닫기', '도움말'],
73
+ ['q Ctrl-C', '나가기', '나가기'],
74
+ ],
75
+ };
76
+
77
+ // 화면 안 도움말의 옵션 묶음. 자세한 설명은 aitop --help.
78
+ export const OPTIONS = [
79
+ ['--notify', '응답 대기가 생기면 데스크톱 알림'],
80
+ ['--on-waiting <명령>', '응답 대기가 생기면 명령 실행'],
81
+ ['--budget-day <달러>', '하루 예산. 헤더에 예상 비용 경고'],
82
+ ['--show-requests', '응답 대기 요청 전체를 상세·--json 에'],
83
+ ['--color-blind', '적록 색약용 색 (대기·돈·경고를 노랑·파랑 계열로)'],
84
+ ['--debug', '디버그 로그를 남긴다'],
85
+ ['--print-config', '설정 값과 출처, 설정 파일 위치'],
86
+ ['AITOP_AMBIGUOUS=wide', '·→─● 를 2칸으로 그리는 터미널용 (1칸 기호로)'],
87
+ ];
88
+
89
+ // 지금 보는 화면: 파일 목록·diff 에 들어가 있으면 그 화면, 아니면 세션 표. 검색어는 표에 붙는 것이라 helpSections 가 따로 본다.
90
+ export function helpContext(view) {
91
+ if (view.focus === 'diff' || view.focus === 'files') return view.focus;
92
+ return 'table';
93
+ }
94
+
95
+ const cliKey = ([k, , , cli]) => cli ?? k.split(' ')[0];
96
+ // 한 화면의 키를 한 줄로: '/ 검색 · ↑↓/jk 선택 · ...'
97
+ // 'common' 은 어디서나 쓰는 키. 없는 화면 이름은 예전처럼 오류로 드러낸다.
98
+ export const keysLine = (name) => (name === 'common' ? COMMON : VIEWS[name]).keys.map((e) => `${cliKey(e)} ${e[2]}`).join(' · ');
99
+
100
+ // aitop --help 끝의 키 안내.
101
+ export function keysText() {
102
+ return [`키: ${keysLine('table')} · ${keysLine('common')}`, ...['files', 'diff', 'filtered', 'search'].map((n) => ` ${VIEWS[n].title}: ${keysLine(n)}`)].join('\n');
103
+ }
104
+
105
+ // 지금 설정 값 한 줄 (예: 예산 $50 · 알림 켜짐 · 디버그 꺼짐). settings 가 없으면 null.
106
+ export function settingsLine(s) {
107
+ if (!s) return null;
108
+ const onOff = (v) => (v ? '켜짐' : '꺼짐');
109
+ const usd = (x) => (Number.isInteger(x) ? `$${x}` : `$${x.toFixed(2)}`);
110
+ return [`예산 ${s.budgetDay > 0 ? usd(s.budgetDay) : '없음'}`, `알림 ${onOff(s.notify)}`, ...(s.onWaiting ? ['대기 명령 있음'] : []), `디버그 ${onOff(s.debug)}`].join(' · ');
111
+ }
package/src/json.mjs ADDED
@@ -0,0 +1,57 @@
1
+ import { projectBudget } from './budget.mjs';
2
+ import { toolsJson } from './doctor.mjs';
3
+
4
+ // aitop --json 출력. 다른 스크립트나 AI 가 읽기 쉬운 모양으로 세션 목록과 사용량 합계를 담는다.
5
+ // 텍스트는 수집 단계에서 이미 비밀값 가림이 적용돼 있다.
6
+
7
+ const pickTokens = (t = {}) => ({ total: t.total ?? 0, input: t.input ?? 0, output: t.output ?? 0, cacheRead: t.cacheRead ?? 0, cacheWrite: t.cacheWrite ?? 0, main: t.main ?? null, sub: t.sub ?? null, subCount: t.subCount ?? null });
8
+
9
+ const iso = (ms) => (ms ? new Date(ms).toISOString() : null);
10
+ // 비용(달러)은 소수 넷째 자리까지 반올림한다.
11
+ const money = (x) => (typeof x === 'number' ? Math.round(x * 1e4) / 1e4 : (x ?? null));
12
+ // 최근 1시간 비용 (5분 칸 단위 근사).
13
+ const lastHourOf = (h) => (h ? { cost: money(h.cost), costNoCache: money(h.costNoCache), unknown: Boolean(h.unknown) } : null);
14
+ // last7days.daily: 날짜별 API 환산 비용 (오래된 날 → 오늘). unknown 은 가격표에 없는 모델이 섞인 날.
15
+ const dailyOf = (days) => (days ? { daily: days.map((d) => ({ date: d.date, cost: money(d.cost), unknown: Boolean(d.unknown) })) } : {});
16
+ const usageOf = (u) => (u ? { ...u, ...('cost' in u ? { cost: money(u.cost) } : {}) } : u);
17
+
18
+ // version 은 aitop 버전, tools 는 aitop --doctor 와 같은 도구별 점검 결과다.
19
+ // opts.showRequests: 응답 대기 요청 전체(waitDetail)를 넣는다. 기본은 꺼져 있다.
20
+ // opts.budgetDay: 하루 예산. 있으면 usage.budget 에 예산, 오늘 끝 예상 비용(API 정가 환산, 화면과 같은 기준), 단계를 넣는다.
21
+ export function toJson(data, version = null, opts = {}) {
22
+ const b = data.usage ? projectBudget(data.usage, opts.budgetDay, { now: data.at ?? Date.now() }) : null;
23
+ return {
24
+ version,
25
+ at: iso(data.at),
26
+ tools: toolsJson(data.tools?.tools),
27
+ usage: data.usage ? { today: usageOf(data.usage.today), last7days: { ...usageOf(data.usage.week), ...dailyOf(data.usage.days7) }, lastHour: lastHourOf(data.usage.lastHour), complete: data.usage.loading == null, ...(b ? { budget: { day: b.day, projected: money(b.projected), unknown: b.unknown, level: b.level } } : {}) } : null,
28
+ sessions: data.sessions.map((s) => ({
29
+ tool: s.tool,
30
+ id: s.id,
31
+ name: s.name ?? null,
32
+ project: s.project ?? null,
33
+ cwd: s.cwd ?? null,
34
+ branch: s.branch ?? null,
35
+ model: s.model ?? null,
36
+ kind: s.kind ?? null,
37
+ pid: s.pid ?? null,
38
+ live: Boolean(s.live),
39
+ status: s.status,
40
+ phase: s.phase?.text ?? null,
41
+ waitingFor: s.waitingFor ?? null,
42
+ // 허락을 기다리는 요청의 전체 내용 (Bash 명령, 파일 경로 등). --show-requests 를 켰을 때만 넣는다. 비밀값 가림은 최선을 다할 뿐이다.
43
+ ...(opts.showRequests ? { waitDetail: s.waitDetail ?? null } : {}),
44
+ now: s.now || null,
45
+ startedAt: iso(s.startedAt),
46
+ updatedAt: iso(s.updatedAt),
47
+ tokens: pickTokens(s.tokens),
48
+ cost: money(s.cost),
49
+ costNoCache: money(s.costNoCache),
50
+ costUnknown: Boolean(s.costUnknown),
51
+ context: { used: s.context?.used ?? 0, window: s.context?.window ?? null },
52
+ // 변경 내용(diff)은 넣지 않고 파일별 통계만 넣는다. 줄 수는 편집마다 더한 누적값이다 (git diff 아님).
53
+ files: (s.files ?? []).map((f) => ({ path: f.path, created: Boolean(f.created), edits: f.edits, writtenLines: f.add, deletedLines: f.del, updatedAt: iso(f.at), agents: f.agents ?? [] })),
54
+ members: (s.members ?? []).map((m) => ({ name: m.name, task: m.task ?? null, model: m.model ?? null, status: m.status, phase: m.phase?.text ?? null, tokens: m.tokens ?? 0, cost: money(m.cost) })),
55
+ })),
56
+ };
57
+ }
package/src/keys.mjs ADDED
@@ -0,0 +1,154 @@
1
+ // 키 입력 처리. 화면 상태(view)를 바꾸고, 종료·갱신·세션 이동처럼 밖에서 해야 할 일은 act 로 부른다.
2
+ // view.focus: null(세션 선택) → 'files'(변경 파일 목록) → 'diff'(고른 파일의 변경 내용). Esc 는 한 단계씩 뒤로 가고 종료하지 않는다.
3
+ // view.tab: 변경 파일을 보는 지시 탭 ('now' 이번 지시, 지시 번호, 'all' 전체). turns.mjs 참고.
4
+ import { turnTabs, visibleFiles } from './turns.mjs';
5
+ import { detailOpen } from './ui/chrome.mjs';
6
+ import { nextGroup, nextSort, togglePin } from './order.mjs';
7
+
8
+ export const KEY = { up: '\x1b[A', down: '\x1b[B', right: '\x1b[C', left: '\x1b[D', pageUp: '\x1b[5~', pageDown: '\x1b[6~', esc: '\x1b', enter: '\r', tab: '\t', ctrlC: '\x03', backspace: '\x7f' };
9
+
10
+ const isUp = (k) => k === KEY.up || k === 'k';
11
+ const isDown = (k) => k === KEY.down || k === 'j';
12
+ const isBack = (k) => k === KEY.esc || k === 'h';
13
+
14
+ export function selectedSession(view) {
15
+ return view.data?.sessions.find((s) => s.id === view.selectedId) ?? null;
16
+ }
17
+
18
+ const hasFiles = (view) => Boolean(selectedSession(view)?.files?.length);
19
+ // 지금 탭에 보이는 파일
20
+ const filesOf = (view) => visibleFiles(selectedSession(view), view.tab);
21
+
22
+ // 갱신 뒤에 고른 파일이 탭에서 빠졌거나 세션에 고친 파일이 없어졌으면 포커스를 맞춘다.
23
+ export function fixFocus(view) {
24
+ if (!view.focus) return;
25
+ if (!hasFiles(view) || !view.showDetail) {
26
+ view.focus = null;
27
+ return;
28
+ }
29
+ const files = filesOf(view);
30
+ if (files.some((f) => f.path === view.filePath)) return;
31
+ // 보던 diff 의 파일이 탭에서만 빠졌으면(새 지시가 들어와 이번 지시가 바뀜) 닫지 않고 전체 탭으로 옮긴다.
32
+ if (view.focus === 'diff' && selectedSession(view).files.some((f) => f.path === view.filePath)) {
33
+ view.tab = 'all';
34
+ return;
35
+ }
36
+ view.filePath = files[0]?.path ?? null;
37
+ view.focus = 'files';
38
+ }
39
+
40
+ // 이번 지시에 고친 파일이 없어도 목록에는 들어간다. ←/→ 로 다른 지시를 볼 수 있다.
41
+ function enterFiles(view) {
42
+ if (!hasFiles(view)) return;
43
+ view.showDetail = true;
44
+ view.focus = 'files';
45
+ const files = filesOf(view);
46
+ if (!files.some((f) => f.path === view.filePath)) view.filePath = files[0]?.path ?? null;
47
+ }
48
+
49
+ function stepTab(view, delta) {
50
+ const tabs = turnTabs(selectedSession(view));
51
+ const i = Math.max(0, tabs.findIndex((t) => t.key === (view.tab ?? 'now')));
52
+ view.tab = tabs[Math.min(tabs.length - 1, Math.max(0, i + delta))].key;
53
+ const files = filesOf(view);
54
+ if (!files.some((f) => f.path === view.filePath)) view.filePath = files[0]?.path ?? null;
55
+ }
56
+
57
+ function stepFile(view, delta) {
58
+ const files = filesOf(view);
59
+ const i = Math.max(0, files.findIndex((f) => f.path === view.filePath));
60
+ view.filePath = files[Math.min(files.length - 1, Math.max(0, i + delta))]?.path ?? null;
61
+ }
62
+
63
+ function filesKey(view, key) {
64
+ if (isUp(key)) stepFile(view, -1);
65
+ else if (isDown(key)) stepFile(view, 1);
66
+ else if (key === KEY.left || key === KEY.right) stepTab(view, key === KEY.left ? -1 : 1);
67
+ else if (key === KEY.enter || key === 'l') {
68
+ if (!view.filePath) return true;
69
+ view.focus = 'diff';
70
+ view.diffScroll = 0;
71
+ } else if (isBack(key) || key === 'f' || key === KEY.tab) view.focus = null;
72
+ else return false;
73
+ return true;
74
+ }
75
+
76
+ function diffKey(view, key) {
77
+ const page = view.diffPage ?? 10;
78
+ if (isUp(key)) view.diffScroll = Math.max(0, (view.diffScroll ?? 0) - 1);
79
+ else if (isDown(key)) view.diffScroll = (view.diffScroll ?? 0) + 1;
80
+ else if (key === ' ' || key === KEY.pageDown) view.diffScroll = (view.diffScroll ?? 0) + page;
81
+ else if (key === KEY.pageUp) view.diffScroll = Math.max(0, (view.diffScroll ?? 0) - page);
82
+ else if (key === '[' || key === ']') {
83
+ stepFile(view, key === '[' ? -1 : 1);
84
+ view.diffScroll = 0;
85
+ } else if (isBack(key) || key === KEY.left) view.focus = 'files';
86
+ else return false;
87
+ return true;
88
+ }
89
+
90
+ // 검색 중에는 글자를 검색어로 받는다. Enter 는 검색어를 남기고 목록으로, Esc 는 검색을 지운다.
91
+ function searchKey(view, key, act) {
92
+ if (key === KEY.enter) view.searching = false;
93
+ else if (key === KEY.esc) {
94
+ view.searching = false;
95
+ view.filter = '';
96
+ } else if (key === KEY.backspace || key === '\b') view.filter = [...(view.filter ?? '')].slice(0, -1).join('');
97
+ else if (!/[\x00-\x1f\x7f]/.test(key)) view.filter = (view.filter ?? '') + key;
98
+ act.keepVisible();
99
+ }
100
+
101
+ // 도움말은 모달이다: 열려 있는 동안은 스크롤(↑↓ j k space PgUp PgDn), 닫기(? Esc), 나가기(q)만 듣고 나머지 키는 버린다.
102
+ // 스크롤 끝과 한 페이지 줄 수는 마지막으로 그린 도움말이 정한다 (view.helpMax, view.helpPage).
103
+ function helpKey(view, key, act) {
104
+ if (key === '?' || key === KEY.esc) view.help = false;
105
+ else if (key === 'q') act.quit();
106
+ else {
107
+ const page = view.helpPage ?? 10;
108
+ const step = isUp(key) ? -1 : isDown(key) ? 1 : key === ' ' || key === KEY.pageDown ? page : key === KEY.pageUp ? -page : 0;
109
+ if (step) view.helpScroll = Math.min(view.helpMax ?? 0, Math.max(0, (view.helpScroll ?? 0) + step));
110
+ }
111
+ }
112
+
113
+ export function onKey(view, key, act) {
114
+ if (key === KEY.ctrlC) return act.quit();
115
+ if (view.help) return helpKey(view, key, act);
116
+ if (view.searching) return searchKey(view, key, act);
117
+ if (key === '?') {
118
+ view.help = true;
119
+ view.helpScroll = 0;
120
+ return;
121
+ }
122
+ if (key === 'q') return act.quit();
123
+ if (view.focus === 'diff' && diffKey(view, key)) return;
124
+ if (view.focus === 'files' && filesKey(view, key)) return;
125
+ if (key === KEY.esc) {
126
+ view.filter = '';
127
+ return;
128
+ }
129
+ if (key === 'f' || key === KEY.tab) return enterFiles(view);
130
+ if (key === '/') {
131
+ view.searching = true;
132
+ view.filter = '';
133
+ view.focus = null;
134
+ } else if (isUp(key)) act.move(-1);
135
+ else if (isDown(key)) act.move(1);
136
+ else if (key === 'n') act.next();
137
+ // 정렬·묶기는 순서만 바꾸고 선택은 같은 세션 id 에 남는다.
138
+ else if (key === 's') view.sort = nextSort(view.sort);
139
+ else if (key === 'g') view.group = nextGroup(view.group);
140
+ // 고정(p)은 이번 실행에만 기억한다. 고른 세션은 그대로 남고 자리만 위로 옮긴다.
141
+ // 파일 목록·diff 에 들어가 있을 때는 키 안내에 없으므로 받지 않는다.
142
+ else if (key === 'p') {
143
+ if (view.selectedId != null && !view.focus) view.pins = togglePin(view.pins, view.selectedId);
144
+ }
145
+ else if (key === 'c') view.costMode = view.costMode === 'net' ? 'api' : 'net';
146
+ else if (key === 'd') {
147
+ // view.rows: 마지막으로 그린 화면 높이. 'auto' 일 때 지금 보이는 상태를 뒤집는 데 쓴다.
148
+ view.showDetail = !detailOpen(view, view.rows ?? Infinity);
149
+ if (!view.showDetail) view.focus = null;
150
+ } else if (key === 'e') {
151
+ view.showEnded = !view.showEnded;
152
+ act.refresh();
153
+ }
154
+ }