termcut 1.0.2 → 1.0.3
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 +2 -2
- package/package.json +1 -1
- package/src/cli.ts +19 -14
- package/src/config.ts +2 -1
- package/src/live.ts +15 -9
- package/src/promptguess.ts +34 -0
- package/src/recorder.ts +8 -0
- package/src/replies.ts +37 -0
- package/src/scriptgen.ts +7 -19
- package/src/types.ts +2 -1
- package/src/usershell.ts +160 -26
package/README.md
CHANGED
|
@@ -19,14 +19,14 @@ Or a standalone binary for macOS, Linux or Windows from [Releases](https://githu
|
|
|
19
19
|
|
|
20
20
|
## Record
|
|
21
21
|
|
|
22
|
-
**Live.** Your shell opens —
|
|
22
|
+
**Live.** Your own shell opens — prompt, config, aliases; type; `exit`. You get the video, the exact recording (`demo.cast`) and an editable script of what you typed (`demo.video.ts`): it reopens your shell (`shell: "user"`), waits for your prompt (`promptPattern`, detected from the recording) and replays the commands as `run()` calls. `--clean` opens a plain shell with a `>` prompt instead.
|
|
23
23
|
|
|
24
24
|
```sh
|
|
25
25
|
tcut rec -o demo.gif
|
|
26
26
|
tcut rec -o demo.mp4 -- npm create vite # one command, ends when it exits
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
`-- command` runs through that same shell, so `tcut rec -- ls` records what *your* `ls` shows — aliases, functions and colours included. `--raw` runs the binary directly.
|
|
29
|
+
`-- command` runs through that same shell, so `tcut rec -- ls` records what *your* `ls` shows — aliases, functions, fish abbreviations and colours included. `--raw` runs the binary directly.
|
|
30
30
|
|
|
31
31
|
**Scripted.** Plain TypeScript, so loops, helpers and imports work, and the script lives in the repo next to the code it shows.
|
|
32
32
|
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { readCast, writeCast } from "./cast";
|
|
|
6
6
|
import { applyOverrides, resolveConfig } from "./config";
|
|
7
7
|
import * as api from "./index";
|
|
8
8
|
import { recordLive } from "./live";
|
|
9
|
+
import { detectPromptPattern } from "./promptguess";
|
|
9
10
|
import { throughShell, userShell } from "./usershell";
|
|
10
11
|
import { ensurePublicBucket, loadPublishConfig, publicUrlFor, publishFiles, savePublishConfig, type PublishConfig, type Published } from "./publish";
|
|
11
12
|
import { diffCasts, type DiffResult } from "./diff";
|
|
@@ -71,8 +72,8 @@ Options (override the script's config):
|
|
|
71
72
|
--gap <dur> concat: still time between parts
|
|
72
73
|
--preset <name> readme | x | youtube | square
|
|
73
74
|
--browser <url> rec: record a browser window too (--browser-position right|left|top|bottom|overlay)
|
|
74
|
-
--
|
|
75
|
-
--
|
|
75
|
+
--clean rec: a clean shell with a plain > prompt instead of your own (also: run -- command bare)
|
|
76
|
+
--raw rec: run -- command as a bare binary, not through your shell (no aliases/functions)
|
|
76
77
|
--at <seconds> diff: compare the screen at this time instead of the end
|
|
77
78
|
--images <dir> diff: also write a.png / b.png
|
|
78
79
|
--cast <path> where to read/write the .cast
|
|
@@ -475,26 +476,28 @@ async function main(): Promise<void> {
|
|
|
475
476
|
const overrides = overridesFromFlags();
|
|
476
477
|
const rawOutputs = overrides.output ?? ["rec.mp4"];
|
|
477
478
|
const outputs = Array.isArray(rawOutputs) ? rawOutputs : [rawOutputs];
|
|
478
|
-
|
|
479
|
-
//
|
|
480
|
-
|
|
481
|
-
const
|
|
482
|
-
const shell = (rest.length > 0 ? !values.raw : !clean) ? userShell() : null;
|
|
479
|
+
// The shell tcut was typed into is the session (your prompt, config, aliases) and what `-- cmd` runs
|
|
480
|
+
// through. --clean / --raw opt out: a plain shell with a `>` prompt, or the bare binary.
|
|
481
|
+
const shell = values.clean || values.raw ? null : userShell();
|
|
482
|
+
const config = resolveConfig({ ...overrides, output: outputs, cast: overrides.cast, ...(rest.length === 0 && shell && { shell: "user" }) });
|
|
483
483
|
let command: string[] | undefined;
|
|
484
|
-
let
|
|
484
|
+
let portable: string[] | undefined;
|
|
485
485
|
if (rest.length > 0) {
|
|
486
|
-
|
|
487
|
-
|
|
486
|
+
if (shell) {
|
|
487
|
+
const through = throughShell(rest, shell);
|
|
488
|
+
command = through.argv;
|
|
489
|
+
portable = through.portable;
|
|
490
|
+
log(dim(` via ${shell.name} — your aliases and functions apply (--raw runs the binary directly)`));
|
|
491
|
+
} else {
|
|
492
|
+
command = rest;
|
|
493
|
+
}
|
|
488
494
|
} else if (shell) {
|
|
489
|
-
command = [shell.path, "-il"];
|
|
490
|
-
interactive = true;
|
|
491
495
|
log(dim(` your ${shell.name}, with its config (--clean for a plain shell with a > prompt)`));
|
|
492
496
|
}
|
|
493
497
|
// Size: --cols/--rows if given, else derived from --width/--height, else the terminal tcut runs in.
|
|
494
498
|
const sized = overrides.width !== undefined || overrides.height !== undefined;
|
|
495
499
|
const recording = await recordLive(config, {
|
|
496
500
|
command,
|
|
497
|
-
interactive,
|
|
498
501
|
describe: rest.length > 0 ? rest.join(" ") : shell ? `your ${shell.name}` : undefined,
|
|
499
502
|
log,
|
|
500
503
|
cols: overrides.cols ?? (sized ? config.cols : undefined),
|
|
@@ -508,7 +511,9 @@ async function main(): Promise<void> {
|
|
|
508
511
|
ok(`wrote ${config.cast}`, `${recording.events.length} events, ${(recording.header.duration ?? 0).toFixed(1)}s`);
|
|
509
512
|
if (!values["no-script"]) {
|
|
510
513
|
const scriptPath = config.cast.replace(/\.cast$/, "") + ".video.ts";
|
|
511
|
-
|
|
514
|
+
// A session in the user's own shell replays with run(): wait for whatever their prompt ends with.
|
|
515
|
+
const promptPattern = !command && config.shell === "user" ? ((await detectPromptPattern(recording, config.core)) ?? undefined) : undefined;
|
|
516
|
+
await Bun.write(scriptPath, generateScript(recording, { output: outputs, cleanShell: !command, command: portable ?? command, castPath: config.cast, promptPattern }));
|
|
512
517
|
ok(`wrote ${scriptPath}`, "editable script — tweak it, then `tcut " + scriptPath + "`");
|
|
513
518
|
}
|
|
514
519
|
if (values["record-only"]) {
|
package/src/config.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
import { USER_PROMPT_PATTERN } from "./promptguess";
|
|
2
3
|
import { toMs } from "./duration";
|
|
3
4
|
import { applyPreset } from "./presets";
|
|
4
5
|
import { resolveTheme } from "./themes";
|
|
@@ -83,7 +84,7 @@ export function resolveConfig(input: VideoConfig): ResolvedConfig {
|
|
|
83
84
|
cast: config.cast ?? castDefault,
|
|
84
85
|
shell: config.shell ?? "bash",
|
|
85
86
|
prompt,
|
|
86
|
-
promptPattern: (config.promptPattern ?? defaultPromptPattern(prompt)).source,
|
|
87
|
+
promptPattern: (config.promptPattern ?? (config.shell === "user" ? new RegExp(USER_PROMPT_PATTERN) : defaultPromptPattern(prompt))).source,
|
|
87
88
|
cwd: config.cwd ?? process.cwd(),
|
|
88
89
|
env: config.env ?? {},
|
|
89
90
|
cols: cols ?? 80,
|
package/src/live.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { startBrowserCapture } from "./browser";
|
|
2
2
|
import { MARKER } from "./cast";
|
|
3
3
|
import { shellSetup } from "./recorder";
|
|
4
|
+
import { CURSOR_QUERY, hasCursorReport, stripTerminalReplies } from "./replies";
|
|
4
5
|
import type { CastEvent, Recording, ResolvedConfig } from "./types";
|
|
5
6
|
|
|
6
7
|
/** What live recording needs from a keystroke source: `process.stdin`, or any readable stream (a PassThrough in tests). */
|
|
@@ -26,8 +27,6 @@ export interface LiveOptions {
|
|
|
26
27
|
log?: (message: string) => void;
|
|
27
28
|
/** How to name the command in status lines (defaults to the argv). */
|
|
28
29
|
describe?: string;
|
|
29
|
-
/** The command is a shell the user types into (ends on `exit`), not a program that runs and exits. */
|
|
30
|
-
interactive?: boolean;
|
|
31
30
|
}
|
|
32
31
|
|
|
33
32
|
/**
|
|
@@ -44,10 +43,11 @@ export async function recordLive(config: ResolvedConfig, opts: LiveOptions = {})
|
|
|
44
43
|
const events: CastEvent[] = [];
|
|
45
44
|
const startedAt = performance.now();
|
|
46
45
|
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
const stampAt = (seconds: number): number => (config.quantize ? Math.ceil(seconds * config.fps - 1e-6) / config.fps : Number(seconds.toFixed(6)));
|
|
47
|
+
const elapsed = (): number => (performance.now() - startedAt) / 1000;
|
|
48
|
+
const stamp = (): number => stampAt(elapsed());
|
|
49
|
+
// The program asked where the cursor is: the next position report on stdin is the terminal's answer, not a key.
|
|
50
|
+
let cursorQueried = false;
|
|
51
51
|
const push = (type: CastEvent[1], data: string): void => {
|
|
52
52
|
events.push([stamp(), type, data]);
|
|
53
53
|
};
|
|
@@ -76,6 +76,7 @@ export async function recordLive(config: ResolvedConfig, opts: LiveOptions = {})
|
|
|
76
76
|
data(_terminal, chunk) {
|
|
77
77
|
const text = decoder.decode(chunk, { stream: true });
|
|
78
78
|
if (!text) return;
|
|
79
|
+
if (text.includes(CURSOR_QUERY)) cursorQueried = true;
|
|
79
80
|
stdout.write(chunk);
|
|
80
81
|
push("o", text);
|
|
81
82
|
},
|
|
@@ -92,7 +93,12 @@ export async function recordLive(config: ResolvedConfig, opts: LiveOptions = {})
|
|
|
92
93
|
const onData = (chunk: Buffer | string): void => {
|
|
93
94
|
if (exited || terminal.closed) return;
|
|
94
95
|
terminal.write(chunk);
|
|
95
|
-
|
|
96
|
+
// Everything goes to the program, but only keystrokes are recorded: the terminal's answers to its queries
|
|
97
|
+
// would otherwise show up as key chips and be typed back on replay.
|
|
98
|
+
const text = chunk instanceof Uint8Array ? chunk.toString("utf8") : chunk;
|
|
99
|
+
const keys = stripTerminalReplies(text, { cursorQueried });
|
|
100
|
+
if (cursorQueried && hasCursorReport(text)) cursorQueried = false;
|
|
101
|
+
if (keys) push("i", keys);
|
|
96
102
|
};
|
|
97
103
|
const onResize = (): void => {
|
|
98
104
|
const c = process.stdout.columns ?? cols;
|
|
@@ -108,13 +114,13 @@ export async function recordLive(config: ResolvedConfig, opts: LiveOptions = {})
|
|
|
108
114
|
stdin.on("data", onData);
|
|
109
115
|
}
|
|
110
116
|
process.on("SIGWINCH", onResize);
|
|
111
|
-
log(`recording ${opts.describe ?? setup.cmd.join(" ")} at ${cols}×${rows} — ${opts.command
|
|
117
|
+
log(`recording ${opts.describe ?? setup.cmd.join(" ")} at ${cols}×${rows} — ${opts.command ? "ends when the command exits" : "type exit to stop"}`);
|
|
112
118
|
|
|
113
119
|
try {
|
|
114
120
|
await Promise.race([exitedPromise, proc.exited]);
|
|
115
121
|
// Hold the final screen for `endPause`, like a scripted recording does — otherwise a command that exits in
|
|
116
122
|
// 20 ms (`tcut rec -- ls`) becomes a three-frame video. The timeline is virtual: stamp it, don't sleep.
|
|
117
|
-
events.push([
|
|
123
|
+
events.push([stampAt(elapsed() + config.endPause / 1000), "m", MARKER.end]);
|
|
118
124
|
} finally {
|
|
119
125
|
await browser?.stop();
|
|
120
126
|
const emitter: NodeJS.EventEmitter = process; // @types/bun's process.off() lacks the signal overload; the generic emitter has it
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { loadCore } from "./screen";
|
|
2
|
+
import type { Recording } from "./types";
|
|
3
|
+
|
|
4
|
+
const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
5
|
+
|
|
6
|
+
/** Prompts end in a symbol: what a generated script waits for when the user's own shell is recorded. */
|
|
7
|
+
export const USER_PROMPT_PATTERN = "[❯>$%#»➜λ]\\s*$";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* What the user's prompt ends with, read from the recording: the text up to the cursor just before the first
|
|
11
|
+
* keystroke is the prompt; its final symbol (`❯`, `$`, `%`, …) is what `run()` should wait for. Replaying through
|
|
12
|
+
* the headless core handles right-hand prompts and cursor movement that a plain text scan would misread.
|
|
13
|
+
*/
|
|
14
|
+
export async function detectPromptPattern(rec: Recording, core: "ghostty" | "lite" = "ghostty"): Promise<string | null> {
|
|
15
|
+
const firstInput = rec.events.findIndex((e) => e[1] === "i");
|
|
16
|
+
const before = (firstInput < 0 ? rec.events : rec.events.slice(0, firstInput)).filter((e) => e[1] === "o");
|
|
17
|
+
if (before.length === 0) return null;
|
|
18
|
+
const term = await loadCore(core);
|
|
19
|
+
term.init(rec.header.width, rec.header.height);
|
|
20
|
+
for (const e of before) term.writeString(e[2]);
|
|
21
|
+
const { row, col } = term.getCursor();
|
|
22
|
+
let line = "";
|
|
23
|
+
for (let x = 0; x < col; x++) {
|
|
24
|
+
const cell = term.getCell(row, x);
|
|
25
|
+
if (cell.width === 0) continue;
|
|
26
|
+
line += cell.chars ?? (cell.char === 0 ? " " : String.fromCodePoint(cell.char));
|
|
27
|
+
}
|
|
28
|
+
const trimmed = line.trimEnd();
|
|
29
|
+
if (!trimmed) return null;
|
|
30
|
+
const ender = [...trimmed].at(-1)!;
|
|
31
|
+
// A letter or digit is not a prompt symbol (a bare path, say); fall back to the generic pattern.
|
|
32
|
+
if (/[\p{L}\p{N}]/u.test(ender)) return null;
|
|
33
|
+
return `${escapeRegExp(ender)}\\s*$`;
|
|
34
|
+
}
|
package/src/recorder.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { MarkdownRenderer } from "@wterm/markdown";
|
|
2
|
+
import { userShell } from "./usershell";
|
|
2
3
|
import { startBrowserCapture, type BrowserCapture } from "./browser";
|
|
3
4
|
import { MARKER } from "./cast";
|
|
4
5
|
import { toMs } from "./duration";
|
|
@@ -45,6 +46,13 @@ export function shellSetup(config: ResolvedConfig): ShellSetup {
|
|
|
45
46
|
const { shell, prompt } = config;
|
|
46
47
|
if (Array.isArray(shell)) return { cmd: shell, env: {} };
|
|
47
48
|
switch (shell) {
|
|
49
|
+
case "user": {
|
|
50
|
+
// The user's own shell, interactive + login, so its config, prompt and aliases apply. No known shell
|
|
51
|
+
// (Windows, or tcut launched from something that is not bash/zsh/fish) → the clean bash below.
|
|
52
|
+
const own = userShell();
|
|
53
|
+
if (own) return { cmd: [own.path, "-il"], env: {} };
|
|
54
|
+
return shellSetup({ ...config, shell: "bash" });
|
|
55
|
+
}
|
|
48
56
|
case "bash":
|
|
49
57
|
return {
|
|
50
58
|
cmd: ["bash", "--norc", "--noprofile"],
|
package/src/replies.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Answers a terminal sends to a program's queries — device attributes, kitty keyboard flags, colours via OSC,
|
|
2
|
+
// XTGETTCAP via DCS, cursor position reports. In a live recording they arrive on stdin together with the
|
|
3
|
+
// user's keystrokes, but they are not keystrokes: the key overlay must not show them and a replay must not
|
|
4
|
+
// type them (tcut answers the queries itself then).
|
|
5
|
+
|
|
6
|
+
const ESC = String.fromCharCode(0x1b);
|
|
7
|
+
const BEL = String.fromCharCode(0x07);
|
|
8
|
+
|
|
9
|
+
const REPLIES = new RegExp(
|
|
10
|
+
[
|
|
11
|
+
`${ESC}\\[\\?[\\d;]*[cu]`, // primary DA, kitty keyboard flags
|
|
12
|
+
`${ESC}\\[>[\\d;]*c`, // secondary DA
|
|
13
|
+
`${ESC}\\][\\d;]*[^${BEL}${ESC}]*(?:${BEL}|${ESC}\\\\)`, // OSC (colour queries)
|
|
14
|
+
`${ESC}P[^${ESC}]*${ESC}\\\\`, // DCS (XTGETTCAP)
|
|
15
|
+
].join("|"),
|
|
16
|
+
"g",
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
/** Cursor position report `ESC [ row ; col R` — the same bytes as a modified F3 key (`ESC [ 1 ; 2-8 R`). */
|
|
20
|
+
const CPR = new RegExp(`${ESC}\\[\\d+;\\d+R`, "g");
|
|
21
|
+
/** A CPR whose parameters cannot be a modifier-encoded F3: row other than 1, or a "modifier" outside 2–8. */
|
|
22
|
+
const UNAMBIGUOUS_CPR = new RegExp(`${ESC}\\[(?!1;[2-8]R)\\d+;\\d+R`, "g");
|
|
23
|
+
|
|
24
|
+
/** The program asked for the cursor position; the next CPR-shaped input is an answer, not a key. */
|
|
25
|
+
export const CURSOR_QUERY = `${ESC}[6n`;
|
|
26
|
+
|
|
27
|
+
export interface StripOptions {
|
|
28
|
+
/** The program asked `ESC[6n`, so any CPR in this chunk is a reply; without it, only unambiguous reports are removed. */
|
|
29
|
+
cursorQueried?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function stripTerminalReplies(input: string, opts: StripOptions = {}): string {
|
|
33
|
+
return input.replace(REPLIES, "").replace(opts.cursorQueried ? CPR : UNAMBIGUOUS_CPR, "");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Does this input chunk carry a cursor position report (or the look-alike modified F3)? */
|
|
37
|
+
export const hasCursorReport = (input: string): boolean => new RegExp(CPR.source).test(input);
|
package/src/scriptgen.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
import { stripTerminalReplies } from "./replies";
|
|
2
3
|
import { keySequence } from "./keys";
|
|
3
4
|
import type { Recording } from "./types";
|
|
4
5
|
|
|
@@ -9,6 +10,8 @@ export interface ScriptGenOptions {
|
|
|
9
10
|
cleanShell: boolean;
|
|
10
11
|
/** The command that was recorded in `-- command` mode (becomes `shell: [...]`). */
|
|
11
12
|
command?: string[];
|
|
13
|
+
/** Regex source `run()` should wait for — detected from the user's prompt when their own shell was recorded. */
|
|
14
|
+
promptPattern?: string;
|
|
12
15
|
/** Gaps between keystrokes longer than this become `sleep()` calls. Default 400 ms. */
|
|
13
16
|
pauseThresholdMs?: number;
|
|
14
17
|
/** Where the cast lives, for the header comment. */
|
|
@@ -128,24 +131,6 @@ function formatMs(ms: number): string {
|
|
|
128
131
|
|
|
129
132
|
const q = (s: string) => JSON.stringify(s);
|
|
130
133
|
|
|
131
|
-
/**
|
|
132
|
-
* Answers the terminal sent to the program's queries (device attributes, kitty keyboard, colours via OSC,
|
|
133
|
-
* XTGETTCAP via DCS, cursor position). They arrive on stdin and get recorded as input, but they are not
|
|
134
|
-
* keystrokes: when a script replays, tcut answers the queries itself.
|
|
135
|
-
*/
|
|
136
|
-
const ESC = String.fromCharCode(0x1b);
|
|
137
|
-
const BEL = String.fromCharCode(0x07);
|
|
138
|
-
const TERMINAL_REPLIES = new RegExp(
|
|
139
|
-
[
|
|
140
|
-
`${ESC}\\[\\?[\\d;]*[cu]`, // primary DA, kitty keyboard flags
|
|
141
|
-
`${ESC}\\[>[\\d;]*c`, // secondary DA
|
|
142
|
-
`${ESC}\\][\\d;]*[^${BEL}${ESC}]*(?:${BEL}|${ESC}\\\\)`, // OSC (colour queries)
|
|
143
|
-
`${ESC}P[^${ESC}]*${ESC}\\\\`, // DCS (XTGETTCAP)
|
|
144
|
-
`${ESC}\\[\\d+;\\d+R`, // cursor position report
|
|
145
|
-
].join("|"),
|
|
146
|
-
"g",
|
|
147
|
-
);
|
|
148
|
-
export const stripTerminalReplies = (input: string): string => input.replace(TERMINAL_REPLIES, "");
|
|
149
134
|
|
|
150
135
|
/** Turn the `i` (input) events of a recording into a list of script operations. */
|
|
151
136
|
export function eventsToOps(rec: Recording, opts: ScriptGenOptions): Op[] {
|
|
@@ -259,6 +244,7 @@ export function generateScript(rec: Recording, opts: ScriptGenOptions): string {
|
|
|
259
244
|
const config: string[] = [`output: ${JSON.stringify(opts.output)}`];
|
|
260
245
|
if (opts.command) config.push(`shell: ${JSON.stringify(opts.command)}`);
|
|
261
246
|
else if (cfg && cfg.shell !== "bash") config.push(`shell: ${JSON.stringify(cfg.shell)}`);
|
|
247
|
+
if (opts.promptPattern) config.push(`promptPattern: /${opts.promptPattern.replace(/\//g, "\\/")}/`);
|
|
262
248
|
config.push(`cols: ${rec.header.width}`, `rows: ${rec.header.height}`);
|
|
263
249
|
if (cfg) {
|
|
264
250
|
if (cfg.theme?.name) config.push(`theme: ${q(cfg.theme.name)}`);
|
|
@@ -271,7 +257,9 @@ export function generateScript(rec: Recording, opts: ScriptGenOptions): string {
|
|
|
271
257
|
const castNote = opts.castPath ? ` The exact recording is in ${path.basename(opts.castPath)}.` : "";
|
|
272
258
|
const modeNote = opts.command
|
|
273
259
|
? "It opens the same program and replays your keys; waits are the pauses you took, so adjust them if it is slower elsewhere."
|
|
274
|
-
:
|
|
260
|
+
: cfg?.shell === "user"
|
|
261
|
+
? "It opens your own shell; typed commands became run(), which waits for your prompt (promptPattern) instead of guessing."
|
|
262
|
+
: "Typed commands became run(), which waits for the prompt instead of guessing.";
|
|
275
263
|
|
|
276
264
|
return `import { defineVideo } from "tcut";
|
|
277
265
|
|
package/src/types.ts
CHANGED
|
@@ -29,7 +29,8 @@ export interface Theme {
|
|
|
29
29
|
*/
|
|
30
30
|
export type ThemeName = "catppuccin-mocha" | "dracula" | "github-dark" | "tokyo-night" | "one-dark" | (string & {});
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
/** `user` is the shell tcut was launched from (interactive login, with its config); the others are clean shells with the configured prompt. */
|
|
33
|
+
export type ShellName = "bash" | "zsh" | "fish" | "sh" | "user";
|
|
33
34
|
/** Terminal emulator core: libghostty (full VT, answers queries) or wterm's lite Zig core (faster, fewer features). */
|
|
34
35
|
export type CoreName = "ghostty" | "lite";
|
|
35
36
|
export type WindowBar = "none" | "colorful" | "colorfulRight" | "rings" | "ringsRight";
|
package/src/usershell.ts
CHANGED
|
@@ -1,33 +1,64 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
|
|
3
|
-
// `tcut rec
|
|
4
|
-
//
|
|
5
|
-
// found" for an alias, plain output for a coloured one).
|
|
6
|
-
// launched from, interactively, the way the user's own terminal runs it.
|
|
3
|
+
// `tcut rec` should record what the user sees in their own terminal: their shell, with its prompt, aliases,
|
|
4
|
+
// functions, abbreviations and colour settings. Running the bare binary or a clean shell gives none of that
|
|
5
|
+
// ("Executable not found" for an alias, plain output for a coloured one).
|
|
7
6
|
|
|
8
7
|
export interface UserShell {
|
|
9
8
|
path: string;
|
|
10
|
-
/** bash | zsh | fish — the shells whose `-ic <command>`
|
|
9
|
+
/** bash | zsh | fish — the shells whose `-ic <command>` / `-il` forms load the interactive config. */
|
|
11
10
|
name: string;
|
|
12
11
|
}
|
|
13
12
|
|
|
14
13
|
const INTERACTIVE_SHELLS = new Set(["bash", "zsh", "fish"]);
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
interface ParentProcess {
|
|
16
|
+
/** Executable as reported by `ps -o comm=` (login shells show a leading dash: "-fish"). */
|
|
17
|
+
command: string;
|
|
18
|
+
/** Full command line as reported by `ps -o args=`. */
|
|
19
|
+
args: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The process that launched tcut (macOS/Linux `ps`). Null when that cannot be read. */
|
|
23
|
+
function parentProcess(): ParentProcess | null {
|
|
24
|
+
const field = (name: string): string => Bun.spawnSync(["ps", "-o", `${name}=`, "-p", String(process.ppid)], { env: process.env }).stdout.toString().trim();
|
|
18
25
|
try {
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
return
|
|
26
|
+
const command = field("comm");
|
|
27
|
+
if (!command) return null;
|
|
28
|
+
return { command, args: field("args") };
|
|
22
29
|
} catch {
|
|
23
30
|
return null;
|
|
24
31
|
}
|
|
25
32
|
}
|
|
26
33
|
|
|
27
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Is this shell invocation the user's interactive session, rather than a script runner? A shell running a
|
|
36
|
+
* script (`bash run.sh`) or a one-liner (`bash -c …`) is not the shell the user lives in — its rc files and
|
|
37
|
+
* aliases are the wrong ones — so only flag-only invocations (`fish`, `-zsh`, `/bin/bash -il`) count.
|
|
38
|
+
*/
|
|
39
|
+
export function isInteractiveInvocation(comm: string, args: string): boolean {
|
|
40
|
+
if (comm.startsWith("-")) return true; // login shell
|
|
41
|
+
const words = args.split(/\s+/).filter(Boolean);
|
|
42
|
+
const rest = words[0] && !words[0].startsWith("-") ? words.slice(1) : words; // drop argv[0] if present
|
|
43
|
+
for (const w of rest) {
|
|
44
|
+
if (w === "-c" || !w.startsWith("-")) return false;
|
|
45
|
+
}
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The shell the user typed tcut into: the parent process when it is an interactive bash/zsh/fish, else $SHELL.
|
|
51
|
+
* Null on Windows or when neither is a known shell.
|
|
52
|
+
*/
|
|
28
53
|
export function userShell(): UserShell | null {
|
|
29
54
|
if (process.platform === "win32") return null;
|
|
30
|
-
const candidates = [
|
|
55
|
+
const candidates: string[] = [];
|
|
56
|
+
const parent = parentProcess();
|
|
57
|
+
if (parent) {
|
|
58
|
+
const comm = parent.command.replace(/^-/, "");
|
|
59
|
+
if (INTERACTIVE_SHELLS.has(path.basename(comm)) && isInteractiveInvocation(parent.command, parent.args)) candidates.push(comm);
|
|
60
|
+
}
|
|
61
|
+
if (process.env.SHELL) candidates.push(process.env.SHELL);
|
|
31
62
|
for (const candidate of candidates) {
|
|
32
63
|
const name = path.basename(candidate);
|
|
33
64
|
if (!INTERACTIVE_SHELLS.has(name)) continue;
|
|
@@ -37,22 +68,118 @@ export function userShell(): UserShell | null {
|
|
|
37
68
|
return null;
|
|
38
69
|
}
|
|
39
70
|
|
|
40
|
-
/**
|
|
41
|
-
export function
|
|
42
|
-
|
|
71
|
+
/** One argument, single-quoted for the given shell. POSIX shells take everything literally; fish reads `\'` and `\\`. */
|
|
72
|
+
export function quoteArg(arg: string, shellName: string): string {
|
|
73
|
+
if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(arg)) return arg;
|
|
74
|
+
const inner = shellName === "fish" ? arg.replace(/\\/g, "\\\\").replace(/'/g, "\\'") : arg.replace(/'/g, `'\\''`);
|
|
75
|
+
return `'${inner}'`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function shellQuote(args: string[], shellName: string): string {
|
|
79
|
+
return args.map((a) => quoteArg(a, shellName)).join(" ");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface FishAbbreviation {
|
|
83
|
+
name: string;
|
|
84
|
+
expansion: string;
|
|
43
85
|
}
|
|
44
86
|
|
|
45
|
-
/**
|
|
87
|
+
/** Fish's own quoting, as `abbr --show` prints it: bare, '…' (escapes \' \\) or "…" (escapes \" \\ \$). */
|
|
88
|
+
function fishTokens(line: string): string[] {
|
|
89
|
+
const out: string[] = [];
|
|
90
|
+
let cur = "";
|
|
91
|
+
let quote: "'" | '"' | null = null;
|
|
92
|
+
let inWord = false;
|
|
93
|
+
for (let i = 0; i < line.length; i++) {
|
|
94
|
+
const ch = line[i]!;
|
|
95
|
+
if (quote) {
|
|
96
|
+
if (ch === "\\" && i + 1 < line.length) {
|
|
97
|
+
const next = line[i + 1]!;
|
|
98
|
+
const escapable = quote === "'" ? "\\'" : '\\"$';
|
|
99
|
+
if (escapable.includes(next)) {
|
|
100
|
+
cur += next;
|
|
101
|
+
i++;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (ch === quote) quote = null;
|
|
106
|
+
else cur += ch;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (ch === "'" || ch === '"') {
|
|
110
|
+
quote = ch;
|
|
111
|
+
inWord = true;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (/\s/.test(ch)) {
|
|
115
|
+
if (inWord) out.push(cur);
|
|
116
|
+
cur = "";
|
|
117
|
+
inWord = false;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
cur += ch;
|
|
121
|
+
inWord = true;
|
|
122
|
+
}
|
|
123
|
+
if (inWord) out.push(cur);
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* One `abbr --show` line → the abbreviation it declares, or null for the kinds that cannot be expanded up
|
|
129
|
+
* front (regex names, function-computed expansions, anywhere-position). `--set-cursor` markers are removed.
|
|
130
|
+
*/
|
|
131
|
+
export function parseAbbrLine(line: string): FishAbbreviation | null {
|
|
132
|
+
const tokens = fishTokens(line.trim());
|
|
133
|
+
if (tokens[0] !== "abbr") return null;
|
|
134
|
+
let cursorMarker: string | null = null;
|
|
135
|
+
let i = 1;
|
|
136
|
+
for (; i < tokens.length; i++) {
|
|
137
|
+
const t = tokens[i]!;
|
|
138
|
+
if (t === "--") {
|
|
139
|
+
i++;
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
if (t === "-a" || t === "--add" || t === "-g" || t === "--global" || t === "-U" || t === "--universal") continue;
|
|
143
|
+
if (t === "--regex" || t === "--function" || t === "-f" || t === "--command") return null;
|
|
144
|
+
if (t === "--position") {
|
|
145
|
+
if (tokens[i + 1] !== "command") return null;
|
|
146
|
+
i++;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (t.startsWith("--position=")) {
|
|
150
|
+
if (t !== "--position=command") return null;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (t === "--set-cursor") {
|
|
154
|
+
cursorMarker = "%";
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (t.startsWith("--set-cursor=")) {
|
|
158
|
+
cursorMarker = t.slice("--set-cursor=".length) || "%";
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (t.startsWith("--regex=") || t.startsWith("--function=") || t.startsWith("--command=")) return null;
|
|
162
|
+
if (t.startsWith("-")) continue; // unknown flag: ignore
|
|
163
|
+
break; // name without a `--` separator (older fish)
|
|
164
|
+
}
|
|
165
|
+
const name = tokens[i];
|
|
166
|
+
const expansion = tokens[i + 1];
|
|
167
|
+
if (!name || expansion === undefined) return null;
|
|
168
|
+
return { name, expansion: cursorMarker ? expansion.split(cursorMarker).join("") : expansion };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* fish abbreviations expand only at the prompt, never in `fish -c`; expand a leading one ourselves. The list is
|
|
173
|
+
* read from an interactive fish, because the stock config.fish declares them inside `if status is-interactive`.
|
|
174
|
+
*/
|
|
46
175
|
export function fishAbbreviation(shell: UserShell, word: string): string | null {
|
|
47
176
|
if (shell.name !== "fish") return null;
|
|
48
177
|
try {
|
|
49
|
-
|
|
178
|
+
// env is passed explicitly: Bun.spawnSync otherwise uses the environment as it was at startup.
|
|
179
|
+
const r = Bun.spawnSync([shell.path, "-ic", "abbr --show"], { stdin: "ignore", env: process.env });
|
|
50
180
|
for (const line of r.stdout.toString().split("\n")) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (!m || m[1] !== word) continue;
|
|
54
|
-
const expansion = m[2]!;
|
|
55
|
-
return expansion.startsWith("'") && expansion.endsWith("'") ? expansion.slice(1, -1).replace(/\\'/g, "'") : expansion;
|
|
181
|
+
const abbr = parseAbbrLine(line);
|
|
182
|
+
if (abbr && abbr.name === word) return abbr.expansion;
|
|
56
183
|
}
|
|
57
184
|
} catch {
|
|
58
185
|
/* no abbreviations available */
|
|
@@ -60,10 +187,17 @@ export function fishAbbreviation(shell: UserShell, word: string): string | null
|
|
|
60
187
|
return null;
|
|
61
188
|
}
|
|
62
189
|
|
|
63
|
-
|
|
64
|
-
|
|
190
|
+
export interface ShellCommand {
|
|
191
|
+
/** What to spawn. */
|
|
192
|
+
argv: string[];
|
|
193
|
+
/** The same command with the shell by name, for scripts that should run on other machines. */
|
|
194
|
+
portable: string[];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** `args` the way the user's shell runs them when typed: interactive, so aliases, functions and abbreviations apply. */
|
|
198
|
+
export function throughShell(args: string[], shell: UserShell): ShellCommand {
|
|
65
199
|
const [first, ...rest] = args;
|
|
66
200
|
const expanded = first ? fishAbbreviation(shell, first) : null;
|
|
67
|
-
const line = expanded ? `${expanded} ${shellQuote(rest)}`.trim() : shellQuote(args);
|
|
68
|
-
return [shell.path, "-ic", line];
|
|
201
|
+
const line = expanded ? `${expanded} ${shellQuote(rest, shell.name)}`.trim() : shellQuote(args, shell.name);
|
|
202
|
+
return { argv: [shell.path, "-ic", line], portable: [shell.name, "-ic", line] };
|
|
69
203
|
}
|