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.
- package/LICENSE +21 -0
- package/README.md +139 -0
- package/bin/ccw.js +29 -0
- package/hooks/session-tracker.mjs +165 -0
- package/hooks/session-tracker.sh +2 -0
- package/hooks/session-tracker.test.mjs +411 -0
- package/package.json +35 -0
- package/src/commands/help.mjs +39 -0
- package/src/commands/sessions.mjs +8 -0
- package/src/commands/setup.mjs +125 -0
- package/src/commands/sound.mjs +52 -0
- package/src/commands/start.mjs +58 -0
- package/src/commands/status.mjs +56 -0
- package/src/core/config.mjs +26 -0
- package/src/core/paths.mjs +12 -0
- package/src/core/session.mjs +107 -0
- package/src/core/store.mjs +153 -0
- package/src/input/keyboard.mjs +126 -0
- package/src/ui/ansi.mjs +164 -0
- package/src/ui/format.mjs +84 -0
- package/src/ui/layout.mjs +10 -0
- package/src/ui/renderer.mjs +507 -0
|
@@ -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
|
+
}
|
package/src/ui/ansi.mjs
ADDED
|
@@ -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 };
|