pinglet-cli 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,481 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.HUD_CACHE_DIR = exports.PROMPT_CACHE_TTL_1H = exports.PROMPT_CACHE_TTL_5M = void 0;
37
+ exports.sanitizeText = sanitizeText;
38
+ exports.parseTranscript = parseTranscript;
39
+ const fs = __importStar(require("fs"));
40
+ const path = __importStar(require("path"));
41
+ const readline = __importStar(require("readline"));
42
+ const crypto_1 = require("crypto");
43
+ const config_1 = require("../config");
44
+ const storage_1 = require("../storage");
45
+ const CACHE_VERSION = 4;
46
+ const CACHE_DIR = path.join(config_1.PINGLET_DIR, "hud-cache");
47
+ const NAME_MAX_LEN = 64;
48
+ const TEXT_MAX_LEN = 120;
49
+ const TOOLS_KEPT = 20;
50
+ const AGENTS_KEPT = 10;
51
+ const MESSAGE_USAGE_MAX = 4096;
52
+ exports.PROMPT_CACHE_TTL_5M = 300;
53
+ exports.PROMPT_CACHE_TTL_1H = 3600;
54
+ const emptyTranscript = () => ({ tools: [], agents: [], todos: [], skills: [] });
55
+ /** 클라이언트 안에서만 처리되는 user 기록 — 요청을 보내지 않으므로 캐시를 갱신하지 않는다. */
56
+ const LOCAL_ONLY_USER_TEXT_PREFIXES = [
57
+ "<command-name>",
58
+ "<command-message>",
59
+ "<local-command-",
60
+ "[Request interrupted by user",
61
+ ];
62
+ /** 터미널에 그리기 전 방어선 — ANSI·제어·서식 문자를 제거하고 길이를 제한한다. */
63
+ function sanitizeText(value, maxLen = TEXT_MAX_LEN) {
64
+ if (typeof value !== "string")
65
+ return "";
66
+ const cleaned = value
67
+ // eslint-disable-next-line no-control-regex
68
+ .replace(/\x1b\[[0-9;]*[A-Za-z]/g, "")
69
+ .replace(/[\p{Cc}\p{Cf}]/gu, "")
70
+ .replace(/\s+/g, " ")
71
+ .trim();
72
+ return cleaned.length > maxLen ? `${cleaned.slice(0, maxLen - 1)}…` : cleaned;
73
+ }
74
+ function cachePath(transcriptPath) {
75
+ const hash = (0, crypto_1.createHash)("sha256").update(transcriptPath).digest("hex").slice(0, 32);
76
+ return path.join(CACHE_DIR, `${hash}.json`);
77
+ }
78
+ function readCache(file) {
79
+ try {
80
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
81
+ if (parsed?.version !== CACHE_VERSION || !parsed.data)
82
+ return null;
83
+ return parsed;
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ }
89
+ /** doctor/uninstall --purge 용: 캐시 디렉터리 경로. */
90
+ exports.HUD_CACHE_DIR = CACHE_DIR;
91
+ async function parseTranscript(transcriptPath) {
92
+ if (!transcriptPath)
93
+ return emptyTranscript();
94
+ let canonical;
95
+ let stat;
96
+ try {
97
+ canonical = fs.realpathSync(transcriptPath);
98
+ stat = fs.statSync(canonical);
99
+ if (!stat.isFile())
100
+ return emptyTranscript();
101
+ }
102
+ catch {
103
+ return emptyTranscript();
104
+ }
105
+ const file = cachePath(canonical);
106
+ const cached = readCache(file);
107
+ if (cached && cached.transcriptPath === canonical && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
108
+ return cached.data;
109
+ }
110
+ const { data, complete } = await parseFile(canonical);
111
+ if (complete) {
112
+ try {
113
+ (0, storage_1.atomicWrite)(file, JSON.stringify({ version: CACHE_VERSION, transcriptPath: canonical, mtimeMs: stat.mtimeMs, size: stat.size, data }));
114
+ }
115
+ catch {
116
+ // 캐시 실패는 표시에 영향을 주면 안 된다.
117
+ }
118
+ }
119
+ return data;
120
+ }
121
+ const positive = (v) => typeof v === "number" && Number.isFinite(v) && v > 0;
122
+ const tokenCount = (v) => (typeof v === "number" && Number.isFinite(v) ? Math.max(0, Math.trunc(v)) : 0);
123
+ /** 이번 요청이 실제로 쓴 캐시 TTL. 캐시만 읽은 요청은 둘 다 0이라 undefined (이전 판정 유지). */
124
+ function detectCacheTtl(creation) {
125
+ if (!creation)
126
+ return undefined;
127
+ if (positive(creation.ephemeral_5m_input_tokens))
128
+ return exports.PROMPT_CACHE_TTL_5M;
129
+ if (positive(creation.ephemeral_1h_input_tokens))
130
+ return exports.PROMPT_CACHE_TTL_1H;
131
+ return undefined;
132
+ }
133
+ /** user 기록이 실제 요청의 시작인지 — 로컬 슬래시 명령 출력·중단 마커는 아니다. */
134
+ function isRequestStart(entry) {
135
+ const content = entry.message?.content;
136
+ if (typeof content === "string")
137
+ return !LOCAL_ONLY_USER_TEXT_PREFIXES.some((p) => content.startsWith(p));
138
+ if (Array.isArray(content)) {
139
+ return content.some((b) => b?.type === "tool_result") ||
140
+ !content.some((b) => b?.type === "text" && LOCAL_ONLY_USER_TEXT_PREFIXES.some((p) => (b.text ?? "").startsWith(p)));
141
+ }
142
+ return true;
143
+ }
144
+ /**
145
+ * 같은 API 응답을 Claude Code가 2~3번 기록하므로 message.id별 최대값의 증가분만 더한다.
146
+ * id가 없는 기록은 직전 기록과 usage 지문이 같으면 중복으로 본다.
147
+ */
148
+ class SessionTokenAccumulator {
149
+ total = { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 };
150
+ byMessageId = new Map();
151
+ lastFingerprint;
152
+ add(messageId, usage) {
153
+ if (messageId !== null) {
154
+ this.lastFingerprint = undefined;
155
+ const prior = this.byMessageId.get(messageId);
156
+ if (!prior && this.byMessageId.size >= MESSAGE_USAGE_MAX) {
157
+ const oldest = this.byMessageId.keys().next().value;
158
+ if (oldest !== undefined)
159
+ this.byMessageId.delete(oldest);
160
+ }
161
+ const base = prior ?? { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 };
162
+ this.total.inputTokens += Math.max(0, usage.inputTokens - base.inputTokens);
163
+ this.total.outputTokens += Math.max(0, usage.outputTokens - base.outputTokens);
164
+ this.total.cacheCreationTokens += Math.max(0, usage.cacheCreationTokens - base.cacheCreationTokens);
165
+ this.total.cacheReadTokens += Math.max(0, usage.cacheReadTokens - base.cacheReadTokens);
166
+ this.byMessageId.set(messageId, {
167
+ inputTokens: Math.max(base.inputTokens, usage.inputTokens),
168
+ outputTokens: Math.max(base.outputTokens, usage.outputTokens),
169
+ cacheCreationTokens: Math.max(base.cacheCreationTokens, usage.cacheCreationTokens),
170
+ cacheReadTokens: Math.max(base.cacheReadTokens, usage.cacheReadTokens),
171
+ });
172
+ return;
173
+ }
174
+ const fingerprint = `${usage.inputTokens}|${usage.outputTokens}|${usage.cacheCreationTokens}|${usage.cacheReadTokens}`;
175
+ const duplicate = fingerprint === this.lastFingerprint;
176
+ this.lastFingerprint = fingerprint;
177
+ if (duplicate)
178
+ return;
179
+ this.total.inputTokens += usage.inputTokens;
180
+ this.total.outputTokens += usage.outputTokens;
181
+ this.total.cacheCreationTokens += usage.cacheCreationTokens;
182
+ this.total.cacheReadTokens += usage.cacheReadTokens;
183
+ }
184
+ reset() {
185
+ this.lastFingerprint = undefined;
186
+ }
187
+ }
188
+ async function parseFile(transcriptPath) {
189
+ const tools = new Map();
190
+ const agents = new Map();
191
+ const skills = new Set();
192
+ let todos = [];
193
+ const taskIdToIndex = new Map();
194
+ const queueCompletion = new Map();
195
+ const tokens = new SessionTokenAccumulator();
196
+ let sessionStart;
197
+ let slug;
198
+ let customTitle;
199
+ let advisorModel;
200
+ let compactionCount = 0;
201
+ let ultracodeActive;
202
+ let lastRequestUsage;
203
+ // prompt cache 시계 — 메인 대화(isSidechain 아님)만 본다.
204
+ let prevMainChainAt;
205
+ let cacheAnchorAt;
206
+ let cacheTtl;
207
+ let cacheRequestId;
208
+ let cacheRequestAnchorAt;
209
+ let cachePendingRequestAt;
210
+ let complete = false;
211
+ try {
212
+ const rl = readline.createInterface({ input: fs.createReadStream(transcriptPath), crlfDelay: Infinity });
213
+ for await (const line of rl) {
214
+ if (!line.trim()) {
215
+ tokens.reset();
216
+ continue;
217
+ }
218
+ let entry;
219
+ try {
220
+ entry = JSON.parse(line);
221
+ }
222
+ catch {
223
+ tokens.reset();
224
+ continue;
225
+ }
226
+ if (!entry || typeof entry !== "object")
227
+ continue;
228
+ const ts = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
229
+ const timestamp = Number.isNaN(ts) ? Date.now() : ts;
230
+ if (sessionStart === undefined && !Number.isNaN(ts))
231
+ sessionStart = ts;
232
+ if (entry.type === "custom-title" && typeof entry.customTitle === "string") {
233
+ customTitle = sanitizeText(entry.customTitle, NAME_MAX_LEN);
234
+ }
235
+ else if (typeof entry.slug === "string") {
236
+ slug = sanitizeText(entry.slug, NAME_MAX_LEN);
237
+ }
238
+ if (entry.type === "assistant" && typeof entry.advisorModel === "string" && entry.advisorModel) {
239
+ advisorModel = sanitizeText(entry.advisorModel, NAME_MAX_LEN) || advisorModel;
240
+ }
241
+ // ultracode 상태: 마커 attachment(한 턴 늦을 수 있음)와 /effort 명령 출력(즉시) — 파일 순서대로 마지막 값.
242
+ if (entry.type === "attachment") {
243
+ if (entry.attachment?.type === "ultra_effort_enter")
244
+ ultracodeActive = true;
245
+ else if (entry.attachment?.type === "ultra_effort_exit")
246
+ ultracodeActive = false;
247
+ }
248
+ if (entry.type === "user" && typeof entry.message?.content === "string") {
249
+ const match = entry.message.content.match(/^<local-command-stdout>Set effort level to (\w+)/);
250
+ if (match)
251
+ ultracodeActive = match[1].toLowerCase() === "ultracode";
252
+ }
253
+ if (entry.type === "assistant" && entry.message?.usage) {
254
+ const usage = entry.message.usage;
255
+ const id = typeof entry.message.id === "string" && entry.message.id.length > 0 && entry.message.id.length <= 128 ? entry.message.id : null;
256
+ const normalized = {
257
+ inputTokens: tokenCount(usage.input_tokens),
258
+ outputTokens: tokenCount(usage.output_tokens),
259
+ cacheCreationTokens: tokenCount(usage.cache_creation_input_tokens),
260
+ cacheReadTokens: tokenCount(usage.cache_read_input_tokens),
261
+ };
262
+ tokens.add(id, normalized);
263
+ if (entry.isSidechain !== true && normalized.inputTokens + normalized.cacheCreationTokens + normalized.cacheReadTokens > 0) {
264
+ lastRequestUsage = normalized;
265
+ }
266
+ }
267
+ else {
268
+ tokens.reset();
269
+ }
270
+ if (entry.type === "system" && entry.subtype === "compact_boundary" && !Number.isNaN(ts))
271
+ compactionCount++;
272
+ // 백그라운드 에이전트의 실제 완료 시각은 queue-operation 기록에 있다 (tool_result는 시작 시각).
273
+ if (entry.type === "queue-operation" && entry.operation === "enqueue" && typeof entry.content === "string" && !Number.isNaN(ts)) {
274
+ const toolUseId = entry.content.match(/<tool-use-id>([^<]+)<\/tool-use-id>/);
275
+ if (toolUseId && /<task-id>[^<]+<\/task-id>/.test(entry.content))
276
+ queueCompletion.set(toolUseId[1], ts);
277
+ }
278
+ if (entry.isSidechain !== true && !Number.isNaN(ts)) {
279
+ if (entry.type === "assistant") {
280
+ // 같은 requestId를 공유하는 기록은 한 요청 — 첫 기록 직전의 메인 대화 기록이 요청 시작이다.
281
+ const requestId = typeof entry.requestId === "string" && entry.requestId.length <= 128 ? entry.requestId : undefined;
282
+ if (requestId === undefined || requestId !== cacheRequestId) {
283
+ cacheRequestId = requestId;
284
+ cacheRequestAnchorAt = prevMainChainAt;
285
+ }
286
+ cacheAnchorAt = cacheRequestAnchorAt !== undefined && cacheRequestAnchorAt <= ts ? cacheRequestAnchorAt : ts;
287
+ const detected = detectCacheTtl(entry.message?.usage?.cache_creation);
288
+ if (detected !== undefined)
289
+ cacheTtl = detected;
290
+ cachePendingRequestAt = undefined;
291
+ }
292
+ // 응답이 아직 안 적힌 요청도 이미 캐시를 갱신했다 — 그 요청을 연 기록이 살아 있는 기준점.
293
+ if (entry.type === "user" && isRequestStart(entry))
294
+ cachePendingRequestAt = ts;
295
+ prevMainChainAt = ts;
296
+ }
297
+ const blocks = entry.message?.content;
298
+ if (!Array.isArray(blocks))
299
+ continue;
300
+ for (const block of blocks) {
301
+ if (!block || typeof block !== "object")
302
+ continue;
303
+ if (block.type === "tool_use" && typeof block.id === "string" && typeof block.name === "string") {
304
+ const name = sanitizeText(block.name, NAME_MAX_LEN);
305
+ const input = block.input && typeof block.input === "object" ? block.input : {};
306
+ if (name === "Skill") {
307
+ const skill = sanitizeText(input.skill, NAME_MAX_LEN);
308
+ if (skill)
309
+ skills.add(skill);
310
+ }
311
+ if (name === "Task" || name === "Agent") {
312
+ agents.set(block.id, {
313
+ id: block.id,
314
+ type: sanitizeText(input.subagent_type, 24) || "agent",
315
+ model: sanitizeText(input.model, 40) || undefined,
316
+ description: sanitizeText(input.description, 40) || undefined,
317
+ status: "running",
318
+ startTime: timestamp,
319
+ background: input.run_in_background === true,
320
+ });
321
+ }
322
+ else if (name === "TodoWrite") {
323
+ const next = normalizeTodos(input.todos);
324
+ if (next) {
325
+ todos = next;
326
+ taskIdToIndex.clear();
327
+ }
328
+ }
329
+ else if (name === "TaskCreate") {
330
+ const subject = sanitizeText(input.subject) || sanitizeText(input.description) || "Untitled task";
331
+ todos.push({ content: subject, status: normalizeTaskStatus(input.status) ?? "pending" });
332
+ const taskId = input.taskId;
333
+ const key = typeof taskId === "string" || typeof taskId === "number" ? String(taskId) : block.id;
334
+ taskIdToIndex.set(key, todos.length - 1);
335
+ }
336
+ else if (name === "TaskUpdate") {
337
+ const index = resolveTaskIndex(input.taskId, taskIdToIndex, todos.length);
338
+ if (index !== null) {
339
+ const status = normalizeTaskStatus(input.status);
340
+ if (status)
341
+ todos[index].status = status;
342
+ const subject = sanitizeText(input.subject) || sanitizeText(input.description);
343
+ if (subject)
344
+ todos[index].content = subject;
345
+ }
346
+ }
347
+ else {
348
+ tools.set(block.id, {
349
+ id: block.id,
350
+ name,
351
+ target: extractTarget(name, input),
352
+ status: "running",
353
+ startTime: timestamp,
354
+ });
355
+ }
356
+ }
357
+ if (block.type === "tool_result" && typeof block.tool_use_id === "string") {
358
+ const tool = tools.get(block.tool_use_id);
359
+ if (tool) {
360
+ tool.status = block.is_error ? "error" : "completed";
361
+ tool.endTime = timestamp;
362
+ }
363
+ const agent = agents.get(block.tool_use_id);
364
+ if (agent) {
365
+ const resolved = sanitizeText(entry.toolUseResult?.resolvedModel, 40);
366
+ if (resolved)
367
+ agent.model = resolved;
368
+ if (entry.toolUseResult?.isAsync === true || entry.toolUseResult?.status === "async_launched") {
369
+ agent.background = true;
370
+ }
371
+ if (!agent.background)
372
+ agent.endTime = timestamp;
373
+ }
374
+ }
375
+ }
376
+ }
377
+ complete = true;
378
+ }
379
+ catch {
380
+ // 읽는 중 오류 — 지금까지 모은 결과로 그린다 (캐시에는 남기지 않는다).
381
+ }
382
+ for (const [toolUseId, endTime] of queueCompletion) {
383
+ const agent = agents.get(toolUseId);
384
+ if (agent?.background) {
385
+ agent.endTime = endTime;
386
+ agent.status = "completed";
387
+ }
388
+ }
389
+ for (const agent of agents.values()) {
390
+ if (agent.status === "running" && agent.endTime !== undefined)
391
+ agent.status = "completed";
392
+ }
393
+ // 응답 없는 요청은 시계를 앞으로 당길 때만 채택한다 (시계가 꼬인 기록은 무시).
394
+ const anchor = cachePendingRequestAt !== undefined && (cacheAnchorAt === undefined || cachePendingRequestAt > cacheAnchorAt)
395
+ ? cachePendingRequestAt
396
+ : cacheAnchorAt;
397
+ return {
398
+ complete,
399
+ data: {
400
+ tools: Array.from(tools.values()).slice(-TOOLS_KEPT),
401
+ agents: Array.from(agents.values()).slice(-AGENTS_KEPT),
402
+ todos,
403
+ skills: Array.from(skills),
404
+ sessionStart,
405
+ sessionName: customTitle ?? slug,
406
+ advisorModel,
407
+ promptCacheAnchorAt: anchor,
408
+ promptCacheTtlSeconds: cacheTtl,
409
+ sessionTokens: tokens.total,
410
+ lastRequestUsage,
411
+ compactionCount,
412
+ ultracodeActive,
413
+ },
414
+ };
415
+ }
416
+ function normalizeTodos(value) {
417
+ if (!Array.isArray(value))
418
+ return null;
419
+ const out = [];
420
+ for (const item of value) {
421
+ if (!item || typeof item !== "object")
422
+ continue;
423
+ const todo = item;
424
+ const content = sanitizeText(todo.content);
425
+ if (!content)
426
+ continue;
427
+ out.push({ content, status: normalizeTaskStatus(todo.status) ?? "pending" });
428
+ }
429
+ return out;
430
+ }
431
+ function normalizeTaskStatus(status) {
432
+ switch (status) {
433
+ case "pending":
434
+ case "not_started":
435
+ return "pending";
436
+ case "in_progress":
437
+ case "running":
438
+ return "in_progress";
439
+ case "completed":
440
+ case "complete":
441
+ case "done":
442
+ return "completed";
443
+ default:
444
+ return null;
445
+ }
446
+ }
447
+ function resolveTaskIndex(taskId, map, length) {
448
+ if (typeof taskId !== "string" && typeof taskId !== "number")
449
+ return null;
450
+ const key = String(taskId);
451
+ const mapped = map.get(key);
452
+ if (mapped !== undefined)
453
+ return mapped;
454
+ if (/^\d+$/.test(key)) {
455
+ const index = Number.parseInt(key, 10) - 1;
456
+ if (index >= 0 && index < length)
457
+ return index;
458
+ }
459
+ return null;
460
+ }
461
+ /** 도구별로 한 줄 요약에 쓸 대상 — 파일 경로, 패턴, 명령 앞부분. */
462
+ function extractTarget(name, input) {
463
+ switch (name) {
464
+ case "Read":
465
+ case "Write":
466
+ case "Edit":
467
+ return sanitizeText(input.file_path ?? input.path) || undefined;
468
+ case "Glob":
469
+ case "Grep":
470
+ return sanitizeText(input.pattern) || undefined;
471
+ case "Skill":
472
+ return sanitizeText(input.skill, NAME_MAX_LEN) || undefined;
473
+ case "Bash": {
474
+ const cmd = sanitizeText(input.command);
475
+ if (!cmd)
476
+ return undefined;
477
+ return cmd.length > 30 ? `${cmd.slice(0, 30).trimEnd()}...` : cmd;
478
+ }
479
+ }
480
+ return undefined;
481
+ }
@@ -0,0 +1,194 @@
1
+ "use strict";
2
+ /**
3
+ * 터미널 셀 폭 계산과 폭 맞춤 — claude-hud와 같은 규칙.
4
+ * - SGR(색)과 OSC 8(하이퍼링크) escape는 폭 0
5
+ * - 한중일 문자·이모지는 2칸
6
+ * - 폭을 넘는 줄은 구분자(" | ", " │ ")에서 감고, 그래도 넘치면 말줄임표로 자른다
7
+ * statusline이 터미널 폭을 넘으면 Claude Code가 줄을 감아 HUD 줄 수가 흔들리므로
8
+ * 렌더러가 먼저 폭에 맞춘다.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.stripAnsi = stripAnsi;
12
+ exports.visualWidth = visualWidth;
13
+ exports.truncateToWidth = truncateToWidth;
14
+ exports.wrapLineToWidth = wrapLineToWidth;
15
+ exports.terminalWidth = terminalWidth;
16
+ exports.adaptiveBarWidth = adaptiveBarWidth;
17
+ exports.safeHyperlink = safeHyperlink;
18
+ // eslint-disable-next-line no-control-regex
19
+ const ANSI_ESCAPE_PATTERN = /^(?:\x1b\[[0-9;]*m|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\))/;
20
+ // eslint-disable-next-line no-control-regex
21
+ const ANSI_ESCAPE_GLOBAL = /(?:\x1b\[[0-9;]*m|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\))/g;
22
+ // eslint-disable-next-line no-control-regex
23
+ const OSC8_OPEN_OR_CLOSE = /\x1b\]8;;([^\x07\x1b]*)(?:\x07|\x1b\\)/g;
24
+ const OSC8_CLOSE = "\x1b]8;;\x1b\\";
25
+ const RESET = "\x1b[0m";
26
+ function stripAnsi(text) {
27
+ return text.replace(ANSI_ESCAPE_GLOBAL, "");
28
+ }
29
+ function codePointWidth(cp) {
30
+ if (cp === 0)
31
+ return 0;
32
+ if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0))
33
+ return 0;
34
+ if (cp >= 0x300 && cp <= 0x36f)
35
+ return 0; // combining
36
+ if (cp === 0x200b || cp === 0x200d || cp === 0xfe0e || cp === 0xfe0f)
37
+ return 0;
38
+ if ((cp >= 0x1100 && cp <= 0x115f) ||
39
+ (cp >= 0x2e80 && cp <= 0xa4cf) ||
40
+ (cp >= 0xac00 && cp <= 0xd7a3) ||
41
+ (cp >= 0xf900 && cp <= 0xfaff) ||
42
+ (cp >= 0xfe30 && cp <= 0xfe4f) ||
43
+ (cp >= 0xff00 && cp <= 0xff60) ||
44
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
45
+ (cp >= 0x1f300 && cp <= 0x1f64f) ||
46
+ (cp >= 0x1f900 && cp <= 0x1f9ff) ||
47
+ (cp >= 0x20000 && cp <= 0x3fffd)) {
48
+ return 2;
49
+ }
50
+ return 1;
51
+ }
52
+ function visualWidth(text) {
53
+ let width = 0;
54
+ for (const ch of stripAnsi(text))
55
+ width += codePointWidth(ch.codePointAt(0) ?? 0);
56
+ return width;
57
+ }
58
+ /** escape를 보존하면서 표시 폭 maxVisible 까지만 남긴다. */
59
+ function sliceVisible(text, maxVisible) {
60
+ if (maxVisible <= 0)
61
+ return "";
62
+ let out = "";
63
+ let width = 0;
64
+ let i = 0;
65
+ while (i < text.length) {
66
+ const escape = ANSI_ESCAPE_PATTERN.exec(text.slice(i));
67
+ if (escape) {
68
+ out += escape[0];
69
+ i += escape[0].length;
70
+ continue;
71
+ }
72
+ const cp = text.codePointAt(i) ?? 0;
73
+ const ch = String.fromCodePoint(cp);
74
+ const w = codePointWidth(cp);
75
+ if (width + w > maxVisible)
76
+ break;
77
+ out += ch;
78
+ width += w;
79
+ i += ch.length;
80
+ }
81
+ return out;
82
+ }
83
+ /** 잘린 자리에서 OSC 8 링크가 열려 있으면 닫아, 말줄임표까지 밑줄이 이어지지 않게 한다. */
84
+ function closeOpenHyperlink(text) {
85
+ let last = null;
86
+ let match;
87
+ OSC8_OPEN_OR_CLOSE.lastIndex = 0;
88
+ while ((match = OSC8_OPEN_OR_CLOSE.exec(text)) !== null)
89
+ last = match;
90
+ return last && last[1].length > 0 ? OSC8_CLOSE : "";
91
+ }
92
+ function truncateToWidth(text, maxWidth) {
93
+ if (maxWidth <= 0 || visualWidth(text) <= maxWidth)
94
+ return text;
95
+ const suffix = maxWidth >= 3 ? "..." : ".".repeat(maxWidth);
96
+ const sliced = sliceVisible(text, Math.max(0, maxWidth - suffix.length));
97
+ return `${sliced}${closeOpenHyperlink(sliced)}${suffix}${RESET}`;
98
+ }
99
+ /** " | " / " │ " 구분자 위치로 줄을 나눈다 (escape 안의 문자는 구분자로 보지 않는다). */
100
+ function splitWrapParts(line) {
101
+ const parts = [];
102
+ let currentStart = 0;
103
+ let separator = "";
104
+ let i = 0;
105
+ while (i < line.length) {
106
+ const escape = ANSI_ESCAPE_PATTERN.exec(line.slice(i));
107
+ if (escape) {
108
+ i += escape[0].length;
109
+ continue;
110
+ }
111
+ const found = line.startsWith(" | ", i) ? " | " : line.startsWith(" │ ", i) ? " │ " : null;
112
+ if (found) {
113
+ parts.push({ separator, segment: line.slice(currentStart, i) });
114
+ separator = found;
115
+ i += found.length;
116
+ currentStart = i;
117
+ continue;
118
+ }
119
+ i++;
120
+ }
121
+ parts.push({ separator, segment: line.slice(currentStart) });
122
+ // 첫 세그먼트가 "[model" 처럼 열린 대괄호로 시작하면 닫히는 세그먼트까지 묶어 모델 배지가 갈라지지 않게 한다.
123
+ const firstVisible = stripAnsi(parts[0].segment).trimStart();
124
+ if (firstVisible.startsWith("[") && !stripAnsi(parts[0].segment).includes("]") && parts.length > 1) {
125
+ let merged = parts[0].segment;
126
+ let consume = 1;
127
+ while (consume < parts.length) {
128
+ const next = parts[consume];
129
+ merged += `${next.separator}${next.segment}`;
130
+ consume++;
131
+ if (stripAnsi(next.segment).includes("]"))
132
+ break;
133
+ }
134
+ return [{ separator: "", segment: merged }, ...parts.slice(consume)];
135
+ }
136
+ return parts;
137
+ }
138
+ /** 폭을 넘는 줄을 구분자에서 감는다. 한 세그먼트가 그 자체로 넘치면 자른다. */
139
+ function wrapLineToWidth(line, maxWidth) {
140
+ if (maxWidth <= 0 || visualWidth(line) <= maxWidth)
141
+ return [line];
142
+ const parts = splitWrapParts(line);
143
+ if (parts.length <= 1)
144
+ return [truncateToWidth(line, maxWidth)];
145
+ const wrapped = [];
146
+ let current = parts[0].segment;
147
+ for (const part of parts.slice(1)) {
148
+ const candidate = `${current}${part.separator}${part.segment}`;
149
+ if (visualWidth(candidate) <= maxWidth) {
150
+ current = candidate;
151
+ continue;
152
+ }
153
+ wrapped.push(truncateToWidth(current, maxWidth));
154
+ current = part.segment;
155
+ }
156
+ if (current)
157
+ wrapped.push(truncateToWidth(current, maxWidth));
158
+ return wrapped;
159
+ }
160
+ function terminalWidth(configured, force = false) {
161
+ if (force && configured !== null)
162
+ return configured;
163
+ const env = Number.parseInt(process.env.COLUMNS ?? "", 10);
164
+ if (Number.isFinite(env) && env > 0)
165
+ return Math.min(1000, env);
166
+ const cols = process.stdout?.columns;
167
+ if (typeof cols === "number" && cols > 0)
168
+ return Math.min(1000, cols);
169
+ return configured;
170
+ }
171
+ /** 진행 바 폭 — 넓은 터미널 10칸, 중간 6칸, 좁으면 4칸 (claude-hud와 동일). */
172
+ function adaptiveBarWidth(width) {
173
+ if (width === null)
174
+ return 10;
175
+ if (width >= 100)
176
+ return 10;
177
+ if (width >= 60)
178
+ return 6;
179
+ return 4;
180
+ }
181
+ /** OSC 8 하이퍼링크. https:/file: 만 허용하고, 그 외에는 텍스트만 돌려준다. */
182
+ function safeHyperlink(uri, text) {
183
+ if (!uri)
184
+ return text;
185
+ try {
186
+ const parsed = new URL(uri.replace(/[\p{Cc}\p{Cf}]/gu, ""));
187
+ if (parsed.protocol !== "https:" && parsed.protocol !== "file:")
188
+ return text;
189
+ return `\x1b]8;;${parsed.toString()}\x1b\\${text}\x1b]8;;\x1b\\`;
190
+ }
191
+ catch {
192
+ return text;
193
+ }
194
+ }