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 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 — your prompt, config and aliases; type; `exit`. You get the video, the exact recording (`demo.cast`) and an editable script of what you typed (`demo.video.ts`). `--clean` opens a plain shell with a `>` prompt instead.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termcut",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "Script terminal sessions in TypeScript, render them to reproducible MP4/GIF/WebM/SVG/HTML with Bun.",
5
5
  "license": "MIT",
6
6
  "author": "Aman Varshney",
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
- --raw rec: run the command directly instead of through your shell (no aliases/functions)
75
- --clean rec: open tcut's clean shell (plain > prompt, no personal config) instead of yours
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
- const config = resolveConfig({ ...overrides, output: outputs, cast: overrides.cast });
479
- // The shell tcut was typed into: `-- cmd` runs through it (aliases, functions, colours) unless --raw;
480
- // with no command it IS the session your prompt and config — unless --clean or an explicit --shell.
481
- const clean = Boolean(values.clean) || overrides.shell !== undefined;
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 interactive = false;
484
+ let portable: string[] | undefined;
485
485
  if (rest.length > 0) {
486
- command = shell ? throughShell(rest, shell) : rest;
487
- if (shell) log(dim(` via ${shell.name} — your aliases and functions apply (--raw to run the binary directly)`));
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
- await Bun.write(scriptPath, generateScript(recording, { output: outputs, cleanShell: !command, command, castPath: config.cast }));
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 stamp = (): number => {
48
- const t = (performance.now() - startedAt) / 1000;
49
- return config.quantize ? Math.ceil(t * config.fps - 1e-6) / config.fps : Number(t.toFixed(6));
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
- push("i", chunk instanceof Uint8Array ? chunk.toString("utf8") : chunk);
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 && !opts.interactive ? "ends when the command exits" : "type exit to stop"}`);
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([Number((stamp() + config.endPause / 1000).toFixed(6)), "m", MARKER.end]);
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
- : "Typed commands became run(), which waits for the prompt instead of guessing.";
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
- export type ShellName = "bash" | "zsh" | "fish" | "sh";
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 -- ls` should record what the user sees when they type `ls`: their shell's alias, function or
4
- // abbreviation, with their colour settings. Running the bare binary gives none of that ("Executable not
5
- // found" for an alias, plain output for a coloured one). So the command goes through the shell tcut was
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>` form loads the interactive config. */
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
- /** The process that launched tcut, by executable name (macOS/Linux `ps`). Null when that cannot be read. */
17
- function parentCommand(): string | null {
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 r = Bun.spawnSync(["ps", "-o", "comm=", "-p", String(process.ppid)]);
20
- const comm = r.stdout.toString().trim().replace(/^-/, ""); // login shells report as "-fish"
21
- return comm || null;
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
- /** The shell the user typed tcut into, falling back to $SHELL. Null on Windows or when neither is a known shell. */
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 = [parentCommand(), process.env.SHELL].filter((c): c is string => Boolean(c));
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
- /** Join argv into one command line; single quotes are understood the same way by bash, zsh and fish. */
41
- export function shellQuote(args: string[]): string {
42
- return args.map((a) => (/^[A-Za-z0-9_@%+=:,./-]+$/.test(a) ? a : `'${a.replace(/'/g, `'\\''`)}'`)).join(" ");
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
- /** fish abbreviations expand only at the prompt, not in `fish -c`; expand a leading one ourselves. */
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
- const r = Bun.spawnSync([shell.path, "-c", "abbr --show"]);
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
- // `abbr -a -- lzg lazygit` (options may precede `--`; the expansion may be quoted)
52
- const m = /^abbr .*-- (\S+) (.+)$/.exec(line.trim());
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
- /** `args` as the user's shell would run them when typed: interactive, so aliases, functions and abbreviations apply. */
64
- export function throughShell(args: string[], shell: UserShell): string[] {
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
  }