open-claude-p 1.2.1 → 1.2.2
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/CHANGELOG.md +69 -0
- package/bin/cli.js +32 -2
- package/package.json +1 -1
- package/src/chat/index.js +37 -1
- package/src/index.js +47 -2
- package/src/parsers/ansi-strip.js +149 -42
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,75 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [1.2.2] — 2026-09-13
|
|
11
|
+
|
|
12
|
+
Fixes the two defects listed as known issues in 1.2.1. Both were
|
|
13
|
+
pre-existing — they predate the 1.2.x work — and both were tracked to
|
|
14
|
+
root cause before anything was changed.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- **Replies lost every space between words.** The upstream TUI does not
|
|
19
|
+
emit literal spaces between words on a redraw; it positions each word
|
|
20
|
+
with an absolute column move and lets the terminal leave the gap blank:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
A\x1b[5Gpseudoterminal\x1b[20Gis\x1b[23Ga\x1b[25Gsoftware
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`ansi-strip` translated only CUF (`ESC [ Pn C`) into spaces. CHA
|
|
27
|
+
(`ESC [ Pn G`) — which the current CLI uses for nearly every word — was
|
|
28
|
+
deleted by the general CSI strip, so the reply came back as
|
|
29
|
+
`Apseudoterminalisasoftware…`. The parser now tracks the cursor column
|
|
30
|
+
and expands any forward horizontal move, CUF and CHA alike, into the
|
|
31
|
+
run of spaces it would have left on screen. CUP (`H`/`f`) adopts the
|
|
32
|
+
column without padding, since it is a jump to another region rather
|
|
33
|
+
than a gap.
|
|
34
|
+
|
|
35
|
+
- **The CLI printed the TUI's spinner cell and usage meters around the
|
|
36
|
+
answer.** `cleanResponse` exists as the last line of defence for
|
|
37
|
+
PTY-extracted text — its own docstring says so — but only the chat
|
|
38
|
+
client ever called it. The CLI printed raw PTY text whenever the
|
|
39
|
+
upstream session file was unavailable, which is exactly when the scrub
|
|
40
|
+
is needed. Both CLI paths now scrub, and the pattern set gained the
|
|
41
|
+
rate-limit meters (`Usage ██░░ 16% (resets in 3h)`), the bare context
|
|
42
|
+
meter, and the spinner status cell with its token counters (`63✢ 88`).
|
|
43
|
+
Guards keep real prose intact: meters must carry bar glyphs, counter
|
|
44
|
+
cells must carry a digit beside the glyph and the multi-space padding
|
|
45
|
+
the TUI uses, and a reply that is legitimately just a number ("what is
|
|
46
|
+
6*7" → "42") is never swallowed.
|
|
47
|
+
|
|
48
|
+
- **Sessions started from inside a Claude Code session were never
|
|
49
|
+
persisted.** When `ocp` itself runs under Claude Code — an agent
|
|
50
|
+
driving it, or a developer testing from one — the spawned `claude`
|
|
51
|
+
inherited the caller's session identity, most decisively
|
|
52
|
+
`CLAUDE_CODE_CHILD_SESSION`, and concluded it was a continuation of the
|
|
53
|
+
parent rather than a new session. It then wrote no session JSONL at
|
|
54
|
+
all: `sessionId` came back `null`, the clean transcript ocp prefers was
|
|
55
|
+
unavailable so every answer fell back to PTY-scraped text, and
|
|
56
|
+
`--continue` / `--resume` had nothing to resume. The driver now removes
|
|
57
|
+
the caller's session-identity variables when spawning. User-facing
|
|
58
|
+
configuration (`CLAUDE_CODE_SIMPLE`, `CLAUDE_CODE_SAFE_MODE`, provider
|
|
59
|
+
toggles, …) is untouched, an explicit value in `opts.env` still wins,
|
|
60
|
+
and `OCP_KEEP_PARENT_SESSION_ENV=1` restores the old behaviour.
|
|
61
|
+
|
|
62
|
+
### Tests
|
|
63
|
+
|
|
64
|
+
- 237 → 243. New coverage: `test/ansi-cha.test.js` (absolute column moves,
|
|
65
|
+
including the verbatim byte sequence captured off the wire) and
|
|
66
|
+
`test/parent-session-env.test.js`. `test/clean-response.test.js` gains
|
|
67
|
+
the meter, spinner-cell and numeric-answer cases.
|
|
68
|
+
|
|
69
|
+
### Verification
|
|
70
|
+
|
|
71
|
+
Each fix was reproduced first, then confirmed against the real CLI:
|
|
72
|
+
replaying the captured session through the parser now yields the sentence
|
|
73
|
+
with its spacing intact; a warm-daemon second turn prints the answer with
|
|
74
|
+
no chrome; and `--continue` recalls a word from the previous turn, which
|
|
75
|
+
it could not do before.
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
10
79
|
## [1.2.1] — 2026-09-13
|
|
11
80
|
|
|
12
81
|
Re-sync with Claude Code 2.1.270 and the current model lineup, and fix a
|
package/bin/cli.js
CHANGED
|
@@ -65,6 +65,27 @@ const EXIT = {
|
|
|
65
65
|
NOT_IMPLEMENTED: 8,
|
|
66
66
|
};
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Scrub TUI chrome out of PTY-extracted text.
|
|
70
|
+
*
|
|
71
|
+
* Only for text reconstructed from terminal frames. Text read from the
|
|
72
|
+
* upstream session JSONL is already clean and must be passed through
|
|
73
|
+
* untouched. Failures are swallowed: a scrub that throws must never cost
|
|
74
|
+
* the caller their answer.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} text
|
|
77
|
+
* @returns {Promise<string>}
|
|
78
|
+
*/
|
|
79
|
+
async function scrubPtyText(text) {
|
|
80
|
+
if (typeof text !== 'string' || text === '') return text;
|
|
81
|
+
try {
|
|
82
|
+
const { cleanResponse } = await import('../src/chat/index.js');
|
|
83
|
+
return cleanResponse(text);
|
|
84
|
+
} catch {
|
|
85
|
+
return text;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
68
89
|
async function main() {
|
|
69
90
|
const { options, positional, unknown, errors, supplied } = parseArgv(process.argv.slice(2));
|
|
70
91
|
|
|
@@ -309,8 +330,10 @@ async function main() {
|
|
|
309
330
|
process.stdout,
|
|
310
331
|
);
|
|
311
332
|
for (const ev of (result.events ?? [])) adapter.onEvent(ev);
|
|
333
|
+
// Daemon results are always PTY-extracted — the daemon does not read
|
|
334
|
+
// the upstream session file — so they need the same scrub.
|
|
312
335
|
adapter.end({
|
|
313
|
-
text: result.text,
|
|
336
|
+
text: await scrubPtyText(result.text),
|
|
314
337
|
isError: result.isError,
|
|
315
338
|
sessionId: result.sessionId,
|
|
316
339
|
cost: result.cost,
|
|
@@ -466,17 +489,24 @@ async function runOneTurn({ driver, adapter, options, unknown, ac, prompt, resum
|
|
|
466
489
|
let finalText = result.text;
|
|
467
490
|
let usage = null;
|
|
468
491
|
let toolsFromSession = [];
|
|
492
|
+
let fromSessionFile = false;
|
|
469
493
|
if (!result.isError) {
|
|
470
494
|
try {
|
|
471
495
|
const { readSessionText } = await import('../src/chat/index.js');
|
|
472
496
|
const sessionRead = await readSessionText(
|
|
473
497
|
result.sessionId, startTime, options.cwd, { expectPrompt: prompt },
|
|
474
498
|
);
|
|
475
|
-
if (sessionRead?.text) finalText = sessionRead.text;
|
|
499
|
+
if (sessionRead?.text) { finalText = sessionRead.text; fromSessionFile = true; }
|
|
476
500
|
if (sessionRead?.usage) usage = sessionRead.usage;
|
|
477
501
|
if (sessionRead?.tools) toolsFromSession = sessionRead.tools;
|
|
478
502
|
} catch { /* keep PTY-extracted fallback */ }
|
|
479
503
|
}
|
|
504
|
+
// The JSONL is always clean; PTY-extracted text is not. `cleanResponse`
|
|
505
|
+
// exists as the last line of defence for exactly this fallback, but the
|
|
506
|
+
// CLI never called it — only the chat client did — so a run that could
|
|
507
|
+
// not read the session file printed the TUI's spinner cell and usage
|
|
508
|
+
// meters around the answer.
|
|
509
|
+
if (!fromSessionFile) finalText = await scrubPtyText(finalText);
|
|
480
510
|
|
|
481
511
|
adapter.end({
|
|
482
512
|
text: finalText,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "open-claude-p",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.2",
|
|
4
4
|
"description": "A PTY-backed compatibility shim for `claude -p` (Claude Code headless/print mode). Drives the interactive `claude` CLI via node-pty and exposes the same option surface and stream-json contract. Ships as both a library and an `ocp` CLI binary.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
package/src/chat/index.js
CHANGED
|
@@ -1328,7 +1328,28 @@ export const TUI_CHROME_PATTERNS = [
|
|
|
1328
1328
|
// 2 MCPs | 4 hooks - Zero-config…"). These must NOT drop the whole
|
|
1329
1329
|
// line — only the matched fragment. Order matters: longer / more
|
|
1330
1330
|
// specific variants first so the shorter ones don't partial-match.
|
|
1331
|
+
// Spinner glyphs the TUI cycles through. Kept in sync with
|
|
1332
|
+
// `PATTERNS.spinnerLeading` in `src/parsers/tui-frame.js`.
|
|
1333
|
+
const SPINNER_GLYPHS = '✻✶✺✹✸✷✵●✢✳✽·✾✿❀❁❂❃❄❅❆❇❈❉❊❋';
|
|
1334
|
+
|
|
1331
1335
|
export const TUI_CHROME_INLINE_PATTERNS = [
|
|
1336
|
+
// Rate-limit meters painted to the right of a streamed reply:
|
|
1337
|
+
// `Usage ██░░░░░░░░ 16% (resets in 3h 18m)`, `| Weekly ░░░ 3% (resets in 6d 1h)`.
|
|
1338
|
+
// Anchored on the bar glyphs so ordinary prose with a percentage is safe.
|
|
1339
|
+
new RegExp(
|
|
1340
|
+
String.raw`\s*\|?\s*(?:Usage|Weekly)\s*[█░▒▓]+\s*\d+%\s*\(resets in [^)]*\)`,
|
|
1341
|
+
'gi',
|
|
1342
|
+
),
|
|
1343
|
+
// Bare context meter: `██████░░░░ 22% │`.
|
|
1344
|
+
/\s*[█░▒▓]{2,}\s*\d+%\s*│?/g,
|
|
1345
|
+
// The animated status cell the TUI paints to the LEFT of a reply,
|
|
1346
|
+
// carrying token counters: `63✢ 88 `. Two guards keep real prose
|
|
1347
|
+
// safe: a digit must sit beside the glyph, and the cell must be
|
|
1348
|
+
// separated from the content by the multi-space padding the TUI uses.
|
|
1349
|
+
// That padding is what distinguishes the chrome `✳ 128 Here is …`
|
|
1350
|
+
// from an ordinary bullet like `· 5 minutes later`.
|
|
1351
|
+
new RegExp(String.raw`^\s*\d+\s*[${SPINNER_GLYPHS}]\s*\d*\s{2,}`, 'g'),
|
|
1352
|
+
new RegExp(String.raw`^\s*[${SPINNER_GLYPHS}]\s*\d+\s{2,}`, 'g'),
|
|
1332
1353
|
/\s*\d+\s+CLAUDE\.md\s*\|\s*\d+\s+rules?\s*\|\s*\d+\s+MCPs?\s*\|\s*\d+\s+hooks?/gi,
|
|
1333
1354
|
/\s*\d+\s+rules?\s*\|\s*\d+\s+MCPs?\s*\|\s*\d+\s+hooks?/gi,
|
|
1334
1355
|
/\s*\d+\s+MCP servers?\s+need(?:s)?\s+auth\s*·\s*\/mcp/gi,
|
|
@@ -1344,7 +1365,7 @@ export const TUI_CHROME_INLINE_PATTERNS = [
|
|
|
1344
1365
|
* @param {string} text
|
|
1345
1366
|
*/
|
|
1346
1367
|
export function cleanResponse(text) {
|
|
1347
|
-
|
|
1368
|
+
const cleaned = String(text ?? '')
|
|
1348
1369
|
.replace(/\r/g, '\n')
|
|
1349
1370
|
.split('\n')
|
|
1350
1371
|
.map((l) => l.trimEnd())
|
|
@@ -1368,4 +1389,19 @@ export function cleanResponse(text) {
|
|
|
1368
1389
|
.replace(/\n{3,}/g, '\n\n')
|
|
1369
1390
|
.replace(SENTINEL_REGEX, '')
|
|
1370
1391
|
.trim();
|
|
1392
|
+
|
|
1393
|
+
// The TUI paints bare token counters (`63`, `128`) around a streamed
|
|
1394
|
+
// reply, and once the surrounding chrome is gone they are left stranded
|
|
1395
|
+
// on their own lines. Drop them — but ONLY when other content survives,
|
|
1396
|
+
// so a reply that is legitimately just a number ("what is 6*7" -> "42")
|
|
1397
|
+
// is never swallowed.
|
|
1398
|
+
const lines = cleaned.split('\n');
|
|
1399
|
+
// A counter cell is a bare integer, optionally still carrying the
|
|
1400
|
+
// spinner glyph it was painted next to.
|
|
1401
|
+
const isBareCounter = (l) =>
|
|
1402
|
+
new RegExp(String.raw`^\s*[${SPINNER_GLYPHS}]?\s*\d{1,6}\s*$`).test(l);
|
|
1403
|
+
if (lines.some((l) => l.trim() !== '' && !isBareCounter(l))) {
|
|
1404
|
+
return lines.filter((l) => !isBareCounter(l)).join('\n').trim();
|
|
1405
|
+
}
|
|
1406
|
+
return cleaned;
|
|
1371
1407
|
}
|
package/src/index.js
CHANGED
|
@@ -143,6 +143,51 @@ export function sanitizePassThroughArgv(argv) {
|
|
|
143
143
|
* `runOneShot()` spawns a fresh PTY session; pass `poolSize: N` to opt
|
|
144
144
|
* into warm-session reuse.
|
|
145
145
|
*/
|
|
146
|
+
// Environment variables that identify the *calling* Claude Code session —
|
|
147
|
+
// its id, its IPC bridge, its resolved binary — rather than configuring
|
|
148
|
+
// Claude generally. When `ocp` itself runs inside a Claude Code session
|
|
149
|
+
// (an agent driving it, or a developer testing from one) the child
|
|
150
|
+
// `claude` inherits all of them and concludes it is a continuation of the
|
|
151
|
+
// parent rather than a new session. The visible consequence is that it
|
|
152
|
+
// writes no session JSONL at all: `sessionId` comes back null, the clean
|
|
153
|
+
// transcript ocp prefers is unavailable so answers fall back to
|
|
154
|
+
// PTY-scraped text, and `--continue` / `--resume` have nothing to resume.
|
|
155
|
+
//
|
|
156
|
+
// The child is a new, independent session, so these must not carry over.
|
|
157
|
+
// User-facing configuration (`CLAUDE_CODE_SIMPLE`, `CLAUDE_CODE_SAFE_MODE`,
|
|
158
|
+
// provider toggles, …) is deliberately NOT in this list and passes through
|
|
159
|
+
// untouched. Set OCP_KEEP_PARENT_SESSION_ENV=1 to disable the scrub.
|
|
160
|
+
const PARENT_SESSION_ENV = [
|
|
161
|
+
'CLAUDE_CODE_SESSION_ID',
|
|
162
|
+
'CLAUDE_CODE_BRIDGE_SESSION_ID',
|
|
163
|
+
'CLAUDE_CODE_CHILD_SESSION',
|
|
164
|
+
'CLAUDE_CODE_ENTRYPOINT',
|
|
165
|
+
'CLAUDE_CODE_MESSAGING_SOCKET',
|
|
166
|
+
'CLAUDE_CODE_MESSAGING_TOKEN',
|
|
167
|
+
'CLAUDE_CODE_EXECPATH',
|
|
168
|
+
];
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Remove the calling Claude Code session's identity from a spawn env.
|
|
172
|
+
*
|
|
173
|
+
* Returns a new object; the input is not mutated. An explicit value in
|
|
174
|
+
* `opts.env` wins — if a caller deliberately sets one of these, we honour
|
|
175
|
+
* it rather than second-guessing them.
|
|
176
|
+
*
|
|
177
|
+
* @param {Record<string, string|undefined>} env
|
|
178
|
+
* @param {Record<string, string|undefined>} [explicit] caller-supplied overrides
|
|
179
|
+
* @returns {Record<string, string|undefined>}
|
|
180
|
+
*/
|
|
181
|
+
export function scrubParentSessionEnv(env, explicit = {}) {
|
|
182
|
+
if (process.env.OCP_KEEP_PARENT_SESSION_ENV === '1') return { ...env };
|
|
183
|
+
const out = { ...env };
|
|
184
|
+
for (const key of PARENT_SESSION_ENV) {
|
|
185
|
+
if (Object.prototype.hasOwnProperty.call(explicit, key)) continue;
|
|
186
|
+
delete out[key];
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
146
191
|
export function createDriver(driverOpts = {}) {
|
|
147
192
|
return new Driver(driverOpts);
|
|
148
193
|
}
|
|
@@ -252,7 +297,7 @@ class Driver {
|
|
|
252
297
|
process.env.OCP_PRINT_MODE === 'true';
|
|
253
298
|
if (printMode) {
|
|
254
299
|
const cwd = req.cwd ?? this.opts.cwd;
|
|
255
|
-
const env = { ...process.env, ...this.opts.env };
|
|
300
|
+
const env = scrubParentSessionEnv({ ...process.env, ...this.opts.env }, this.opts.env);
|
|
256
301
|
const r = await runPrintMode({
|
|
257
302
|
bin: this.opts.claudeBin,
|
|
258
303
|
req,
|
|
@@ -331,7 +376,7 @@ class Driver {
|
|
|
331
376
|
// Pool eligibility: explicit resume/continue bind to a specific past
|
|
332
377
|
// session and are not poolable (they bypass the pool entirely).
|
|
333
378
|
const cwd = req.cwd ?? this.opts.cwd;
|
|
334
|
-
const env = { ...process.env, ...this.opts.env };
|
|
379
|
+
const env = scrubParentSessionEnv({ ...process.env, ...this.opts.env }, this.opts.env);
|
|
335
380
|
const pool = this._getPool();
|
|
336
381
|
const isResumeLike =
|
|
337
382
|
(typeof req.resume === 'string' && req.resume !== '') ||
|
|
@@ -1,8 +1,24 @@
|
|
|
1
1
|
// Baseline parser: strip ANSI escape sequences.
|
|
2
2
|
//
|
|
3
|
-
// We do not attempt to reconstruct a terminal screen here.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// We do not attempt to reconstruct a terminal screen here. Colors, clears
|
|
4
|
+
// and vertical moves are simply removed. The one thing we DO model is the
|
|
5
|
+
// cursor's horizontal position, because the upstream TUI uses horizontal
|
|
6
|
+
// moves in place of literal spaces:
|
|
7
|
+
//
|
|
8
|
+
// A\x1b[5Gpseudoterminal\x1b[20Gis\x1b[23Ga\x1b[25Gsoftware
|
|
9
|
+
//
|
|
10
|
+
// Every inter-word gap there is an absolute column move, not a space. Strip
|
|
11
|
+
// the sequences blindly and the words run together — "Apseudoterminalisa…".
|
|
12
|
+
// So we track the column and translate a forward move into the run of
|
|
13
|
+
// spaces it would have left blank on screen.
|
|
14
|
+
//
|
|
15
|
+
// Two sequences produce those gaps:
|
|
16
|
+
// CUF — `ESC [ Pn C` move right Pn columns (relative, default 1)
|
|
17
|
+
// CHA — `ESC [ Pn G` move to column Pn (absolute, 1-based, default 1)
|
|
18
|
+
//
|
|
19
|
+
// CUF alone used to be enough; the current CLI emits CHA for nearly every
|
|
20
|
+
// word, which is what made this worth modelling properly rather than
|
|
21
|
+
// special-casing one sequence.
|
|
6
22
|
//
|
|
7
23
|
// Buffering rule for partial chunks: if the chunk ends with an ESC that has
|
|
8
24
|
// not yet been terminated (e.g. mid-CSI), hold it until the next chunk.
|
|
@@ -10,37 +26,9 @@
|
|
|
10
26
|
const ESC = '\x1b';
|
|
11
27
|
const BEL = '\x07';
|
|
12
28
|
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
// rendering optimization). Stripping the sequence collapses words
|
|
17
|
-
// together — `each<CUF1>other` becomes `eachother`. We translate CUF
|
|
18
|
-
// into the equivalent run of spaces BEFORE the general CSI strip so the
|
|
19
|
-
// visible text is preserved.
|
|
20
|
-
const RE_CSI_CURSOR_FORWARD = /\x1b\[(\d*)C/g;
|
|
21
|
-
function expandCursorForward(text) {
|
|
22
|
-
return text.replace(RE_CSI_CURSOR_FORWARD, (_match, n) => {
|
|
23
|
-
const count = parseInt(n || '1', 10);
|
|
24
|
-
return ' '.repeat(Math.min(count, 200)); // cap defensively
|
|
25
|
-
});
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// Complete-sequence regexes — used only on input slices we have proven to be
|
|
29
|
-
// complete (i.e. without a dangling partial at the end).
|
|
30
|
-
const RE_CSI = /\x1b\[[0-?]*[ -/]*[@-~]/g; // ESC [ params intermediates final
|
|
31
|
-
// OSC-style sequences: ESC introducer then arbitrary content then BEL or ST.
|
|
32
|
-
// Covers OSC (`]`), DCS (`P`), SOS (`X`), PM (`^`), APC (`_`).
|
|
33
|
-
const RE_STRING_TERMINATED =
|
|
34
|
-
/\x1b[\]PX^_][^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
|
35
|
-
// Two-byte ESC forms — covers C1 7-bit aliases (`ESC @`..`ESC _` except the
|
|
36
|
-
// CSI/OSC/etc. introducers, which are handled above) AND the DEC private
|
|
37
|
-
// single-letter sequences in the 0x30..0x3F range (`ESC 7` = DECSC save
|
|
38
|
-
// cursor, `ESC 8` = DECRC restore, `ESC =` / `ESC >` keypad modes, etc.).
|
|
39
|
-
// Order is: CSI -> string-terminated -> this, so by the time we reach
|
|
40
|
-
// RE_C1 the only `\x1b[`/`\x1b]`/`\x1b_`/... left in the input is either
|
|
41
|
-
// complete-and-already-stripped or partial-and-held-back by the pending
|
|
42
|
-
// buffer.
|
|
43
|
-
const RE_C1 = /\x1b[0-?@-_]/g;
|
|
29
|
+
// Defensive ceiling on generated padding. A corrupt or hostile column
|
|
30
|
+
// target must not let one escape sequence expand into megabytes.
|
|
31
|
+
const MAX_PAD = 200;
|
|
44
32
|
|
|
45
33
|
/** Introducer characters for OSC-style sequences (BEL/ST terminated). */
|
|
46
34
|
const STRING_INTRODUCERS = new Set([']', 'P', 'X', '^', '_']);
|
|
@@ -56,11 +44,136 @@ function isCompleteEscapeTail(tail) {
|
|
|
56
44
|
return tail.length >= 2;
|
|
57
45
|
}
|
|
58
46
|
|
|
47
|
+
/** First numeric parameter of a CSI parameter string, or `fallback`. */
|
|
48
|
+
function firstParam(params, fallback) {
|
|
49
|
+
const n = parseInt(params, 10);
|
|
50
|
+
return Number.isNaN(n) ? fallback : n;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Nth (0-based) numeric parameter of a CSI parameter string. */
|
|
54
|
+
function nthParam(params, index, fallback) {
|
|
55
|
+
const raw = params.split(';')[index];
|
|
56
|
+
if (raw === undefined || raw === '') return fallback;
|
|
57
|
+
const n = parseInt(raw, 10);
|
|
58
|
+
return Number.isNaN(n) ? fallback : n;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Strip escapes from `input` while tracking the cursor column, expanding
|
|
63
|
+
* forward horizontal moves into spaces.
|
|
64
|
+
*
|
|
65
|
+
* `state.col` is the 0-based column and persists across chunks, since a
|
|
66
|
+
* rendered line routinely spans several PTY reads.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} input
|
|
69
|
+
* @param {{col: number}} state
|
|
70
|
+
* @returns {string}
|
|
71
|
+
*/
|
|
72
|
+
function stripTrackingColumn(input, state) {
|
|
73
|
+
let out = '';
|
|
74
|
+
let i = 0;
|
|
75
|
+
const len = input.length;
|
|
76
|
+
|
|
77
|
+
while (i < len) {
|
|
78
|
+
const ch = input[i];
|
|
79
|
+
|
|
80
|
+
if (ch === '\r' || ch === '\n') {
|
|
81
|
+
out += ch;
|
|
82
|
+
state.col = 0;
|
|
83
|
+
i += 1;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (ch !== ESC) {
|
|
88
|
+
out += ch;
|
|
89
|
+
state.col += 1;
|
|
90
|
+
i += 1;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const c1 = input[i + 1];
|
|
95
|
+
|
|
96
|
+
// ── CSI: ESC [ params intermediates final ────────────────────────────
|
|
97
|
+
if (c1 === '[') {
|
|
98
|
+
let j = i + 2;
|
|
99
|
+
while (j < len) {
|
|
100
|
+
const c = input.charCodeAt(j);
|
|
101
|
+
if (c >= 0x30 && c <= 0x3f) j += 1; // parameter bytes
|
|
102
|
+
else break;
|
|
103
|
+
}
|
|
104
|
+
const paramsEnd = j;
|
|
105
|
+
while (j < len) {
|
|
106
|
+
const c = input.charCodeAt(j);
|
|
107
|
+
if (c >= 0x20 && c <= 0x2f) j += 1; // intermediate bytes
|
|
108
|
+
else break;
|
|
109
|
+
}
|
|
110
|
+
if (j >= len) {
|
|
111
|
+
// Incomplete despite the pending-buffer guard; drop the remainder.
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
const final = input[j];
|
|
115
|
+
const params = input.slice(i + 2, paramsEnd);
|
|
116
|
+
i = j + 1;
|
|
117
|
+
|
|
118
|
+
// Private-parameter sequences (`ESC [ ? 25 h`) never move the cursor
|
|
119
|
+
// horizontally in a way we can model; strip them.
|
|
120
|
+
if (params.charCodeAt(0) >= 0x3c && params.charCodeAt(0) <= 0x3f) continue;
|
|
121
|
+
|
|
122
|
+
if (final === 'C') {
|
|
123
|
+
// CUF — relative forward move.
|
|
124
|
+
const n = Math.max(0, firstParam(params, 1));
|
|
125
|
+
const pad = Math.min(n, MAX_PAD);
|
|
126
|
+
out += ' '.repeat(pad);
|
|
127
|
+
state.col += pad;
|
|
128
|
+
} else if (final === 'G' || final === '`') {
|
|
129
|
+
// CHA / HPA — absolute column, 1-based.
|
|
130
|
+
const target = Math.max(1, firstParam(params, 1)) - 1;
|
|
131
|
+
if (target > state.col) {
|
|
132
|
+
const pad = Math.min(target - state.col, MAX_PAD);
|
|
133
|
+
out += ' '.repeat(pad);
|
|
134
|
+
state.col += pad;
|
|
135
|
+
}
|
|
136
|
+
// A backwards move would overwrite already-emitted text. We cannot
|
|
137
|
+
// represent that without a screen model, so we neither pad nor
|
|
138
|
+
// rewind — the text simply continues.
|
|
139
|
+
} else if (final === 'H' || final === 'f') {
|
|
140
|
+
// CUP — absolute row;col. This is a jump to another part of the
|
|
141
|
+
// screen (status line, input box), so it never means "gap here";
|
|
142
|
+
// adopt the column but emit no padding.
|
|
143
|
+
state.col = Math.max(1, nthParam(params, 1, 1)) - 1;
|
|
144
|
+
}
|
|
145
|
+
// Everything else (SGR, erase, vertical moves, scroll regions) is
|
|
146
|
+
// zero-width for our purposes: stripped, column untouched.
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── OSC and friends: ESC ] … BEL | ST ────────────────────────────────
|
|
151
|
+
if (STRING_INTRODUCERS.has(c1)) {
|
|
152
|
+
let j = i + 2;
|
|
153
|
+
while (j < len) {
|
|
154
|
+
if (input[j] === BEL) { j += 1; break; }
|
|
155
|
+
if (input[j] === ESC && input[j + 1] === '\\') { j += 2; break; }
|
|
156
|
+
j += 1;
|
|
157
|
+
}
|
|
158
|
+
i = j;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Two-byte ESC forms (ESC 7, ESC 8, ESC =, C1 aliases) ─────────────
|
|
163
|
+
if (c1 === undefined) break; // dangling ESC; pending guard should catch it
|
|
164
|
+
i += 2;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
|
|
59
170
|
export const ansiStripParser = {
|
|
60
171
|
name: 'ansi-strip',
|
|
61
172
|
priority: 10,
|
|
62
173
|
create() {
|
|
63
174
|
let pending = '';
|
|
175
|
+
// Cursor column persists across feeds — a line spans many PTY reads.
|
|
176
|
+
const state = { col: 0 };
|
|
64
177
|
return {
|
|
65
178
|
feed(chunk) {
|
|
66
179
|
let input = pending + chunk;
|
|
@@ -77,17 +190,11 @@ export const ansiStripParser = {
|
|
|
77
190
|
}
|
|
78
191
|
}
|
|
79
192
|
|
|
80
|
-
|
|
81
|
-
// literal spaces BEFORE the general CSI strip eats them.
|
|
82
|
-
const expanded = expandCursorForward(input);
|
|
83
|
-
const out = expanded
|
|
84
|
-
.replace(RE_CSI, '')
|
|
85
|
-
.replace(RE_STRING_TERMINATED, '')
|
|
86
|
-
.replace(RE_C1, '');
|
|
87
|
-
return { text: out, events: [] };
|
|
193
|
+
return { text: stripTrackingColumn(input, state), events: [] };
|
|
88
194
|
},
|
|
89
195
|
reset() {
|
|
90
196
|
pending = '';
|
|
197
|
+
state.col = 0;
|
|
91
198
|
},
|
|
92
199
|
};
|
|
93
200
|
},
|