tui-cap 0.1.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.
package/dist/paths.js ADDED
@@ -0,0 +1,100 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.DEFAULT_CAPTURE_PREFIX = void 0;
7
+ exports.capturesDir = capturesDir;
8
+ exports.isExplicitPath = isExplicitPath;
9
+ exports.resolveOut = resolveOut;
10
+ exports.resolveInput = resolveInput;
11
+ exports.nextCaptureName = nextCaptureName;
12
+ const promises_1 = require("node:fs/promises");
13
+ const node_os_1 = __importDefault(require("node:os"));
14
+ const node_path_1 = __importDefault(require("node:path"));
15
+ /** Default base name for captures recorded without an explicit `-o`. */
16
+ exports.DEFAULT_CAPTURE_PREFIX = 'copilot-capture';
17
+ /**
18
+ * The folder where captures and exports live by default, so files land in one
19
+ * place regardless of which repo you run from: `$GHCP_CAPTURE_DIR`, or
20
+ * `~/captures` when that isn't set.
21
+ */
22
+ function capturesDir() {
23
+ const fromEnv = process.env.GHCP_CAPTURE_DIR;
24
+ if (fromEnv && fromEnv.trim() !== '')
25
+ return fromEnv;
26
+ return node_path_1.default.join(node_os_1.default.homedir(), 'captures');
27
+ }
28
+ /**
29
+ * True when a name is an explicit location the user clearly meant to be
30
+ * relative to the current directory — an absolute path or one containing a path
31
+ * separator — rather than a bare filename destined for the captures folder.
32
+ */
33
+ function isExplicitPath(name) {
34
+ return node_path_1.default.isAbsolute(name) || name.includes('/') || name.includes(node_path_1.default.sep);
35
+ }
36
+ /**
37
+ * Resolve an output path so captures land in one place regardless of the repo
38
+ * you run from. A bare filename (or an omitted value) goes in the captures
39
+ * folder; a value containing a path separator (or an absolute path) is kept
40
+ * as-is, relative to the current working directory.
41
+ */
42
+ function resolveOut(value, defaultName) {
43
+ const name = value ?? defaultName;
44
+ if (isExplicitPath(name))
45
+ return name;
46
+ return node_path_1.default.join(capturesDir(), name);
47
+ }
48
+ /**
49
+ * Resolve an input path. An explicit path (absolute or containing a separator)
50
+ * is used as-is. A bare filename is looked up in the current directory first,
51
+ * then in the captures folder, so `render shot.ans` finds a capture recorded
52
+ * with `record -o shot.ans` regardless of where you run it.
53
+ */
54
+ async function resolveInput(value) {
55
+ if (isExplicitPath(value))
56
+ return value;
57
+ try {
58
+ await (0, promises_1.stat)(value);
59
+ return value;
60
+ }
61
+ catch {
62
+ // not in the current directory
63
+ }
64
+ const inCaptures = node_path_1.default.join(capturesDir(), value);
65
+ try {
66
+ await (0, promises_1.stat)(inCaptures);
67
+ return inCaptures;
68
+ }
69
+ catch {
70
+ // fall through so readFile reports a clear error for the original name
71
+ }
72
+ return value;
73
+ }
74
+ function escapeRegExp(input) {
75
+ return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
76
+ }
77
+ /**
78
+ * The next un-taken auto name in the captures folder — `copilot-capture_01.ans`,
79
+ * `copilot-capture_02.ans`, and so on. Used when `record` runs without an
80
+ * explicit `-o` so repeated captures don't clobber a single fixed filename.
81
+ * Scans the folder and picks the lowest free index (2-digit zero-padded).
82
+ */
83
+ async function nextCaptureName(prefix = exports.DEFAULT_CAPTURE_PREFIX, ext = '.ans') {
84
+ const used = new Set();
85
+ const re = new RegExp(`^${escapeRegExp(prefix)}_(\\d+)${escapeRegExp(ext)}$`, 'i');
86
+ try {
87
+ for (const entry of await (0, promises_1.readdir)(capturesDir())) {
88
+ const match = entry.match(re);
89
+ if (match)
90
+ used.add(Number.parseInt(match[1], 10));
91
+ }
92
+ }
93
+ catch {
94
+ // captures folder doesn't exist yet → start at 1
95
+ }
96
+ let n = 1;
97
+ while (used.has(n))
98
+ n += 1;
99
+ return `${prefix}_${String(n).padStart(2, '0')}${ext}`;
100
+ }
@@ -0,0 +1,148 @@
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.recordSessionPty = recordSessionPty;
37
+ const node_fs_1 = require("node:fs");
38
+ const meta_1 = require("./meta");
39
+ const timing_1 = require("./timing");
40
+ const input_1 = require("./input");
41
+ /**
42
+ * Opt-in capture backend built on `node-pty`. Unlike the default `script`
43
+ * wrapper, node-pty lets us own the PTY directly: we set its size, stream raw
44
+ * bytes straight to the output file, and record the dimensions together with the
45
+ * bytes — no reliance on the system `script` arg quirks.
46
+ *
47
+ * `node-pty` is an OPTIONAL native dependency (it needs a compile step), so it's
48
+ * imported lazily through a variable specifier; tsc won't require it to be
49
+ * installed, and a missing install yields a clear, actionable error.
50
+ */
51
+ async function recordSessionPty(command, outFile, opts = {}) {
52
+ if (command.length === 0) {
53
+ throw new Error('record: no command provided (use `-- <command...>`)');
54
+ }
55
+ // A variable specifier keeps tsc from resolving (and thus requiring) the
56
+ // optional native module at build time.
57
+ const moduleName = 'node-pty';
58
+ let pty;
59
+ try {
60
+ pty = await Promise.resolve(`${moduleName}`).then(s => __importStar(require(s)));
61
+ }
62
+ catch {
63
+ throw new Error('The pty backend needs the optional `node-pty` dependency.\n' +
64
+ ' Install it with: npm install node-pty\n' +
65
+ ' Then re-run with: --backend pty\n' +
66
+ '(node-pty is native; the default `script` backend needs no build step.)');
67
+ }
68
+ const { cols, rows } = (0, meta_1.terminalSize)();
69
+ const [file, ...args] = command;
70
+ const out = (0, node_fs_1.createWriteStream)(outFile);
71
+ // One shared clock for both sidecars so keystroke times line up with the
72
+ // frame appearance times derived from the timing checkpoints.
73
+ const startMs = Date.now();
74
+ const tracker = new timing_1.TimingTracker(startMs);
75
+ const captureInput = opts.input !== false && Boolean(process.stdin.isTTY);
76
+ const inputTracker = captureInput ? new input_1.InputTracker(startMs) : null;
77
+ const child = pty.spawn(file, args, {
78
+ name: process.env.TERM ?? 'xterm-256color',
79
+ cols,
80
+ rows,
81
+ cwd: process.cwd(),
82
+ env: process.env,
83
+ });
84
+ // Tee the child's output to both the file (raw, for replay) and our stdout
85
+ // (so the user sees the live session while it records), timestamping each
86
+ // chunk so the timing sidecar can map byte offsets back to wall-clock time.
87
+ child.onData((data) => {
88
+ tracker.mark(Buffer.byteLength(data, 'utf8'));
89
+ out.write(data);
90
+ process.stdout.write(data);
91
+ });
92
+ const isTTY = Boolean(process.stdin.isTTY);
93
+ // Record a printable-count of each keystroke burst before forwarding it, so
94
+ // the parser can tell which output frames were produced by user typing.
95
+ const onStdin = (d) => {
96
+ inputTracker?.mark(d);
97
+ child.write(d.toString('utf8'));
98
+ };
99
+ if (isTTY) {
100
+ process.stdin.setRawMode?.(true);
101
+ process.stdin.resume();
102
+ process.stdin.on('data', onStdin);
103
+ }
104
+ const onResize = () => {
105
+ const s = (0, meta_1.terminalSize)();
106
+ try {
107
+ child.resize(s.cols, s.rows);
108
+ }
109
+ catch {
110
+ /* child may have exited */
111
+ }
112
+ };
113
+ process.stdout.on('resize', onResize);
114
+ return new Promise((resolve) => {
115
+ child.onExit(({ exitCode }) => {
116
+ if (isTTY) {
117
+ process.stdin.setRawMode?.(false);
118
+ process.stdin.pause();
119
+ process.stdin.off('data', onStdin);
120
+ }
121
+ process.stdout.off('resize', onResize);
122
+ const finish = () => resolve(exitCode ?? 0);
123
+ if (opts.meta === false) {
124
+ out.end(finish);
125
+ return;
126
+ }
127
+ // Flush the capture file before writing sidecars so they describe the
128
+ // complete stream and a reader sees the full `.ans`.
129
+ out.end(() => {
130
+ const capturedAt = new Date().toISOString();
131
+ const sidecars = [
132
+ (0, meta_1.writeMeta)(outFile, {
133
+ schemaVersion: meta_1.META_SCHEMA_VERSION,
134
+ cols,
135
+ rows,
136
+ capturedAt,
137
+ command: command.join(' '),
138
+ }),
139
+ (0, timing_1.writeTiming)(outFile, tracker.build()),
140
+ ];
141
+ if (inputTracker && inputTracker.length > 0) {
142
+ sidecars.push((0, input_1.writeInput)(outFile, inputTracker.build()));
143
+ }
144
+ Promise.all(sidecars).then(finish, finish); // sidecars are best-effort
145
+ });
146
+ });
147
+ });
148
+ }
package/dist/record.js ADDED
@@ -0,0 +1,100 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.recordSession = recordSession;
7
+ const node_child_process_1 = require("node:child_process");
8
+ const node_fs_1 = require("node:fs");
9
+ const node_os_1 = __importDefault(require("node:os"));
10
+ const meta_1 = require("./meta");
11
+ const timing_1 = require("./timing");
12
+ /**
13
+ * Record a live program's raw terminal output (escape codes intact) to a file
14
+ * using the system `script` utility, which allocates a real PTY so the target
15
+ * still behaves like it's attached to a terminal.
16
+ *
17
+ * Rather than let `script` write the capture file directly, we point its own
18
+ * output at `/dev/null` and **pipe its stdout** to ourselves: every chunk is
19
+ * timestamped (for the timing sidecar that powers animation), written to the
20
+ * `.ans` file, and echoed to our stdout so the live session is still visible.
21
+ * Capturing the bytes ourselves keeps the `.ans` offsets and the timing
22
+ * checkpoints perfectly aligned. The child still sees a genuine PTY from
23
+ * `script`, sized from the controlling terminal.
24
+ *
25
+ * The PTY dimensions are written to a `<out>.meta.json` sidecar (so `render`
26
+ * replays at the recorded size) and the chunk timing to `<out>.timing.json`.
27
+ *
28
+ * The resulting `.ans` file can be replayed with `parseAnsiToGrid`.
29
+ */
30
+ function recordSession(command, outFile, opts = {}) {
31
+ if (command.length === 0) {
32
+ throw new Error('record: no command provided (use `-- <command...>`)');
33
+ }
34
+ // Snapshot the terminal size now: `script` sizes the child PTY to the current
35
+ // controlling terminal, so this matches the dimensions the program renders at.
36
+ const { cols, rows } = (0, meta_1.terminalSize)();
37
+ // Send `script`'s own typescript to the bit bucket — we capture the session
38
+ // from its stdout instead (see above). The platforms differ only in flag shape.
39
+ const sink = '/dev/null';
40
+ let bin;
41
+ let args;
42
+ if (node_os_1.default.platform() === 'darwin') {
43
+ // BSD/macOS: script [-q] file [command ...]
44
+ bin = 'script';
45
+ args = ['-q', sink, ...command];
46
+ }
47
+ else {
48
+ // util-linux: script -q -e -c "<cmd>" file
49
+ bin = 'script';
50
+ args = ['-q', '-e', '-c', command.join(' '), sink];
51
+ }
52
+ return new Promise((resolve, reject) => {
53
+ const out = (0, node_fs_1.createWriteStream)(outFile);
54
+ const tracker = new timing_1.TimingTracker();
55
+ // Inherit stdin only when we're attached to a real TTY (interactive
56
+ // recording, where a human drives the program). For non-interactive
57
+ // captures (piped stdin, or commands like `copilot -p "..."` that exit on
58
+ // their own), inheriting a non-TTY pipe makes `script` abort before running
59
+ // the command, so fall back to /dev/null via 'ignore'.
60
+ //
61
+ // NB: `script` requires its stdin to be the controlling terminal — it calls
62
+ // tcgetattr on stdin and aborts ("Operation not supported on socket") if
63
+ // handed a pipe. So we never pipe its stdin, which means the `script`
64
+ // backend can't tee keystrokes. Keystroke capture (the `.input.json`
65
+ // sidecar that powers precise typing correlation) lives in the node-pty
66
+ // backend instead; the `script` backend still gets typing detection via the
67
+ // parser's content-growth fallback.
68
+ const stdin = process.stdin.isTTY ? 'inherit' : 'ignore';
69
+ const child = (0, node_child_process_1.spawn)(bin, args, { stdio: [stdin, 'pipe', 'inherit'] });
70
+ child.stdout.on('data', (chunk) => {
71
+ tracker.mark(chunk.length);
72
+ out.write(chunk);
73
+ process.stdout.write(chunk);
74
+ });
75
+ child.on('error', reject);
76
+ child.on('exit', (code) => {
77
+ // Flush the capture file before writing sidecars / resolving so callers
78
+ // (and tests) that read it immediately see the full stream.
79
+ out.end(() => {
80
+ const finish = () => resolve(code ?? 0);
81
+ if (opts.meta === false) {
82
+ finish();
83
+ return;
84
+ }
85
+ const capturedAt = new Date().toISOString();
86
+ const sidecars = [
87
+ (0, meta_1.writeMeta)(outFile, {
88
+ schemaVersion: meta_1.META_SCHEMA_VERSION,
89
+ cols,
90
+ rows,
91
+ capturedAt,
92
+ command: command.join(' '),
93
+ }),
94
+ (0, timing_1.writeTiming)(outFile, tracker.build()),
95
+ ];
96
+ Promise.all(sidecars).then(finish, finish); // sidecars are best-effort
97
+ });
98
+ });
99
+ });
100
+ }