termcut 0.2.2 → 0.4.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/README.md +11 -2
- package/package.json +2 -2
- package/scripts/build-themes.ts +86 -0
- package/src/cli.ts +85 -7
- package/src/config.ts +30 -10
- package/src/export/svg.ts +16 -8
- package/src/index.ts +5 -1
- package/src/keys.ts +28 -0
- package/src/loop.ts +45 -0
- package/src/publish.ts +226 -0
- package/src/recorder.ts +21 -2
- package/src/renderer/encoder.ts +4 -0
- package/src/renderer/generated/page.js +1 -1
- package/src/renderer/page-entry.ts +2 -1
- package/src/renderer/webview.ts +21 -7
- package/src/screen.ts +5 -0
- package/src/scriptgen.ts +230 -0
- package/src/themes.generated.json +1 -0
- package/src/themes.ts +35 -6
- package/src/types.ts +22 -7
package/src/scriptgen.ts
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import type { Recording } from "./types";
|
|
3
|
+
|
|
4
|
+
export interface ScriptGenOptions {
|
|
5
|
+
/** Output paths to put in the generated config. */
|
|
6
|
+
output: string[];
|
|
7
|
+
/** True when the recording drove the clean shell (so "text + Enter" can become `run()`). */
|
|
8
|
+
cleanShell: boolean;
|
|
9
|
+
/** The command that was recorded in `-- command` mode (becomes `shell: [...]`). */
|
|
10
|
+
command?: string[];
|
|
11
|
+
/** Gaps between keystrokes longer than this become `sleep()` calls. Default 400 ms. */
|
|
12
|
+
pauseThresholdMs?: number;
|
|
13
|
+
/** Where the cast lives, for the header comment. */
|
|
14
|
+
castPath?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type Op =
|
|
18
|
+
| { kind: "type"; text: string }
|
|
19
|
+
| { kind: "run"; command: string }
|
|
20
|
+
| { kind: "key"; name: string; times: number }
|
|
21
|
+
| { kind: "ctrl"; letter: string; times: number }
|
|
22
|
+
| { kind: "alt"; key: string; times: number }
|
|
23
|
+
| { kind: "raw"; data: string }
|
|
24
|
+
| { kind: "sleep"; ms: number };
|
|
25
|
+
|
|
26
|
+
const NAMED: Record<string, string> = {
|
|
27
|
+
"\r": "enter",
|
|
28
|
+
"\n": "enter",
|
|
29
|
+
"\t": "tab",
|
|
30
|
+
"\x7f": "backspace",
|
|
31
|
+
"\x1b": "escape",
|
|
32
|
+
"\x1b[A": "up",
|
|
33
|
+
"\x1b[B": "down",
|
|
34
|
+
"\x1b[C": "right",
|
|
35
|
+
"\x1b[D": "left",
|
|
36
|
+
"\x1bOA": "up",
|
|
37
|
+
"\x1bOB": "down",
|
|
38
|
+
"\x1bOC": "right",
|
|
39
|
+
"\x1bOD": "left",
|
|
40
|
+
"\x1b[H": "home",
|
|
41
|
+
"\x1b[F": "end",
|
|
42
|
+
"\x1b[1~": "home",
|
|
43
|
+
"\x1b[4~": "end",
|
|
44
|
+
"\x1b[3~": "delete",
|
|
45
|
+
"\x1b[5~": "pageUp",
|
|
46
|
+
"\x1b[6~": "pageDown",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Split a raw input chunk into individual key tokens (escape sequences, control chars, printable runs). */
|
|
50
|
+
export function tokenize(input: string): string[] {
|
|
51
|
+
const tokens: string[] = [];
|
|
52
|
+
let i = 0;
|
|
53
|
+
while (i < input.length) {
|
|
54
|
+
const ch = input[i]!;
|
|
55
|
+
if (ch === "\x1b") {
|
|
56
|
+
// CSI: ESC [ params final | SS3: ESC O x | Alt+key: ESC x
|
|
57
|
+
const csi = /^\x1b\[[0-9;?]*[A-Za-z~]/.exec(input.slice(i));
|
|
58
|
+
const ss3 = /^\x1bO[A-Za-z]/.exec(input.slice(i));
|
|
59
|
+
if (csi) {
|
|
60
|
+
tokens.push(csi[0]);
|
|
61
|
+
i += csi[0].length;
|
|
62
|
+
} else if (ss3) {
|
|
63
|
+
tokens.push(ss3[0]);
|
|
64
|
+
i += ss3[0].length;
|
|
65
|
+
} else if (i + 1 < input.length) {
|
|
66
|
+
tokens.push(input.slice(i, i + 2));
|
|
67
|
+
i += 2;
|
|
68
|
+
} else {
|
|
69
|
+
tokens.push(ch);
|
|
70
|
+
i += 1;
|
|
71
|
+
}
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (ch < " " || ch === "\x7f") {
|
|
75
|
+
tokens.push(ch);
|
|
76
|
+
i += 1;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
let j = i;
|
|
80
|
+
while (j < input.length && input[j]! >= " " && input[j] !== "\x7f") j++;
|
|
81
|
+
tokens.push(input.slice(i, j));
|
|
82
|
+
i = j;
|
|
83
|
+
}
|
|
84
|
+
return tokens;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function roundMs(ms: number): number {
|
|
88
|
+
if (ms < 1000) return Math.round(ms / 100) * 100;
|
|
89
|
+
return Math.round(ms / 250) * 250;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function formatMs(ms: number): string {
|
|
93
|
+
return ms % 1000 === 0 ? `"${ms / 1000}s"` : ms >= 1000 ? `"${(ms / 1000).toFixed(2).replace(/0+$/, "")}s"` : `"${ms}ms"`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const q = (s: string) => JSON.stringify(s);
|
|
97
|
+
|
|
98
|
+
/** Turn the `i` (input) events of a recording into a list of script operations. */
|
|
99
|
+
export function eventsToOps(rec: Recording, opts: ScriptGenOptions): Op[] {
|
|
100
|
+
const threshold = opts.pauseThresholdMs ?? 400;
|
|
101
|
+
const ops: Op[] = [];
|
|
102
|
+
let pendingText = "";
|
|
103
|
+
let lastTime: number | null = null;
|
|
104
|
+
|
|
105
|
+
const flushText = () => {
|
|
106
|
+
if (pendingText) ops.push({ kind: "type", text: pendingText });
|
|
107
|
+
pendingText = "";
|
|
108
|
+
};
|
|
109
|
+
const pushKey = (op: Op) => {
|
|
110
|
+
const last = ops[ops.length - 1];
|
|
111
|
+
if (last && last.kind === op.kind && op.kind !== "type" && op.kind !== "sleep" && op.kind !== "raw" && op.kind !== "run") {
|
|
112
|
+
const a = last as { name?: string; letter?: string; key?: string; times: number };
|
|
113
|
+
const b = op as { name?: string; letter?: string; key?: string; times: number };
|
|
114
|
+
if (a.name === b.name && a.letter === b.letter && a.key === b.key) {
|
|
115
|
+
a.times += b.times;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
ops.push(op);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
for (const [time, type, data] of rec.events) {
|
|
123
|
+
if (type !== "i") continue;
|
|
124
|
+
if (lastTime !== null) {
|
|
125
|
+
const gap = (time - lastTime) * 1000;
|
|
126
|
+
if (gap > threshold) {
|
|
127
|
+
flushText();
|
|
128
|
+
ops.push({ kind: "sleep", ms: roundMs(gap) });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
lastTime = time;
|
|
132
|
+
|
|
133
|
+
for (const token of tokenize(data)) {
|
|
134
|
+
if (token.length > 1 && token[0]! >= " ") {
|
|
135
|
+
pendingText += token;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (token.length === 1 && token >= " " && token !== "\x7f") {
|
|
139
|
+
pendingText += token;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const named = NAMED[token];
|
|
143
|
+
if (named === "enter") {
|
|
144
|
+
if (opts.cleanShell && pendingText.trim()) {
|
|
145
|
+
const command = pendingText;
|
|
146
|
+
pendingText = "";
|
|
147
|
+
ops.push({ kind: "run", command });
|
|
148
|
+
} else {
|
|
149
|
+
flushText();
|
|
150
|
+
pushKey({ kind: "key", name: "enter", times: 1 });
|
|
151
|
+
}
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
flushText();
|
|
155
|
+
if (named) {
|
|
156
|
+
pushKey({ kind: "key", name: named, times: 1 });
|
|
157
|
+
} else if (token.length === 1 && token.charCodeAt(0) < 32) {
|
|
158
|
+
const letter = String.fromCharCode(token.charCodeAt(0) + 96);
|
|
159
|
+
pushKey({ kind: "ctrl", letter, times: 1 });
|
|
160
|
+
} else if (token.length === 2 && token[0] === "\x1b") {
|
|
161
|
+
pushKey({ kind: "alt", key: token[1]!, times: 1 });
|
|
162
|
+
} else {
|
|
163
|
+
ops.push({ kind: "raw", data: token });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
flushText();
|
|
168
|
+
|
|
169
|
+
// Drop a trailing `run("exit")` from clean-shell sessions: the recorder ends the shell itself.
|
|
170
|
+
const last = ops[ops.length - 1];
|
|
171
|
+
if (opts.cleanShell && last?.kind === "run" && /^\s*exit\s*$/.test(last.command)) ops.pop();
|
|
172
|
+
while (ops.length && ops[ops.length - 1]!.kind === "sleep") ops.pop();
|
|
173
|
+
return ops;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function opToLine(op: Op): string {
|
|
177
|
+
switch (op.kind) {
|
|
178
|
+
case "type":
|
|
179
|
+
return `await t.type(${q(op.text)});`;
|
|
180
|
+
case "run":
|
|
181
|
+
return `await t.run(${q(op.command)});`;
|
|
182
|
+
case "key":
|
|
183
|
+
return op.times > 1 ? `await t.${op.name}(${op.times});` : `await t.${op.name}();`;
|
|
184
|
+
case "ctrl":
|
|
185
|
+
return op.times > 1 ? `await t.ctrl(${q(op.letter)}, ${op.times});` : `await t.ctrl(${q(op.letter)});`;
|
|
186
|
+
case "alt":
|
|
187
|
+
return op.times > 1 ? `await t.alt(${q(op.key)}, ${op.times});` : `await t.alt(${q(op.key)});`;
|
|
188
|
+
case "raw":
|
|
189
|
+
return `await t.raw(${q(op.data)});`;
|
|
190
|
+
case "sleep":
|
|
191
|
+
return `await t.sleep(${formatMs(op.ms)});`;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Generate an editable TypeScript script that replays the input side of a recording. */
|
|
196
|
+
export function generateScript(rec: Recording, opts: ScriptGenOptions): string {
|
|
197
|
+
const ops = eventsToOps(rec, opts);
|
|
198
|
+
const cfg = rec.header.bunVideo;
|
|
199
|
+
const config: string[] = [`output: ${JSON.stringify(opts.output)}`];
|
|
200
|
+
if (opts.command) config.push(`shell: ${JSON.stringify(opts.command)}`);
|
|
201
|
+
else if (cfg && cfg.shell !== "bash") config.push(`shell: ${JSON.stringify(cfg.shell)}`);
|
|
202
|
+
config.push(`cols: ${rec.header.width}`, `rows: ${rec.header.height}`);
|
|
203
|
+
if (cfg) {
|
|
204
|
+
if (cfg.theme?.name) config.push(`theme: ${q(cfg.theme.name)}`);
|
|
205
|
+
if (cfg.fps !== 60) config.push(`fps: ${cfg.fps}`);
|
|
206
|
+
if (cfg.windowBar !== "none") config.push(`windowBar: ${q(cfg.windowBar)}`);
|
|
207
|
+
if (cfg.title) config.push(`title: ${q(cfg.title)}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const body = ops.length ? ops.map((op) => ` ${opToLine(op)}`).join("\n") : " // (no input was recorded)";
|
|
211
|
+
const castNote = opts.castPath ? ` The exact recording is in ${path.basename(opts.castPath)}.` : "";
|
|
212
|
+
const modeNote = opts.command
|
|
213
|
+
? "It runs the same command and replays your keys; waits are the pauses you took, so adjust them if the program is slower elsewhere."
|
|
214
|
+
: "Typed commands became run(), which waits for the prompt instead of guessing.";
|
|
215
|
+
|
|
216
|
+
return `import { defineVideo } from "tcut";
|
|
217
|
+
|
|
218
|
+
// Generated by \`tcut rec\` from what you typed — edit freely, then re-run with \`tcut <this file>\`.
|
|
219
|
+
// ${modeNote}${castNote}
|
|
220
|
+
export default defineVideo(
|
|
221
|
+
{
|
|
222
|
+
${config.map((c) => ` ${c},`).join("\n")}
|
|
223
|
+
},
|
|
224
|
+
async (t) => {
|
|
225
|
+
${body}
|
|
226
|
+
await t.sleep("1s");
|
|
227
|
+
},
|
|
228
|
+
);
|
|
229
|
+
`;
|
|
230
|
+
}
|