claude-code-watcher 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,58 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { SessionStore } from '../core/store.mjs';
3
+ import { Renderer } from '../ui/renderer.mjs';
4
+ import { Keyboard } from '../input/keyboard.mjs';
5
+
6
+ if (!process.stdin.isTTY) {
7
+ console.error('Error: ccw requires an interactive terminal (TTY).');
8
+ console.error('Run this in a VS Code terminal tab or similar TTY environment.');
9
+ process.exit(1);
10
+ }
11
+
12
+ // Suppress EIO errors emitted on stdin during shutdown (expected when the TTY
13
+ // handle is destroyed or the terminal tears down while reads are still pending).
14
+ process.stdin.on('error', () => {});
15
+
16
+ const store = new SessionStore();
17
+ const keyboard = new Keyboard();
18
+ const renderer = new Renderer(store, keyboard);
19
+
20
+ // Guard flag prevents duplicate cleanup from concurrent signal paths
21
+ let cleanedUp = false;
22
+ function cleanup() {
23
+ if (cleanedUp) return;
24
+ cleanedUp = true;
25
+ renderer.stop();
26
+ keyboard.stop();
27
+ store.stop();
28
+ }
29
+
30
+ process.on('SIGINT', () => { cleanup(); process.exit(0); });
31
+ process.on('SIGTERM', () => { cleanup(); process.exit(0); });
32
+
33
+ keyboard.on('quit', () => { cleanup(); process.exit(0); });
34
+ keyboard.on('respawn', () => {
35
+ if (cleanedUp) return;
36
+ cleanedUp = true;
37
+ renderer.stop();
38
+ store.stop();
39
+ // Restore raw mode but keep stdin open so the child can inherit it.
40
+ // unref() lets the event loop drain naturally instead of calling exit(),
41
+ // which avoids the EIO error the child would get if the parent closed fd 0 first.
42
+ if (process.stdin.isTTY) {
43
+ try { process.stdin.setRawMode(false); } catch { /* ignore */ }
44
+ }
45
+ process.stdin.removeAllListeners('data');
46
+ process.stdin.unref();
47
+ spawn(process.argv[0], process.argv.slice(1), { stdio: 'inherit' });
48
+ });
49
+
50
+ store.on('error', (err) => {
51
+ cleanup();
52
+ console.error('Store error:', err.message);
53
+ process.exit(1);
54
+ });
55
+
56
+ keyboard.start();
57
+ store.start();
58
+ renderer.start();
@@ -0,0 +1,56 @@
1
+ import { SessionStore } from '../core/store.mjs';
2
+ import { BOLD, color, FG, RESET, visibleLength } from '../ui/ansi.mjs';
3
+ import { formatStatus, truncate } from '../ui/format.mjs';
4
+
5
+ const store = new SessionStore();
6
+ store.start();
7
+ store.stop();
8
+
9
+ const sessions = store.getSessions();
10
+
11
+ if (sessions.length === 0) {
12
+ console.log('No active Claude sessions.');
13
+ console.log(
14
+ `\nRun "ccw setup" to install hooks, then start a Claude Code session.`,
15
+ );
16
+ process.exit(0);
17
+ }
18
+
19
+ const colW = { project: 24, status: 12, message: 40, since: 6 };
20
+
21
+ // Header
22
+ console.log('');
23
+ console.log(
24
+ ` ${BOLD}${color(FG.BRIGHT_WHITE, '#')} ` +
25
+ `${'Project'.padEnd(colW.project)} ` +
26
+ `${'Status'.padEnd(colW.status)} ` +
27
+ `${'Last Message'.padEnd(colW.message)} ` +
28
+ `${'Since'.padEnd(colW.since)}${RESET}`,
29
+ );
30
+ console.log(
31
+ 'โ”€'.repeat(
32
+ 4 + 1 + colW.project + 1 + colW.status + 1 + colW.message + 1 + colW.since,
33
+ ),
34
+ );
35
+
36
+ sessions.forEach((session, i) => {
37
+ const num = String(i + 1).padEnd(4);
38
+ const project = truncate(session.displayName, colW.project).padEnd(
39
+ colW.project,
40
+ );
41
+ const msg = truncate(
42
+ session.message || session.lastResponse || '',
43
+ colW.message,
44
+ ).padEnd(colW.message);
45
+ const since = (session.sinceLabel || '').padEnd(colW.since);
46
+
47
+ const statusColored = formatStatus(session.status);
48
+ const statusPadded =
49
+ statusColored +
50
+ ' '.repeat(Math.max(0, colW.status - visibleLength(session.status)));
51
+
52
+ console.log(`${num} ${project} ${statusPadded} ${msg} ${since}`);
53
+ });
54
+
55
+ console.log('');
56
+ console.log(`Total: ${sessions.length} session(s)`);
@@ -0,0 +1,26 @@
1
+ import fs from 'node:fs';
2
+ import { CONFIG_FILE } from './paths.mjs';
3
+
4
+ const DEFAULTS = {
5
+ sounds: {
6
+ noti: 'Funk',
7
+ stop: 'Blow',
8
+ },
9
+ };
10
+
11
+ export function readConfig() {
12
+ try {
13
+ const parsed = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
14
+ return {
15
+ ...DEFAULTS,
16
+ ...parsed,
17
+ sounds: { ...DEFAULTS.sounds, ...parsed.sounds },
18
+ };
19
+ } catch {
20
+ return { ...DEFAULTS, sounds: { ...DEFAULTS.sounds } };
21
+ }
22
+ }
23
+
24
+ export function writeConfig(config) {
25
+ fs.writeFileSync(CONFIG_FILE, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
26
+ }
@@ -0,0 +1,12 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+
4
+ export const HOME_DIR = homedir();
5
+ export const CLAUDE_DIR = join(HOME_DIR, '.claude');
6
+ export const DASHBOARD_DIR = join(CLAUDE_DIR, 'dashboard');
7
+ export const ACTIVE_DIR = join(DASHBOARD_DIR, 'active');
8
+ export const HOOKS_DIR = join(CLAUDE_DIR, 'hooks');
9
+ export const SETTINGS_FILE = join(CLAUDE_DIR, 'settings.json');
10
+ export const HOOK_SCRIPT_NAME = 'session-tracker.sh';
11
+ export const HOOK_SCRIPT_PATH = join(HOOKS_DIR, HOOK_SCRIPT_NAME);
12
+ export const CONFIG_FILE = join(DASHBOARD_DIR, 'config.json');
@@ -0,0 +1,107 @@
1
+ export const STALE_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes
2
+
3
+ /**
4
+ * Parse raw JSON data into a SessionRecord.
5
+ * Returns null if the data is invalid.
6
+ * @param {unknown} raw
7
+ * @param {string} filename
8
+ * @returns {SessionRecord|null}
9
+ */
10
+ export function parseSession(raw, filename) {
11
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
12
+
13
+ const sessionId = raw.session || filename.replace(/\.json$/, '');
14
+ if (!sessionId) return null;
15
+
16
+ return {
17
+ sessionId,
18
+ project: raw.project || 'unknown',
19
+ cwd: raw.cwd || '',
20
+ status: raw.status || 'waiting',
21
+ message: raw.message || '',
22
+ lastResponse: raw.lastResponse || '',
23
+ sessionName: raw.sessionName || '',
24
+ transcript: raw.transcript || '',
25
+ alertAt: raw.alertAt || '',
26
+ alertEvent: raw.alertEvent || '',
27
+ updated: raw.updated || '',
28
+ startedAt: raw.startedAt || '',
29
+ tty: raw.tty || '',
30
+ subagents: Array.isArray(raw.subagents) ? raw.subagents : [],
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Add derived display fields to a SessionRecord.
36
+ * @param {SessionRecord} record
37
+ * @param {Date} now
38
+ * @returns {DerivedSession}
39
+ */
40
+ export function deriveSession(record, now = new Date()) {
41
+ const updatedAt = parseDate(record.updated) || now;
42
+ const startedAt = parseDate(record.startedAt) || now;
43
+ const status = classifyStatus(record.status, updatedAt, now);
44
+
45
+ return {
46
+ ...record,
47
+ updatedAt,
48
+ startedAt,
49
+ status,
50
+ sinceLabel: formatClock(updatedAt),
51
+ displayName: buildDisplayName(record),
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Classify session status, marking as stale if inactive for > STALE_THRESHOLD_MS.
57
+ * @param {string} status
58
+ * @param {Date} updatedAt
59
+ * @param {Date} now
60
+ * @returns {string}
61
+ */
62
+ export function classifyStatus(status, updatedAt, now = new Date()) {
63
+ if (status === 'error') return 'error';
64
+
65
+ const sinceMs = now - updatedAt;
66
+ if (sinceMs > STALE_THRESHOLD_MS && status !== 'working' && status !== 'notification') return 'stale';
67
+
68
+ return status || 'waiting';
69
+ }
70
+
71
+ /**
72
+ * Parse a date string in the format "YYYY-MM-DD HH:MM:SS" or ISO 8601.
73
+ * @param {string} str
74
+ * @returns {Date|null}
75
+ */
76
+ function parseDate(str) {
77
+ if (!str) return null;
78
+ // Handle "YYYY-MM-DD HH:MM:SS" format
79
+ const normalized = str.replace(' ', 'T');
80
+ const d = new Date(normalized);
81
+ return Number.isNaN(d.getTime()) ? null : d;
82
+ }
83
+
84
+ /**
85
+ * Build a human-readable display name, appending the TTY short name
86
+ * so multiple sessions in the same project can be told apart.
87
+ * e.g. "/dev/ttys003" โ†’ "my-project ยท s003"
88
+ */
89
+ function buildDisplayName(record) {
90
+ const base = record.sessionName || record.project || record.sessionId;
91
+ if (!record.tty) return base;
92
+ // "/dev/ttys003" โ†’ "s003", "/dev/pts/3" โ†’ "pts/3"
93
+ const short = record.tty.replace(/^\/dev\/(tty)?/, '');
94
+ return `[${short}] ${base}`;
95
+ }
96
+
97
+ /**
98
+ * Format a Date to HH:MM string (last-updated clock time).
99
+ * @param {Date} date
100
+ * @returns {string}
101
+ */
102
+ function formatClock(date) {
103
+ if (!(date instanceof Date)) return '--:--';
104
+ const h = String(date.getHours()).padStart(2, '0');
105
+ const m = String(date.getMinutes()).padStart(2, '0');
106
+ return `${h}:${m}`;
107
+ }
@@ -0,0 +1,153 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import fs from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { ACTIVE_DIR } from './paths.mjs';
5
+ import { deriveSession, parseSession } from './session.mjs';
6
+
7
+ const POLL_INTERVAL_MS = 2000;
8
+
9
+ export class SessionStore extends EventEmitter {
10
+ #sessions = new Map(); // sessionId -> SessionRecord
11
+ #watcher = null;
12
+ #pollTimer = null;
13
+ // Per-file debounce timers: filename -> TimeoutId
14
+ #pendingTimers = new Map();
15
+
16
+ start() {
17
+ fs.mkdirSync(ACTIVE_DIR, { recursive: true });
18
+ this.#loadAll();
19
+ this.#startWatching();
20
+ }
21
+
22
+ stop() {
23
+ if (this.#watcher) {
24
+ this.#watcher.close();
25
+ this.#watcher = null;
26
+ }
27
+ if (this.#pollTimer) {
28
+ clearInterval(this.#pollTimer);
29
+ this.#pollTimer = null;
30
+ }
31
+ for (const timer of this.#pendingTimers.values()) {
32
+ clearTimeout(timer);
33
+ }
34
+ this.#pendingTimers.clear();
35
+ }
36
+
37
+ getSessions() {
38
+ const now = new Date();
39
+ return Array.from(this.#sessions.values())
40
+ .map(s => deriveSession(s, now))
41
+ .sort((a, b) => (a.tty || '').localeCompare(b.tty || ''));
42
+ }
43
+
44
+ getSubagents(parentId) {
45
+ return this.#sessions.get(parentId)?.subagents ?? [];
46
+ }
47
+
48
+ reload() {
49
+ this.#loadAll();
50
+ this.emit('update', this.getSessions());
51
+ }
52
+
53
+ #loadAll() {
54
+ let files;
55
+ try {
56
+ files = fs.readdirSync(ACTIVE_DIR);
57
+ } catch {
58
+ return;
59
+ }
60
+
61
+ for (const file of files) {
62
+ if (!file.endsWith('.json')) continue;
63
+ this.#loadFile(join(ACTIVE_DIR, file), file);
64
+ }
65
+ }
66
+
67
+ #loadFile(filePath, filename) {
68
+ try {
69
+ const content = fs.readFileSync(filePath, 'utf8');
70
+ const raw = JSON.parse(content);
71
+ const record = parseSession(raw, filename);
72
+ if (!record) return;
73
+
74
+ // Auto-delete session files left by force-killed Claude Code.
75
+ // process.kill(pid, 0) checks existence without sending a signal.
76
+ if (raw.ppid) {
77
+ try {
78
+ process.kill(raw.ppid, 0);
79
+ } catch (err) {
80
+ if (err.code === 'ESRCH') {
81
+ // Process no longer exists โ€” orphaned file, clean up.
82
+ try { fs.unlinkSync(filePath); } catch { /* already gone */ }
83
+ return;
84
+ }
85
+ // EPERM means process exists but we lack permission โ€” still alive.
86
+ }
87
+ }
88
+
89
+ this.#sessions.set(record.sessionId, record);
90
+ } catch {
91
+ // Skip files with parse errors; will retry on next event
92
+ }
93
+ }
94
+
95
+ #startWatching() {
96
+ try {
97
+ this.#watcher = fs.watch(
98
+ ACTIVE_DIR,
99
+ { persistent: false },
100
+ (_, filename) => {
101
+ if (!filename || !filename.endsWith('.json')) return;
102
+
103
+ // Debounce per file: cancel previous timer for this file and reset
104
+ const existing = this.#pendingTimers.get(filename);
105
+ if (existing) clearTimeout(existing);
106
+
107
+ const timer = setTimeout(() => {
108
+ this.#pendingTimers.delete(filename);
109
+ const filePath = join(ACTIVE_DIR, filename);
110
+ const sessionId = filename.replace(/\.json$/, '');
111
+
112
+ try {
113
+ if (!fs.existsSync(filePath)) {
114
+ this.#sessions.delete(sessionId);
115
+ } else {
116
+ this.#loadFile(filePath, filename);
117
+ }
118
+ this.emit('update', this.getSessions());
119
+ } catch (err) {
120
+ this.emit('error', err);
121
+ }
122
+ }, 50);
123
+
124
+ this.#pendingTimers.set(filename, timer);
125
+ },
126
+ );
127
+
128
+ this.#watcher.on('error', err => {
129
+ // Close properly before falling back to polling
130
+ try {
131
+ this.#watcher.close();
132
+ } catch {
133
+ /* ignore */
134
+ }
135
+ this.#watcher = null;
136
+ this.#startPolling();
137
+ });
138
+ } catch {
139
+ this.#startPolling();
140
+ }
141
+ }
142
+
143
+ #startPolling() {
144
+ this.#pollTimer = setInterval(() => {
145
+ try {
146
+ this.#loadAll();
147
+ this.emit('update', this.getSessions());
148
+ } catch (err) {
149
+ this.emit('error', err);
150
+ }
151
+ }, POLL_INTERVAL_MS);
152
+ }
153
+ }
@@ -0,0 +1,126 @@
1
+ import { EventEmitter } from 'node:events';
2
+
3
+ export class Keyboard extends EventEmitter {
4
+ #wasRaw = false;
5
+ // Incomplete escape sequence buffered from a previous chunk
6
+ #escBuf = '';
7
+ #escTimer = null;
8
+ // How long to wait for the rest of an escape sequence before treating \x1b as bare ESC
9
+ static #ESC_TIMEOUT_MS = 30;
10
+
11
+ start() {
12
+ if (!process.stdin.isTTY) {
13
+ return;
14
+ }
15
+
16
+ this.#wasRaw = process.stdin.isRaw || false;
17
+ process.stdin.setRawMode(true);
18
+ process.stdin.resume();
19
+ process.stdin.setEncoding('utf8');
20
+
21
+ process.stdin.on('data', chunk => this.#handleChunk(chunk));
22
+ }
23
+
24
+ stop() {
25
+ if (this.#escTimer !== null) {
26
+ clearTimeout(this.#escTimer);
27
+ this.#escTimer = null;
28
+ }
29
+ if (process.stdin.isTTY) {
30
+ try {
31
+ process.stdin.setRawMode(this.#wasRaw);
32
+ } catch {
33
+ // ignore
34
+ }
35
+ }
36
+ // destroy() fully closes the handle so the event loop drains cleanly.
37
+ // pause() only stops reading but keeps the handle ref'd, causing EIO on exit.
38
+ process.stdin.destroy();
39
+ }
40
+
41
+ #handleChunk(chunk) {
42
+ // Combine with any buffered incomplete escape sequence from the previous chunk
43
+ if (this.#escBuf) {
44
+ chunk = this.#escBuf + chunk;
45
+ this.#escBuf = '';
46
+ clearTimeout(this.#escTimer);
47
+ this.#escTimer = null;
48
+ }
49
+
50
+ // If the chunk ends with a potentially incomplete escape sequence (\x1b or \x1b[),
51
+ // buffer the tail and wait for the next chunk to complete it.
52
+ const tail = chunk.endsWith('\x1b[') ? 2
53
+ : chunk.endsWith('\x1b') ? 1
54
+ : 0;
55
+
56
+ if (tail > 0) {
57
+ const before = chunk.slice(0, -tail);
58
+ if (before) {
59
+ for (const key of parseKeys(before)) this.emit('key', key);
60
+ }
61
+ this.#escBuf = chunk.slice(-tail);
62
+ // After timeout, the user just pressed ESC alone โ€” flush it
63
+ this.#escTimer = setTimeout(() => {
64
+ this.#escTimer = null;
65
+ const buf = this.#escBuf;
66
+ this.#escBuf = '';
67
+ if (buf === '\x1b') {
68
+ this.emit('key', { name: 'escape', ctrl: false, meta: false });
69
+ }
70
+ // '\x1b[' with no final byte: discard (unknown sequence)
71
+ }, Keyboard.#ESC_TIMEOUT_MS);
72
+ return;
73
+ }
74
+
75
+ for (const key of parseKeys(chunk)) {
76
+ this.emit('key', key);
77
+ }
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Parse a raw terminal input chunk into zero or more key descriptors.
83
+ * A single chunk can contain multiple sequences when keys are pressed rapidly.
84
+ * @param {string} chunk
85
+ * @returns {Array<{ name: string, ctrl: boolean, meta: boolean }>}
86
+ */
87
+ function parseKeys(chunk) {
88
+ const keys = [];
89
+ let i = 0;
90
+ while (i < chunk.length) {
91
+ const ch = chunk[i];
92
+
93
+ if (ch === '\x03') { // Ctrl+C
94
+ keys.push({ name: 'c', ctrl: true, meta: false });
95
+ i += 1;
96
+ } else if (ch === '\r' || ch === '\n') {
97
+ keys.push({ name: 'return', ctrl: false, meta: false });
98
+ i += 1;
99
+ } else if (ch === '\x1b') {
100
+ if (chunk[i + 1] === '[') {
101
+ if (chunk[i + 2] === 'A') {
102
+ keys.push({ name: 'up', ctrl: false, meta: false });
103
+ i += 3;
104
+ } else if (chunk[i + 2] === 'B') {
105
+ keys.push({ name: 'down', ctrl: false, meta: false });
106
+ i += 3;
107
+ } else {
108
+ // Unknown CSI sequence โ€” skip ESC and let next iteration handle '['
109
+ keys.push({ name: 'escape', ctrl: false, meta: false });
110
+ i += 1;
111
+ }
112
+ } else {
113
+ // Bare ESC (or ESC not followed by '[')
114
+ keys.push({ name: 'escape', ctrl: false, meta: false });
115
+ i += 1;
116
+ }
117
+ } else if (ch >= ' ') {
118
+ // Regular printable character (preserve case so callers can distinguish r vs R)
119
+ keys.push({ name: ch, ctrl: false, meta: false });
120
+ i += 1;
121
+ } else {
122
+ i += 1; // skip unknown control character
123
+ }
124
+ }
125
+ return keys;
126
+ }
@@ -0,0 +1,164 @@
1
+ // ANSI escape code constants and helpers
2
+
3
+ const ESC = '\x1b';
4
+ const CSI = `${ESC}[`;
5
+ const OSC = `${ESC}]`;
6
+
7
+ export const RESET = `${CSI}0m`;
8
+ export const BOLD = `${CSI}1m`;
9
+ export const DIM = `${CSI}2m`;
10
+ export const ITALIC = `${CSI}3m`;
11
+ export const UNDERLINE = `${CSI}4m`;
12
+ export const REVERSE = `${CSI}7m`;
13
+
14
+ // Foreground colors
15
+ export const FG = {
16
+ BLACK: `${CSI}30m`,
17
+ RED: `${CSI}31m`,
18
+ GREEN: `${CSI}32m`,
19
+ YELLOW: `${CSI}33m`,
20
+ BLUE: `${CSI}34m`,
21
+ MAGENTA: `${CSI}35m`,
22
+ CYAN: `${CSI}36m`,
23
+ WHITE: `${CSI}37m`,
24
+ BRIGHT_BLACK: `${CSI}90m`, // gray
25
+ BRIGHT_RED: `${CSI}91m`,
26
+ BRIGHT_GREEN: `${CSI}92m`,
27
+ BRIGHT_YELLOW: `${CSI}93m`,
28
+ BRIGHT_BLUE: `${CSI}94m`,
29
+ BRIGHT_MAGENTA: `${CSI}95m`,
30
+ BRIGHT_CYAN: `${CSI}96m`,
31
+ BRIGHT_WHITE: `${CSI}97m`,
32
+ };
33
+
34
+ // Background colors
35
+ export const BG = {
36
+ BLACK: `${CSI}40m`,
37
+ RED: `${CSI}41m`,
38
+ GREEN: `${CSI}42m`,
39
+ YELLOW: `${CSI}43m`,
40
+ BLUE: `${CSI}44m`,
41
+ MAGENTA: `${CSI}45m`,
42
+ CYAN: `${CSI}46m`,
43
+ WHITE: `${CSI}47m`,
44
+ BRIGHT_BLACK: `${CSI}100m`,
45
+ BRIGHT_WHITE: `${CSI}107m`,
46
+ };
47
+
48
+ // Cursor control
49
+ export const CURSOR_HOME = `${CSI}H`;
50
+ export const CURSOR_HIDE = `${CSI}?25l`;
51
+ export const CURSOR_SHOW = `${CSI}?25h`;
52
+ export const CLEAR_SCREEN = `${CSI}2J`;
53
+ export const CLEAR_LINE = `${CSI}K`;
54
+
55
+ // Alternate screen buffer
56
+ export const ALT_SCREEN_ON = `${CSI}?1049h`;
57
+ export const ALT_SCREEN_OFF = `${CSI}?1049l`;
58
+
59
+ // Auto-wrap mode (disable to prevent last-column wrapping artifacts)
60
+ export const WRAP_OFF = `${CSI}?7l`;
61
+ export const WRAP_ON = `${CSI}?7h`;
62
+
63
+ /**
64
+ * Move cursor to row, col (1-indexed)
65
+ */
66
+ export function moveTo(row, col) {
67
+ return `${CSI}${row};${col}H`;
68
+ }
69
+
70
+ /**
71
+ * Wrap text in ANSI color codes, reset at end.
72
+ */
73
+ export function color(ansiCode, text) {
74
+ return `${ansiCode}${text}${RESET}`;
75
+ }
76
+
77
+ /**
78
+ * Create an OSC 8 hyperlink (clickable in supported terminals like VS Code)
79
+ * @param {string} url
80
+ * @param {string} text
81
+ */
82
+ export function hyperlink(url, text) {
83
+ return `${OSC}8;;${url}\x07${text}${OSC}8;;\x07`;
84
+ }
85
+
86
+ /**
87
+ * Strip all ANSI escape sequences from a string (for length measurement)
88
+ */
89
+ // RegExp constructor used intentionally: Biome noControlCharactersInRegex
90
+ // does not check string arguments, only regex literals.
91
+ const STRIP_ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07/g;
92
+
93
+ export function stripAnsi(str) {
94
+ STRIP_ANSI_RE.lastIndex = 0;
95
+ return str.replace(STRIP_ANSI_RE, '');
96
+ }
97
+
98
+ /**
99
+ * Returns the terminal display width of a single Unicode code point.
100
+ * CJK/Hangul/fullwidth/emoji characters occupy 2 columns; most others occupy 1.
101
+ * Zero-width characters (ZWJ, variation selectors, combining marks) return 0.
102
+ */
103
+ export function charDisplayWidth(cp) {
104
+ if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0)) return 0; // control chars
105
+ if (cp === 0x200d) return 0; // ZWJ (Zero Width Joiner)
106
+ if (cp >= 0xfe00 && cp <= 0xfe0f) return 0; // variation selectors VS1โ€“VS16
107
+ if (cp >= 0xe0100 && cp <= 0xe01ef) return 0; // variation selectors supplement
108
+ if (cp >= 0x300 && cp <= 0x36f) return 0; // combining diacritical marks
109
+ if (
110
+ (cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
111
+ cp === 0x2329 ||
112
+ cp === 0x232a ||
113
+ (cp >= 0x2e80 && cp <= 0x303e) || // CJK Radicals
114
+ (cp >= 0x3040 && cp <= 0x33ff) || // Kana, Bopomofo, etc.
115
+ (cp >= 0x3400 && cp <= 0x4dbf) || // CJK Extension A
116
+ (cp >= 0x4e00 && cp <= 0xa4cf) || // CJK Unified Ideographs + Yi
117
+ (cp >= 0xa960 && cp <= 0xa97f) || // Hangul Jamo Extended-A
118
+ (cp >= 0xac00 && cp <= 0xd7ff) || // Hangul Syllables
119
+ (cp >= 0xf900 && cp <= 0xfaff) || // CJK Compatibility Ideographs
120
+ (cp >= 0xfe10 && cp <= 0xfe6f) || // CJK Compatibility Forms + Small Forms
121
+ (cp >= 0xff01 && cp <= 0xff60) || // Fullwidth Forms
122
+ (cp >= 0xffe0 && cp <= 0xffe6) || // Fullwidth Signs
123
+ (cp >= 0x1b000 && cp <= 0x1b0ff) || // Kana Supplement
124
+ (cp >= 0x1f1e0 && cp <= 0x1f1ff) || // Regional indicator symbols (flags)
125
+ (cp >= 0x1f300 && cp <= 0x1faff) || // Emoji: Misc Symbols, Transport, Supplemental, etc.
126
+ (cp >= 0x1fb00 && cp <= 0x1fbff) || // Symbols for Legacy Computing
127
+ (cp >= 0x20000 && cp <= 0x2fffd) || // CJK Extension Bโ€“F
128
+ (cp >= 0x30000 && cp <= 0x3fffd) // CJK Extension G+
129
+ )
130
+ return 2;
131
+ return 1;
132
+ }
133
+
134
+ // Module-level segmenter for grapheme cluster splitting (Node.js 16+)
135
+ const _segmenter = new Intl.Segmenter();
136
+
137
+ /**
138
+ * Returns the terminal display width of a grapheme cluster (one user-perceived character).
139
+ * Handles ZWJ sequences (๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ), regional indicator pairs (๐Ÿ‡ฐ๐Ÿ‡ท), skin tone modifiers (๐Ÿ‘‹๐Ÿฝ),
140
+ * and VS16 emoji presentation (โค๏ธ).
141
+ */
142
+ export function graphemeDisplayWidth(segment) {
143
+ const cp = segment.codePointAt(0);
144
+ const w = charDisplayWidth(cp);
145
+ // A grapheme cluster containing VS16 (U+FE0F) forces emoji presentation โ†’ 2 columns
146
+ if (w === 1 && segment.includes('\uFE0F')) return 2;
147
+ return w;
148
+ }
149
+
150
+ /**
151
+ * Get the visible display width of a string (without ANSI codes).
152
+ * Uses grapheme cluster segmentation to correctly handle emoji sequences,
153
+ * ZWJ sequences, regional indicator pairs, and skin tone modifiers.
154
+ */
155
+ export function visibleLength(str) {
156
+ const plain = stripAnsi(str);
157
+ let width = 0;
158
+ for (const { segment } of _segmenter.segment(plain)) {
159
+ width += graphemeDisplayWidth(segment);
160
+ }
161
+ return width;
162
+ }
163
+
164
+ export { _segmenter };