@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,152 @@
1
+ // 그리기 기본: 조각(segment) 줄 만들기·자르기·채우기, 상자, 가로줄, 게이지, 시각·금액 표기.
2
+ import { pad, stripControl, truncate, width } from '../text.mjs';
3
+ import { homedir } from 'node:os';
4
+ import { NO_COLOR, PALETTE, PHASE_COLOR, RESET, style } from './theme.mjs';
5
+ import { glyphs } from './glyphs.mjs';
6
+
7
+ // segs: [{ t: 텍스트, ...style }]. 폭 W 에 맞춰 자르고, 남는 칸은 bg 로 채운다.
8
+ // 화면에 나가는 글자는 모두 여기를 지나므로, 기록에서 온 제어문자도 여기서 한 번 더 지운다.
9
+ export function line(segs, W, bg) {
10
+ let out = '';
11
+ let used = 0;
12
+ for (const seg of segs) {
13
+ if (used >= W) break;
14
+ const t = truncate(stripControl(seg.t ?? ''), W - used);
15
+ out += style({ bg, ...seg }) + t;
16
+ used += width(t);
17
+ }
18
+ return out + style({ bg }) + ' '.repeat(Math.max(0, W - used)) + RESET;
19
+ }
20
+
21
+ // 조각들의 화면 폭 합계.
22
+ export const segsWidth = (segs) => segs.reduce((a, x) => a + width(x.t ?? ''), 0);
23
+
24
+ export function clip(segs, max) {
25
+ const out = [];
26
+ let used = 0;
27
+ for (const s of segs) {
28
+ if (used >= max) break;
29
+ const t = truncate(s.t, max - used);
30
+ out.push({ ...s, t });
31
+ used += width(t);
32
+ }
33
+ return out;
34
+ }
35
+
36
+ export const SPIN = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏';
37
+
38
+ export function glyph(s, frame) {
39
+ const k = s.phase?.kind;
40
+ const G = glyphs();
41
+ if (s.status === 'waiting') return { t: frame % 8 < 5 ? G.waiting : G.waitingBlink, fg: PALETTE.waiting, bold: true };
42
+ if (k === 'ended') return { t: G.ended, fg: PALETTE.ended };
43
+ if (k === 'done') return { t: '✓', fg: PALETTE.info };
44
+ if (k === 'unseen') return { t: G.unseen, fg: PALETTE.busy, bold: true };
45
+ if (k === 'idle') return s.status === 'live' ? { t: G.live, fg: PALETTE.tools[s.tool] } : { t: G.idle, fg: PALETTE.idle };
46
+ return { t: SPIN[frame % SPIN.length], fg: PHASE_COLOR[k] ?? PALETTE.busy, bold: true };
47
+ }
48
+
49
+ // glyph 가 frame 에 따라 모양을 바꾸는지 (응답 대기 깜빡임, 작업 중 스피너).
50
+ export const glyphMoves = (s) => s.status === 'waiting' || !['ended', 'done', 'unseen', 'idle'].includes(s.phase?.kind);
51
+
52
+ // 막대는 같은 높이의 얇은 블록(▃)으로 그리고 채운 칸과 빈칸은 색으로 나눈다. 칸 높이가 모두 같아 끝이 튀어나오지 않는다.
53
+ // 색이 없으면 빈칸을 가장 얇은 ▁ 로 그려 모양으로 구분한다. 기호는 glyphs().bar 에 있다.
54
+
55
+ // 0% 보다 크면 최소 1칸, 100% 가 아니면 마지막 칸은 비워 둔다.
56
+ export function filledCells(r, cells) {
57
+ if (r <= 0) return 0;
58
+ if (r >= 1) return cells;
59
+ return Math.min(cells - 1, Math.max(1, Math.round(r * cells)));
60
+ }
61
+
62
+ export function gauge(used, total, cells) {
63
+ const BAR = glyphs().bar;
64
+ if (!total) return [{ t: BAR.empty.repeat(cells) + ' ', fg: PALETTE.faint }];
65
+ const r = Math.min(1, Math.max(0, used / total || 0));
66
+ const full = cells ? filledCells(r, cells) : 0;
67
+ const color = r >= 0.85 ? PALETTE.alert : r >= 0.6 ? PALETTE.text : PALETTE.busy;
68
+ return [
69
+ { t: BAR.fill.repeat(full), fg: color },
70
+ { t: BAR.empty.repeat(cells - full), fg: PALETTE.faint },
71
+ // 85% 이상은 색이 없어도 보이게 ! 를 붙인다.
72
+ { t: pad(`${r >= 0.85 ? '!' : ''}${Math.round(r * 100)}%`, 5, 'right'), fg: r >= 0.85 ? PALETTE.alert : PALETTE.muted },
73
+ ];
74
+ }
75
+
76
+ export const dollars = (usd) => (usd >= 1000 ? `$${Math.round(usd).toLocaleString('en-US')}` : usd >= 100 ? `$${usd.toFixed(0)}` : `$${usd.toFixed(2)}`);
77
+
78
+ // 비용을 모르는 도구는 '-'. 가격표에 없는 모델이 섞이면 아는 몫 뒤에 '?' 를 붙이고, 아는 몫이 없으면 '?' 만 보인다.
79
+ // 비용 표시 기준: 'api' 는 API 정가 환산(캐시 포함), 'net' 은 캐시 읽기·쓰기를 뺀 입력·출력만.
80
+ export const costOf = (x, mode = 'api') => (mode === 'net' ? x?.costNoCache ?? null : x?.cost ?? null);
81
+
82
+ export function money(usd, unknown = false) {
83
+ if (usd == null) return '-';
84
+ if (unknown) return usd > 0 ? `${dollars(usd)}?` : '?';
85
+ return dollars(usd);
86
+ }
87
+
88
+ export const tilde = (p) => (p ?? '').replace(homedir(), '~');
89
+
90
+ export const two = (n) => String(n).padStart(2, '0');
91
+
92
+ export const clock = (ms) => {
93
+ const d = new Date(ms);
94
+ return `${two(d.getHours())}:${two(d.getMinutes())}:${two(d.getSeconds())}`;
95
+ };
96
+
97
+ // 한 행 = 표시(1) + 상태 기호(2) + 각 열 + 열 뒤 공백 1칸. CONTEXT 는 게이지 11 + 퍼센트 5.
98
+ export const shortModel = (m) => (m ? m.replace(/^claude-/, '') : '-');
99
+
100
+ export let NOW = Date.now();
101
+ export function setNow(t) {
102
+ NOW = t;
103
+ }
104
+
105
+ // segs 를 폭 n 에 맞춰 자르고 남는 칸을 공백으로 채운다. bg 를 주면 칸 끝까지 그 배경을 깐다.
106
+ export function fill(segs, n, bg) {
107
+ const c = clip(segs, n);
108
+ const out = [...c, { t: ' '.repeat(Math.max(0, n - c.reduce((a, x) => a + width(x.t), 0))) }];
109
+ return bg ? out.map((x) => ({ bg, ...x })) : out;
110
+ }
111
+
112
+ // title: 조각 배열 또는 문자열. leftLines 가 null 이면 칸을 나누지 않고 rightLines 가 안쪽 전체 폭(W - 4)을 쓴다.
113
+ export function box(title, W, H, leftLines, rightLines, leftW, accent, focused = false) {
114
+ const E = glyphs().edge;
115
+ const e = focused && NO_COLOR ? E.heavy : E.normal;
116
+ const fg = focused ? PALETTE.brand : PALETTE.line;
117
+ const out = [];
118
+ const titleSegs = typeof title === 'string' ? [{ t: title, fg: accent, bold: true }] : title;
119
+ const titleW = segsWidth(titleSegs) + 2;
120
+ out.push(line([{ t: e.tl + e.h, fg }, { t: ' ' }, ...titleSegs, { t: ' ' }, { t: e.h.repeat(Math.max(0, W - 3 - titleW)) + e.tr, fg }], W));
121
+ // '│ ' + 왼쪽 + ' │ ' + 오른쪽 + ' │' = W
122
+ const rightW = W - leftW - 7;
123
+ for (let i = 0; i < H - 2; i++) {
124
+ const body = leftLines ? [...fill(leftLines[i] ?? [], leftW), { t: ` ${e.v} `, fg }, ...fill(rightLines[i] ?? [], rightW)] : fill(rightLines[i] ?? [], W - 4);
125
+ out.push(line([{ t: `${e.v} `, fg }, ...body, { t: ` ${e.v}`, fg }], W));
126
+ }
127
+ out.push(line([{ t: e.bl + e.h.repeat(W - 2) + e.br, fg }], W));
128
+ return out;
129
+ }
130
+
131
+ export const hhmm = (ms) => clock(ms).slice(0, 5);
132
+
133
+ // ── 라벨 ───────── 메타 ─. 오른쪽 칸의 구획(변경 파일·최근 활동·diff)을 나누는 가로줄.
134
+ // 폭이 모자라면 메타부터 뺀다.
135
+ export function rule(label, meta, w, color = PALETTE.line) {
136
+ const h = glyphs().rule;
137
+ const head = [{ t: h + h + ' ', fg: color }, ...label];
138
+ const headW = head.reduce((a, x) => a + width(x.t), 0);
139
+ const tail = meta && headW + width(meta) + 4 <= w ? [{ t: ` ${meta} `, fg: PALETTE.info }, { t: h, fg: color }] : [];
140
+ const used = [...head, ...tail].reduce((a, x) => a + width(x.t), 0);
141
+ return [...head, { t: ' ' + h.repeat(Math.max(0, w - used - 1)), fg: color }, ...tail];
142
+ }
143
+
144
+ // 값 목록을 ▁▂…█ 막대로. 가장 큰 값을 █ 로 맞추고, 0 은 장식 색(faint) ▁, 0 보다 크면 ▂ 이상이라 색이 없어도 갈린다.
145
+ // 헤더 7일 막대(table.mjs weekBar)와 상세 패널의 시간별 활동 막대가 같이 쓴다.
146
+ // AITOP_AMBIGUOUS=wide 이면 ▁…█ 대신 1칸 ASCII 막대(glyphs().week)를 쓴다.
147
+ export function levelBar(values) {
148
+ const bars = glyphs().week;
149
+ const v = values.map((x) => Math.max(0, x ?? 0));
150
+ const max = Math.max(0, ...v);
151
+ return v.map((x) => (!(x > 0) || !(max > 0) ? { t: bars[0], fg: PALETTE.faint } : { t: bars[Math.min(7, Math.max(1, Math.ceil((x / max) * 7)))], fg: PALETTE.info }));
152
+ }
@@ -0,0 +1,58 @@
1
+ // 칸 수가 정해진 자리에 그리는 기호: 상자 테두리, 가로줄, 막대, 상태 기호, 선택 표시.
2
+ // 모두 동아시아 폭이 모호한(A) 글자라, 2칸으로 그리는 터미널(AITOP_AMBIGUOUS=wide)에서는 1칸 ASCII 로 바꾼다.
3
+ // 그 밖의 글(·, → 등)은 text.mjs 의 width 가 2칸으로 세어 자리를 맞춘다.
4
+ // 그릴 때마다 고르므로 setAmbiguous 를 부른 뒤에도 바로 따른다.
5
+ import { ambiguousWide } from '../text.mjs';
6
+ import { EDGE, NO_COLOR } from './theme.mjs';
7
+
8
+ const NORMAL = {
9
+ edge: EDGE,
10
+ rule: '─',
11
+ bar: { fill: '▃', empty: NO_COLOR ? '▁' : '▃' },
12
+ week: '▁▂▃▄▅▆▇█',
13
+ waiting: '◆',
14
+ waitingBlink: '◇',
15
+ unseen: '●',
16
+ live: '●',
17
+ idle: '○',
18
+ ended: '·',
19
+ select: '▌',
20
+ tool: '▍',
21
+ hot: '▲',
22
+ member: '├─ ',
23
+ memberLast: '└─ ',
24
+ event: '•',
25
+ cursor: '▏',
26
+ pin: '★',
27
+ };
28
+
29
+ const SAFE = {
30
+ edge: {
31
+ normal: { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|' },
32
+ heavy: { tl: '#', tr: '#', bl: '#', br: '#', h: '=', v: '#' },
33
+ },
34
+ rule: '-',
35
+ // 색이 없어도 채운 칸과 빈칸이 모양으로 갈리게 한다.
36
+ bar: { fill: '#', empty: NO_COLOR ? '.' : '#' },
37
+ week: '_.:-=+*#',
38
+ waiting: '!',
39
+ waitingBlink: '.',
40
+ unseen: '*',
41
+ live: '*',
42
+ idle: 'o',
43
+ ended: '.',
44
+ select: '>',
45
+ tool: '|',
46
+ hot: '^',
47
+ member: '|- ',
48
+ memberLast: '`- ',
49
+ event: '*',
50
+ cursor: '|',
51
+ pin: '*',
52
+ };
53
+
54
+ export const glyphs = () => (ambiguousWide() ? SAFE : NORMAL);
55
+
56
+ // 도움말 범례처럼 기본 기호로 적어 둔 글을 지금 기호로 바꾼다.
57
+ const SWAP = { '◆': 'waiting', '●': 'unseen', '○': 'idle', '·': 'ended', '★': 'pin' };
58
+ export const legendMark = (t) => (SWAP[t] ? glyphs()[SWAP[t]] : t);
@@ -0,0 +1,425 @@
1
+ // 위쪽 화면: 요약 헤더, 도구 줄, 세션 표(팀원 행 포함), 세션이 없을 때의 점검 줄.
2
+ import { describe } from '../doctor.mjs';
3
+ import { BRAND } from '../brand.mjs';
4
+ import { projectBudget } from '../budget.mjs';
5
+ import { fmtAgo, fmtTokens, pad, truncate, width } from '../text.mjs';
6
+ import { BUDGET_COLOR, PALETTE, PHASE_COLOR, RESET, style } from './theme.mjs';
7
+ import { NOW, SPIN, clock, costOf, gauge, glyph, levelBar, line, money, segsWidth, shortModel } from './draw.mjs';
8
+ import { glyphs } from './glyphs.mjs';
9
+
10
+ // 검색어가 세션 이름·폴더·모델 중 하나에 들어 있으면 남긴다 (대소문자 무시).
11
+ export function filterSessions(sessions, query) {
12
+ const q = (query ?? '').trim().toLowerCase();
13
+ if (!q) return sessions;
14
+ return sessions.filter((s) => [s.name, s.project, s.cwd, s.model, s.tool].some((v) => String(v ?? '').toLowerCase().includes(q)));
15
+ }
16
+
17
+ // 표 폭이 WIDE 이상이면 COST 와 CONTEXT 막대 11칸을 그대로 두고, 폭에 따라 MODEL(104~)·PROJECT(110~) 를 더한다.
18
+ // 그보다 좁으면 위에서부터 차례로 시도한다: CONTEXT 막대를 4칸으로 줄이고, 막대 없이 % 만 남긴다.
19
+ // 그래도 STATUS 가 12칸이 안 되면 COST 를 빼고, 마지막에 SESSION 을 줄인다.
20
+ // cells 는 CONTEXT 막대 칸 수이고 % 는 늘 5칸을 쓴다. 각 단계는 STATUS 가 minStatus 칸 이상 남을 때만 쓴다.
21
+ const WIDE = 96;
22
+ const NARROW = [
23
+ { cost: true, cells: 4, minName: 14, minStatus: 20 },
24
+ { cost: true, cells: 0, minName: 14, minStatus: 12 },
25
+ { cells: 0, minName: 14, minStatus: 12 },
26
+ { cells: 0, minName: 10, minStatus: 0 },
27
+ ];
28
+
29
+ function layout(W, l) {
30
+ const fixed = { mark: 1, glyph: 2, tool: 7, model: l.model ? 10 : 0, tokens: 7, cost: l.cost ? 9 : 0, ctx: l.cells + 5, age: 4 };
31
+ const gaps = 5 + (l.project ? 1 : 0) + (l.cost ? 1 : 0) + (l.model ? 1 : 0);
32
+ const flex = W - Object.values(fixed).reduce((a, b) => a + b, 0) - gaps - 1;
33
+ const name = Math.max(l.minName, Math.min(32, Math.floor(flex * 0.36)));
34
+ const project = l.project ? Math.max(10, Math.min(22, Math.floor(flex * 0.22))) : 0;
35
+ const status = flex - name - project;
36
+ return { ...fixed, cells: l.cells, name, project, status, showProject: Boolean(l.project), showCost: Boolean(l.cost), showModel: Boolean(l.model) };
37
+ }
38
+
39
+ export function columns(W) {
40
+ if (W >= WIDE) return layout(W, { project: W >= 110, model: W >= 104, cost: true, cells: 11, minName: 14 });
41
+ for (const l of NARROW) {
42
+ const c = layout(W, l);
43
+ if (c.status >= l.minStatus) return c;
44
+ }
45
+ // 가장 좁은 단계에서도 모자라면 SESSION 을 줄여 IDLE 이 밀려나지 않게 한다.
46
+ const c = layout(W, NARROW[NARROW.length - 1]);
47
+ const short = Math.max(0, 4 - c.status);
48
+ return { ...c, name: Math.max(1, c.name - short), status: Math.max(4, c.status) };
49
+ }
50
+
51
+ // 헤더의 7일 비용 막대. days: usage.days7 (오래된 날 → 오늘). 7일 중 가장 큰 날을 █ 로 맞춘다.
52
+ // 비용이 0 인 날은 ▁ 를 장식 색(faint)으로, 쓴 날은 ▂~█ 로 그린다. 색이 없어도 쓴 날(▂ 이상)과 안 쓴 날(▁)이 갈린다.
53
+ // 가격표에 없는 모델만 쓴 날(아는 비용 0)은 쓰지 않은 날과 헷갈리지 않게 ? 로 둔다.
54
+ export function weekBar(days, costMode = 'api') {
55
+ const bar = levelBar(days.map((d) => costOf(d, costMode)));
56
+ return bar.map((x, i) => (x.t === glyphs().week[0] && days[i].unknown ? { t: '?', fg: PALETTE.muted } : x));
57
+ }
58
+
59
+ export function header(view, W) {
60
+ const { sessions } = view.data;
61
+ const live = sessions.filter((s) => s.live);
62
+ const count = (pred) => live.filter(pred).length;
63
+ const working = count((s) => ['busy', 'shell'].includes(s.status));
64
+ const waiting = count((s) => s.status === 'waiting');
65
+ const unseen = count((s) => s.unseen);
66
+ const idle = count((s) => (s.status === 'idle' || s.status === 'live') && !s.unseen);
67
+ const B = PALETTE.bar;
68
+ const G = glyphs();
69
+ // 0 인 상태는 빼서 오른쪽 사용량 자리를 남긴다. 좁으면 이름을 줄인다.
70
+ const stat = (glyph, n, long, short, color, bold, narrow) =>
71
+ n
72
+ ? [
73
+ { t: ` ${glyph} `, fg: color, bg: B },
74
+ { t: `${n} ${narrow ? short : long}`, fg: bold ? color : PALETTE.text, bg: B, bold },
75
+ ]
76
+ : [];
77
+ // drop: 0 은 모두 보이고, 1 은 live 수(다른 수의 합)를 빼고, 2 는 ○ 대기까지 뺀다.
78
+ // 빼고 나서 남는 상태 수가 없으면 live 수는 남긴다.
79
+ const leftOf = (narrow, drop = 0) => {
80
+ const others = [
81
+ ...stat('⠿', working, '작업 중', '작업', PALETTE.busy, false, narrow),
82
+ ...stat(G.unseen, unseen, '확인 전', '확인', PALETTE.busy, true, narrow),
83
+ ...(drop < 2 ? stat(G.idle, idle, '대기', '대기', PALETTE.idle, false, narrow) : []),
84
+ ];
85
+ const counts = [
86
+ ...(drop < 1 || !others.length ? [{ t: `${live.length} live`, fg: PALETTE.text, bg: B, bold: true }] : []),
87
+ ...others,
88
+ ].map((x, i) => (i === 0 ? { ...x, t: x.t.trimStart() } : x));
89
+ return [
90
+ { t: ` ${BRAND.logo} `, fg: PALETTE.chipText, bg: PALETTE.brand, bold: true },
91
+ { t: ' ', bg: B },
92
+ // 응답 대기가 있으면 사람이 할 일이므로 맨 앞에 둔다.
93
+ ...(waiting ? [...stat(G.waiting, waiting, '응답 대기', '응답', PALETTE.waiting, true, narrow).map((x, i) => (i === 0 ? { ...x, t: x.t.trimStart() } : x)), ...(counts.length ? [{ t: ' ', bg: B }] : [])] : []),
94
+ ...counts,
95
+ ];
96
+ };
97
+ // 오른쪽: 오늘 $52 ($8.40/h) 132M · 7일 $644 ▂▃▅█▁▄▆ 1.2B · 12:42:05. 폭이 모자라면 지출 속도, 7일 막대, 토큰 수, API 표시, 7일, 오늘 순으로 뺀다 (캐시 제외 기준이면 그 표시는 끝까지 남긴다).
98
+ // 하루 예산이 있으면 오늘 $12.30/$50 ($5.00/h) 58.0M · 예상 $45 처럼 붙고, 예상 경고는 시각 다음으로 오래 남는다.
99
+ const u = view.data.usage;
100
+ const dot = { t: ' · ', fg: PALETTE.faint, bg: B };
101
+ const net = view.costMode === 'net';
102
+ // 짧은 달러 표기: 10 이상은 정수, 그 아래는 소수 둘째 자리.
103
+ const usd = (x) => (x >= 10 ? `$${Math.round(x).toLocaleString('en-US')}` : `$${x.toFixed(2)}`);
104
+ // 하루 예산은 비용 기준(c 키)과 상관없이 API 정가 환산으로 본다 (--json 과 같은 단계).
105
+ // API 기준이면 오늘 금액 뒤에 /$50 을 붙이고, 캐시 제외 기준이면 '예산 $12.30/$50' 칸을 따로 둔다.
106
+ // 예상이 80% 이상이면 '예상 $45', 100% 이상이면 경고 색과 '!'. 지출 속도와 헷갈리지 않게 · 로 나눈 칸에 둔다.
107
+ const bud = u ? projectBudget(u, view.budgetDay, { now: view.data.at ?? Date.now() }) : null;
108
+ const ofBudget = bud ? [{ t: `/${Number.isInteger(bud.day) ? `$${bud.day.toLocaleString('en-US')}` : `$${bud.day.toFixed(2)}`}`, fg: PALETTE.muted, bg: B }] : [];
109
+ // 캐시 제외 화면에서 예산 칸 없이 예상만 남으면 캐시 제외 금액으로 읽히지 않게 'API 예상' 이라고 적는다.
110
+ const projOf = (label) => (bud && bud.level !== 'ok' ? { t: `${label} ${usd(bud.projected)}${bud.unknown ? '?' : ''}${bud.level === 'warn' ? '!' : ''}`, fg: BUDGET_COLOR[bud.level], bg: B, bold: true } : null);
111
+ const projSeg = projOf('예상');
112
+ const projApi = net ? projOf('API 예상') : projSeg;
113
+ // budget: 'full' | 'proj' (아래 right 참고). 오늘 칸 뒤에 붙는 예산 칸.
114
+ const budgetPart = (budget) => {
115
+ if (!bud) return [];
116
+ if (budget === 'full' && net) return [dot, { t: '예산 ', fg: PALETTE.muted, bg: B }, { t: money(bud.spent, bud.unknown), fg: PALETTE.money, bg: B }, ...ofBudget, ...(projSeg ? [{ t: ' ', bg: B }, projSeg] : [])];
117
+ return projApi ? [dot, projApi] : [];
118
+ };
119
+ const period = (label, p, tok, rate = [], extra = []) => [
120
+ { t: `${label} `, fg: PALETTE.muted, bg: B },
121
+ { t: money(net ? p.costNoCache ?? null : p.cost, p.unknown), fg: PALETTE.money, bg: B, bold: true },
122
+ ...extra,
123
+ ...rate,
124
+ ...(tok ? [{ t: ` ${fmtTokens(p.tokens)}`, fg: PALETTE.info, bg: B }] : []),
125
+ ];
126
+ // 지출 속도: 최근 1시간 비용(5분 칸 근사)을 시간당으로. 0 이거나 아직 집계 중이면 숨기고, 폭이 모자라면 가장 먼저 뺀다.
127
+ // 아는 몫이 0 인데 가격표에 없는 모델이 섞였으면 금액 없이 (?/h) 만 보인다.
128
+ const hour = u?.lastHour && u.loading == null ? (net ? u.lastHour.costNoCache : u.lastHour.cost) : null;
129
+ const rateText = hour >= 0.005 ? `${usd(hour)}${u.lastHour.unknown ? '?' : ''}` : hour != null && u.lastHour.unknown ? '?' : null;
130
+ const rate = rateText ? [{ t: ` (${rateText}/h)`, fg: PALETTE.muted, bg: B }] : null;
131
+ // bar: 7일 금액 뒤의 날짜별 막대. 집계 중이거나 날짜별 값이 없으면 넣지 않는다.
132
+ const bar = u?.loading == null && u?.days7?.length ? [{ t: ' ', bg: B }, ...weekBar(u.days7, view.costMode).map((x) => ({ ...x, bg: B }))] : null;
133
+ const week = (tok, withBar = false) => (u.loading != null ? [{ t: `7일 집계 중 ${Math.floor(u.loading * 100)}%`, fg: PALETTE.info, bg: B }] : period('7일', u.week, tok, [], withBar && bar ? bar : []));
134
+ // 장부를 못 읽었으면 화면에 있는 세션(모든 도구)의 누적 토큰이라도 보인다.
135
+ const fallback = [{ t: `Σ ${fmtTokens(sessions.reduce((a, s) => a + (s.tokens?.total ?? 0), 0))} tok`, fg: PALETTE.muted, bg: B }];
136
+ const clockSeg = { t: clock(view.data.at), fg: PALETTE.muted, bg: B };
137
+ // 비용은 API 정가 환산이다 (구독 요금제의 실제 청구액이 아님). 폭이 되면 그렇다고 적는다.
138
+ const api = { t: net ? '캐시 제외 ' : 'API 환산 ', fg: net ? PALETTE.brand : PALETTE.info, bg: B };
139
+ const apiShort = { t: net ? '캐시제외 ' : 'API ', fg: net ? PALETTE.brand : PALETTE.info, bg: B };
140
+ // 오른쪽 한 줄의 구성. 위에서부터 차례로 시도한다.
141
+ // api: 'long' | 'short' | null, tok: 토큰 수, week: 7일 칸, bar: 7일 막대, budget: 'full'(/$50 와 예상) | 'proj'(예상만) | 'only'(오늘 금액 없이 예상만).
142
+ // clock: false 면 시각을 뺀다 (캐시 제외 기준에서 오늘 비용을 지킬 때만 쓴다).
143
+ const right = ({ api: a = null, rate: withRate = false, tok = false, week: withWeek = false, bar: withBar = false, budget = 'full', clock = true }) => {
144
+ const label = a === 'long' ? [api] : a === 'short' ? [apiShort] : [];
145
+ const today = budget === 'only' ? [projApi] : [...period('오늘', u.today, tok, withRate ? rate : [], budget === 'full' && !net ? ofBudget : []), ...budgetPart(budget)];
146
+ return [...label, ...today, ...(withWeek ? [dot, ...week(tok, withBar)] : []), ...(clock ? [dot, clockSeg] : [])];
147
+ };
148
+ // 7일 막대는 지출 속도 다음으로 먼저 뺀다 (토큰 수보다 먼저).
149
+ const shapes = [
150
+ ...(rate && bar ? [{ api: 'long', rate: true, tok: true, week: true, bar: true }] : []),
151
+ ...(bar ? [{ api: 'long', tok: true, week: true, bar: true }] : []),
152
+ ...(rate && !bar ? [{ api: 'long', rate: true, tok: true, week: true }] : []),
153
+ { api: 'long', tok: true, week: true },
154
+ { tok: true, week: true },
155
+ { api: 'long', week: true },
156
+ { api: 'short', week: true },
157
+ { week: true },
158
+ { api: 'short' },
159
+ {},
160
+ // 예산이 있을 때만: /$50 을 빼고, 그다음 오늘 금액을 빼서 예상 경고를 시각 바로 앞까지 지킨다.
161
+ ...(bud ? [{ api: 'short', budget: 'proj' }, { budget: 'proj' }] : []),
162
+ // 캐시 제외 화면에서도 'API 예상' 은 기준이 드러나므로 캐시 제외 표시 없이 남길 수 있다.
163
+ ...(projSeg ? [{ api: 'short', budget: 'only' }, { budget: 'only' }] : []),
164
+ ];
165
+ const rights = u ? [...shapes.filter((o) => !net || o.api || o.budget === 'only').map(right), [clockSeg]] : [[...fallback, dot, clockSeg], [clockSeg]];
166
+ // 사용량 숫자가 상태 이름보다 중요하므로, 이름을 먼저 줄이고 그래도 모자라면 오른쪽을 줄인다.
167
+ // 시각만 남기 전에는 live 수와 ○ 대기를 먼저 빼서 오늘 비용을 지킨다.
168
+ const fit = (r, narrow, drop) => {
169
+ const left = leftOf(narrow, drop);
170
+ const right = [...r, { t: ' ', bg: B }];
171
+ return segsWidth(left) + segsWidth(right) + 2 <= W ? line([...left, { t: ' '.repeat(W - segsWidth(left) - segsWidth(right)), bg: B }, ...right], W, B) : null;
172
+ };
173
+ const kept = rights.slice(0, -1);
174
+ // 캐시 제외 기준은 '캐시제외' 표시 때문에 9칸이 더 든다. 시각만 남기기 전에 시각을 빼고 '캐시제외 오늘 $5.20' 을 남긴다.
175
+ const bare = u && net ? [right({ api: 'short', clock: false })] : [];
176
+ const tries = [
177
+ ...kept.flatMap((r) => [[r, false, 0], [r, true, 0]]),
178
+ ...[1, 2].flatMap((drop) => kept.map((r) => [r, true, drop])),
179
+ ...[0, 1, 2].flatMap((drop) => bare.map((r) => [r, true, drop])),
180
+ ...[0, 1, 2].map((drop) => [rights.at(-1), true, drop]),
181
+ ];
182
+ for (const [r, narrow, drop] of tries) {
183
+ const out = fit(r, narrow, drop);
184
+ if (out) return out;
185
+ }
186
+ return line(leftOf(true, 2), W, B);
187
+ }
188
+
189
+ // 폭이 모자랄 때 도구 이름 대신 쓰는 짧은 이름.
190
+ export const TOOL_SHORT = { claude: 'cla', codex: 'cdx', gemini: 'gem' };
191
+
192
+ // 도구 하나의 조각. level 0 은 다 보이고, 2 부터 토큰 수·미설치 글을 빼고, 3 은 이름을 줄인다.
193
+ function toolPart(p, level, showEnded) {
194
+ const name = level >= 3 ? TOOL_SHORT[p.tool] : p.tool;
195
+ const extra = level < 2;
196
+ // 세션이 없는 도구는 이름만 흐리게 둔다.
197
+ if (!p.live) {
198
+ // 종료 세션을 함께 볼 때는 그 도구의 누적 토큰을 흐리게 남긴다. 기록 폴더가 없으면 미설치로 보인다.
199
+ const more = !extra ? [] : p.missing ? [{ t: ' 미설치', fg: PALETTE.info, italic: true }] : showEnded && p.count ? [{ t: ` · ${fmtTokens(p.tok)}`, fg: PALETTE.info }] : [];
200
+ return [{ t: ` ${name}`, fg: PALETTE.info }, ...more];
201
+ }
202
+ const tc = PALETTE.tools[p.tool];
203
+ return [{ t: glyphs().tool, fg: tc }, { t: name, fg: tc, bold: true }, { t: ` ${p.live}`, fg: PALETTE.text }, ...(extra ? [{ t: ` · ${fmtTokens(p.tok)}`, fg: PALETTE.muted }] : [])];
204
+ }
205
+
206
+ // 이 기간 동안 세션이 없는 도구는 도구 줄에서 숨긴다 (설정 파일 hideIdleTools 로 끈다).
207
+ export const IDLE_TOOL_MS = 7 * 86_400_000;
208
+
209
+ // 도구의 마지막 사용 시각: 화면의 세션과 기록 폴더(--doctor 점검)의 가장 새 기록. Gemini 탭에는 agy 기록도 들어간다.
210
+ // 실행 중인 프로세스가 있으면 지금 쓰는 것으로 본다. 그 도구의 점검 결과가 아직 없으면 null (모름).
211
+ export function toolLastUsed(data, tool, now) {
212
+ const checks = data.tools?.tools;
213
+ if (!checks) return null;
214
+ const found = (tool === 'gemini' ? [checks.gemini, checks.agy] : [checks[tool]]).filter(Boolean);
215
+ if (!found.length) return null;
216
+ if (found.some((t) => t.running > 0)) return now;
217
+ const times = [...data.sessions.filter((s) => s.tool === tool).map((s) => s.updatedAt), ...found.map((t) => t.lastAt)].filter((t) => Number.isFinite(t));
218
+ return times.length ? Math.max(...times) : 0;
219
+ }
220
+
221
+ // 도구 줄: 도구마다 실행 중 세션 수와 누적 토큰. 실행 중 세션도 없고 7일 동안 쓰지 않은 도구는 숨기고 끝에 '숨김 codex gemini'
222
+ // (자리가 모자라면 '숨김 2') 를 적는다. 기록 폴더가 없는 도구(미설치)도 7일 동안 쓰지 않은 것이라 함께 숨긴다.
223
+ // 숨기면 줄에 남는 도구가 하나도 없을 때는 숨기지 않고 예전처럼 흐리게 (미설치 포함) 다 보인다.
224
+ // 폭이 모자라면 간격 → 토큰 수 → 이름 순으로 줄이고, 그래도 넘치면 세션이 없는 도구부터 빼고 +N 으로 뺀 수를 적는다.
225
+ // 아주 좁으면 실행 중인 도구가 숨김 표시보다 먼저 남고, 숨김 표시는 중간에서 자르지 않고 뺀다.
226
+ export function toolStrip(view, W) {
227
+ const now = view.data.at ?? Date.now();
228
+ const all = ['claude', 'codex', 'gemini'].map((tool) => {
229
+ const list = view.data.sessions.filter((s) => s.tool === tool);
230
+ const live = list.filter((s) => s.live).length;
231
+ const last = toolLastUsed(view.data, tool, now);
232
+ return {
233
+ tool,
234
+ count: list.length,
235
+ live,
236
+ tok: list.reduce((a, s) => a + (s.tokens?.total ?? 0), 0),
237
+ missing: view.data.tools?.tools?.[tool]?.state === 'missing',
238
+ // 실행 중 세션이 있는 도구는 늘 보인다. 마지막 사용을 모르면(점검 전) 숨기지 않는다.
239
+ idle: view.hideIdleTools !== false && !live && last != null && now - last >= IDLE_TOOL_MS,
240
+ };
241
+ });
242
+ const visible = all.filter((p) => !p.idle);
243
+ const parts = visible.length ? visible : all;
244
+ const hidden = all.filter((p) => !parts.includes(p));
245
+ // 숨김 표시: 도구 이름을 적고, 자리가 모자라면 개수만 적는다.
246
+ const notes = hidden.length ? [[{ t: `숨김 ${hidden.map((p) => p.tool).join(' ')}`, fg: PALETTE.info }], [{ t: `숨김 ${hidden.length}`, fg: PALETTE.info }]] : [[]];
247
+ const plus = (n) => (n ? [{ t: `+${n}`, fg: PALETTE.info }] : []);
248
+ const build = (shown, level, tail = []) => {
249
+ const gap = { t: level ? ' ' : ' ' };
250
+ const end = tail.flatMap((x, i) => [...(i ? [{ t: ' ' }] : []), x]);
251
+ return [{ t: ' ' }, ...shown.flatMap((p, i) => [...(i ? [gap] : []), ...toolPart(p, level, view.showEnded)]), ...(end.length ? [...(shown.length ? [gap] : []), ...end] : [])];
252
+ };
253
+ const fits = (x) => segsWidth(x) <= W;
254
+ let segs = null;
255
+ for (const level of [0, 1, 2, 3]) {
256
+ for (const note of notes) {
257
+ const s = build(parts, level, note);
258
+ if (fits(s)) {
259
+ segs = s;
260
+ break;
261
+ }
262
+ }
263
+ if (segs) break;
264
+ }
265
+ if (!segs) {
266
+ // 실행 중인 도구를 먼저 남기고, 들어가는 만큼만 원래 순서대로 보인다.
267
+ // 앞 차례 도구가 안 들어가면 거기서 멈춘다 (실행 중인 도구 대신 쉬는 도구가 남지 않게).
268
+ const order = [...parts.filter((p) => p.live), ...parts.filter((p) => !p.live)];
269
+ const greedy = (note) => {
270
+ let kept = [];
271
+ for (const p of order) {
272
+ const next = parts.filter((x) => x === p || kept.includes(x));
273
+ if (!fits(build(next, 3, [...plus(parts.length - next.length), ...note]))) break;
274
+ kept = next;
275
+ }
276
+ return { kept, segs: build(kept, 3, [...plus(parts.length - kept.length), ...note]) };
277
+ };
278
+ // 숨김 표시(개수)를 넣었을 때 실행 중인 도구가 덜 남거나 줄에 안 들어가면 숨김 표시를 뺀다.
279
+ const liveOf = (r) => r.kept.filter((p) => p.live).length;
280
+ const bare = greedy([]);
281
+ const noted = hidden.length ? greedy(notes.at(-1)) : bare;
282
+ segs = fits(noted.segs) && liveOf(noted) >= liveOf(bare) ? noted.segs : bare.segs;
283
+ }
284
+ // 종료 세션 표시는 다 들어갈 때만 붙인다 (잘린 조각이 남지 않게).
285
+ const ended = [{ t: ' ' }, { t: '종료된 세션 포함 (24시간)', fg: PALETTE.info, italic: true }];
286
+ if (view.showEnded && segsWidth(segs) + segsWidth(ended) <= W) segs.push(...ended);
287
+ return line(segs, W);
288
+ }
289
+
290
+ export function tableHeader(c, W) {
291
+ const h = (t, n, align) => ({ t: pad(t, n, align) + ' ', fg: PALETTE.info });
292
+ const segs = [{ t: ' '.repeat(c.mark + c.glyph) }, h('TOOL', c.tool)];
293
+ if (c.showModel) segs.push(h('MODEL', c.model));
294
+ segs.push(h('SESSION', c.name));
295
+ if (c.showProject) segs.push(h('PROJECT', c.project));
296
+ segs.push(h('STATUS', c.status), h('TOKENS', c.tokens, 'right'));
297
+ if (c.showCost) segs.push(h('COST', c.cost, 'right'));
298
+ segs.push(h(c.cells ? 'CONTEXT' : 'CTX', c.ctx, c.cells ? 'left' : 'right'), h('IDLE', c.age, 'right'));
299
+ return line(segs, W);
300
+ }
301
+
302
+ // 표에서는 세션 이름과 상태가 먼저 보이도록 나머지 열은 흐리게 둔다.
303
+ // hot: 화면에서 비용이 가장 큰 세션들. 그 비용만 돈 색으로 칠한다.
304
+ // pinned: p 로 고정한 세션. SESSION 칸 앞에 고정 기호(glyphs().pin)를 붙여 색이 없어도 보이게 한다.
305
+ export function sessionRow(s, c, W, selected, frame, hot, costMode = 'api', pinned = false) {
306
+ const waiting = s.status === 'waiting';
307
+ const bg = selected ? PALETTE.rowFocus : waiting ? PALETTE.rowWaiting : undefined;
308
+ // IDLE: 마지막 활동 후 지난 시간. 지금 일하고 있는 세션은 'now'.
309
+ const working = s.status === 'busy' || s.status === 'shell';
310
+ const idle = working ? 'now' : fmtAgo(NOW - (s.updatedAt ?? 0));
311
+ const ended = s.status === 'ended';
312
+ const tc = PALETTE.tools[s.tool];
313
+ const g = glyph(s, frame);
314
+ // 다음 지시를 기다리는 세션은 상태 글자만 흐리게 한다.
315
+ const pc = s.phase?.kind === 'idle' ? PALETTE.info : PHASE_COLOR[s.phase?.kind] ?? PALETTE.muted;
316
+ // 아직 토큰을 하나도 쓰지 않은 세션은 숫자 칸을 비운다. 기다리는 중이면 '아직 지시 없음'.
317
+ const blank = s.lazy || !(s.tokens?.total > 0);
318
+ const phaseText = blank && s.tool !== 'gemini' && (s.status === 'idle' || s.status === 'live') ? '아직 지시 없음' : statusText(s.phase);
319
+ const segs = [
320
+ { t: selected ? glyphs().select : ' ', fg: PALETTE.brand, bold: true },
321
+ { ...g, t: g.t + ' ' },
322
+ { t: pad(s.tool, c.tool) + ' ', fg: ended ? PALETTE.ended : tc, bold: !ended },
323
+ ];
324
+ if (c.showModel) segs.push({ t: pad(shortModel(s.model), c.model) + ' ', fg: ended ? PALETTE.ended : PALETTE.muted });
325
+ // 좁아도 고정 표시는 남기고 이름 칸을 먼저 줄인다 (이름 칸이 2칸 이하면 기호만).
326
+ const mark = glyphs().pin;
327
+ const pin = pinned ? [{ t: c.name >= width(mark) + 2 ? `${mark} ` : mark, fg: ended ? PALETTE.ended : PALETTE.brand, bold: true }] : [];
328
+ segs.push(...pin, { t: pad(s.name ?? '', c.name - segsWidth(pin)) + ' ', fg: ended ? PALETTE.ended : PALETTE.text, bold: selected });
329
+ if (c.showProject) segs.push({ t: pad(s.project ?? '', c.project) + ' ', fg: ended ? PALETTE.ended : PALETTE.muted });
330
+ segs.push(
331
+ { t: pad(phaseText, c.status) + ' ', fg: pc, bold: s.status === 'waiting' },
332
+ { t: pad(blank ? '' : fmtTokens(s.tokens.total), c.tokens, 'right') + ' ', fg: ended ? PALETTE.ended : PALETTE.muted },
333
+ );
334
+ if (c.showCost) segs.push({ t: pad(blank ? '' : (hot?.has(s.id) ? glyphs().hot : '') + money(costOf(s, costMode), s.costUnknown), c.cost, 'right') + ' ', fg: s.cost == null || ended ? PALETTE.info : hot?.has(s.id) ? PALETTE.money : PALETTE.muted, bold: Boolean(hot?.has(s.id)) });
335
+ segs.push(...gauge(s.context?.used ?? 0, s.context?.window ?? 0, c.cells), { t: ' ' }, { t: pad(idle, c.age, 'right'), fg: waiting ? PALETTE.waiting : PALETTE.info });
336
+ // 종료 행은 밝기 말고도 기울임꼴로 구분한다 (색이 없으면 · 글리프와 '종료' 글자로 구분된다).
337
+ return line(ended ? segs.map((x) => ({ ...x, italic: true })) : segs, W, bg);
338
+ }
339
+
340
+ // 표의 STATUS 칸 글. 응답 대기는 ◆ 와 행 색으로 이미 보이므로 무엇을 허락해 달라는지만 적고, 요청 내용이 없으면 '응답 대기' 를 남긴다.
341
+ // 상세 창은 phase.text 전체를 보인다.
342
+ export function statusText(phase) {
343
+ const text = phase?.text ?? '';
344
+ return phase?.kind === 'waiting' ? text.replace(/^응답 대기 · /, '') : text;
345
+ }
346
+
347
+ // 메인 세션 아래에 붙는 팀원(서브에이전트) 행. 끝나면 잠깐 "완료"로 보였다가 사라진다.
348
+ // parentModel: 부모와 같은 모델이면 모델 칸을 비운다. costMode: 'api' | 'net'.
349
+ export function memberRow(m, last, c, W, frame, parentModel, costMode = 'api') {
350
+ const done = m.status === 'done';
351
+ const g = done ? { t: '✓', fg: PALETTE.info } : { t: SPIN[(frame + 3) % SPIN.length], fg: PALETTE.team, bold: true };
352
+ const branch = last ? glyphs().memberLast : glyphs().member;
353
+ const segs = [
354
+ { t: ' '.repeat(c.mark + c.glyph) },
355
+ { t: pad(' ' + branch, c.tool) + ' ', fg: PALETTE.line },
356
+ ];
357
+ // 같은 타입의 팀원이 여럿이어도 구분되도록, 하는 일(description)을 앞에 두고 타입은 흐리게 붙인다.
358
+ // 팀원 행은 PROJECT 칸이 비므로 그 자리까지 하는 일 글을 이어 쓴다. general-purpose 타입은 정보가 없어 붙이지 않는다.
359
+ const nameW = c.name - 2 + (c.showProject ? c.project + 1 : 0);
360
+ const suffix = m.task && m.name !== 'general-purpose' ? ` · ${m.name}` : '';
361
+ const label = truncate(m.task || m.name, nameW);
362
+ const showSuffix = suffix && nameW - width(label) >= width(suffix);
363
+ const started = m.started !== false;
364
+ const sameModel = parentModel && shortModel(m.model) === shortModel(parentModel);
365
+ if (c.showModel) segs.push({ t: pad(started && !sameModel ? shortModel(m.model) : '', c.model) + ' ', fg: PALETTE.info });
366
+ segs.push(
367
+ { ...g, t: g.t + ' ' },
368
+ { t: label, fg: done ? PALETTE.info : PALETTE.team },
369
+ { t: pad(showSuffix ? suffix : '', nameW - width(label)) + ' ', fg: PALETTE.info },
370
+ );
371
+ segs.push(
372
+ { t: pad(statusText(m.phase), c.status) + ' ', fg: done ? PALETTE.info : PHASE_COLOR[m.phase?.kind] ?? PALETTE.muted },
373
+ { t: pad(started ? fmtTokens(m.tokens ?? 0) : '', c.tokens, 'right') + ' ', fg: done ? PALETTE.info : PALETTE.muted },
374
+ );
375
+ if (c.showCost) segs.push({ t: pad(started ? money(costOf(m, costMode), m.costUnknown) : '', c.cost, 'right') + ' ', fg: done ? PALETTE.info : PALETTE.muted });
376
+ // 끝난 팀원은 종료 행처럼 기울임꼴로 둔다.
377
+ return line(done ? segs.map((x) => ({ ...x, italic: true })) : segs, W);
378
+ }
379
+
380
+ // 세션이 없을 때: aitop --doctor 와 같은 도구별 점검 줄과 종료 세션 보기 안내.
381
+ export const MARK_COLOR = { ok: PALETTE.busy, missing: PALETTE.alert, limited: PALETTE.muted };
382
+
383
+ export function emptyLines(view, W) {
384
+ const out = [line([{ t: view.showEnded ? ' 지난 24시간 안에 AI 세션이 없습니다.' : ' 실행 중인 AI 세션이 없습니다.', fg: PALETTE.muted, italic: true }], W), line([], W)];
385
+ const tools = Object.values(view.data.tools?.tools ?? {});
386
+ const pathW = Math.max(0, ...tools.map((t) => width(t.path)));
387
+ for (const t of tools) {
388
+ const d = describe(t, NOW);
389
+ // 일부 지원(○)은 폭이 모호한 글자라 기호 세트를 따른다.
390
+ const mark = t.state === 'limited' ? glyphs().idle : d.mark;
391
+ out.push(line([{ t: ` ${mark} `, fg: MARK_COLOR[t.state], bold: true }, { t: pad(t.tool, 7), fg: PALETTE.tools[t.tool], bold: true }, { t: pad(d.state, 10), fg: PALETTE.text }, { t: pad(d.path, pathW + 2), fg: PALETTE.muted }, { t: d.detail, fg: PALETTE.info }], W));
392
+ if (d.hint) out.push(line([{ t: ` → ${d.hint}`, fg: PALETTE.info }], W));
393
+ }
394
+ if (!view.showEnded) out.push(line([], W), line([{ t: ' 지난 24시간 세션 보기: ', fg: PALETTE.info }, { t: 'e', fg: PALETTE.text, bold: true }, { t: ' 또는 ', fg: PALETTE.info }, { t: 'aitop --all', fg: PALETTE.text }], W));
395
+ return out;
396
+ }
397
+
398
+ // 화면에 보이는 세션 중 비용이 가장 큰 두 개 (0 보다 클 때만).
399
+ export function topCost(sessions, costMode = 'api') {
400
+ const v = (s) => costOf(s, costMode) ?? 0;
401
+ const top = sessions.filter((s) => v(s) > 0 && s.status !== 'ended').sort((a, b) => v(b) - v(a)).slice(0, 2);
402
+ return new Set(top.map((s) => s.id));
403
+ }
404
+
405
+ // 표를 둥근 상자로 감싼다. 안쪽 줄은 이미 폭 W-4 로 그려져 있다.
406
+ export function tableBox(inner, W, count) {
407
+ const e = glyphs().edge.normal;
408
+ const ln = (t) => style({ fg: PALETTE.line }) + t + RESET;
409
+ const title = ` 세션 ${count} `;
410
+ const top = line([{ t: e.tl + e.h, fg: PALETTE.line }, { t: title, fg: PALETTE.muted }, { t: e.h.repeat(Math.max(0, W - 3 - width(title))) + e.tr, fg: PALETTE.line }], W);
411
+ return [top, ...inner.map((l) => ln(`${e.v} `) + l + ln(` ${e.v}`)), line([{ t: e.bl + e.h.repeat(W - 2) + e.br, fg: PALETTE.line }], W)];
412
+ }
413
+
414
+ // 이 높이 이상이면 세션 표를 상자로 감싼다 (상자 테두리에 두 줄이 든다).
415
+ export const TABLE_BOX_H = 30;
416
+
417
+ // 묶기(g) 화면의 묶음 머리 한 줄: 이름 · 세션 수 · 묶음 비용. 세션 행보다 한 단계 흐리게 둔다.
418
+ export function groupRow(g, W) {
419
+ return line([
420
+ { t: ' ▾ ', fg: PALETTE.info },
421
+ { t: g.name, fg: PALETTE.muted, bold: true },
422
+ { t: ` ${g.count}개`, fg: PALETTE.info },
423
+ ...(g.cost > 0 || g.unknown ? [{ t: ' · ', fg: PALETTE.info }, { t: money(g.cost, g.unknown), fg: PALETTE.muted }] : []),
424
+ ], W);
425
+ }