@aragon-agent/cli 0.6.26 → 0.6.28

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.
@@ -50,8 +50,21 @@ const POSIX_CANDIDATES = Object.freeze([
50
50
  ]);
51
51
  /** The env var that overrides the shell the new window runs (D-7). */
52
52
  export const ARAGON_BRANCH_SHELL_ENV = 'ARAGON_BRANCH_SHELL';
53
- /** `PowerShell` and `cmd` both stop being reliable far below the 32767 OS limit. */
54
- const BARE_POWER_SHELL_WORD_RE = /^[A-Za-z0-9._\\/:+=@-]+$/;
53
+ /**
54
+ * A bare word is one PowerShell will not read as anything else.
55
+ *
56
+ * THE FIRST CHARACTER IS THE LOAD-BEARING PART (D-3). `@name` in argument position
57
+ * is the SPLAT operator: `& node.exe -p 'process.argv.length' @foo` hands node an
58
+ * EMPTY argument list, exits 0 and prints nothing (measured on Windows PowerShell
59
+ * 5.1, branch-terminal-powershell-fix appendix B-11). As the only word on the line
60
+ * it is not even parseable - `error col=1 id=SplattingNotPermitted`. Nothing after
61
+ * the first character can do that, which is why the class differs in the first
62
+ * position. `-` stays allowed there so that every flag this feature emits
63
+ * (`--resume`) keeps its bare form, and quoting it would change nothing except the
64
+ * look of the fallback line (appendix B-6 A/B, quoting is transparent to a native
65
+ * exe).
66
+ */
67
+ const BARE_POWER_SHELL_WORD_RE = /^[A-Za-z0-9._\\/:+=\-][A-Za-z0-9._\\/:+=@\-]*$/;
55
68
  /**
56
69
  * Build the ladder for this platform, most-preferred rung first.
57
70
  *
@@ -201,9 +214,29 @@ function resolveCandidate(candidate, env) {
201
214
  function posixWindowScript(cwd, shell, command) {
202
215
  return `cd ${posixQuote(cwd)} && ${posixScript(command)} ; exec ${posixQuote(shell.path)} -i`;
203
216
  }
204
- /** PowerShell: the whole command as one base64(UTF-16LE) blob (D-20). */
217
+ /**
218
+ * The PowerShell statement that runs the aragon command (D-1).
219
+ *
220
+ * THE LEADING `&` IS NOT DECORATION. In command position PowerShell reads a quoted
221
+ * string as an EXPRESSION, so `'C:\Program Files\nodejs\node.exe' <script>` is a
222
+ * parse error - "unexpected token ..." at column 36 - and the child is never
223
+ * created, while the parent still believes the window opened (K-1: `wt`/`start`
224
+ * forward and exit). That is exactly the report this branch exists to fix.
225
+ * `&` is the call operator: it takes the following word as a command name whatever
226
+ * its shape, and it does not change name resolution (`& 'npm.cmd'` still finds
227
+ * `npm` through PATH/PATHEXT, appendix B-8).
228
+ *
229
+ * `posixScript` MUST NOT be given the same treatment: in sh `&` means run in the
230
+ * background, so the window would reach the trailing `exec <shell> -i` immediately
231
+ * and the branch would start behind a prompt that looks idle (I-3 / K-1).
232
+ */
205
233
  export function powerShellScript(parts) {
206
- return parts.map(powerShellQuote).join(' ');
234
+ // TOTAL, NOT THROWING (D-4): an empty argv cannot arise from
235
+ // `resolveAragonCommand` (T-3 pins that), and an empty script is a parse-valid
236
+ // no-op rather than a throw inside a key handler.
237
+ if (parts.length === 0)
238
+ return '';
239
+ return ['&', ...parts.map(powerShellQuote)].join(' ');
207
240
  }
208
241
  /** Bare words stay bare; everything else becomes a single-quoted literal. */
