@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.
- package/LICENSE +21 -0
- package/README.md +40 -2
- package/bin/aitop.mjs +360 -0
- package/package.json +36 -4
- package/src/attention.mjs +70 -0
- package/src/brand.mjs +30 -0
- package/src/budget.mjs +29 -0
- package/src/cache.mjs +78 -0
- package/src/collect.mjs +51 -0
- package/src/config.mjs +169 -0
- package/src/doctor.mjs +218 -0
- package/src/frames.mjs +24 -0
- package/src/help.mjs +111 -0
- package/src/json.mjs +57 -0
- package/src/keys.mjs +154 -0
- package/src/log.mjs +130 -0
- package/src/notify.mjs +251 -0
- package/src/order.mjs +69 -0
- package/src/pricing.mjs +130 -0
- package/src/procs.mjs +205 -0
- package/src/sources/agy-ledger.mjs +153 -0
- package/src/sources/antigravity.mjs +763 -0
- package/src/sources/claude-ledger.mjs +181 -0
- package/src/sources/claude-tracker.mjs +183 -0
- package/src/sources/claude.mjs +352 -0
- package/src/sources/codex-ledger.mjs +141 -0
- package/src/sources/codex.mjs +374 -0
- package/src/sources/common.mjs +228 -0
- package/src/sources/files.mjs +116 -0
- package/src/sources/gemini.mjs +26 -0
- package/src/sources/patch.mjs +78 -0
- package/src/sqlite.mjs +50 -0
- package/src/tail.mjs +109 -0
- package/src/text.mjs +256 -0
- package/src/turns.mjs +79 -0
- package/src/ui/chrome.mjs +163 -0
- package/src/ui/detail.mjs +360 -0
- package/src/ui/draw.mjs +152 -0
- package/src/ui/glyphs.mjs +58 -0
- package/src/ui/table.mjs +425 -0
- package/src/ui/theme.mjs +137 -0
- package/src/ui.mjs +99 -0
package/src/turns.mjs
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// 변경 파일을 지시(사용자 프롬프트) 단위로 나눈다. 편집이 어느 지시 뒤에 있는지로 정한다.
|
|
2
|
+
// 서브에이전트 편집은 서브에이전트가 시작한 시각(turnAt)으로 나누므로, 그 서브에이전트를 부른 지시에 들어간다.
|
|
3
|
+
|
|
4
|
+
// lock·빌드 결과처럼 사람이 볼 일이 적은 파일은 목록 끝에 흐리게 둔다.
|
|
5
|
+
const NOISE = /(^|\/)(package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb|Cargo\.lock|poetry\.lock|Pipfile\.lock|uv\.lock|go\.sum|composer\.lock|Gemfile\.lock)$|\.min\.(js|css)$|(^|\/)(dist|build|out|coverage)\//;
|
|
6
|
+
|
|
7
|
+
export const isNoiseFile = (path) => NOISE.test(path);
|
|
8
|
+
|
|
9
|
+
// 편집 기록이 없는 파일(예전 캐시 등)은 마지막 편집 한 번에 세션 합계가 몰린 것으로 본다.
|
|
10
|
+
const logOf = (f) => f.log ?? [{ at: f.at, add: f.add, del: f.del, created: f.created, count: f.edits }];
|
|
11
|
+
const turnAt = (x) => x.turnAt ?? x.at;
|
|
12
|
+
|
|
13
|
+
// 화면은 초당 여러 번 그리므로, 파일·지시 목록이 그대로면 계산을 다시 하지 않는다.
|
|
14
|
+
const memo = new WeakMap();
|
|
15
|
+
function cached(s) {
|
|
16
|
+
let c = memo.get(s);
|
|
17
|
+
if (!c || c.files$ !== s.files || c.turns$ !== s.turns || c.turnsLen !== s.turns?.length) {
|
|
18
|
+
memo.set(s, (c = { files$: s.files, turns$: s.turns, turnsLen: s.turns?.length, tabs: null, files: new Map() }));
|
|
19
|
+
}
|
|
20
|
+
return c;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 탭: [이번 지시] → 편집이 있었던 이전 지시들(최신부터) → [전체].
|
|
24
|
+
export function turnTabs(s) {
|
|
25
|
+
const c = cached(s);
|
|
26
|
+
if (c.tabs) return c.tabs;
|
|
27
|
+
const turns = s.turns ?? [];
|
|
28
|
+
const times = (s.files ?? []).flatMap((f) => logOf(f).map(turnAt));
|
|
29
|
+
const last = turns.at(-1);
|
|
30
|
+
const tabs = [{ key: 'now', label: '이번 지시', from: last?.at ?? 0, to: Infinity, turn: last ?? null }];
|
|
31
|
+
for (let i = turns.length - 2; i >= 0; i--) {
|
|
32
|
+
const from = turns[i].at;
|
|
33
|
+
const to = turns[i + 1].at;
|
|
34
|
+
if (times.some((t) => t >= from && t < to)) tabs.push({ key: turns[i].n, label: `T${turns[i].n}`, from, to, turn: turns[i] });
|
|
35
|
+
}
|
|
36
|
+
tabs.push({ key: 'all', label: '전체', from: 0, to: Infinity, turn: null });
|
|
37
|
+
return (c.tabs = tabs);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function currentTab(s, key) {
|
|
41
|
+
const tabs = turnTabs(s);
|
|
42
|
+
return tabs.find((t) => t.key === key) ?? tabs[0];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 고른 탭에 들어가는 파일. 편집 횟수·줄 수·새 파일 여부·마지막 시각·변경 조각을 그 탭 기준으로 다시 센다.
|
|
46
|
+
export function filesForTab(s, tab) {
|
|
47
|
+
const c = cached(s);
|
|
48
|
+
if (c.files.has(tab.key)) return c.files.get(tab.key);
|
|
49
|
+
const inTab = (x) => turnAt(x) >= tab.from && turnAt(x) < tab.to;
|
|
50
|
+
const out = [];
|
|
51
|
+
for (const f of s.files ?? []) {
|
|
52
|
+
if (tab.key === 'all') {
|
|
53
|
+
out.push({ ...f, noise: isNoiseFile(f.path) });
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const log = logOf(f).filter(inTab);
|
|
57
|
+
if (!log.length) continue;
|
|
58
|
+
const hunks = f.hunks.filter(inTab);
|
|
59
|
+
const kept = new Set(hunks.map((h) => h.at));
|
|
60
|
+
out.push({
|
|
61
|
+
...f,
|
|
62
|
+
edits: log.reduce((a, e) => a + (e.count ?? 1), 0),
|
|
63
|
+
add: log.reduce((a, e) => a + e.add, 0),
|
|
64
|
+
del: log.reduce((a, e) => a + e.del, 0),
|
|
65
|
+
created: log.some((e) => e.created),
|
|
66
|
+
at: Math.max(...log.map((e) => e.at)),
|
|
67
|
+
hunks,
|
|
68
|
+
dropped: f.hidden ? 0 : log.filter((e) => !kept.has(e.at)).length,
|
|
69
|
+
noise: isNoiseFile(f.path),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
out.sort((a, b) => Number(a.noise) - Number(b.noise) || b.at - a.at);
|
|
73
|
+
c.files.set(tab.key, out);
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function visibleFiles(s, key) {
|
|
78
|
+
return s ? filesForTab(s, currentTab(s, key)) : [];
|
|
79
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// 도움말 화면과 아래 키 안내 줄.
|
|
2
|
+
import { pad, width } from '../text.mjs';
|
|
3
|
+
import { NO_COLOR, PALETTE } from './theme.mjs';
|
|
4
|
+
import { box, line, segsWidth } from './draw.mjs';
|
|
5
|
+
import { glyphs, legendMark } from './glyphs.mjs';
|
|
6
|
+
import { GROUP_LABEL, SORT_LABEL } from '../order.mjs';
|
|
7
|
+
import { COMMON, OPTIONS, VIEWS, helpContext, keysLine, settingsLine } from '../help.mjs';
|
|
8
|
+
|
|
9
|
+
// 상세 패널을 기본으로 여는 최소 화면 높이. 이보다 낮으면 세션 표에 줄을 준다.
|
|
10
|
+
export const DETAIL_MIN_H = 24;
|
|
11
|
+
|
|
12
|
+
// view.showDetail: 'auto' 는 화면 높이로 정하고, true/false 는 사용자가 d·f 로 고른 값이라 높이와 상관없이 따른다.
|
|
13
|
+
export function detailOpen(view, H) {
|
|
14
|
+
return view.showDetail === 'auto' ? H >= DETAIL_MIN_H : Boolean(view.showDetail);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// 상태 기호와 열 설명. 키는 help.mjs 의 VIEWS 에서 화면별로 고른다.
|
|
18
|
+
export const LEGEND = [
|
|
19
|
+
['상태 기호', null],
|
|
20
|
+
['⠋', '작업 중 (STATUS 칸에 생각·답변, 도구 실행, 팀원 작업. 색으로도 나뉜다)'],
|
|
21
|
+
['◆', '응답 대기 (권한·질문). 행 배경이 강조된다'],
|
|
22
|
+
['●', '완료 · 확인 전 (작업이 끝났는데 아직 선택해 보지 않음)'],
|
|
23
|
+
['○', '다음 지시 대기'],
|
|
24
|
+
['·', '종료'],
|
|
25
|
+
['★', '고정한 세션 (p). SESSION 칸 앞에 붙는다'],
|
|
26
|
+
['', ''],
|
|
27
|
+
['열', null],
|
|
28
|
+
['STATUS', '지금 단계. 큰 기록을 처음 읽는 동안은 기록 읽는 중 N%'],
|
|
29
|
+
['TOKENS', '세션 누적 토큰 (서브에이전트 포함, 캐시 읽기 포함)'],
|
|
30
|
+
['COST', 'API 정가로 환산한 비용 (Codex 는 OpenAI 정가 추정). ? 는 가격표에 없는 모델 포함'],
|
|
31
|
+
['CONTEXT', '마지막 요청의 컨텍스트 사용률 (모델별 창 크기 기준)'],
|
|
32
|
+
['IDLE', '마지막 활동 후 지난 시간. 작업 중이면 now'],
|
|
33
|
+
['숨김 N', '도구 줄 끝: 7일 동안 안 쓴 도구를 숨김. 다시 보려면 설정 파일에 "hideIdleTools": false'],
|
|
34
|
+
['+N', '도구 줄 끝: 화면이 좁아 접은 도구 수 (넓히면 보인다)'],
|
|
35
|
+
['오늘 / 7일', '헤더 오른쪽. 날짜별 합계, API 정가 환산이며 구독 요금제의 청구액이 아니다'],
|
|
36
|
+
['변경 파일', 'Edit/Write(Codex 는 apply_patch) 로 고친 파일과 편집 횟수. git diff 가 아니다'],
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
// 도움말 줄 목록: 지금 화면의 키 → (표에 검색어가 걸려 있으면) 그 키 → (상세 패널이 열려 있으면) 상세 패널 키 → 어디서나 쓰는 키 → 다른 화면 한 줄씩 → 옵션 → 상태 기호·열.
|
|
40
|
+
// 넓으면(100칸 이상) 키는 왼쪽, 옵션·기호·열은 오른쪽 칸에 둔다.
|
|
41
|
+
export function helpSections(view = {}, open = false) {
|
|
42
|
+
const ctx = helpContext(view);
|
|
43
|
+
const here = ctx === 'table' ? ['table', ...(view.filter ? ['filtered'] : []), ...(open ? ['detail'] : [])] : [ctx];
|
|
44
|
+
const keys = [...here.map((n) => VIEWS[n]), COMMON].flatMap((v, i) => [...(i ? [['', '']] : []), [`${v.title}${i ? '' : ' (지금 화면)'}`, null], ...v.keys.map(([k, t]) => [k, t])]);
|
|
45
|
+
const others = Object.keys(VIEWS).filter((n) => !here.includes(n));
|
|
46
|
+
const rest = [['', ''], ['다른 화면', null], ...others.map((n) => [VIEWS[n].title, keysLine(n), 'dim'])];
|
|
47
|
+
const now = settingsLine(view.settings);
|
|
48
|
+
const options = [
|
|
49
|
+
['옵션', null],
|
|
50
|
+
...OPTIONS,
|
|
51
|
+
...(view.settings?.configPath ? [['설정 파일', view.settings.configPath, 'dim']] : []),
|
|
52
|
+
...(now ? [['지금', now]] : []),
|
|
53
|
+
];
|
|
54
|
+
return { keys: [...keys, ...rest], more: [...options, ['', ''], ...LEGEND.map(([k, t]) => [legendMark(k), t])] };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 키 칸 폭은 제목 줄 사이 묶음마다 그 묶음의 가장 긴 키에 맞춘다 (12~21칸).
|
|
58
|
+
function helpLines(list) {
|
|
59
|
+
const out = [];
|
|
60
|
+
let kw = 12;
|
|
61
|
+
list.forEach(([k, v, dim], i) => {
|
|
62
|
+
if (v == null) {
|
|
63
|
+
const next = list.slice(i + 1);
|
|
64
|
+
const block = next.slice(0, Math.max(0, next.findIndex((e) => e[1] == null)) || next.length);
|
|
65
|
+
kw = Math.min(21, Math.max(12, ...block.filter((e) => e[1]).map((e) => width(e[0]) + 1)));
|
|
66
|
+
out.push([{ t: k, fg: PALETTE.brand, bold: true }]);
|
|
67
|
+
} else out.push([{ t: pad(k, kw), fg: PALETTE.text, bold: !dim }, { t: v, fg: dim ? PALETTE.info : PALETTE.muted }]);
|
|
68
|
+
});
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 줄이 넘치면 scroll 만큼 내려 보이고, 아래에 남은 줄이 있으면 마지막 줄에 알린다.
|
|
73
|
+
function windowOf(lines, n, scroll) {
|
|
74
|
+
const more = (k) => [{ t: `… ${k}줄 더 · ↑↓ 로 스크롤`, fg: PALETTE.info, italic: true }];
|
|
75
|
+
const part = lines.slice(scroll, scroll + n);
|
|
76
|
+
const left = lines.length - scroll - n;
|
|
77
|
+
return left > 0 ? [...part.slice(0, n - 1), more(left + 1)] : part;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// view.helpScroll: ↑↓ 로 내린 줄 수. 넘치지 않으면 0. 내릴 수 있는 끝은 view.helpMax 에 남긴다 (keys.mjs 가 쓴다).
|
|
81
|
+
export function help(W, H, view = {}, open = false) {
|
|
82
|
+
const { keys, more } = helpSections(view, open);
|
|
83
|
+
const title = '도움말 · ? 또는 Esc 로 닫기';
|
|
84
|
+
const rows = H - 2;
|
|
85
|
+
const wide = W >= 100;
|
|
86
|
+
const cols = wide ? [helpLines(keys), helpLines(more)] : [helpLines([...keys, ['', ''], ...more])];
|
|
87
|
+
const max = Math.max(0, ...cols.map((c) => c.length - rows));
|
|
88
|
+
const scroll = Math.min(max, Math.max(0, view.helpScroll ?? 0));
|
|
89
|
+
view.helpMax = max;
|
|
90
|
+
// PgUp·PgDn 한 번에 넘길 줄 수: 보이는 줄에서 한 줄을 겹쳐 남긴다.
|
|
91
|
+
view.helpPage = Math.max(1, rows - 1);
|
|
92
|
+
view.helpScroll = scroll;
|
|
93
|
+
if (wide) return box(title, W, H, windowOf(cols[0], rows, scroll), windowOf(cols[1], rows, scroll), Math.min(64, Math.floor((W - 7) * 0.5)), PALETTE.brand);
|
|
94
|
+
return box(title, W, H, null, windowOf(cols[0], rows, scroll), 0, PALETTE.brand);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 키 칩. 색이 없으면 [f] 처럼 괄호로 보인다.
|
|
98
|
+
export const keyChip = (k) => (NO_COLOR ? { t: `[${k}]` } : { t: ` ${k} `, fg: PALETTE.chipText, bg: PALETTE.muted, bold: true });
|
|
99
|
+
|
|
100
|
+
export const keyLabel = (t) => ({ t: ` ${t} `, fg: PALETTE.muted });
|
|
101
|
+
|
|
102
|
+
// items: { k, t, keep } 또는 { segs, keep }. 폭이 모자라면 keep 이 아닌 항목을 오른쪽부터 뺀다.
|
|
103
|
+
export function hintBar(items, W, right = '') {
|
|
104
|
+
const segsOf = (list) => [{ t: ' ' }, ...list.flatMap((i) => i.segs ?? [keyChip(i.k), keyLabel(i.t)])];
|
|
105
|
+
const wOf = (list) => segsWidth(segsOf(list));
|
|
106
|
+
const list = [...items];
|
|
107
|
+
while (wOf(list) > W && list.some((i) => !i.keep)) list.splice(list.findLastIndex((i) => !i.keep), 1);
|
|
108
|
+
const segs = segsOf(list);
|
|
109
|
+
const used = wOf(list);
|
|
110
|
+
if (right && W - used >= width(right) + 1) segs.push({ t: ' '.repeat(W - used - width(right)) }, { t: right, fg: PALETTE.info });
|
|
111
|
+
return line(segs, W);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 정렬·묶기가 기본이 아닐 때만 지금 상태를 짧게 보인다 (예: s:비용 g:프로젝트). 표 순서를 읽는 데 필요하므로 d·e·c 안내보다 늦게 빠진다.
|
|
115
|
+
function orderHint(view) {
|
|
116
|
+
const parts = [
|
|
117
|
+
...(view.sort && view.sort !== 'default' ? [['s', SORT_LABEL[view.sort]]] : []),
|
|
118
|
+
...(view.group && view.group !== 'none' ? [['g', GROUP_LABEL[view.group]]] : []),
|
|
119
|
+
];
|
|
120
|
+
if (!parts.length) return null;
|
|
121
|
+
const segs = parts.flatMap(([k, t], i) => [{ t: i ? ' ' : '', fg: PALETTE.info }, { t: k, fg: PALETTE.muted, bold: true }, { t: `:${t}`, fg: PALETTE.info }]);
|
|
122
|
+
return { segs: [...segs, { t: ' ' }] };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function footer(view, W, matched, sel) {
|
|
126
|
+
const tail = [
|
|
127
|
+
{ k: '?', t: '도움', keep: true },
|
|
128
|
+
{ k: 'q', t: '나가기', keep: true },
|
|
129
|
+
];
|
|
130
|
+
// 도움말이 열려 있으면 ↑↓ 는 도움말을 스크롤하고 ? · Esc 는 닫는다.
|
|
131
|
+
if (view.help) return hintBar([{ k: '↑↓', t: '스크롤' }, { k: '?', t: '도움 닫기', keep: true }, { k: 'Esc', t: '닫기' }, { k: 'q', t: '나가기', keep: true }], W, '⊘ 읽기전용');
|
|
132
|
+
if (view.searching) {
|
|
133
|
+
const input = NO_COLOR ? [{ t: '[/]' }] : [{ t: ' / ', fg: PALETTE.chipText, bg: PALETTE.brand, bold: true }];
|
|
134
|
+
return hintBar([{ segs: [...input, { t: ` ${view.filter ?? ''}`, fg: PALETTE.text, bold: true }, { t: glyphs().cursor, fg: PALETTE.brand }, { t: ` ${matched}개 `, fg: PALETTE.muted }], keep: true }, { k: '⏎', t: '적용', keep: true }, { k: 'Esc', t: '지우기', keep: true }], W);
|
|
135
|
+
}
|
|
136
|
+
if (view.focus) {
|
|
137
|
+
const keys =
|
|
138
|
+
view.focus === 'diff'
|
|
139
|
+
? [{ k: '↑↓', t: '스크롤' }, { k: 'space', t: '페이지' }, { k: '[ ]', t: '이전·다음 파일' }, { k: 'Esc', t: '파일 목록으로', keep: true }]
|
|
140
|
+
: [{ k: '←→', t: '지시' }, { k: '↑↓', t: '파일 선택' }, { k: '⏎', t: 'diff 보기' }, { k: 'Esc', t: '세션으로', keep: true }];
|
|
141
|
+
return hintBar([...keys, ...tail], W, '⊘ 읽기전용');
|
|
142
|
+
}
|
|
143
|
+
// 세션이 하나도 없으면 쓸 수 있는 키만 남긴다.
|
|
144
|
+
if (!view.filter && !view.data?.sessions?.length) return hintBar([{ k: 'e', t: view.showEnded ? '종료 숨김' : '종료 포함' }, ...tail], W, '⊘ 읽기전용');
|
|
145
|
+
const order = orderHint(view);
|
|
146
|
+
const search = view.filter
|
|
147
|
+
? { segs: [{ t: ` /${view.filter} `, fg: PALETTE.chipText, bg: PALETTE.brand, bold: true }, { t: ` ${matched}개 `, fg: PALETTE.muted }, keyChip('Esc'), keyLabel('검색 지우기')], keep: true }
|
|
148
|
+
: { k: '/', t: '검색' };
|
|
149
|
+
const items = [
|
|
150
|
+
search,
|
|
151
|
+
{ k: '↑↓', t: '선택' },
|
|
152
|
+
{ k: 'n', t: '다음' },
|
|
153
|
+
...(order ? [order] : []),
|
|
154
|
+
...(sel?.files?.length ? [{ k: 'f', t: '파일' }] : []),
|
|
155
|
+
{ k: 'd', t: view.showDetail ? '상세 닫기' : '상세 열기' },
|
|
156
|
+
{ k: 'e', t: view.showEnded ? '종료 숨김' : '종료 포함' },
|
|
157
|
+
{ k: 'c', t: '비용 기준' },
|
|
158
|
+
// 고정은 폭이 모자라면 가장 먼저 빠진다.
|
|
159
|
+
...(sel ? [{ k: 'p', t: view.pins?.includes(sel.id) ? '고정 해제' : '고정' }] : []),
|
|
160
|
+
...tail,
|
|
161
|
+
];
|
|
162
|
+
return hintBar(items, W, '⊘ 읽기전용');
|
|
163
|
+
}
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
// 선택한 세션 상세 패널: 왼쪽 요약, 오른쪽 변경 파일·지시 탭·diff·최근 활동. 100칸 미만에서는 요약을 위쪽 두 줄로 접는다.
|
|
2
|
+
import { ellipsis, fmtAgo, fmtTokens, pad, truncateLeft, width } from '../text.mjs';
|
|
3
|
+
import { currentTab, filesForTab, turnTabs } from '../turns.mjs';
|
|
4
|
+
import { isAbsolute, relative } from 'node:path';
|
|
5
|
+
import { PALETTE, PHASE_COLOR } from './theme.mjs';
|
|
6
|
+
import { NOW, box, clock, fill, gauge, glyph, hhmm, levelBar, money, rule, segsWidth, tilde } from './draw.mjs';
|
|
7
|
+
import { activitySeries } from '../sources/common.mjs';
|
|
8
|
+
import { glyphs } from './glyphs.mjs';
|
|
9
|
+
|
|
10
|
+
// 세션 폴더 기준 상대 경로. 폴더 밖이면 ~ 로 줄인다.
|
|
11
|
+
export function relPath(cwd, p) {
|
|
12
|
+
if (cwd && isAbsolute(p)) {
|
|
13
|
+
const r = relative(cwd, p);
|
|
14
|
+
if (r && !r.startsWith('..') && !isAbsolute(r)) return r;
|
|
15
|
+
}
|
|
16
|
+
return tilde(p);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// 오른쪽 칸 여백: 구획 안 내용은 1칸 들여 쓰고, 배경 띠 안은 좌우 2칸, 항목 사이 3칸.
|
|
20
|
+
export const INDENT = { t: ' ' };
|
|
21
|
+
|
|
22
|
+
export const BAND_PAD = 2;
|
|
23
|
+
|
|
24
|
+
export const BAND_LEFT = { t: ' '.repeat(BAND_PAD) };
|
|
25
|
+
|
|
26
|
+
export const GAP = ' ';
|
|
27
|
+
|
|
28
|
+
// 오른쪽 칸 안쪽 줄 수가 이만큼이면 빈 줄 여백을 둔다 (목록·diff 공통).
|
|
29
|
+
export const ROOMY = 16;
|
|
30
|
+
|
|
31
|
+
// ▸ src/ui.mjs 편집 ×3 새 파일 14:02. 파일 이름은 진하게, 폴더는 흐리게.
|
|
32
|
+
// 줄 수는 편집마다 더한 값이라 git diff 처럼 읽히지 않게 목록에는 넣지 않는다. 좁으면 시각, 새 파일 표시 순으로 뺀다.
|
|
33
|
+
export function fileRow(f, cwd, w, selected) {
|
|
34
|
+
const tail = [{ t: `${GAP}편집 ×${f.edits}`, fg: PALETTE.muted }, ...(f.created ? [{ t: `${GAP}새 파일`, fg: PALETTE.brand }] : []), { t: `${GAP}${hhmm(f.at)}`, fg: PALETTE.info }];
|
|
35
|
+
const tailW = () => tail.reduce((a, x) => a + width(x.t), 0);
|
|
36
|
+
while (tail.length > 1 && w - 2 * BAND_PAD - tailW() < 12) tail.pop();
|
|
37
|
+
const pathW = Math.max(1, w - 2 * BAND_PAD - tailW());
|
|
38
|
+
const path = truncateLeft(relPath(cwd, f.path), pathW);
|
|
39
|
+
const cut = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1;
|
|
40
|
+
const quiet = f.hidden || f.noise;
|
|
41
|
+
return fill(
|
|
42
|
+
[
|
|
43
|
+
{ t: pad(selected ? '▸' : '', BAND_PAD), fg: PALETTE.brand, bold: true },
|
|
44
|
+
{ t: path.slice(0, cut), fg: PALETTE.muted },
|
|
45
|
+
{ t: pad(path.slice(cut), pathW - width(path.slice(0, cut))), fg: quiet ? PALETTE.muted : PALETTE.text, bold: !quiet },
|
|
46
|
+
...tail,
|
|
47
|
+
],
|
|
48
|
+
w,
|
|
49
|
+
selected ? PALETTE.rowFocus : PALETTE.bar,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 변경 조각을 고친 순서의 역순(최신 먼저)으로, 조각마다 시각 · 누가 · 위치 머리를 달아 보인다.
|
|
54
|
+
// 같은 파일을 여러 번 고치면 줄 번호 기준이 매번 달라져 하나의 diff 로 합칠 수 없다.
|
|
55
|
+
export function diffBody(f, w) {
|
|
56
|
+
if (f.hidden) return [[INDENT, { t: '비밀값이 있을 수 있는 파일이라 변경 내용을 숨겼습니다', fg: PALETTE.info, italic: true }]];
|
|
57
|
+
if (!f.hunks.length) return [[INDENT, { t: f.deleted ? '이 파일을 삭제했습니다' : '보관된 변경 내용이 없습니다', fg: PALETTE.info, italic: true }]];
|
|
58
|
+
const nums = w >= 50;
|
|
59
|
+
const textW = w - 1 - (nums ? 5 : 0);
|
|
60
|
+
const out = [];
|
|
61
|
+
for (const h of f.hunks) {
|
|
62
|
+
const head = h.unnumbered ? `@@ ${h.context ?? ''}`.trimEnd() : `@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@`;
|
|
63
|
+
out.push([{ t: `${clock(h.at)} · ${h.agent ?? '메인'} · ${head}`, fg: PALETTE.tool, dim: true }]);
|
|
64
|
+
let n = h.newStart;
|
|
65
|
+
for (const l of h.lines) {
|
|
66
|
+
const k = l[0];
|
|
67
|
+
// 줄 번호를 모르는 변경(Codex)은 번호 칸을 비운다.
|
|
68
|
+
const no = { t: pad(k === '-' || h.unnumbered ? '' : String(n), 4, 'right') + ' ', fg: PALETTE.info };
|
|
69
|
+
if (k !== '-') n++;
|
|
70
|
+
const seg = k === '+' ? { fg: PALETTE.busy, bg: PALETTE.addBg } : k === '-' ? { fg: PALETTE.alert, bg: PALETTE.delBg } : { fg: PALETTE.muted };
|
|
71
|
+
out.push([...(nums ? [no] : []), { ...seg, t: pad(l, textW) }]);
|
|
72
|
+
}
|
|
73
|
+
if (h.cut) out.push([{ t: `… ${h.cut}줄 생략`, fg: PALETTE.info, italic: true }]);
|
|
74
|
+
}
|
|
75
|
+
if (f.dropped) out.push([{ t: `… 이전 변경 ${f.dropped}개는 보관 한도를 넘어 생략`, fg: PALETTE.info, italic: true }]);
|
|
76
|
+
return out.map((r) => [INDENT, ...r]);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// diff 는 오른쪽 칸 전체를 쓴다. 스크롤 위치는 여기서 내용 길이에 맞춰 다시 맞춘다.
|
|
80
|
+
export function diffView(s, files, f, w, rows, view) {
|
|
81
|
+
const body = diffBody(f, w);
|
|
82
|
+
const gap = rows >= ROOMY ? 1 : 0;
|
|
83
|
+
const page = rows - 1 - gap;
|
|
84
|
+
view.diffPage = Math.max(1, page - 1);
|
|
85
|
+
const top = Math.min(Math.max(0, view.diffScroll ?? 0), Math.max(0, body.length - page));
|
|
86
|
+
view.diffScroll = top;
|
|
87
|
+
const meta = `${files.indexOf(f) + 1}/${files.length}${body.length > page ? ` · ${Math.round(((top + page) / body.length) * 100)}%` : ''}`;
|
|
88
|
+
// 좁으면 편집 수를 줄이거나 빼고, 파일 위치와 스크롤 위치는 남긴다.
|
|
89
|
+
const room = w - 10 - width(meta);
|
|
90
|
+
const stats = [` · 편집 ×${f.edits} · 쓴 줄 ${f.add} · 지운 줄 ${f.del}`, ` · ×${f.edits}`, ''].find((x) => room - width(x) >= 12) ?? '';
|
|
91
|
+
const name = truncateLeft(relPath(s.cwd, f.path), Math.max(8, room - width(stats)));
|
|
92
|
+
const label = [{ t: '‹ ', fg: PALETTE.brand, bold: true }, { t: name, fg: PALETTE.text, bold: true }, { t: stats, fg: PALETTE.info }];
|
|
93
|
+
return [rule(label, meta, w, PALETTE.brand), ...(gap ? [[]] : []), ...body.slice(top, top + page)];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 사용자 입력(❯)만 진하게 두고 나머지 본문은 흐리게. 파일 목록에 들어가 있으면(dim) 전부 흐리게.
|
|
97
|
+
export function activity(s, n, dim = false) {
|
|
98
|
+
const tc = PALETTE.tools[s.tool];
|
|
99
|
+
const icon = { prompt: { t: '❯ ', fg: tc, bold: true }, tool: { t: `${glyphs().event} `, fg: PALETTE.tool }, reply: { t: '↳ ', fg: PALETTE.muted } };
|
|
100
|
+
// 기록 줄 순서와 시각이 어긋날 때가 있어(병렬 도구 호출 등) 시각 순으로 다시 정렬한다. 같은 시각이면 기록 순서를 지킨다.
|
|
101
|
+
const events = [...(s.events ?? [])].sort((a, b) => a.at - b.at).slice(-n);
|
|
102
|
+
if (!events.length) return [[INDENT, { t: '아직 기록된 활동이 없습니다', fg: PALETTE.info, italic: true }]];
|
|
103
|
+
const rows = events.map((e) => [INDENT, { t: clock(e.at) + ' ', fg: PALETTE.info }, icon[e.kind], { t: e.text, fg: e.kind === 'prompt' ? PALETTE.text : PALETTE.muted, bold: e.kind === 'prompt' }]);
|
|
104
|
+
return dim ? rows.map((r) => r.map((x) => ({ ...x, fg: PALETTE.info, bold: false }))) : rows;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 최근 활동 가로줄 뒤에 붙는 시간별 토큰 막대: '── 최근 활동 ▁▁▃█▂ 24시간 ──'. 한 칸이 1시간이고 오른쪽 끝이 지금이다.
|
|
108
|
+
// 24칸이 들어가지 않으면 최근 12시간, 그것도 안 되면 막대를 뺀다. 24시간 안에 토큰이 하나도 없으면 그리지 않는다.
|
|
109
|
+
// 0 인 시간은 ▁, 쓴 시간은 ▂ 이상이라 색이 없어도(NO_COLOR) 갈린다. dim 이면(파일 목록에 들어가 있으면) 제목처럼 흐리게.
|
|
110
|
+
export function activitySpark(s, room, dim = false) {
|
|
111
|
+
const all = activitySeries(s.activity, NOW);
|
|
112
|
+
if (!all.some((x) => x > 0)) return [];
|
|
113
|
+
for (const n of [24, 12]) {
|
|
114
|
+
const tail = { t: ` ${n}시간`, fg: PALETTE.info };
|
|
115
|
+
const bar = levelBar(all.slice(-n)).map((x) => (dim ? { ...x, fg: PALETTE.faint } : x));
|
|
116
|
+
const segs = [{ t: ' ' }, ...bar, dim ? { ...tail, fg: PALETTE.faint } : tail];
|
|
117
|
+
if (segsWidth(segs) <= room) return segs;
|
|
118
|
+
}
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ‹이번 지시› T5 T3 전체. 색을 꺼도 보이게 고른 탭은 ‹ › 로 감싼다. 폭이 모자라면 가운데 지시 탭부터 줄인다.
|
|
123
|
+
// 이전 지시 탭은 3개까지만 보이고 나머지는 … +N 으로 접는다. 고른 탭이 들어가게 창을 옮긴다.
|
|
124
|
+
export const TAB_WINDOW = 3;
|
|
125
|
+
|
|
126
|
+
// 숨긴 탭 수는 숨긴 쪽에 따로 붙인다: 이번 지시 … +2 T4 T3 ‹T2› … +1 전체.
|
|
127
|
+
// 폭이 모자라면 가운데 탭 → … +N → '이번 지시' 이름 → 고르지 않은 양끝 탭 순으로 줄여, 고른 탭은 늘 남긴다.
|
|
128
|
+
export function tabsLine(tabs, tab, w) {
|
|
129
|
+
const seg = (t, short) => (t.key === tab.key ? { t: `‹${t.label}›`, fg: PALETTE.text, bg: PALETTE.rowSel, bold: true } : { t: short && t.key === 'now' ? '이번' : t.label, fg: PALETTE.muted });
|
|
130
|
+
const turns = tabs.slice(1, -1);
|
|
131
|
+
const at = turns.findIndex((t) => t.key === tab.key);
|
|
132
|
+
const start = at >= TAB_WINDOW ? at - TAB_WINDOW + 1 : 0;
|
|
133
|
+
let shown = turns.slice(start, start + TAB_WINDOW);
|
|
134
|
+
let ends = [tabs[0], tabs.at(-1)];
|
|
135
|
+
let marks = true;
|
|
136
|
+
let short = false;
|
|
137
|
+
const build = () => {
|
|
138
|
+
const first = turns.indexOf(shown[0]);
|
|
139
|
+
const before = shown.length ? first : 0;
|
|
140
|
+
const after = turns.length - before - shown.length;
|
|
141
|
+
const parts = [];
|
|
142
|
+
if (ends.includes(tabs[0])) parts.push(seg(tabs[0], short));
|
|
143
|
+
if (marks && before) parts.push({ t: `… +${before}`, fg: PALETTE.info });
|
|
144
|
+
parts.push(...shown.map((t) => seg(t, short)));
|
|
145
|
+
if (marks && after) parts.push({ t: `… +${after}`, fg: PALETTE.info });
|
|
146
|
+
if (ends.includes(tabs.at(-1))) parts.push(seg(tabs.at(-1), short));
|
|
147
|
+
return parts.flatMap((x) => [x, { t: ' ' }]);
|
|
148
|
+
};
|
|
149
|
+
const fits = () => segsWidth(build()) <= w;
|
|
150
|
+
while (!fits() && shown.some((t) => t.key !== tab.key)) shown.splice(shown.findIndex((t) => t.key !== tab.key), 1);
|
|
151
|
+
if (!fits()) marks = false;
|
|
152
|
+
if (!fits()) short = true;
|
|
153
|
+
for (const end of [tabs[0], tabs.at(-1)]) if (!fits() && end.key !== tab.key) ends = ends.filter((t) => t !== end);
|
|
154
|
+
return build();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 탭 아래 설명: 지시면 시각과 첫 줄, 전체면 편집 수.
|
|
158
|
+
export function tabNote(tab, files) {
|
|
159
|
+
if (tab.key === 'all') return [{ t: `세션 전체 · 편집 ${files.reduce((a, f) => a + f.edits, 0)}회`, fg: PALETTE.info, italic: true }];
|
|
160
|
+
const p = tab.turn;
|
|
161
|
+
const edits = files.reduce((a, f) => a + f.edits, 0);
|
|
162
|
+
return [{ t: p ? `${hhmm(p.at)} "${p.text}"` : '지시 기록 없음', fg: PALETTE.info, italic: true }, ...(edits ? [{ t: ` 편집 ${edits}회`, fg: PALETTE.muted }] : [])];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 허락을 기다리는 요청을 폭에 맞춰 여러 줄로 나눈다. 명령은 띄어쓰기와 상관없이 글자 단위로 끊어 빠짐없이 보인다.
|
|
166
|
+
export const REQUEST_LINES = 6;
|
|
167
|
+
|
|
168
|
+
export function wrapText(text, w) {
|
|
169
|
+
const out = [];
|
|
170
|
+
for (const para of String(text).split('\n')) {
|
|
171
|
+
let cur = '';
|
|
172
|
+
let cw = 0;
|
|
173
|
+
for (const ch of para) {
|
|
174
|
+
const k = width(ch);
|
|
175
|
+
if (cw + k > w && cur) {
|
|
176
|
+
out.push(cur);
|
|
177
|
+
cur = '';
|
|
178
|
+
cw = 0;
|
|
179
|
+
}
|
|
180
|
+
cur += ch;
|
|
181
|
+
cw += k;
|
|
182
|
+
}
|
|
183
|
+
out.push(cur);
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── 요청 ── 구획: 응답 대기 중이고 요청 내용을 알 때만. 최대 max 줄, 넘치면 마지막 줄 끝을 … 로 바꾼다.
|
|
189
|
+
export function requestBlock(s, w, max = REQUEST_LINES) {
|
|
190
|
+
if (s.status !== 'waiting' || !s.waitDetail || max < 1) return [];
|
|
191
|
+
const textW = Math.max(1, w - width(INDENT.t));
|
|
192
|
+
let lines = wrapText(s.waitDetail, textW);
|
|
193
|
+
if (lines.length > max) {
|
|
194
|
+
lines = lines.slice(0, max);
|
|
195
|
+
const last = [...lines[max - 1]];
|
|
196
|
+
const mark = ellipsis(textW);
|
|
197
|
+
while (last.length && width(last.join('')) + width(mark) > textW) last.pop();
|
|
198
|
+
lines[max - 1] = last.join('') + mark;
|
|
199
|
+
}
|
|
200
|
+
return [rule([{ t: '요청', fg: PALETTE.waiting, bold: true }], '', w), ...lines.map((l) => [INDENT, { t: l, fg: PALETTE.text }])];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 오른쪽 칸: 고친 파일이 없으면 최근 활동만, 있으면 위쪽 절반까지 변경 파일 구획(배경 띠), 파일을 열면 diff.
|
|
204
|
+
// 머리는 안쪽 줄 수에 따라: 16 이상 가로줄+탭+지시, 11~15 가로줄+탭(지시는 탭 줄 오른쪽), 10 이하 가로줄만.
|
|
205
|
+
export function rightColumn(s, w, rows, view) {
|
|
206
|
+
// --show-requests 를 켰고 응답 대기 중이면 요청 내용을 맨 위에 두고, 아래 구획에는 적어도 4줄을 남긴다.
|
|
207
|
+
const req = view?.showRequests ? requestBlock(s, w, Math.min(REQUEST_LINES, rows - 5)) : [];
|
|
208
|
+
if (req.length) return [...req, ...rightColumn({ ...s, waitDetail: null }, w, rows - req.length, view)];
|
|
209
|
+
const all = s.files ?? [];
|
|
210
|
+
const focused = view?.focus === 'files';
|
|
211
|
+
// 가로줄 끝에 '─' 두 칸은 남긴다 (── 3칸 + 최근 활동 9칸 + 막대 + ' ─' 2칸).
|
|
212
|
+
const activityRule = () => rule([{ t: '최근 활동', fg: focused ? PALETTE.muted : PALETTE.text, bold: true }, ...activitySpark(s, w - 3 - 9 - 2, focused)], '', w);
|
|
213
|
+
if (!all.length) return [activityRule(), ...activity(s, rows - 1)];
|
|
214
|
+
const tab = currentTab(s, view?.tab);
|
|
215
|
+
const files = filesForTab(s, tab);
|
|
216
|
+
const open = view?.focus === 'diff' && files.find((f) => f.path === view.filePath);
|
|
217
|
+
if (open) return diffView(s, files, open, w, rows, view);
|
|
218
|
+
// 이번 지시에 고친 파일이 없으면 한 줄로 접고 남는 줄을 최근 활동에 준다. 목록에 들어가면(f) 다시 펼친다.
|
|
219
|
+
if (!files.length && !focused) {
|
|
220
|
+
const folded = rule([{ t: '변경 파일', fg: PALETTE.text, bold: true }, { t: ` · ${tab.label} 0개`, fg: PALETTE.muted }], `전체 ${all.length}개 · f`, w);
|
|
221
|
+
return [folded, activityRule(), ...activity(s, rows - 2)];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// 목록에 들어가 있으면 구획 왼쪽에 ▌ 막대를 세우고 가로줄을 강조색으로 바꾼다.
|
|
225
|
+
const gutter = focused ? [{ t: glyphs().select, fg: PALETTE.brand }] : [];
|
|
226
|
+
const inner = w - segsWidth(gutter);
|
|
227
|
+
const tier = rows >= ROOMY ? 3 : rows >= 11 ? 2 : 1;
|
|
228
|
+
const label = [{ t: '변경 파일', fg: focused ? PALETTE.brand : PALETTE.text, bold: true }, ...(tier === 1 ? [{ t: ` · ${tab.label}`, fg: PALETTE.muted }] : [])];
|
|
229
|
+
// git diff 가 아니라 Edit/Write 기록이고 Bash 로 바꾼 파일은 빠진다는 것을 화면에서 바로 알 수 있게 한다.
|
|
230
|
+
const meta = `편집 기록 · Bash 제외 · ${files.length}개${focused ? '' : ' · f'}`;
|
|
231
|
+
const head = [rule(label, meta, inner, focused ? PALETTE.brand : PALETTE.line)];
|
|
232
|
+
// 11~15 줄에서는 지시 문장이 탭에 밀려 사라지지 않게 그 자리를 먼저 남긴다.
|
|
233
|
+
const note = tabNote(tab, files);
|
|
234
|
+
const noteW = tier === 2 ? Math.min(note.reduce((a, x) => a + width(x.t), 0), Math.floor(inner / 2)) + 1 : 0;
|
|
235
|
+
if (tier >= 2) head.push([INDENT, ...tabsLine(turnTabs(s), tab, inner - 1 - noteW), ...(tier === 2 ? [{ t: ' ' }, ...note] : [])]);
|
|
236
|
+
if (tier === 3) head.push([INDENT, ...note]);
|
|
237
|
+
|
|
238
|
+
// 넉넉하면(tier 3) 지시 문장과 목록 사이, 목록과 최근 활동 사이에 빈 줄을 하나씩 둔다.
|
|
239
|
+
// 목록은 칸의 절반까지만 쓰므로 빈 줄을 넣어도 최근 활동은 6줄 넘게 남는다.
|
|
240
|
+
const gap = tier === 3 ? 1 : 0;
|
|
241
|
+
const selIdx = focused ? files.findIndex((f) => f.path === view.filePath) : -1;
|
|
242
|
+
const listRows = Math.max(1, Math.ceil(rows / 2) - head.length - gap);
|
|
243
|
+
const overflow = files.length > listRows;
|
|
244
|
+
const slots = overflow ? listRows - 1 : listRows;
|
|
245
|
+
const start = selIdx >= slots ? selIdx - slots + 1 : 0;
|
|
246
|
+
const shown = files.slice(start, start + slots);
|
|
247
|
+
const list = [];
|
|
248
|
+
if (!files.length) list.push(fill([BAND_LEFT, { t: '· 이 지시에서는 고친 파일 없음', fg: PALETTE.info, italic: true }, { t: `${GAP}${focused ? '←/→' : 'f 다음 ←/→'} 다른 지시`, fg: PALETTE.info }], inner, PALETTE.bar));
|
|
249
|
+
shown.forEach((f, k) => list.push(fileRow(f, s.cwd, inner, start + k === selIdx)));
|
|
250
|
+
if (overflow) list.push(fill([BAND_LEFT, { t: `+${files.length - shown.length}개 더`, fg: PALETTE.muted }], inner, PALETTE.bar));
|
|
251
|
+
const block = [...head, ...(gap ? [[]] : []), ...list, ...(gap ? [[]] : [])];
|
|
252
|
+
const R = block.map((segs) => [...gutter, ...segs]);
|
|
253
|
+
R.push(activityRule(), ...activity(s, rows - R.length - 1, focused));
|
|
254
|
+
return R;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// 이 폭보다 좁으면 왼쪽 칸을 위쪽 요약 두 줄로 접고, 변경 파일·최근 활동이 안쪽 전체 폭을 쓴다.
|
|
258
|
+
export const NARROW_W = 100;
|
|
259
|
+
|
|
260
|
+
const FOLDER_MIN = 28;
|
|
261
|
+
|
|
262
|
+
// 조각 묶음을 폭에 맞춘다. 넘치면 drop 값이 큰 묶음부터 빼고, 그래도 넘치면 fill 이 뒤를 자른다.
|
|
263
|
+
function fitGroups(groups, w) {
|
|
264
|
+
const kept = groups.filter((g) => g);
|
|
265
|
+
const total = () => kept.reduce((a, g) => a + segsWidth(g.segs), 0);
|
|
266
|
+
while (total() > w) {
|
|
267
|
+
const i = kept.reduce((best, g, k) => ((g.drop ?? 0) > (kept[best].drop ?? 0) ? k : best), 0);
|
|
268
|
+
if (!kept[i].drop) break;
|
|
269
|
+
kept.splice(i, 1);
|
|
270
|
+
}
|
|
271
|
+
return kept.flatMap((g) => g.segs);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// 좁은 화면의 요약 두 줄.
|
|
275
|
+
// 1줄: 상태 · 폴더 · 브랜치 · 종류 · 시작.
|
|
276
|
+
// 2줄: 571k +서브2 (입력 90.3k·출력 18.4k·캐시 463k) · $0.17 · ctx 5% · pid 218093. 캐시는 읽기와 쓰기를 더한 값이다.
|
|
277
|
+
// 폭이 모자라면 pid → 토큰 내역 → 서브에이전트 수 → ctx 순으로 뺀다.
|
|
278
|
+
export function summaryLines(s, w, frame, view) {
|
|
279
|
+
const g = glyph(s, frame);
|
|
280
|
+
const phase = [{ ...g, t: g.t + ' ' }, { t: s.phase?.text ?? s.status, fg: PHASE_COLOR[s.phase?.kind] ?? PALETTE.text, bold: true }];
|
|
281
|
+
const sep = { t: ' · ', fg: PALETTE.info };
|
|
282
|
+
const optional = [
|
|
283
|
+
s.branch && [sep, { t: s.branch, fg: PALETTE.info }],
|
|
284
|
+
s.kind && [sep, { t: s.kind, fg: PALETTE.info }],
|
|
285
|
+
s.startedAt && [sep, { t: `시작 ${hhmm(s.startedAt)} (${fmtAgo(NOW - s.startedAt)} 전)`, fg: PALETTE.info }],
|
|
286
|
+
].filter(Boolean);
|
|
287
|
+
// 폴더가 FOLDER_MIN 칸(짧으면 제 길이)을 얻을 때까지 시작 → 종류 → 브랜치 순으로 뺀다. 폴더는 왼쪽부터 줄여 끝 이름을 남긴다.
|
|
288
|
+
const path = tilde(s.cwd ?? '');
|
|
289
|
+
const want = Math.min(width(path), FOLDER_MIN);
|
|
290
|
+
const room = (k) => w - segsWidth(phase) - (path ? 3 : 0) - optional.slice(0, k).reduce((a, x) => a + segsWidth(x), 0);
|
|
291
|
+
let k = optional.length;
|
|
292
|
+
while (k > 0 && room(k) < want) k--;
|
|
293
|
+
const folder = path ? [sep, { t: truncateLeft(path, Math.max(1, room(k))), fg: PALETTE.muted }] : [];
|
|
294
|
+
const first = [...phase, ...folder, ...optional.slice(0, k).flat()];
|
|
295
|
+
|
|
296
|
+
const t = s.tokens ?? {};
|
|
297
|
+
const net = view?.costMode === 'net';
|
|
298
|
+
const total = { segs: [{ t: fmtTokens(t.total), fg: PALETTE.text, bold: true }] };
|
|
299
|
+
const parts = { segs: [{ t: ' (', fg: PALETTE.info }, { t: `입력 ${fmtTokens(t.input)}·출력 ${fmtTokens(t.output)}·캐시 ${fmtTokens((t.cacheRead ?? 0) + (t.cacheWrite ?? 0))}`, fg: PALETTE.info }, { t: ')', fg: PALETTE.info }], drop: 3 };
|
|
300
|
+
const sub = t.subCount ? { segs: [{ t: ` +서브${t.subCount}`, fg: PALETTE.muted }], drop: 2 } : null;
|
|
301
|
+
const cost = s.cost != null ? { segs: [sep, ...(net ? [{ t: '캐시제외 ', fg: PALETTE.muted }] : []), { t: money(net ? s.costNoCache : s.cost, s.costUnknown), fg: PALETTE.money, bold: true }] } : null;
|
|
302
|
+
let ctx = null;
|
|
303
|
+
if (s.context?.window) {
|
|
304
|
+
const r = Math.min(1, Math.max(0, s.context.used / s.context.window || 0));
|
|
305
|
+
const hot = r >= 0.85;
|
|
306
|
+
ctx = { segs: [sep, { t: 'ctx ', fg: PALETTE.info }, { t: `${hot ? '!' : ''}${Math.round(r * 100)}%`, fg: hot ? PALETTE.alert : PALETTE.muted, bold: hot }], drop: 1 };
|
|
307
|
+
}
|
|
308
|
+
const pid = s.pid ? { segs: [sep, { t: `pid ${s.pid}`, fg: PALETTE.info }], drop: 4 } : null;
|
|
309
|
+
// 둘째 줄은 상태 글자 아래에 맞춰 2칸 들여 쓴다.
|
|
310
|
+
const second = fitGroups([total, sub, parts, cost, ctx, pid], w - 2);
|
|
311
|
+
return [first, [{ t: ' ' }, ...second]];
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function detail(s, W, H, frame, view) {
|
|
315
|
+
const tc = PALETTE.tools[s.tool];
|
|
316
|
+
// 제목: 세션 이름은 진하게, 도구·모델은 도구 색으로.
|
|
317
|
+
const title = [{ t: s.name ?? s.tool, fg: PALETTE.text, bold: true }, { t: ` ${s.tool}${s.model ? ' · ' + s.model : ''}`, fg: tc }];
|
|
318
|
+
if (W < NARROW_W) {
|
|
319
|
+
const inner = W - 4;
|
|
320
|
+
const top = summaryLines(s, inner, frame, view);
|
|
321
|
+
const R = rightColumn(s, inner, H - 2 - top.length, view);
|
|
322
|
+
return box(title, W, H, null, [...top, ...R], 0, tc, Boolean(view?.focus));
|
|
323
|
+
}
|
|
324
|
+
const leftW = Math.min(46, Math.max(30, Math.floor((W - 4) * 0.38)));
|
|
325
|
+
const L = [];
|
|
326
|
+
// 왼쪽 칸도 오른쪽 칸처럼 라벨 가로줄로 나누고 내용은 1칸 들여 쓴다.
|
|
327
|
+
const section = (name) => L.push(rule([{ t: name, fg: PALETTE.text, bold: true }], '', leftW));
|
|
328
|
+
const kv = (k, v, fg = PALETTE.text) => L.push([INDENT, { t: pad(k, 9), fg: PALETTE.muted }, { t: v ?? '-', fg }]);
|
|
329
|
+
const more = (segs) => L.push([INDENT, { t: ' '.repeat(9) }, ...segs]);
|
|
330
|
+
|
|
331
|
+
const g = glyph(s, frame);
|
|
332
|
+
L.push([{ ...g, t: g.t + ' ' }, { t: s.phase?.text ?? s.status, fg: PHASE_COLOR[s.phase?.kind] ?? PALETTE.text, bold: true }]);
|
|
333
|
+
section('세션');
|
|
334
|
+
kv('폴더', tilde(s.cwd));
|
|
335
|
+
if (s.branch) kv('브랜치', s.branch);
|
|
336
|
+
kv('모델', s.model ?? '-');
|
|
337
|
+
kv('종류', `${s.kind ?? '-'}${s.pid ? ` · pid ${s.pid}` : ''}`);
|
|
338
|
+
if (s.startedAt) kv('시작', `${clock(s.startedAt)} (${fmtAgo(NOW - s.startedAt)} 전)`);
|
|
339
|
+
|
|
340
|
+
// 합계 = 메인 + 서브 = 입력 + 출력 + 캐시 읽기 + 캐시 쓰기 가 화면에서 그대로 맞아떨어지게 보인다.
|
|
341
|
+
const t = s.tokens ?? {};
|
|
342
|
+
section('토큰');
|
|
343
|
+
kv('합계', fmtTokens(t.total));
|
|
344
|
+
if (t.subCount) more([{ t: `메인 ${fmtTokens(t.main)} + 서브 ${t.subCount}개 ${fmtTokens(t.sub)}`, fg: PALETTE.info }]);
|
|
345
|
+
more([{ t: `입력 ${fmtTokens(t.input)} · 출력 ${fmtTokens(t.output)}`, fg: PALETTE.info }]);
|
|
346
|
+
more([{ t: `캐시 읽기 ${fmtTokens(t.cacheRead)} · 쓰기 ${fmtTokens(t.cacheWrite)}`, fg: PALETTE.info }]);
|
|
347
|
+
if (s.cost != null) section('비용');
|
|
348
|
+
else if (s.context?.window) section('컨텍스트');
|
|
349
|
+
if (s.cost != null) {
|
|
350
|
+
const net = view?.costMode === 'net';
|
|
351
|
+
L.push([INDENT, { t: pad(net ? '캐시제외' : 'API 환산', 9), fg: PALETTE.muted }, { t: money(net ? s.costNoCache : s.cost, s.costUnknown), fg: PALETTE.money, bold: true }]);
|
|
352
|
+
more([{ t: net ? `API 환산 ${money(s.cost, s.costUnknown)}` : `캐시 제외 ${money(s.costNoCache, s.costUnknown)}`, fg: PALETTE.info }, ...(s.costUnknown ? [{ t: ' ? 가격표에 없는 모델 포함', fg: PALETTE.info }] : [])]);
|
|
353
|
+
}
|
|
354
|
+
if (s.context?.window) {
|
|
355
|
+
L.push([INDENT, { t: pad('컨텍스트', 9), fg: PALETTE.muted }, { t: pad(`${fmtTokens(s.context.used)}/${fmtTokens(s.context.window)}`, 11) + ' ', fg: PALETTE.text }, ...gauge(s.context.used, s.context.window, Math.max(4, leftW - width(INDENT.t) - 26))]);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const R = rightColumn(s, W - leftW - 7, H - 2, view);
|
|
359
|
+
return box(title, W, H, L, R, leftW, tc, Boolean(view?.focus));
|
|
360
|
+
}
|