@sejuneoh/aitop 0.0.0-stage → 0.21.2

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,137 @@
1
+ // 색: 팔레트, 색 코드, 테두리 글자. NO_COLOR 이면 색 코드를 넣지 않는다.
2
+ // 팔레트는 불러올 때 한 번 고른다: AITOP_THEME(bin 의 --theme) → COLORFGBG 의 배경색 → 어두운 테마.
3
+
4
+ export const NO_COLOR = Boolean(process.env.NO_COLOR);
5
+
6
+ // COLORFGBG 는 "글자;배경" 색 번호다. 배경이 7(밝은 회색) 이나 9~15(밝은 색) 면 밝은 배경으로 본다.
7
+ export function pickTheme(env = process.env) {
8
+ if (env.AITOP_THEME === 'light' || env.AITOP_THEME === 'dark') return env.AITOP_THEME;
9
+ const bg = Number(String(env.COLORFGBG ?? '').split(';').pop());
10
+ return Number.isInteger(bg) && (bg === 7 || (bg >= 9 && bg <= 15)) ? 'light' : 'dark';
11
+ }
12
+
13
+ // 정보를 담은 글자색(text, muted, info, 상태색)은 터미널 배경, 선택 행, 대기 행, 배경 띠 어디에서도 4.5:1 이상이다.
14
+ // faint 는 구분점이나 게이지 빈 칸 같은 장식에만 쓴다.
15
+ const DARK = {
16
+ text: '#e6e8ee',
17
+ muted: '#babec8',
18
+ // 글자 위계는 text > muted > info. info 는 가장 낮은 단계 (시각, 토큰 내역, 힌트, 종료 행)로 선택 행에서 4.5:1 바로 위에 둔다.
19
+ info: '#999eae',
20
+ faint: '#4b5060',
21
+ line: '#2b2f3a',
22
+ rowSel: '#1d2433',
23
+ // 변경 파일 목록의 배경 띠(bar) 위에서도 보이는 선택 행 배경
24
+ rowFocus: '#2a3550',
25
+ rowWaiting: '#2e2610',
26
+ bar: '#141821',
27
+ brand: '#7dd3fc',
28
+ busy: '#4ade80',
29
+ waiting: '#fbbf24',
30
+ tool: '#38bdf8',
31
+ team: '#f0abfc',
32
+ idle: '#babec8',
33
+ ended: '#999eae',
34
+ alert: '#f87878',
35
+ // 돈은 대기(호박색)와 다른 연두 계열이다. 대기 색은 사람이 응답해야 할 때만 쓴다.
36
+ money: '#a3e635',
37
+ addBg: '#10261a',
38
+ delBg: '#2c1517',
39
+ tools: { claude: '#e8865f', codex: '#5eead4', gemini: '#8ab4f8' },
40
+ // 색 칩(키 안내, 로고) 위 글자
41
+ chipText: '#0b0e14',
42
+ };
43
+
44
+ // 흰 배경용. 글자는 짙게, 배경 띠는 옅게 둔다.
45
+ const LIGHT = {
46
+ text: '#1f2328',
47
+ muted: '#495059',
48
+ info: '#5f6771',
49
+ faint: '#6e7781',
50
+ line: '#d0d7de',
51
+ rowSel: '#eaeef2',
52
+ rowFocus: '#dbe7f7',
53
+ rowWaiting: '#fff3cd',
54
+ bar: '#f3f5f7',
55
+ brand: '#0862cb',
56
+ busy: '#187533',
57
+ waiting: '#895c00',
58
+ tool: '#0550ae',
59
+ team: '#7649cb',
60
+ idle: '#495059',
61
+ ended: '#5f6771',
62
+ alert: '#c5202c',
63
+ money: '#3f6212',
64
+ addBg: '#dafbe1',
65
+ delBg: '#ffebe9',
66
+ tools: { claude: '#ab4500', codex: '#097264', gemini: '#1f5fbf' },
67
+ chipText: '#ffffff',
68
+ };
69
+
70
+ // 색약(적록 색약: 1형·2형) 팔레트. Okabe-Ito 색 계열을 터미널 배경에 맞게 밝기만 조정했다.
71
+ // 적록 색약은 빨강·초록·노랑을 노랑 계열 한 가지로, 파랑·보라를 파랑 계열로 본다. 그래서 꼭 갈라야 하는 쌍을 노랑 계열과 파랑 계열로 나눈다.
72
+ // 응답 대기(노랑) ↔ 돈(파랑), 작업 중·diff 더한 줄(청록) ↔ 경고·diff 지운 줄(주홍), 예산 주의(자주) ↔ 경고(주홍).
73
+ // 대기와 경고는 둘 다 노랑 계열로 보이지만 ◆ 와 ! 기호로 갈린다.
74
+ // 어두운 배경의 돈은 도구 실행(하늘색)과 같은 행에 서므로 같은 파랑 계열 안에서 훨씬 밝게 둔다.
75
+ // 글자·배경·도구 색은 기본 팔레트와 같고, 상태색과 diff 배경만 바꾼다. 대비 규칙은 기본 팔레트와 같다.
76
+ const DARK_CB = {
77
+ ...DARK,
78
+ rowWaiting: '#2b2808',
79
+ busy: '#3cc8a0',
80
+ waiting: '#f0e442',
81
+ tool: '#56b4e9',
82
+ team: '#e8a0c8',
83
+ alert: '#ff8060',
84
+ money: '#d4dcff',
85
+ addBg: '#0d2333',
86
+ delBg: '#331b0a',
87
+ };
88
+
89
+ const LIGHT_CB = {
90
+ ...LIGHT,
91
+ rowWaiting: '#fbf3c0',
92
+ busy: '#00735a',
93
+ waiting: '#6e5f00',
94
+ tool: '#0060a0',
95
+ team: '#9a3d76',
96
+ alert: '#c03010',
97
+ money: '#3a4fc0',
98
+ addBg: '#dcefff',
99
+ delBg: '#ffe8d6',
100
+ };
101
+
102
+ // AITOP_COLOR_BLIND=1 (bin 의 --color-blind, 설정 파일 colorBlind) 이면 배경에 맞는 색약 팔레트를 쓴다.
103
+ // 밝기(light/dark)와 색약 여부는 따로 고른다. 그래야 COLORFGBG 자동 판별을 색약 팔레트에서도 그대로 쓴다.
104
+ export const pickColorBlind = (env = process.env) => env.AITOP_COLOR_BLIND === '1';
105
+ export const paletteName = (theme, colorBlind) => (colorBlind ? `${theme}-cb` : theme);
106
+
107
+ export const THEME = pickTheme();
108
+ export const COLOR_BLIND = pickColorBlind();
109
+ export const PALETTES = { dark: DARK, light: LIGHT, 'dark-cb': DARK_CB, 'light-cb': LIGHT_CB };
110
+ export const PALETTE = PALETTES[paletteName(THEME, COLOR_BLIND)];
111
+
112
+ export const hex = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)).join(';');
113
+
114
+ export function style({ fg, bg, bold, dim, italic } = {}) {
115
+ if (NO_COLOR) return '';
116
+ let s = '\x1b[0m';
117
+ if (bold) s += '\x1b[1m';
118
+ if (dim) s += '\x1b[2m';
119
+ if (italic) s += '\x1b[3m';
120
+ if (fg) s += `\x1b[38;2;${hex(fg)}m`;
121
+ if (bg) s += `\x1b[48;2;${hex(bg)}m`;
122
+ return s;
123
+ }
124
+
125
+ export const RESET = NO_COLOR ? '' : '\x1b[0m';
126
+
127
+ // 하루 예산 경고. 주의(예상이 예산의 80% 이상)는 대기 색과 헷갈리지 않게 팀원 색을, 초과 예상은 경고 색을 쓴다.
128
+ export const BUDGET_TOKEN = { caution: 'team', warn: 'alert' };
129
+ export const BUDGET_COLOR = { caution: PALETTE[BUDGET_TOKEN.caution], warn: PALETTE[BUDGET_TOKEN.warn] };
130
+
131
+ export const PHASE_COLOR = { tool: PALETTE.tool, team: PALETTE.team, think: PALETTE.busy, write: PALETTE.busy, waiting: PALETTE.waiting, idle: PALETTE.idle, ended: PALETTE.ended, done: PALETTE.info, unseen: PALETTE.busy };
132
+
133
+ // 상자 테두리. 포커스가 있으면 강조색으로, 색이 없으면 굵은 선으로 구분한다.
134
+ export const EDGE = {
135
+ normal: { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│' },
136
+ heavy: { tl: '┏', tr: '┓', bl: '┗', br: '┛', h: '━', v: '┃' },
137
+ };
package/src/ui.mjs ADDED
@@ -0,0 +1,99 @@
1
+ // 화면 그리기: 상단 요약 바 → 세션 표(팀원 트리 포함) → 선택한 세션 상세 → 단축키 바.
2
+ import { width } from './text.mjs';
3
+ import { BRAND } from './brand.mjs';
4
+ import { PALETTE } from './ui/theme.mjs';
5
+ import { glyphMoves, line, setNow } from './ui/draw.mjs';
6
+ import { TABLE_BOX_H, columns, emptyLines, filterSessions, groupRow, header, memberRow, sessionRow, tableBox, tableHeader, toolStrip, topCost } from './ui/table.mjs';
7
+ import { detail } from './ui/detail.mjs';
8
+ import { detailOpen, footer, help } from './ui/chrome.mjs';
9
+ import { arrange } from './order.mjs';
10
+
11
+ export { PALETTE, style } from './ui/theme.mjs';
12
+ export { money } from './ui/draw.mjs';
13
+ export { filterSessions } from './ui/table.mjs';
14
+ export { DETAIL_MIN_H, detailOpen } from './ui/chrome.mjs';
15
+
16
+ // 프레임마다 바뀌는 표시(세션 스피너·응답 대기 깜빡임, 일하는 팀원 스피너)가 화면에 있는지.
17
+ // 표에서 가려진 세션까지 셈에 넣으므로, 실제보다 조금 더 자주 그릴 수는 있어도 덜 그리지는 않는다.
18
+ export function animating(view) {
19
+ if (!view.data || view.help) return false;
20
+ return filterSessions(view.data.sessions, view.filter).some((s) => glyphMoves(s) || (s.members ?? []).some((m) => m.status !== 'done'));
21
+ }
22
+
23
+ export function render(view, W, H) {
24
+ setNow(Date.now());
25
+ const lines = [];
26
+ if (!view.data) {
27
+ // 첫 수집은 동기로 돌아 이 화면이 다시 그려지지 않으므로 움직이지 않는 로고를 둔다.
28
+ const msg = `${BRAND.logo} 세션 기록을 읽는 중…`;
29
+ for (let i = 0; i < H; i++) lines.push(i === Math.floor(H / 2) ? line([{ t: ' '.repeat(Math.max(0, Math.floor((W - width(msg)) / 2))) }, { t: msg, fg: PALETTE.brand }], W) : line([], W));
30
+ return lines;
31
+ }
32
+ const { errors } = view.data;
33
+ const { sessions, groups } = arrange(filterSessions(view.data.sessions, view.filter), view);
34
+ const boxed = H >= TABLE_BOX_H && W >= 60;
35
+ const TW = boxed ? W - 4 : W;
36
+ const c = columns(TW);
37
+ if (view.help) {
38
+ lines.push(header(view, W), ...help(W, Math.max(3, H - 2), view, detailOpen(view, H)));
39
+ lines.length = H - 1;
40
+ lines.push(footer({ ...view, showDetail: detailOpen(view, H) }, W, sessions.length));
41
+ return lines;
42
+ }
43
+ lines.push(header(view, W), toolStrip(view, W));
44
+ if (!boxed) lines.push(line([], W));
45
+ const table = [tableHeader(c, TW)];
46
+
47
+ const rows = [];
48
+ const pushSession = (s) => {
49
+ rows.push({ s });
50
+ s.members.forEach((m, i) => rows.push({ s, m, last: i === s.members.length - 1 }));
51
+ };
52
+ if (groups) {
53
+ for (const g of groups) {
54
+ rows.push({ g });
55
+ g.sessions.forEach(pushSession);
56
+ }
57
+ } else sessions.forEach(pushSession);
58
+ const sel = sessions.find((s) => s.id === view.selectedId) ?? sessions[0];
59
+ const open = detailOpen(view, H);
60
+ const wantDetail = open && Boolean(sel);
61
+ let minDetail = wantDetail ? 12 : 0;
62
+ const used = lines.length + table.length + (boxed ? 2 : 0);
63
+ let room = Math.max(1, Math.min(Math.max(1, rows.length), H - used - minDetail - 2));
64
+ // 표가 다 들어가지 않으면 마지막 줄에 몇 개가 가려졌는지 알린다.
65
+ // 고른 세션 한 줄밖에 안 남으면 상세 패널에서 한 줄을 가져와 그 알림 줄을 둔다.
66
+ // 화면 자체가 표 두 줄(맨 아래 키 줄 제외)을 못 담으면 알림 없이 고른 세션 행만 남긴다.
67
+ if (rows.length > room && room < 2 && H - used - 1 >= 2) {
68
+ room = 2;
69
+ minDetail = Math.max(0, minDetail - 1);
70
+ }
71
+ const clipped = rows.length > room && room > 1;
72
+ const shown = clipped ? room - 1 : room;
73
+ const selRow = Math.max(0, rows.findIndex((r) => r.s && !r.m && r.s === sel));
74
+ const lastOfSel = selRow + (sel?.members.length ?? 0);
75
+ let start = Math.min(Math.max(0, lastOfSel - shown + 1), Math.max(0, rows.length - shown), selRow);
76
+ // 고른 세션이 묶음의 첫 행이고 자리가 남으면 그 묶음 머리도 보이게 한 줄 올린다.
77
+ if (start === selRow && rows[start - 1]?.g && lastOfSel - start + 2 <= shown) start--;
78
+ const isSession = (r) => r.s && !r.m;
79
+ const visible = rows.slice(start, start + shown);
80
+ const hot = topCost(visible.filter(isSession).map((r) => r.s), view.costMode);
81
+ if (!sessions.length) table.push(...(view.filter ? [line([{ t: ` '${view.filter}' 에 맞는 세션이 없습니다.`, fg: PALETTE.muted, italic: true }], TW)] : emptyLines(view, TW)));
82
+ for (const r of visible) table.push(r.g ? groupRow(r.g, TW) : r.m ? memberRow(r.m, r.last, c, TW, view.frame, r.s.model, view.costMode) : sessionRow(r.s, c, TW, r.s === sel, view.frame, hot, view.costMode, Boolean(view.pins?.includes(r.s.id))));
83
+ if (clipped) {
84
+ const onScreen = new Set(visible.filter(isSession).map((r) => r.s));
85
+ const hidden = sessions.filter((s) => !onScreen.has(s));
86
+ const waitingHidden = hidden.filter((s) => s.status === 'waiting').length;
87
+ table.push(line([{ t: ` … +${hidden.length} 세션`, fg: PALETTE.muted }, ...(waitingHidden ? [{ t: ` (응답 대기 ${waitingHidden})`, fg: PALETTE.waiting, bold: true }] : []), { t: ' ↑↓ 로 이동', fg: PALETTE.info }], TW));
88
+ }
89
+ lines.push(...(boxed ? tableBox(table, W, sessions.length) : table));
90
+ if (wantDetail) {
91
+ lines.push(line([], W));
92
+ lines.push(...detail(sel, W, Math.max(minDetail, H - lines.length - 1), view.frame, view));
93
+ }
94
+ if (errors?.length) lines[lines.length - 1] = line([{ t: ' ! ' + errors.join(' / '), fg: PALETTE.alert }], W);
95
+ while (lines.length < H - 1) lines.push(line([], W));
96
+ lines.length = H - 1;
97
+ lines.push(footer({ ...view, showDetail: open }, W, sessions.length, sel));
98
+ return lines;
99
+ }