209
242
  export function powerShellQuote(value) {
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * ASCII ONLY - `src/session/**` is inside `glyphs.test.ts::inScope`.
5
5
  *
6
- * THIS MODULE HAS NO IO ON PURPOSE. It answers two questions and nothing else:
6
+ * THIS MODULE PRODUCES NO IO ITSELF. It answers two questions and nothing else:
7
7
  * "which argv pieces make up the aragon command" and "how does that sentence read
8
8
  * when printed to a human". Spawning lives in `session/branch-launch.ts`, and the
9
9
  * terminal ladder lives in `proc/terminal-plan.ts`, so the one part of this
@@ -12,6 +12,14 @@
12
12
  * `proc/readiness.ts` and `proc/classify.ts` are pure, `proc/supervisor.ts` is the
13
13
  * part that touches processes.
14
14
  *
15
+ * IT DOES IMPORT `proc/terminal-plan.ts` (D-5), and that is still the right
16
+ * direction: `session -> proc`. The manual line needs both quoting dialects, and
17
+ * the dialect owner is `proc/terminal-plan.ts`; putting it in `proc/` would mean
18
+ * `proc/` importing `session/`, which is the cycle the header above forbids.
19
+ * What that import brings with it is one `statSync` probe used by the PATH lookup -
20
+ * not a process, not a write - so "no IO on purpose" still describes this module's
21
+ * own behaviour.
22
+ *
15
23
  * NAMED `branch-invocation`, NOT `branch-command` (R-22): `commands/branch-command.ts`
16
24
  * is the command body, and two files with one basename make every import, stack
17
25
  * trace and grep ambiguous. What this module exports is an *invocation*.
@@ -28,7 +36,15 @@ export interface AragonCommandResolution {
28
36
  export interface BranchInvocation {
29
37
  /** `[...aragon.parts, '--resume', id]`. */
30
38
  parts: string[];
31
- /** One line, pasteable into cmd / PowerShell / sh (quoted where needed). */
39
+ /**
40
+ * One line, for the toast and the log.
41
+ *
42
+ * NOT THE PASTEABLE FORM ON WIN32 - and the defect that produced this comment is
43
+ * the proof: a double-quoted command word is a string EXPRESSION to PowerShell, so
44
+ * pasting this line into the shell Windows Terminal opens by default yields the
45
+ * same `ParserError` the new window used to. `renderManualCommand(parts,
46
+ * platform)` is the paste-ready form; on win32 it is two labelled lines.
47
+ */
32
48
  command: string;
33
49
  }
34
50
  export declare const BRANCH_RESUME_FLAG = "--resume";
@@ -54,21 +70,49 @@ export declare function resolveAragonCommand(input: {
54
70
  execPath: string;
55
71
  env: Record<string, string | undefined>;
56
72
  }): AragonCommandResolution;
57
- /** `parts` + `--resume <id>`, plus the one-line form for toasts and manual fallback. */
73
+ /**
74
+ * `parts` + `--resume <id>`, plus the ONE-LINE form for the toast and the log.
75
+ *
76
+ * The paste-ready form is NOT this field (see `BranchInvocation.command` and
77
+ * `renderManualCommand`): "one line for every shell" was the claim this branch
78
+ * disproved, and leaving it here would keep the next reader believing it.
79
+ */
58
80
  export declare function buildResumeInvocation(input: {
59
81
  id: string;
60
82
  aragon: AragonCommandResolution;
61
83
  }): BranchInvocation;
62
84
  /**
63
- * Join argv pieces into one line a human can paste into any of the three shells.
85
+ * Join argv pieces into one line a human can read at a glance.
64
86
  *
65
- * NOT A SHELL ESCAPER. This text goes to the toast, to the notice and to the
66
- * README, never back through a parser: the three platforms agree on what double
67
- * quotes mean around a path with spaces, and that is the whole requirement. The
68
- * real command lines are built by `proc/terminal-plan.ts`, which quotes for the
69
- * exact parser each rung lands in.
87
+ * NOT A SHELL ESCAPER, and no longer claimed to be a universal paste line: it is
88
+ * the RIGHT line for `cmd.exe` and for POSIX shells, and the WRONG one for
89
+ * PowerShell on win32 (a double-quoted command word is an expression there). Its
90
+ * product is what a POSIX/darwin user pastes, and what the `cmd.exe` half of the
91
+ * win32 manual block shows; the PowerShell half comes from `powerShellScript` via
92
+ * `renderManualCommand`. The real command lines are built by
93
+ * `proc/terminal-plan.ts`, which quotes for the exact parser each rung lands in.
70
94
  */
71
95
  export declare function joinCommandLine(parts: readonly string[]): string;
96
+ /**
97
+ * The paste-ready form of the command, per platform (D-5 / D-6 / I-4).
98
+ *
99
+ * ONE LINE CANNOT BE CORRECT FOR BOTH WINDOWS PARSERS. A double-quoted command word
100
+ * is a string EXPRESSION to PowerShell (the defect this branch fixes) while it is
101
+ * exactly right for `cmd.exe`; `& 'x'` is right for PowerShell and wrong for
102
+ * `cmd.exe` (which keeps the single quotes and tries to run `'x'`). So Windows gets
103
+ * two labelled lines and the user copies the one matching the terminal they opened;
104
+ * POSIX and macOS keep the single line they have always printed.
105
+ *
106
+ * The first line is `powerShellScript(parts)` VERBATIM, which is what makes I-1
107
+ * ("one command, one artefact") testable: the fallback the user pastes is the same
108
+ * text the new window receives in its `-EncodedCommand` payload.
109
+ *
110
+ * @param parts argv pieces of the aragon invocation, `--resume <id>` included.
111
+ * @param platform the platform whose parsers the two lines have to satisfy.
112
+ * @returns one line off Windows; two ASCII lines labelled `PowerShell:` and
113
+ * `cmd.exe :` on win32. Both labels are 11 columns wide on purpose.
114
+ */
115
+ export declare function renderManualCommand(parts: readonly string[], platform: NodeJS.Platform): string;
72
116
  /**
73
117
  * The second gate on D-9: an id is about to be concatenated into a command line.
74
118
  *
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * ASCII ONLY - `src/session/**` is inside `glyphs.test.ts::inScope`.
5
5
  *
6
- * THIS MODULE HAS NO IO ON PURPOSE. It answers two questions and nothing else:
6
+ * THIS MODULE PRODUCES NO IO ITSELF. It answers two questions and nothing else:
7
7
  * "which argv pieces make up the aragon command" and "how does that sentence read
8
8
  * when printed to a human". Spawning lives in `session/branch-launch.ts`, and the
9
9
  * terminal ladder lives in `proc/terminal-plan.ts`, so the one part of this
@@ -12,11 +12,20 @@
12
12
  * `proc/readiness.ts` and `proc/classify.ts` are pure, `proc/supervisor.ts` is the
13
13
  * part that touches processes.
14
14
  *
15
+ * IT DOES IMPORT `proc/terminal-plan.ts` (D-5), and that is still the right
16
+ * direction: `session -> proc`. The manual line needs both quoting dialects, and
17
+ * the dialect owner is `proc/terminal-plan.ts`; putting it in `proc/` would mean
18
+ * `proc/` importing `session/`, which is the cycle the header above forbids.
19
+ * What that import brings with it is one `statSync` probe used by the PATH lookup -
20
+ * not a process, not a write - so "no IO on purpose" still describes this module's
21
+ * own behaviour.
22
+ *
15
23
  * NAMED `branch-invocation`, NOT `branch-command` (R-22): `commands/branch-command.ts`
16
24
  * is the command body, and two files with one basename make every import, stack
17
25
  * trace and grep ambiguous. What this module exports is an *invocation*.
18
26
  */
19
27
  import { isAbsolute } from 'node:path';
28
+ import { powerShellScript } from '../proc/terminal-plan.js';
20
29
  import { isValidSessionId } from './store.js';
21
30
  export const BRANCH_RESUME_FLAG = '--resume';
22
31
  /** The env var that overrides "what executable the new window runs" (D-7 sibling). */
@@ -65,23 +74,58 @@ export function resolveAragonCommand(input) {
65
74
  }
66
75
  return { parts: ['aragon'], kind: 'bin', origin: 'aragon on PATH' };
67
76
  }
68
- /** `parts` + `--resume <id>`, plus the one-line form for toasts and manual fallback. */
77
+ /**
78
+ * `parts` + `--resume <id>`, plus the ONE-LINE form for the toast and the log.
79
+ *
80
+ * The paste-ready form is NOT this field (see `BranchInvocation.command` and
81
+ * `renderManualCommand`): "one line for every shell" was the claim this branch
82
+ * disproved, and leaving it here would keep the next reader believing it.
83
+ */
69
84
  export function buildResumeInvocation(input) {
70
85
  const parts = [...input.aragon.parts, BRANCH_RESUME_FLAG, input.id];
71
86
  return { parts, command: joinCommandLine(parts) };
72
87
  }
73
88
  /**
74
- * Join argv pieces into one line a human can paste into any of the three shells.
89
+ * Join argv pieces into one line a human can read at a glance.
75
90
  *
76
- * NOT A SHELL ESCAPER. This text goes to the toast, to the notice and to the
77
- * README, never back through a parser: the three platforms agree on what double
78
- * quotes mean around a path with spaces, and that is the whole requirement. The
79
- * real command lines are built by `proc/terminal-plan.ts`, which quotes for the
80
- * exact parser each rung lands in.
91
+ * NOT A SHELL ESCAPER, and no longer claimed to be a universal paste line: it is
92
+ * the RIGHT line for `cmd.exe` and for POSIX shells, and the WRONG one for
93
+ * PowerShell on win32 (a double-quoted command word is an expression there). Its
94
+ * product is what a POSIX/darwin user pastes, and what the `cmd.exe` half of the
95
+ * win32 manual block shows; the PowerShell half comes from `powerShellScript` via
96
+ * `renderManualCommand`. The real command lines are built by
97
+ * `proc/terminal-plan.ts`, which quotes for the exact parser each rung lands in.
81
98
  */
82
99
  export function joinCommandLine(parts) {
83
100
  return parts.map(quoteForDisplay).join(' ');
84
101
  }
102
+ /**
103
+ * The paste-ready form of the command, per platform (D-5 / D-6 / I-4).
104
+ *
105
+ * ONE LINE CANNOT BE CORRECT FOR BOTH WINDOWS PARSERS. A double-quoted command word
106
+ * is a string EXPRESSION to PowerShell (the defect this branch fixes) while it is
107
+ * exactly right for `cmd.exe`; `& 'x'` is right for PowerShell and wrong for
108
+ * `cmd.exe` (which keeps the single quotes and tries to run `'x'`). So Windows gets
109
+ * two labelled lines and the user copies the one matching the terminal they opened;
110
+ * POSIX and macOS keep the single line they have always printed.
111
+ *
112
+ * The first line is `powerShellScript(parts)` VERBATIM, which is what makes I-1
113
+ * ("one command, one artefact") testable: the fallback the user pastes is the same
114
+ * text the new window receives in its `-EncodedCommand` payload.
115
+ *
116
+ * @param parts argv pieces of the aragon invocation, `--resume <id>` included.
117
+ * @param platform the platform whose parsers the two lines have to satisfy.
118
+ * @returns one line off Windows; two ASCII lines labelled `PowerShell:` and
119
+ * `cmd.exe :` on win32. Both labels are 11 columns wide on purpose.
120
+ */
121
+ export function renderManualCommand(parts, platform) {
122
+ if (platform !== 'win32')
123
+ return joinCommandLine(parts);
124
+ return [
125
+ `PowerShell: ${powerShellScript(parts)}`,
126
+ `cmd.exe : ${joinCommandLine(parts)}`,
127
+ ].join('\n');
128
+ }
85
129
  /** Empty and whitespace-or-quote-bearing pieces get double quotes; the rest stay bare. */
86
130
  function quoteForDisplay(part) {
87
131
  if (part.length === 0)
@@ -29,6 +29,15 @@ export interface BranchRequest {
29
29
  }
30
30
  export type BranchOpenResult = {
31
31
  ok: true;
32
+ /**
33
+ * The paste-ready command block for this platform (D-7).
34
+ *
35
+ * "Always present, so no failure is a dead end" is I-4 for the FAILURE arm;
36
+ * the success arm carries it for the mirror-image reason - "what did the
37
+ * window I just opened actually run" is copyable without retyping it from the
38
+ * toast. Same meaning as the field of the same name below: on win32 it is the
39
+ * two-line PowerShell / `cmd.exe` block, off win32 it is one line.
40
+ */
32
41
  command: string;
33
42
  shell: string;
34
43
  rung: string;
@@ -22,7 +22,7 @@ import { spawn } from 'node:child_process';
22
22
  import { getLogger } from '../logging/logger.js';
23
23
  import { defaultIsExecutable, pathDirsOf, planTerminalLaunch, resolveShell, truncateTitle, } from '../proc/terminal-plan.js';
24
24
  import { BRANCH_COPY } from '../commands/branch-command.js';
25
- import { buildResumeInvocation, joinCommandLine, resolveAragonCommand, validateBranchId, } from './branch-invocation.js';
25
+ import { buildResumeInvocation, renderManualCommand, resolveAragonCommand, validateBranchId, } from './branch-invocation.js';
26
26
  import { BRANCH_TERMINAL_LIMITS } from './limits.js';
27
27
  /**
28
28
  * Build the port. The only mutable state is this closure's four values, none of
@@ -41,12 +41,16 @@ export function createBranchLauncher(deps) {
41
41
  let cachedAragon = null;
42
42
  let cachedShell = null;
43
43
  /**
44
- * Resolve WHAT the new window runs, and the command line built from it.
44
+ * Resolve WHAT the new window runs, and the two command forms built from it.
45
45
  *
46
46
  * The resolution is cached for the process (R-13 / K-14). It costs no
47
47
  * filesystem probe - the three rules are env, `argv[1]` and a literal - so the
48
48
  * cache is about identity, not speed: the value that opened the first window is
49
49
  * the value the log keeps reporting.
50
+ *
51
+ * `manual` is derived HERE from the same `parts` on purpose (D-7): one source of
52
+ * truth for the command means the paste block and the `-EncodedCommand` payload
53
+ * cannot drift apart (I-1).
50
54
  */
51
55
  function planFor(id) {
52
56
  cachedAragon ??= resolveAragonCommand({
@@ -54,9 +58,11 @@ export function createBranchLauncher(deps) {
54
58
  execPath: deps.execPath,
55
59
  env: deps.env,
56
60
  });
61
+ const invocation = buildResumeInvocation({ id, aragon: cachedAragon });
57
62
  return {
58
63
  aragon: cachedAragon,
59
- invocation: buildResumeInvocation({ id, aragon: cachedAragon }),
64
+ invocation,
65
+ manual: renderManualCommand(invocation.parts, deps.platform),
60
66
  };
61
67
  }
62
68
  function shellFor() {
@@ -93,22 +99,25 @@ export function createBranchLauncher(deps) {
93
99
  return cooldown(command, Math.max(1, windowMs - waited));
94
100
  }
95
101
  async function openOnce(request) {
96
- const { aragon, invocation } = planFor(request.id);
102
+ const { aragon, invocation, manual } = planFor(request.id);
97
103
  const command = invocation.command;
98
104
  // 4.5 BEFORE ANY SPAWN (R-7 / T-46): a command line that `cmd` or AppleScript
99
105
  // would truncate is worse than no command line, because the window opens and
100
- // then fails in a way this process cannot see.
106
+ // then fails in a way this process cannot see. The MEASURED object stays the
107
+ // single unquoted line: it is the lower bound of every dialect (quoting only
108
+ // ever adds characters), so the check is conservative and needs no second
109
+ // source of truth.
101
110
  if (command.length > BRANCH_TERMINAL_LIMITS.maxCommandLineCells) {
102
111
  // A COMPLETE SENTENCE WITH THE FALLBACK INSIDE IT (I-4 / appendix A): the
103
112
  // over-long case is the one failure where the user most needs a command to
104
113
  // paste, so it may not report the bare reason "command line too long" - that
105
114
  // is an internal code, not an answer, and it would leave a dead end.
106
- return failure('spawn-failed', BRANCH_COPY.spawnFailed('command line too long', command), command);
115
+ return failure('spawn-failed', BRANCH_COPY.spawnFailed('command line too long', manual), manual);
107
116
  }
108
117
  // D-9's second gate: an id is about to enter a command line.
109
118
  const invalid = validateBranchId(request.id);
110
119
  if (invalid !== null)
111
- return failure('spawn-failed', invalid, command);
120
+ return failure('spawn-failed', invalid, manual);
112
121
  // Step 6: flush FIRST, spawn only on `ok` (I-1). `force` skips the fingerprint
113
122
  // short circuit and nothing else (D-15 / I-7).
114
123
  const flushed = deps.checkpoint({ force: true });
@@ -118,7 +127,10 @@ export function createBranchLauncher(deps) {
118
127
  // disk problem, so it must not be printed as "could not checkpoint
119
128
  // (no-identity)" - that reads like corruption. It gets `/session`'s sentence.
120
129
  const message = reason === 'no-identity' ? BRANCH_COPY.noIdentity : BRANCH_COPY.checkpointFailed(reason);
121
- return failure('checkpoint-failed', message, command);
130
+ // The THIRD argument changes, never the sentence (parent-R-19): these two
131
+ // failures deliberately do NOT embed the command, because their job is to
132
+ // explain why nothing opened. The command still travels in `result.command`.
133
+ return failure('checkpoint-failed', message, manual);
122
134
  }
123
135
  const shell = shellFor();
124
136
  // Step 7: the TITLE is what gets truncated, never the id in the command line (T-47).
@@ -132,11 +144,13 @@ export function createBranchLauncher(deps) {
132
144
  command: invocation.parts,
133
145
  title,
134
146
  cwd: request.cwd,
135
- manual: joinCommandLine(invocation.parts),
147
+ // The paste block the fallback prints, built by the same dialect owner that
148
+ // encodes the window script (I-1: one command, one artefact).
149
+ manual,
136
150
  });
137
151
  if (!planned.ok) {
138
152
  // Not cached: the next press must be allowed to find a terminal that appeared.
139
- return failure('no-launcher', BRANCH_COPY.noLauncher(planned.manual), command);
153
+ return failure('no-launcher', BRANCH_COPY.noLauncher(planned.manual), manual);
140
154
  }
141
155
  // Step 8: walk the ladder, bounded by maxRungs (T-33).
142
156
  const rungs = planned.plans.slice(0, BRANCH_TERMINAL_LIMITS.maxRungs);
@@ -156,14 +170,14 @@ export function createBranchLauncher(deps) {
156
170
  aragon: aragon.kind,
157
171
  origin: aragon.origin,
158
172
  });
159
- return { ok: true, command, shell: planned.shell.path, rung: plan.rung };
173
+ return { ok: true, command: manual, shell: planned.shell.path, rung: plan.rung };
160
174
  }
161
175
  logger.warn('rung_failed', { rung: plan.rung, message: outcome.message });
162
176
  lastError = outcome;
163
177
  }
164
178
  // Step 9: every rung failed. `wt`'s own semantic failures land here too only if
165
179
  // the error event fired; a forwarded-but-rejected `wt` invocation does not (K-1).
166
- return failure('spawn-failed', BRANCH_COPY.spawnFailed(lastError?.message ?? 'no rung available', command), command);
180
+ return failure('spawn-failed', BRANCH_COPY.spawnFailed(lastError?.message ?? 'no rung available', manual), manual);
167
181
  }
168
182
  /**
169
183
  * One rung, with EXACTLY ONE `'error'` LISTENER THAT IS NEVER REMOVED (R-3 / I-10).
@@ -230,7 +244,10 @@ export function createBranchLauncher(deps) {
230
244
  }
231
245
  return {
232
246
  async open(request) {
233
- const blocked = gated(planFor(request.id).invocation.command);
247
+ // The cooldown path carries the same paste block. It never displays it, but the
248
+ // result type has exactly one meaning for `command` and inventing a second one
249
+ // for this arm is how the two forms would drift apart (D-7).
250
+ const blocked = gated(planFor(request.id).manual);
234
251
  if (blocked !== null)
235
252
  return blocked;
236
253
  inFlight = true;
@@ -65,6 +65,14 @@ export interface SanitizeResult {
65
65
  /**
66
66
  * Remove every escape sequence and control byte except `\t` `\n` `\r`.
67
67
  *
68
+ * SEQUENCE SYNTAX HAS ONE OWNER (`input/control-sequences.ts`); this function
69
+ * adds only the control-byte sweep a DISPLAY row wants. Both call sites in this
70
+ * module already cut an unfinished escape off into the carry before calling it
71
+ * (`partialEscapeStart`), so the shared one-shot rule for a truncated sequence
72
+ * is exactly what they need -- and it fixes AC-24's failure in place: the old
73
+ * two-byte rule consumed the `\x1b[` of an unfinished CSI and left the
74
+ * PARAMETERS behind, so `\x1b[31m` split across chunks rendered as `red 3`.
75
+ *
68
76
  * Exported for the tests, and used by `toDisplayRow` as the second line of
69
77
  * defence over a carry that ends mid-escape.
70
78
  */
@@ -22,6 +22,7 @@
22
22
  * interpreting cursor motion would mean owning a screen buffer. Neither is a
23
23
  * presentation round's job.
24
24
  */
25
+ import { stripControlSequences } from '../input/control-sequences.js';
25
26
  /** Columns a live row may occupy before it is clipped. */
26
27
  export const LIVE_ROW_MAX_CHARS = 200;
27
28
  /** Tab stop width, matching every terminal's default. */
@@ -57,22 +58,24 @@ export const TRUNCATION_MARK = '...';
57
58
  * and holding it forever would pin the carry.
58
59
  */
59
60
  const MAX_PARTIAL_ESCAPE = 64;
60
- /** CSI: `\x1b[` params intermediates final. */
61
- const CSI = /\x1b\[[0-?]*[ -/]*[@-~]/g;
62
- /** OSC: `\x1b]` ... terminated by BEL or ST. */
63
- const OSC = /\x1b\][\s\S]*?(?:\x07|\x1b\\)/g;
64
- /** The two-byte forms: `\x1b` followed by one character. */
65
- const ESC_PAIR = /\x1b[\s\S]/g;
66
61
  /** C1, plus every C0 control except `\t` `\n` `\r`. */
67
62
  const CONTROLS = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g;
68
63
  /**
69
64
  * Remove every escape sequence and control byte except `\t` `\n` `\r`.
70
65
  *
66
+ * SEQUENCE SYNTAX HAS ONE OWNER (`input/control-sequences.ts`); this function
67
+ * adds only the control-byte sweep a DISPLAY row wants. Both call sites in this
68
+ * module already cut an unfinished escape off into the carry before calling it
69
+ * (`partialEscapeStart`), so the shared one-shot rule for a truncated sequence
70
+ * is exactly what they need -- and it fixes AC-24's failure in place: the old
71
+ * two-byte rule consumed the `\x1b[` of an unfinished CSI and left the
72
+ * PARAMETERS behind, so `\x1b[31m` split across chunks rendered as `red 3`.
73
+ *
71
74
  * Exported for the tests, and used by `toDisplayRow` as the second line of
72
75
  * defence over a carry that ends mid-escape.
73
76
  */
74
77
  export function stripAnsi(text) {
75
- return text.replace(CSI, '').replace(OSC, '').replace(ESC_PAIR, '').replace(CONTROLS, '');
78
+ return stripControlSequences(text).replace(CONTROLS, '');
76
79
  }
77
80
  /**
78
81
  * Index at which a trailing INCOMPLETE escape begins, or `-1`.
@@ -0,0 +1,51 @@
1
+ /**
2
+ * TAB expansion that agrees with the TERMINAL, not with UTF-16 (fix A'-2 of
3
+ * paste-control-sequence-residue).
4
+ *
5
+ * WHY THIS IS A MODULE OF ITS OWN. A TAB has no width; it advances the pen to
6
+ * the next tab stop, so what it occupies depends on where the line currently
7
+ * is. That makes "one TAB" a property of a CURSOR, and the same character was
8
+ * being read three different ways in this package:
9
+ *
10
+ * - `ui/editor-navigation.ts` painted it at FOUR-column stops in the composer,
11
+ * - `entries/UserEntry.tsx` handed the raw byte to `<Text>`, so the terminal
12
+ * painted it at EIGHT-column stops in the transcript,
13
+ * - `tools/terminal-output.ts` expanded it by UTF-16 code-unit count, so a
14
+ * pasted `中文<TAB>X` table came out two columns short.
15
+ *
16
+ * A user who pastes a TAB-indented block or an Excel/Word table therefore saw
17
+ * the draft and the sent message disagree about their own text. One function,
18
+ * used by the composer AND by every renderer of stored text, is what makes the
19
+ * "before send" and "after send" rows character-identical.
20
+ *
21
+ * `input/` is the wrong home for it: `cli/input` may depend on `node:stream`
22
+ * and nothing else (`.claude-index/index.md`), and column arithmetic needs the
23
+ * width table. `ui/` already owns that (`selection/selection.ts` uses
24
+ * `string-width` for exactly this reason).
25
+ *
26
+ * Pure, React-free, Ink-free.
27
+ */
28
+ /**
29
+ * Tab stop width, matching every terminal's default.
30
+ *
31
+ * A COPY OF `tools/terminal-output.ts`'s number, on purpose and in the same
32
+ * shape `composer-limits.ts` copies `budget.ts::HINT_MIN_ROWS`: importing the
33
+ * tools module here would point an arrow from `ui/` into `tools/` for one
34
+ * integer, and the two are asserted equal by a test instead. (`tools/` keeps
35
+ * its own expander because its rows are clipped to 200 columns before anyone
36
+ * looks at them, so a code-unit approximation there cannot reach a user.)
37
+ */
38
+ export declare const TAB_WIDTH = 8;
39
+ /**
40
+ * Expand every TAB in `text` to spaces up to the next `TAB_WIDTH` stop, given
41
+ * the display column the first character will be painted at.
42
+ *
43
+ * `fromColumn` is a DISPLAY column, not an offset: a caller that has already
44
+ * painted `中文` must pass 4. Lines are independent -- a `\n` resets the column
45
+ * to zero, because the terminal's pen returns to column 0.
46
+ *
47
+ * Non-ASCII text is measured with `string-width`, which is the whole point: a
48
+ * CJK glyph occupies two cells, and counting it as one space is what left
49
+ * CJK-plus-TAB tables misaligned.
50
+ */
51
+ export declare function expandTabs(text: string, fromColumn?: number): string;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * TAB expansion that agrees with the TERMINAL, not with UTF-16 (fix A'-2 of
3
+ * paste-control-sequence-residue).
4
+ *
5
+ * WHY THIS IS A MODULE OF ITS OWN. A TAB has no width; it advances the pen to
6
+ * the next tab stop, so what it occupies depends on where the line currently
7
+ * is. That makes "one TAB" a property of a CURSOR, and the same character was
8
+ * being read three different ways in this package:
9
+ *
10
+ * - `ui/editor-navigation.ts` painted it at FOUR-column stops in the composer,
11
+ * - `entries/UserEntry.tsx` handed the raw byte to `<Text>`, so the terminal
12
+ * painted it at EIGHT-column stops in the transcript,
13
+ * - `tools/terminal-output.ts` expanded it by UTF-16 code-unit count, so a
14
+ * pasted `中文<TAB>X` table came out two columns short.
15
+ *
16
+ * A user who pastes a TAB-indented block or an Excel/Word table therefore saw
17
+ * the draft and the sent message disagree about their own text. One function,
18
+ * used by the composer AND by every renderer of stored text, is what makes the
19
+ * "before send" and "after send" rows character-identical.
20
+ *
21
+ * `input/` is the wrong home for it: `cli/input` may depend on `node:stream`
22
+ * and nothing else (`.claude-index/index.md`), and column arithmetic needs the
23
+ * width table. `ui/` already owns that (`selection/selection.ts` uses
24
+ * `string-width` for exactly this reason).
25
+ *
26
+ * Pure, React-free, Ink-free.
27
+ */
28
+ import stringWidth from 'string-width';
29
+ /**
30
+ * Tab stop width, matching every terminal's default.
31
+ *
32
+ * A COPY OF `tools/terminal-output.ts`'s number, on purpose and in the same
33
+ * shape `composer-limits.ts` copies `budget.ts::HINT_MIN_ROWS`: importing the
34
+ * tools module here would point an arrow from `ui/` into `tools/` for one
35
+ * integer, and the two are asserted equal by a test instead. (`tools/` keeps
36
+ * its own expander because its rows are clipped to 200 columns before anyone
37
+ * looks at them, so a code-unit approximation there cannot reach a user.)
38
+ */
39
+ export const TAB_WIDTH = 8;
40
+ /**
41
+ * Expand every TAB in `text` to spaces up to the next `TAB_WIDTH` stop, given
42
+ * the display column the first character will be painted at.
43
+ *
44
+ * `fromColumn` is a DISPLAY column, not an offset: a caller that has already
45
+ * painted `中文` must pass 4. Lines are independent -- a `\n` resets the column
46
+ * to zero, because the terminal's pen returns to column 0.
47
+ *
48
+ * Non-ASCII text is measured with `string-width`, which is the whole point: a
49
+ * CJK glyph occupies two cells, and counting it as one space is what left
50
+ * CJK-plus-TAB tables misaligned.
51
+ */
52
+ export function expandTabs(text, fromColumn = 0) {
53
+ if (text.indexOf('\t') === -1)
54
+ return text;
55
+ let out = '';
56
+ let column = Math.max(0, Math.floor(fromColumn));
57
+ let run = '';
58
+ for (const part of graphemesOf(text)) {
59
+ if (part === '\t') {
60
+ out += run;
61
+ run = '';
62
+ const width = TAB_WIDTH - (column % TAB_WIDTH);
63
+ out += ' '.repeat(width);
64
+ column += width;
65
+ continue;
66
+ }
67
+ run += part;
68
+ if (part === '\n')
69
+ column = 0;
70
+ else
71
+ column += stringWidth(part);
72
+ }
73
+ return out + run;
74
+ }
75
+ const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
76
+ /**
77
+ * Grapheme clusters rather than code units, so a combining mark or an emoji ZWJ
78
+ * sequence advances the column once, exactly as the terminal draws it.
79
+ */
80
+ function graphemesOf(text) {
81
+ return Array.from(segmenter.segment(text), ({ segment }) => segment);
82
+ }
@@ -25,7 +25,17 @@ export interface EditorVisualMap {
25
25
  readonly positions: VisualPosition[];
26
26
  readonly cursor: VisualPosition;
27
27
  }
28
- /** Map graphemes to hard-wrapped rows, sharing tab stops and caret reservation. */
28
+ /**
29
+ * Map graphemes to hard-wrapped rows, sharing tab stops and caret reservation.
30
+ *
31
+ * THE TAB STOP IS THE TERMINAL'S (`TAB_WIDTH`), NOT AN EDITOR-SIZED FOUR. It
32
+ * used to be four, while the transcript handed the raw `\t` to `<Text>` and
33
+ * the terminal painted it at eight: the same pasted table sat one column off
34
+ * from itself before and after sending. A tab that would cross the right edge
35
+ * is still clipped to the row (the caret cannot point past the last cell), so
36
+ * a tab STRADDLING the wrap is the one shape where the draft and the stored
37
+ * text can differ by the remainder of the advance.
38
+ */
29
39
  export declare function buildEditorVisualMap(input: {
30
40
  buffer: string;
31
41
  cols: number;
@@ -1,5 +1,6 @@
1
1
  /** Shared UTF-16 grapheme boundaries and terminal-cell layout for the editor. */
2
2
  import stringWidth from 'string-width';
3
+ import { TAB_WIDTH } from './display-tab.js';
3
4
  const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
4
5
  /** Segment text without changing its UTF-16 offsets or stored whitespace. */
5
6
  export function graphemes(text) {
@@ -31,7 +32,17 @@ export function graphemeWidth(text) {
31
32
  return 1;
32
33
  return stringWidth(text);
33
34
  }
34
- /** Map graphemes to hard-wrapped rows, sharing tab stops and caret reservation. */
35
+ /**
36
+ * Map graphemes to hard-wrapped rows, sharing tab stops and caret reservation.
37
+ *
38
+ * THE TAB STOP IS THE TERMINAL'S (`TAB_WIDTH`), NOT AN EDITOR-SIZED FOUR. It
39
+ * used to be four, while the transcript handed the raw `\t` to `<Text>` and
40
+ * the terminal painted it at eight: the same pasted table sat one column off
41
+ * from itself before and after sending. A tab that would cross the right edge
42
+ * is still clipped to the row (the caret cannot point past the last cell), so
43
+ * a tab STRADDLING the wrap is the one shape where the draft and the stored
44
+ * text can differ by the remainder of the advance.
45
+ */
35
46
  export function buildEditorVisualMap(input) {
36
47
  const cols = Math.max(1, Math.floor(input.cols));
37
48
  const cursor = snapGrapheme(input.buffer, input.cursor);
@@ -50,12 +61,12 @@ export function buildEditorVisualMap(input) {
50
61
  breakRow();
51
62
  continue;
52
63
  }
53
- let width = part.text === '\t' ? Math.min(cols, 4 - column % 4)
64
+ let width = part.text === '\t' ? Math.min(cols, TAB_WIDTH - column % TAB_WIDTH)
54
65
  : graphemeWidth(part.text);
55
66
  if (column > 0 && column + width > cols) {
56
67
  breakRow();
57
68
  if (part.text === '\t')
58
- width = Math.min(cols, 4);
69
+ width = Math.min(cols, TAB_WIDTH);
59
70
  }
60
71
  locate(part.index);
61
72
  rows[rows.length - 1].push({ ...part,