Merge remote-tracking branch 'origin/main' into OrcaWin/neil-editor-dep-updates

This commit is contained in:
m4air
2026-09-07 19:17:21 -07:00
26 changed files with 1024 additions and 157 deletions
File diff suppressed because one or more lines are too long
@@ -1104,31 +1104,124 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..d4d5106d8dd723eceed87310c193ccfe
}
}
diff --git a/src/common/SortedList.ts b/src/common/SortedList.ts
index 8a10076e3963e33b4a7d1e4602333eb3f4772dc9..df0761c35907ddc48eb102ba181b0dac8e61f00d 100644
index 8a10076e3963e33b4a7d1e4602333eb3f4772dc9..c6dcf18b762e3c56fe22e9c2d49b8e550d96f915 100644
--- a/src/common/SortedList.ts
+++ b/src/common/SortedList.ts
@@ -87,6 +87,24 @@ export class SortedList<T> {
if (key === undefined) {
return false;
@@ -22,7 +22,8 @@ export class SortedList<T> {
private readonly _flushInsertedTask: InstanceType<typeof IdleTaskQueue>;
private _isFlushingInserted = false;
- private readonly _deletedIndices: number[] = [];
+ private readonly _deletedIndices = new Set<number>();
+ private readonly _indicesByValue = new Map<T, number | number[]>();
private readonly _flushDeletedTask: InstanceType<typeof IdleTaskQueue>;
private _isFlushingDeleted = false;
@@ -36,10 +37,11 @@ export class SortedList<T> {
public clear(): void {
this._array.length = 0;
+ this._indicesByValue.clear();
this._insertedValues.length = 0;
this._flushInsertedTask.clear();
this._isFlushingInserted = false;
- this._deletedIndices.length = 0;
+ this._deletedIndices.clear();
this._flushDeletedTask.clear();
this._isFlushingDeleted = false;
}
@@ -69,6 +71,7 @@ export class SortedList<T> {
}
+ if (this._deleteAtKey(value, key)) {
+ return true;
this._array = newArray;
+ this._rebuildIdentityIndex();
this._insertedValues.length = 0;
}
@@ -78,54 +81,60 @@ export class SortedList<T> {
}
}
+ private _rebuildIdentityIndex(): void {
+ this._indicesByValue.clear();
+ // Reverse indices let duplicate identities remove their first occurrence in O(1).
+ for (let index = this._array.length - 1; index >= 0; index--) {
+ const value = this._array[index];
+ const indices = this._indicesByValue.get(value);
+ if (indices === undefined) {
+ this._indicesByValue.set(value, index);
+ } else if (typeof indices === 'number') {
+ this._indicesByValue.set(value, [indices, index]);
+ } else {
+ indices.push(index);
+ }
+ }
+ // A pending deletion whose key mutated after `delete()` (disposing a marker
+ // resets `line` to -1, and `line` is the sort key) leaves `_array` out of
+ // order, so the binary search above can miss a value that is present.
+ // Compacting those entries out restores the order; retry before reporting
+ // the value absent, else its `onDecorationRemoved` never fires and the
+ // decoration paints forever. Miss path only, so the common bulk delete
+ // keeps its O(log n) search and deferred-compaction batching.
+ if (this._deletedIndices.length === 0) {
+ return false;
+ }
+ this._flushCleanupDeleted();
+ return this._deleteAtKey(value, key);
+ }
+
+ private _deleteAtKey(value: T, key: number): boolean {
i = this._search(key);
if (i === -1) {
public delete(value: T): boolean {
this._flushCleanupInserted();
- if (this._array.length === 0) {
+ // Marker disposal mutates the sort key before removal; identity stays stable.
+ const indices = this._indicesByValue.get(value);
+ if (indices === undefined) {
return false;
}
- const key = this._getKey(value);
- if (key === undefined) {
+ const index = typeof indices === 'number' ? indices : indices.pop();
+ if (index === undefined) {
return false;
}
- i = this._search(key);
- if (i === -1) {
- return false;
+ if (typeof indices === 'number' || indices.length === 0) {
+ this._indicesByValue.delete(value);
}
- if (this._getKey(this._array[i]) !== key) {
- return false;
+ if (this._deletedIndices.size === 0) {
+ this._flushDeletedTask.enqueue(() => this._flushDeleted());
}
- do {
- if (this._array[i] === value) {
- if (this._deletedIndices.length === 0) {
- this._flushDeletedTask.enqueue(() => this._flushDeleted());
- }
- this._deletedIndices.push(i);
- return true;
- }
- } while (++i < this._array.length && this._getKey(this._array[i]) === key);
- return false;
+ this._deletedIndices.add(index);
+ return true;
}
private _flushDeleted(): void {
this._isFlushingDeleted = true;
- const sortedDeletedIndices = this._deletedIndices.sort((a, b) => a - b);
- let sortedDeletedIndicesIndex = 0;
- const newArray = new Array(this._array.length - sortedDeletedIndices.length);
+ const newArray = new Array(this._array.length - this._deletedIndices.size);
let newArrayIndex = 0;
for (let i = 0; i < this._array.length; i++) {
- if (sortedDeletedIndices[sortedDeletedIndicesIndex] === i) {
- sortedDeletedIndicesIndex++;
- } else {
+ if (!this._deletedIndices.has(i)) {
newArray[newArrayIndex++] = this._array[i];
}
}
this._array = newArray;
- this._deletedIndices.length = 0;
+ this._rebuildIdentityIndex();
+ this._deletedIndices.clear();
this._isFlushingDeleted = false;
}
private _flushCleanupDeleted(): void {
- if (!this._isFlushingDeleted && this._deletedIndices.length > 0) {
+ if (!this._isFlushingDeleted && this._deletedIndices.size > 0) {
this._flushDeletedTask.flush();
}
}
+8 -2
View File
@@ -1,4 +1,3 @@
import { readFileSync } from 'node:fs'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
@@ -369,7 +368,14 @@ function matchesPrefix(file, prefixes) {
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const files = readFileSync(0, 'utf8').split('\n').filter(Boolean)
// Why streamed, not readFileSync(0): a single read of fd 0 throws EAGAIN once the writer
// outgrows the 64 KB pipe buffer, which a stale PR base.sha reaches easily.
let input = ''
process.stdin.setEncoding('utf8')
for await (const chunk of process.stdin) {
input += chunk
}
const files = input.split(/\r?\n/).filter(Boolean)
const classification = classifyPrJobs(files)
for (const [name, value] of Object.entries(classification)) {
process.stdout.write(`${name}=${value ? 'true' : 'false'}\n`)
+49 -1
View File
@@ -1,4 +1,4 @@
import { spawnSync } from 'node:child_process'
import { spawn, spawnSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
@@ -353,6 +353,54 @@ describe('per-job path classification', () => {
expect(result.stdout).toContain('package=false\n')
expect(result.stdout).toContain('test=true\n')
})
// A long-lived PR whose base.sha has gone stale diffs thousands of files, so the writer
// outruns one pipe buffer. A single fd-0 read then returns early, breaks the writer's pipe,
// and still exits 0 -- emitting no pairs at all, which silently skips every lane.
it('classifies a path that arrives after the first pipe buffer', async () => {
const filler = Array.from(
{ length: 12_000 },
(_, index) => `docs/reference/generated-placeholder-${index}.md`
)
const input = `${[...filler, 'config/patches/xterm-upstream.json'].join('\n')}\n`
expect(input.length).toBeGreaterThan(64 * 1024)
const child = spawn(process.execPath, ['config/scripts/pr-code-change-scope.mjs'], {
cwd: projectDir,
stdio: ['pipe', 'pipe', 'pipe']
})
let stdout = ''
let stderr = ''
let brokePipe = false
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
child.stdout.on('data', (chunk) => (stdout += chunk))
child.stderr.on('data', (chunk) => (stderr += chunk))
child.stdin.on('error', (error) => {
brokePipe ||= error.code === 'EPIPE'
})
const exitCode = await new Promise((resolvePromise) => {
child.on('close', resolvePromise)
let offset = 0
const step = () => {
if (offset >= input.length) {
child.stdin.end()
return
}
child.stdin.write(input.slice(offset, offset + 64 * 1024))
offset += 64 * 1024
setTimeout(step, 20)
}
step()
})
expect(stderr).not.toContain('EAGAIN')
expect(brokePipe).toBe(false)
expect(exitCode, stderr).toBe(0)
expect(stdout).toContain('should_run=true\n')
expect(stdout).toContain('xterm_patch_sync=true\n')
})
})
describe('PR Checks skip wiring', () => {
+1 -1
View File
@@ -236,7 +236,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dompurify": "3.4.14",
"electron": "^43.4.1",
"electron": "43.6.0",
"electron-builder": "^26.15.3",
"electron-builder-squirrel-windows": "^26.15.3",
"electron-vite": "^5.0.0",
+35 -35
View File
@@ -114,7 +114,7 @@ patchedDependencies:
'@xterm/addon-search@0.17.0-beta.300': eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0
'@xterm/addon-serialize@0.15.0-beta.300': 851eac3d75e6d8c013b9f4c053e61d824b23965cb19ecc28e335e05059f3a294
'@xterm/addon-webgl@0.20.0-beta.299': 94687e89a0115e6e6aa102837f986debdc029c091527ee5eb4a4e17ceaf9473e
'@xterm/xterm@6.1.0-beta.303': 98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d
'@xterm/xterm@6.1.0-beta.303': 1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4
lint-staged@16.4.0: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673
node-pty@1.1.0: bac3a53fb15efc9b3b944fbe3c4718b5174a0b3bd6ead84e21975edad4bc6615
@@ -127,10 +127,10 @@ importers:
version: 0.3.251(@anthropic-ai/sdk@0.122.0(zod@4.5.4))(@modelcontextprotocol/sdk@1.30.0(supports-color@7.2.0)(zod@4.5.4))(zod@4.5.4)
'@electron-toolkit/preload':
specifier: ^3.0.2
version: 3.0.2(electron@43.4.1(supports-color@7.2.0))
version: 3.0.2(electron@43.6.0(supports-color@7.2.0))
'@electron-toolkit/utils':
specifier: ^4.0.0
version: 4.0.0(electron@43.4.1(supports-color@7.2.0))
version: 4.0.0(electron@43.6.0(supports-color@7.2.0))
'@floating-ui/dom':
specifier: 1.7.6
version: 1.7.6
@@ -142,7 +142,7 @@ importers:
version: 2.5.6
'@xterm/addon-serialize':
specifier: 0.15.0-beta.300
version: 0.15.0-beta.300(patch_hash=851eac3d75e6d8c013b9f4c053e61d824b23965cb19ecc28e335e05059f3a294)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))
version: 0.15.0-beta.300(patch_hash=851eac3d75e6d8c013b9f4c053e61d824b23965cb19ecc28e335e05059f3a294)(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))
'@xterm/headless':
specifier: 6.1.0-beta.302
version: 6.1.0-beta.302
@@ -317,25 +317,25 @@ importers:
version: 5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4))
'@xterm/addon-fit':
specifier: 0.12.0-beta.300
version: 0.12.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))
version: 0.12.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))
'@xterm/addon-ligatures':
specifier: 0.11.0-beta.300
version: 0.11.0-beta.300(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))
version: 0.11.0-beta.300(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))
'@xterm/addon-search':
specifier: 0.17.0-beta.300
version: 0.17.0-beta.300(patch_hash=eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))
version: 0.17.0-beta.300(patch_hash=eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0)(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))
'@xterm/addon-unicode11':
specifier: 0.10.0-beta.300
version: 0.10.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))
version: 0.10.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))
'@xterm/addon-web-links':
specifier: 0.13.0-beta.300
version: 0.13.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))
version: 0.13.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))
'@xterm/addon-webgl':
specifier: 0.20.0-beta.299
version: 0.20.0-beta.299(patch_hash=94687e89a0115e6e6aa102837f986debdc029c091527ee5eb4a4e17ceaf9473e)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))
version: 0.20.0-beta.299(patch_hash=94687e89a0115e6e6aa102837f986debdc029c091527ee5eb4a4e17ceaf9473e)(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))
'@xterm/xterm':
specifier: 6.1.0-beta.303
version: 6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)
version: 6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -349,8 +349,8 @@ importers:
specifier: 3.4.14
version: 3.4.14
electron:
specifier: ^43.4.1
version: 43.4.1(supports-color@7.2.0)
specifier: 43.6.0
version: 43.6.0(supports-color@7.2.0)
electron-builder:
specifier: ^26.15.3
version: 26.15.3(electron-builder-squirrel-windows@26.15.3)
@@ -4328,8 +4328,8 @@ packages:
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
engines: {node: '>=8.0.0'}
electron@43.4.1:
resolution: {integrity: sha512-5b+EuiwkgG5iRcsEL34rimgRpkYp15SsfZOa0pC5kXs0Tb82TH4n95rpQzTZa7yRCbA7tm0WoEbuBL6NaAhAcA==}
electron@43.6.0:
resolution: {integrity: sha512-DqVKYV+FXheMSLTxcMQ+NCo78BDgpnToSyIzXctlUtbP3lRGEuoo1P+C2n/90rJ7TvHgzP0bpP9fbbXxp4noIg==}
engines: {node: '>= 22.12.0'}
hasBin: true
@@ -7333,17 +7333,17 @@ snapshots:
'@electron-internal/extract-zip@1.0.4': {}
'@electron-toolkit/preload@3.0.2(electron@43.4.1(supports-color@7.2.0))':
'@electron-toolkit/preload@3.0.2(electron@43.6.0(supports-color@7.2.0))':
dependencies:
electron: 43.4.1(supports-color@7.2.0)
electron: 43.6.0(supports-color@7.2.0)
'@electron-toolkit/tsconfig@2.0.0(@types/node@25.9.5)':
dependencies:
'@types/node': 25.9.5
'@electron-toolkit/utils@4.0.0(electron@43.4.1(supports-color@7.2.0))':
'@electron-toolkit/utils@4.0.0(electron@43.6.0(supports-color@7.2.0))':
dependencies:
electron: 43.4.1(supports-color@7.2.0)
electron: 43.6.0(supports-color@7.2.0)
'@electron/asar@3.4.1':
dependencies:
@@ -9831,39 +9831,39 @@ snapshots:
'@xmldom/xmldom@0.8.15': {}
'@xterm/addon-fit@0.12.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))':
'@xterm/addon-fit@0.12.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))':
dependencies:
'@xterm/xterm': 6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)
'@xterm/xterm': 6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4)
'@xterm/addon-ligatures@0.11.0-beta.300(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))':
'@xterm/addon-ligatures@0.11.0-beta.300(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))':
dependencies:
'@xterm/xterm': 6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)
'@xterm/xterm': 6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4)
lru-cache: 11.5.1
opentype.js: 2.0.0
'@xterm/addon-search@0.17.0-beta.300(patch_hash=eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))':
'@xterm/addon-search@0.17.0-beta.300(patch_hash=eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0)(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))':
dependencies:
'@xterm/xterm': 6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)
'@xterm/xterm': 6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4)
'@xterm/addon-serialize@0.15.0-beta.300(patch_hash=851eac3d75e6d8c013b9f4c053e61d824b23965cb19ecc28e335e05059f3a294)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))':
'@xterm/addon-serialize@0.15.0-beta.300(patch_hash=851eac3d75e6d8c013b9f4c053e61d824b23965cb19ecc28e335e05059f3a294)(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))':
dependencies:
'@xterm/xterm': 6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)
'@xterm/xterm': 6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4)
'@xterm/addon-unicode11@0.10.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))':
'@xterm/addon-unicode11@0.10.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))':
dependencies:
'@xterm/xterm': 6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)
'@xterm/xterm': 6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4)
'@xterm/addon-web-links@0.13.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))':
'@xterm/addon-web-links@0.13.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))':
dependencies:
'@xterm/xterm': 6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)
'@xterm/xterm': 6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4)
'@xterm/addon-webgl@0.20.0-beta.299(patch_hash=94687e89a0115e6e6aa102837f986debdc029c091527ee5eb4a4e17ceaf9473e)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))':
'@xterm/addon-webgl@0.20.0-beta.299(patch_hash=94687e89a0115e6e6aa102837f986debdc029c091527ee5eb4a4e17ceaf9473e)(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))':
dependencies:
'@xterm/xterm': 6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)
'@xterm/xterm': 6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4)
'@xterm/headless@6.1.0-beta.302': {}
'@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)': {}
'@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4)': {}
abbrev@4.0.0: {}
@@ -10691,7 +10691,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
electron@43.4.1(supports-color@7.2.0):
electron@43.6.0(supports-color@7.2.0):
dependencies:
'@electron-internal/extract-zip': 1.0.4
'@electron/get': 5.0.0(supports-color@7.2.0)
@@ -200,7 +200,7 @@ describe('NativeChatMessageList assistant messages', () => {
/>
)
const settledTool = screen.getByText('shell pnpm test')
const settledTool = screen.getByText('shell')
const activity = screen.getByText('Working…')
expect(activity.textContent).not.toBe(settledTool.textContent)
expect(activity).not.toHaveTextContent('shell')
@@ -243,7 +243,8 @@ describe('NativeChatMessageList assistant messages', () => {
/>
)
const settledTool = screen.getByText('shell pnpm test')
const settledTool = screen.getByText('shell')
expect(settledTool).toHaveTextContent('shell pnpm test')
expect(settledTool.closest('button')?.querySelector('.animate-pulse')).toBeNull()
expect(settledTool.closest('button')?.querySelector('.lucide-check')).toBeInTheDocument()
const activity = screen.getByText('Preparing the answer')
@@ -299,7 +299,7 @@ describe('NativeChatToolRun with a spawn group', () => {
)
expect(screen.getByText('Ran 1 subagent')).toBeInTheDocument()
expect(screen.queryByText('shell ls')).toBeNull()
expect(screen.queryByText('shell')).toBeNull()
})
it('renders the roster alongside the tool activity of its turn', () => {
@@ -313,6 +313,6 @@ describe('NativeChatToolRun with a spawn group', () => {
)
expect(screen.getByText('Ran 1 subagent')).toBeInTheDocument()
expect(screen.getByText('shell ls')).toBeInTheDocument()
expect(screen.getByText('shell').closest('button')).toHaveTextContent('shell ls')
})
})
@@ -70,7 +70,7 @@ describe('inline tool annotations', () => {
/>
)
expect(screen.queryByRole('link')).toBeNull()
fireEvent.click(screen.getByText('web_search').closest('button')!)
fireEvent.click(screen.getByText('web_search', { selector: 'code' }).closest('button')!)
const link = screen.getByRole('link', { name: /Reference docs/ })
expect(link.getAttribute('href')).toBe('https://example.com/docs')
expect(link.closest('button')).toBeNull()
@@ -112,7 +112,10 @@ it.each(['running', 'completed'] as const)(
expect(screen.getByText('My server')).toBeTruthy()
expect(screen.getByText('ns.tool')).toBeTruthy()
expect(screen.getByTitle(name)).toBeTruthy()
expect(document.querySelectorAll('.lucide-plug')).toHaveLength(2)
// Header glyph plus the row's. A settled header also names each member in a
// pill, which carries that member's own glyph — so three, all plug: the
// identity holds wherever it is drawn.
expect(document.querySelectorAll('.lucide-plug')).toHaveLength(state === 'completed' ? 3 : 2)
}
)
@@ -23,6 +23,16 @@ function leadingGlyphs(container: HTMLElement): (string | null)[] {
)
}
/** The run header — the first button in a run, above its member rows. Its
* members render as separate pills, so it has no single joined summary node. */
function runHeader(container: HTMLElement): HTMLElement {
const header = container.querySelector('button')
if (!header) {
throw new Error('run header did not render')
}
return header
}
describe('NativeChatToolRun', () => {
it('uses the shared clean label for a desktop tool row', () => {
const blocks: NativeChatBlock[] = [
@@ -244,6 +254,107 @@ describe('NativeChatToolRun', () => {
expect(writeClipboardText).toHaveBeenCalledWith(' ctx\n-was\n+now\n+tail')
})
describe('reading a batch as a group', () => {
const batch: NativeChatBlock[] = [
{
type: 'tool-call',
name: 'mcp__linear__list_issues',
input: { query: 'todo' },
state: 'completed',
mcpIdentity: { server: 'linear', tool: 'list_issues' }
},
{ type: 'tool-call', name: 'Bash', input: { command: 'ls -la' }, state: 'completed' },
{
type: 'tool-call',
name: 'tools/read',
input: { file_path: 'README.md' },
state: 'completed'
}
]
it('gives each member its own glyph-led segment instead of one joined string', () => {
const { container } = render(<NativeChatToolRun blocks={batch} expandSignal={false} />)
const pills = runHeader(container).querySelectorAll('[data-tool-run-member]')
expect([...pills].map((pill) => pill.textContent)).toEqual([
'mcp__linear__list_issues todo',
'Bash ls -la',
'tools/read README.md'
])
})
// The header still prints the raw identifier while the row beneath it prints
// the split MCP name. Pinned, not endorsed: reconciling the two changes what
// a tool is called, which is a naming decision rather than a layout one.
it('leaves the header naming a member differently from the row below it', () => {
const { container } = render(<NativeChatToolRun blocks={batch} expandSignal />)
expect(runHeader(container)).toHaveTextContent('mcp__linear__list_issues')
expect(screen.getByText('Linear')).toBeInTheDocument()
})
it('names each member with its own glyph, not the run-wide fallback', () => {
const { container } = render(<NativeChatToolRun blocks={batch} expandSignal={false} />)
const header = runHeader(container)
expect(header.querySelector('[data-tool-run-member] .lucide-plug')).toBeInTheDocument()
expect(
header.querySelector('[data-tool-run-member] .lucide-square-terminal')
).toBeInTheDocument()
// The run-wide glyph still reads generic, the categories being mixed.
expect(header.firstElementChild?.querySelector('.lucide-wrench')).toBeInTheDocument()
})
it('counts the members it could not show rather than ending mid-name', () => {
const wide: NativeChatBlock[] = [
...batch,
{ type: 'tool-call', name: 'Grep', input: { pattern: 'todo' }, state: 'completed' },
{ type: 'tool-call', name: 'Write', input: { file_path: 'a.ts' }, state: 'completed' }
]
const { container } = render(<NativeChatToolRun blocks={wide} expandSignal={false} />)
expect(runHeader(container)).toHaveTextContent('+2 more')
expect(runHeader(container).querySelectorAll('[data-tool-run-member]')).toHaveLength(3)
})
// A margin is invisible to a copied selection and to the accessible name, so
// the boundary needs a real space too — otherwise the header reads
// `ls -latools/read`.
it('separates members with real whitespace, not only a margin', () => {
const { container } = render(<NativeChatToolRun blocks={batch} expandSignal={false} />)
expect(runHeader(container).textContent).toBe(
'3\u00d7mcp__linear__list_issues todo Bash ls -la tools/read README.md'
)
})
it('leaves no remainder marker when every member is shown', () => {
const { container } = render(<NativeChatToolRun blocks={batch} expandSignal={false} />)
expect(runHeader(container)).not.toHaveTextContent('more')
})
it('indents opened members so the run has a visible end', () => {
const { container } = render(<NativeChatToolRun blocks={batch} expandSignal />)
const members = runHeader(container).parentElement?.querySelector('.pl-4')
expect(members).toBeInTheDocument()
expect(members?.querySelectorAll('button').length).toBe(batch.length)
})
it('falls back to the call count when a run names no tool', () => {
const { container } = render(
<NativeChatToolRun
blocks={[{ type: 'tool-call', name: ' ', input: {}, state: 'completed' }]}
expandSignal={false}
/>
)
expect(runHeader(container)).toHaveTextContent('1 tool call')
})
})
it('keeps a grouped active run to one stable row showing only the latest tool', () => {
const blocks: NativeChatBlock[] = [
{ type: 'tool-call', name: 'shell', input: { command: 'date' }, state: 'completed' },
@@ -293,7 +404,9 @@ describe('NativeChatToolRun', () => {
const runningBlocks: NativeChatBlock[] = [
{ type: 'tool-call', name: 'shell', input: { command: 'sleep 1' }, state: 'running' }
]
const { rerender } = render(<NativeChatToolRun blocks={runningBlocks} expandSignal={false} />)
const { rerender, container } = render(
<NativeChatToolRun blocks={runningBlocks} expandSignal={false} />
)
expect(screen.getByText('Running sleep 1')).toBeInTheDocument()
@@ -308,7 +421,7 @@ describe('NativeChatToolRun', () => {
)
expect(screen.queryByText('Running sleep 1')).toBeNull()
expect(screen.getByText('shell sleep 1')).toBeInTheDocument()
expect(runHeader(container)).toHaveTextContent('shell sleep 1')
})
it('never animates a settled tool row with its completion check', () => {
@@ -323,9 +436,10 @@ describe('NativeChatToolRun', () => {
/>
)
const settledRow = screen.getByText('shell pnpm test').closest('button')
expect(settledRow?.querySelector('.lucide-check')).toBeInTheDocument()
expect(settledRow?.querySelector('.animate-pulse')).toBeNull()
const settledRow = runHeader(container)
expect(settledRow).toHaveTextContent('shell pnpm test')
expect(settledRow.querySelector('.lucide-check')).toBeInTheDocument()
expect(settledRow.querySelector('.animate-pulse')).toBeNull()
expect(container.querySelector('.animate-pulse')).toBeNull()
})
@@ -348,7 +462,7 @@ describe('NativeChatToolRun', () => {
{ type: 'tool-result', output: 'exit 128', isError: true }
]
const { rerender } = render(
const { rerender, container } = render(
<NativeChatToolRun
blocks={blocks}
expandSignal={false}
@@ -369,7 +483,7 @@ describe('NativeChatToolRun', () => {
/>
)
expect(screen.getByText('shell git log -1')).toBeInTheDocument()
expect(runHeader(container)).toHaveTextContent('shell git log -1')
})
it('settles an orphaned running call when its turn lifecycle has ended', () => {
@@ -401,7 +515,7 @@ describe('NativeChatToolRun', () => {
const glyph = container.querySelector('.lucide-eye')
expect(glyph).toBeInTheDocument()
expect(glyph).toHaveAttribute('aria-hidden')
expect(screen.getByText('read')).toBeInTheDocument()
expect(screen.getByText('read', { selector: 'code' })).toBeInTheDocument()
})
it('holds one glyph for a category across running, completed, and failed', () => {
@@ -4,7 +4,7 @@ import {
NativeChatCommandMetadata,
NativeChatSearchResults
} from './NativeChatToolAnnotations'
import { useEffect, useMemo, useState } from 'react'
import { Fragment, useEffect, useMemo, useState } from 'react'
import { Check, ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
@@ -26,8 +26,9 @@ import type { NativeChatEditFile } from '../../../../shared/native-chat-edit-mod
import {
countToolCalls,
createToolInputDisplay,
summarizeToolRun,
truncateToolDetail
toolRunSummaryMembers,
truncateToolDetail,
type ToolRunMember
} from './native-chat-tool-summary'
import {
NATIVE_CHAT_TOOL_ACTIVITY_COPY,
@@ -237,7 +238,22 @@ export function NativeChatToolRun({
.filter(isRenderableSubagentGroup)
.map((group) => <NativeChatSubagentRun key={group.groupId} block={group} />)
const callCount = countToolCalls(blocks) || blocks.length
const summary = summarizeToolRun(blocks)
// Members stay separate all the way to the markup: joining them into one
// string is what made a run read as a single call, because the separator also
// occurs inside tool names like `browser.open` and `tools/read`.
const summaryMembers = toolRunSummaryMembers(blocks)
const hiddenCallCount = Math.max(0, callCount - summaryMembers.length)
// Same content-signature keying the member rows below use: two identical calls
// in one run are distinguished by occurrence, never by list position.
const keyedSummaryMembers = ((): (ToolRunMember & { key: string })[] => {
const seen = new Map<string, number>()
return summaryMembers.map((member) => {
const signature = `${member.name}:${member.arg}`
const occurrence = seen.get(signature) ?? 0
seen.set(signature, occurrence + 1)
return { ...member, key: `${signature}:${occurrence}` }
})
})()
const latestActiveCall = structuredActivityUi
? selectActiveToolCall(blocks, { activeTurnIsWorking })
: null
@@ -251,7 +267,7 @@ export function NativeChatToolRun({
() => (open ? buildEditCards(blocks) : NO_EDIT_CARDS),
[open, blocks]
)
// Only the settled header reads this. It stands over `summary`, which speaks
// Only the settled header reads this. It stands over `summaryMembers`, which speaks
// for the run's first calls rather than its last, so a glyph taken from one
// call would assert a category the text beside it doesn't describe. A run that
// spans categories therefore heads with the generic tool glyph. The glyph is
@@ -329,9 +345,52 @@ export function NativeChatToolRun({
<span className="shrink-0 font-mono text-[11px] font-bold text-muted-foreground transition-colors group-hover:text-foreground/80">
{callCount}×
</span>
<span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground transition-colors group-hover:text-foreground/80">
{summary || fallbackLabel}
</span>
{summaryMembers.length > 0 ? (
<>
{/* Each member is led by its own category glyph, which is what marks
the boundary. A separator character cannot: `·` occurs inside
`browser.open` and `tools/read`. The list stays one line and
truncates as a whole rather than wrapping into a block — a
header that grows to three rows stops reading as a header. */}
<span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground transition-colors group-hover:text-foreground/80">
{keyedSummaryMembers.map((member, index) => (
<Fragment key={member.key}>
{/* A real space, not just the margin: a CSS gap is invisible to
a copied selection and to the button's accessible name, which
would otherwise run one member's argument into the next
member's name. The margin is trimmed to pay for its width. */}
{index > 0 ? ' ' : null}
<span data-tool-run-member className={cn(index > 0 && 'ml-2')}>
<NativeChatToolIcon
rowWord={member.name}
mcpIdentity={member.mcpIdentity}
className="mr-1 inline-flex size-3.5 align-middle"
/>
{member.name}
{member.arg ? (
<span className="text-muted-foreground/70">{` ${member.arg}`}</span>
) : null}
</span>
</Fragment>
))}
</span>
{hiddenCallCount > 0 ? (
/* Outside the truncating span, so the count of what is not shown
survives a list the pane is too narrow to print. */
<span className="shrink-0 font-mono text-[11px] text-muted-foreground transition-colors group-hover:text-foreground/80">
{translate(
'components.native-chat.tool.moreCalls',
NATIVE_CHAT_TOOL_ACTIVITY_COPY.moreCalls,
{ value0: hiddenCallCount }
)}
</span>
) : null}
</>
) : (
<span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground transition-colors group-hover:text-foreground/80">
{fallbackLabel}
</span>
)}
{/* Completion reads as a trailing mark so the leading glyph can stay fixed. */}
{structuredActivityUi ? (
<Check aria-hidden className="size-3 shrink-0 text-muted-foreground" />
@@ -346,7 +405,10 @@ export function NativeChatToolRun({
</button>
)}
{open ? (
<div className="mt-1">
// Members are indented under the header because nothing else marks the
// run's extent — flush rows are indistinguishable from the blocks after
// them, so the batch has no visible end.
<div className="mt-1 pl-4">
{(() => {
const seen = new Map<string, number>()
return blocks.map((block) => {
@@ -6,5 +6,7 @@ export {
summarizeToolInput,
summarizeToolRun,
toolFilePath,
toolRunSummaryMembers,
truncateToolDetail
} from '../../../../shared/native-chat-tool-summary'
export type { ToolRunMember } from '../../../../shared/native-chat-tool-summary'
@@ -455,6 +455,83 @@ describe('connectPanePty', () => {
expect(transport.sendInput).toHaveBeenCalledWith('echo hi\r')
})
it('preserves classified user input during replay while suppressing synthetic replies', async () => {
const { connectPanePty } = await import('./pty-connection')
const pane = createPane(1)
const userInputListeners = new Set<() => void>()
Object.assign(pane.terminal, {
_core: {
coreService: {
onUserInput: (listener: () => void) => {
userInputListeners.add(listener)
return { dispose: () => userInputListeners.delete(listener) }
}
}
}
})
const transport = createMockTransport('ssh:ssh-1@@pty-1')
transportFactoryQueue.push(transport)
const deps = createDeps()
const deferred: (() => void)[] = []
Object.assign(deps, {
deferPtyInput: (_paneId: number, data: string, forward: (data: string) => void) => {
deferred.push(() => forward(data))
}
})
connectPanePty(pane as never, createManager(1, 1) as never, deps as never)
await flushAsyncTicks()
transport.sendInput.mockClear()
deps.replayingPanesRef.current.set(pane.id, 1)
for (const listener of userInputListeners) {
listener()
}
sendTerminalInputThroughPane(pane, 'input_under_flood\r')
sendTerminalInputThroughPane(pane, '\x1b[?1;2c')
// A click on replayed scrollback that still has mouse tracking armed is user input to xterm, but must not reach the shell.
for (const listener of userInputListeners) {
listener()
}
sendTerminalInputThroughPane(pane, '\x1b[<0;12;4M')
for (const forward of deferred.splice(0)) {
forward()
}
expect(transport.sendInput).toHaveBeenCalledExactlyOnceWith('input_under_flood\r')
// A wheel over a replayed alt-screen frame becomes cursor keys; the fresh shell must not recall history from them.
pane.terminal.buffer.active.type = 'alternate'
for (const listener of userInputListeners) {
listener()
}
sendTerminalInputThroughPane(pane, '\x1b[B')
for (const forward of deferred.splice(0)) {
forward()
}
expect(transport.sendInput).toHaveBeenCalledExactlyOnceWith('input_under_flood\r')
// The same bytes on the normal buffer can only be a keyboard arrow, which survives replay.
pane.terminal.buffer.active.type = 'normal'
for (const listener of userInputListeners) {
listener()
}
sendTerminalInputThroughPane(pane, '\x1b[B')
for (const forward of deferred.splice(0)) {
forward()
}
expect(transport.sendInput).toHaveBeenCalledTimes(2)
expect(transport.sendInput).toHaveBeenLastCalledWith('\x1b[B')
// Once the guard releases, the same mouse report is ordinary input again.
deps.replayingPanesRef.current.delete(pane.id)
for (const listener of userInputListeners) {
listener()
}
sendTerminalInputThroughPane(pane, '\x1b[<0;12;4M')
for (const forward of deferred.splice(0)) {
forward()
}
expect(transport.sendInput).toHaveBeenLastCalledWith('\x1b[<0;12;4M')
})
it('settles a queued startup only after the pane binds its spawned PTY', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-resume')
@@ -556,13 +556,12 @@ describe('connectPanePty', () => {
const manager = createManager(1)
const deps = createDeps()
const pane = createPane(2)
let userInputListener: (() => void) | null = null
const userInputDispose = vi.fn()
const userInputListeners = new Set<() => void>()
;(pane.terminal as unknown as { _core: unknown })._core = {
coreService: {
onUserInput: vi.fn((listener: () => void) => {
userInputListener = listener
return { dispose: userInputDispose }
userInputListeners.add(listener)
return { dispose: () => userInputListeners.delete(listener) }
})
}
}
@@ -571,7 +570,7 @@ describe('connectPanePty', () => {
dispose: () => void
}
await flushAsyncTicks()
expect(userInputListener).toBeTypeOf('function')
expect(userInputListeners.size).toBeGreaterThan(0)
;(mockStoreState.recordTerminalInput as ReturnType<typeof vi.fn>).mockClear()
// A focus-out report forwarded to the PTY must not count as activity.
@@ -581,11 +580,13 @@ describe('connectPanePty', () => {
expect(transport.sendInput).toHaveBeenCalledWith('\x1b[O')
// Real user input fires the core signal and records activity.
;(userInputListener as unknown as () => void)()
for (const listener of userInputListeners) {
listener()
}
expect(mockStoreState.recordTerminalInput).toHaveBeenCalledTimes(1)
binding.dispose()
expect(userInputDispose).toHaveBeenCalled()
expect(userInputListeners.size).toBe(0)
})
it('falls back to onData hibernation recording when the core user-input signal is unavailable', async () => {
@@ -1,4 +1,5 @@
import type { ManagedPaneInternal } from '@/lib/pane-manager/pane-manager-types'
import { subscribeToTerminalInputData } from '../terminal-user-input-signal'
import { installTerminalImeCompositionRoute } from '../terminal-ime-composition-route'
import { useAppStore } from '@/store'
import { isTerminalQueryReply } from '../../../../../shared/terminal-query-reply'
@@ -9,6 +10,7 @@ import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state'
import { getAppliedSizeReadE2eDelayMs } from '../pty-applied-size-read-e2e-delay'
import { createPtySizeReassertion } from '../pty-size-reassertion'
import { isPaneReplaying } from '../replay-guard'
import { isXtermMouseReport, isXtermWheelCursorKey } from '../terminal-pointer-input-sequences'
import { shouldDropQuarantinedTerminalInput } from '../terminal-input-quarantine'
import {
PANE_PTY_RESIZE_HOLD_FLUSH_EVENT,
@@ -24,15 +26,21 @@ import { isCodexPaneStale } from './codex-pane-stale'
import type { ConnectPanePtySession } from './connect-pane-pty-session'
export function installPtyInputForward(session: ConnectPanePtySession): void {
session.forwardPtyInput = (data: string): void => {
// Why: xterm auto-replies to embedded query sequences (DA1, DECRQM,
// OSC 10/11, focus, CPR) via onData. When we replay recorded PTY bytes
// into xterm for scrollback/cold-restore/snapshot, those queries would
// otherwise pipe replies into the freshly spawned shell as stray input
// ("?1;2c", "2026;2$y", OSC color fragments, ...). The replay sites
// engage the guard via replayIntoTerminal; here we drop everything
// xterm emits while the guard is active. See replay-guard.ts.
if (isPaneReplaying(session.deps.replayingPanesRef, session.pane.id)) {
session.forwardPtyInput = (data: string, wasUserInput = false): void => {
// Why: replaying recorded PTY bytes makes xterm auto-reply to embedded
// queries (DA1/DECRQM/OSC 10-11/CPR) via onData; those must not leak into
// the shell, but keystrokes typed mid-restore must survive. Pointer input
// stays dropped even though xterm flags it as user input: replayed bytes can
// leave mouse tracking armed until the guarded mode reset lands (a click
// would print SGR fragments on the fresh prompt), and a wheel over a
// replayed alt-screen frame becomes cursor keys that would recall history
// at that prompt once ?1049l lands. See replay-guard.ts.
if (
isPaneReplaying(session.deps.replayingPanesRef, session.pane.id) &&
(!wasUserInput ||
isXtermMouseReport(data) ||
(isXtermWheelCursorKey(data) && session.pane.terminal.buffer.active.type === 'alternate'))
) {
return
}
const currentPtyId = session.transport.getPtyId()
@@ -163,13 +171,21 @@ export function installPtyInputForward(session: ConnectPanePtySession): void {
session.requestRecoveryForUndeliverableInput()
}
}
session.onDataDisposable = session.pane.terminal.onData((data) => {
if (session.deps.deferPtyInput) {
session.deps.deferPtyInput(session.pane.id, data, session.forwardPtyInput)
return
// Why bind once: provenance must survive deferPtyInput's later callback, and
// this is the per-keystroke hot path, so no closure allocation per onData event.
const forwardUserInput = (data: string): void => session.forwardPtyInput(data, true)
const forwardUnclassifiedInput = (data: string): void => session.forwardPtyInput(data, false)
session.onDataDisposable = subscribeToTerminalInputData(
session.pane.terminal,
(data, wasUserInput) => {
const forward = wasUserInput ? forwardUserInput : forwardUnclassifiedInput
if (session.deps.deferPtyInput) {
session.deps.deferPtyInput(session.pane.id, data, forward)
return
}
forward(data)
}
session.forwardPtyInput(data)
})
)
session.imeCompositionRouteDisposable = installTerminalImeCompositionRoute({
terminalElement: session.pane.terminal.element,
terminal: session.pane.terminal,
@@ -12,7 +12,7 @@ import {
import { redactPtyIdForDiagnostics } from '../../../../shared/pty-delivery-diagnostics'
// Why this guard exists: xterm auto-replies to query sequences (DA1/DECRQM/OSC 10-11/CPR) via onData → shell stdin, so replaying recorded PTY bytes leaks stray replies onto the new shell's prompt.
// No wasUserInput flag distinguishes replay replies from real keystrokes, so a per-pane in-flight counter gates onData; bounded by xterm's parse completion (not a timer), only auto-replies from replayed bytes are dropped.
// The per-pane counter suppresses synthetic onData during replay parsing; xterm's user-input signal keeps real keystrokes flowing.
export type ReplayingPanesRef = React.RefObject<Map<number, number>>
@@ -3,6 +3,7 @@ import {
isTerminalLinkActionActivation,
isTerminalLinkDirectActivation
} from './terminal-link-activation'
import { isXtermMouseReport } from './terminal-pointer-input-sequences'
const CAPTURE_LISTENER_OPTIONS = { capture: true } as const
const MAX_DEFERRED_PTY_INPUT_FRAMES = 64
@@ -17,13 +18,6 @@ export type TerminalLinkPtyMouseSuppression = IDisposable & {
handlePtyInput: (data: string, forward: (data: string) => void) => void
}
function isXtermMouseReport(data: string): boolean {
return (
(data.startsWith('\x1b[M') && data.length === 6) ||
(data.startsWith('\x1b[<') && /^\d+;\d+;\d+[Mm]$/.test(data.slice(3)))
)
}
export function installTerminalLinkPtyMouseSuppression(
terminal: Terminal,
shouldSuppressMouseEvent: (event: MouseEvent) => boolean,
@@ -0,0 +1,15 @@
// Why: xterm flags pointer-derived bytes as user input alongside keystrokes; callers
// that must treat pointer input differently need to recognise it by shape.
/** True for an xterm mouse report (X10 `CSI M` or SGR `CSI <`): pointer input, never a keystroke. */
export function isXtermMouseReport(data: string): boolean {
return (
(data.startsWith('\x1b[M') && data.length === 6) ||
(data.startsWith('\x1b[<') && /^\d+;\d+;\d+[Mm]$/.test(data.slice(3)))
)
}
/** True for the bare cursor up/down xterm synthesises per wheel notch when the active buffer has no scrollback. */
export function isXtermWheelCursorKey(data: string): boolean {
return data === '\x1b[A' || data === '\x1b[B' || data === '\x1bOA' || data === '\x1bOB'
}
@@ -1,6 +1,9 @@
import { describe, expect, it, vi } from 'vitest'
import { Terminal } from '@xterm/xterm'
import { subscribeToTerminalUserInput } from './terminal-user-input-signal'
import {
subscribeToTerminalInputData,
subscribeToTerminalUserInput
} from './terminal-user-input-signal'
type CoreServiceAccess = {
_core: {
@@ -78,3 +81,26 @@ describe('subscribeToTerminalUserInput', () => {
expect(listener).not.toHaveBeenCalled()
})
})
describe('subscribeToTerminalInputData', () => {
it('classifies real xterm events independently and disposes both subscriptions', () => {
const terminal = new Terminal({ allowProposedApi: true })
const core = (terminal as unknown as CoreServiceAccess)._core.coreService
const listener = vi.fn()
const subscription = subscribeToTerminalInputData(terminal, listener)
core.triggerDataEvent('keyboard', true)
core.triggerDataEvent('\x1b[?1;2c')
core.triggerDataEvent('\x1b[200~paste\x1b[201~', true)
core.triggerDataEvent('\x1b[O', false)
expect(listener.mock.calls).toEqual([
['keyboard', true],
['\x1b[?1;2c', false],
['\x1b[200~paste\x1b[201~', true],
['\x1b[O', false]
])
subscription.dispose()
core.triggerDataEvent('after-dispose', true)
expect(listener).toHaveBeenCalledTimes(4)
terminal.dispose()
})
})
@@ -43,3 +43,25 @@ export function subscribeToTerminalUserInput(
return null
}
}
/** Preserve xterm's input provenance across deferred PTY forwarding. */
export function subscribeToTerminalInputData(
terminal: Terminal,
listener: (data: string, wasUserInput: boolean) => void
): { dispose: () => void } {
let pendingUserInput = false
const userInput = subscribeToTerminalUserInput(terminal, () => {
pendingUserInput = true
})
const dataInput = terminal.onData((data) => {
const wasUserInput = pendingUserInput
pendingUserInput = false
listener(data, wasUserInput)
})
return {
dispose: () => {
dataInput.dispose()
userInput?.dispose()
}
}
}
@@ -0,0 +1,205 @@
// @vitest-environment happy-dom
import { SearchAddon } from '@xterm/addon-search'
import { Terminal } from '@xterm/xterm'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
type Entry = { line: number; id: number }
type SortedList = {
insert(value: Entry): void
delete(value: Entry): boolean
clear(): void
values(): IterableIterator<Entry>
getKeyIterator(key: number): IterableIterator<Entry>
_getKey(value: Entry): number
}
type Decoration = { marker: { line: number } }
type Service = {
_decorations: SortedList
decorations: Iterable<Decoration>
onDecorationRemoved(callback: () => void): { dispose(): void }
}
const terminals: Terminal[] = []
function setup(scrollback = 1000): { terminal: Terminal; service: Service } {
const terminal = new Terminal({ allowProposedApi: true, scrollback, rows: 24 })
terminals.push(terminal)
const container = document.createElement('div')
document.body.append(container)
terminal.open(container)
const service = (terminal as unknown as { _core: { _decorationService: Service } })._core
._decorationService
return { terminal, service }
}
function list(): SortedList {
const { service } = setup()
const Constructor = service._decorations.constructor as new (
key: (entry: Entry) => number,
log: { warn(): void }
) => SortedList
return new Constructor((entry) => entry?.line, { warn() {} })
}
function write(terminal: Terminal, data: string): Promise<void> {
return new Promise((resolve) => terminal.write(data, resolve))
}
beforeEach(() => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
measureText: () => ({ width: 10 })
} as unknown as CanvasRenderingContext2D)
})
afterEach(() => {
for (const terminal of terminals.splice(0)) {
terminal.dispose()
}
vi.restoreAllMocks()
document.body.replaceChildren()
})
describe('scrollback decoration eviction (#10879)', () => {
it.each([250, 500, 1000])('removes %i disposed keys with linear lookup work', (count) => {
const sorted = list()
const entries = Array.from({ length: count }, (_, id) => ({ line: id, id }))
entries.forEach((entry) => sorted.insert(entry))
expect([...sorted.values()]).toEqual(entries)
const keyReads = vi.spyOn(sorted, '_getKey')
try {
for (const entry of entries) {
entry.line = -1
expect(sorted.delete(entry)).toBe(true)
}
expect([...sorted.values()]).toEqual([])
console.info(`evict ${count}: ${keyReads.mock.calls.length} key reads`)
expect(keyReads.mock.calls.length).toBeLessThanOrEqual(count * 4)
} finally {
sorted.clear()
}
})
it('keeps duplicate identities, live-key queries, reinsertion and snapshots correct', () => {
const sorted = list()
const a = { line: 10, id: 1 }
const b = { line: 20, id: 2 }
try {
sorted.insert(b)
sorted.insert(a)
sorted.insert(a)
const snapshot = sorted.values()
a.line -= 5
b.line -= 5
expect([...sorted.getKeyIterator(5)]).toEqual([a, a])
expect(sorted.delete(a)).toBe(true)
expect(sorted.delete(a)).toBe(true)
expect(sorted.delete(a)).toBe(false)
expect([...sorted.values()]).toEqual([b])
expect([...snapshot]).toEqual([a, a, b])
sorted.insert(a)
expect([...sorted.values()]).toEqual([a, b])
sorted.clear()
expect(sorted.delete(a)).toBe(false)
sorted.insert(b)
expect(sorted.delete(b)).toBe(true)
expect([...sorted.values()]).toEqual([])
} finally {
sorted.clear()
}
})
it('preserves duplicate counts through partial compaction and reinsertion', () => {
const sorted = list()
const entry = { line: 0, id: 0 }
try {
sorted.insert(entry)
sorted.insert(entry)
sorted.insert(entry)
expect(sorted.delete(entry)).toBe(true)
expect([...sorted.values()]).toEqual([entry, entry])
expect(sorted.delete(entry)).toBe(true)
expect([...sorted.values()]).toEqual([entry])
sorted.insert(entry)
expect(sorted.delete(entry)).toBe(true)
expect(sorted.delete(entry)).toBe(true)
expect(sorted.delete(entry)).toBe(false)
expect([...sorted.values()]).toEqual([])
} finally {
sorted.clear()
}
})
it('matches identity membership through mixed key mutation and compaction', () => {
const sorted = list()
let seed = 10879
const random = (): number => (seed = (seed * 1664525 + 1013904223) >>> 0) / 2 ** 32
const entries = Array.from({ length: 100 }, (_, id) => ({ line: id, id }))
const live = new Set<Entry>()
try {
for (let step = 0; step < 2000; step++) {
const entry = entries[Math.floor(random() * entries.length)]
if (random() < 0.45 && !live.has(entry)) {
entry.line = entry.id
sorted.insert(entry)
live.add(entry)
} else {
entry.line = -1
expect(sorted.delete(entry)).toBe(live.delete(entry))
}
if (step % 17 === 0) {
expect([...sorted.values()]).toEqual([...live].sort((a, b) => a.line - b.line))
}
}
} finally {
sorted.clear()
}
})
it('evicts real search highlights without changing marker events or retained output', async () => {
const { terminal, service } = setup()
const search = new SearchAddon()
terminal.loadAddon(search)
await write(terminal, 'needle\r\n'.repeat(1000))
expect(
search.findNext('needle', {
decorations: {
matchBackground: '#ffcc00',
matchOverviewRuler: '#ffcc00',
activeMatchColorOverviewRuler: '#ff9900'
}
})
).toBe(true)
const decorations = [...service.decorations]
expect(decorations.length).toBeGreaterThanOrEqual(1000)
const removed = vi.fn()
service.onDecorationRemoved(removed)
const marker = terminal.registerMarker(0)
expect(marker).toBeDefined()
const disposed = vi.fn(() => {
expect(marker?.isDisposed).toBe(true)
expect(marker?.line).toBe(-1)
})
marker?.onDispose(disposed)
const keyReads = vi.spyOn(service._decorations, '_getKey')
await write(terminal, 'plain\r\n'.repeat(2000))
expect(removed).toHaveBeenCalledTimes(decorations.length)
expect([...service.decorations]).toEqual([])
expect(disposed).toHaveBeenCalledOnce()
expect(terminal.buffer.active.length).toBe(1024)
expect(terminal.buffer.active.getLine(1000)?.translateToString(true)).toBe('plain')
expect(keyReads.mock.calls.length).toBeLessThanOrEqual(decorations.length * 4)
search.clearDecorations()
expect(
search.findNext('plain', {
decorations: {
matchBackground: '#ffcc00',
matchOverviewRuler: '#ffcc00',
activeMatchColorOverviewRuler: '#ff9900'
}
})
).toBe(true)
expect([...service.decorations].length).toBeGreaterThan(0)
search.clearDecorations()
expect([...service.decorations]).toEqual([])
})
})
+1
View File
@@ -2644,6 +2644,7 @@
"tool": {
"countN": "{{value0}} tool calls",
"countOne": "1 tool call",
"moreCalls": "+{{value0}} more",
"ranCommandManyToolsSummary": "Ran {{commandCount}} command and used {{toolCount}} tools",
"ranCommandOneToolSummary": "Ran {{commandCount}} command and used {{toolCount}} tool",
"ranCommandsManyToolsSummary": "Ran {{commandCount}} commands and used {{toolCount}} tools",
+1
View File
@@ -17044,6 +17044,7 @@
"result": "Result",
"countOne": "1 tool call",
"countN": "{{value0}} tool calls",
"moreCalls": "+{{value0}} more",
"runningPreview": "Running {{preview}}",
"runningCommand": "Running command",
"runningNamedPreview": "Running {{toolName}} {{preview}}",
+2 -1
View File
@@ -13,7 +13,8 @@ export const NATIVE_CHAT_TOOL_ACTIVITY_COPY = {
runningNamedPreview: 'Running {{toolName}} {{preview}}',
runningNamed: 'Running {{toolName}}',
countOne: '1 tool call',
countN: '{{value0}} tool calls'
countN: '{{value0}} tool calls',
moreCalls: '+{{value0}} more'
} as const
/** Tools whose call is a shell command, so the row reads as terminal activity
@@ -9,6 +9,7 @@ import {
MAX_TOOL_DETAIL_LENGTH,
summarizeToolInput,
summarizeToolRun,
toolRunSummaryMembers,
toolFilePath,
truncateToolDetail
} from './native-chat-tool-summary'
@@ -254,6 +255,73 @@ describe('briefToolArg', () => {
})
})
describe('toolRunSummaryMembers', () => {
it('keeps each call separate so a boundary can be drawn between them', () => {
const blocks: NativeChatBlock[] = [
{ type: 'tool-call', name: 'Bash', input: { command: 'ls -la' } },
{ type: 'tool-call', name: 'tools/read', input: { file_path: 'README.md' } }
]
expect(toolRunSummaryMembers(blocks)).toEqual([
{ name: 'Bash', arg: 'ls -la', mcpIdentity: undefined },
{ name: 'tools/read', arg: 'README.md', mcpIdentity: undefined }
])
})
// `url` is a PRIMARY_ARG_KEY but not a BRIEF_ARG_KEY, so a call carrying only
// a url falls through to the bounded JSON preview and is cut at 28 chars —
// mid-token, brace unbalanced. Pinned as-is: the header renders whatever this
// returns, and changing the key list would move mobile's summary too.
it('still falls through to a clipped JSON preview for a url-only call', () => {
const blocks: NativeChatBlock[] = [
{ type: 'tool-call', name: 'browser.open', input: { url: 'https://example.com' } }
]
expect(toolRunSummaryMembers(blocks)[0]?.arg).toBe('{"url":"https://example.com"')
})
it('caps at the summary limit and skips nameless calls, as the joined string does', () => {
const blocks: NativeChatBlock[] = [
{ type: 'tool-call', name: ' ', input: {} },
{ type: 'tool-call', name: 'Bash', input: { command: 'ls' } },
{ type: 'tool-call', name: 'Read', input: { file_path: 'a.ts' } },
{ type: 'tool-call', name: 'Edit', input: { file_path: 'b.ts' } },
{ type: 'tool-call', name: 'Write', input: { file_path: 'c.ts' } }
]
const members = toolRunSummaryMembers(blocks)
expect(members.map((member) => member.name)).toEqual(['Bash', 'Read', 'Edit'])
// The joined string is derived from these, so the two can never disagree.
expect(summarizeToolRun(blocks)).toBe(
members.map((member) => `${member.name} ${member.arg}`).join(' · ')
)
})
it('carries provider MCP identity through, so a pill can draw the server glyph', () => {
const blocks: NativeChatBlock[] = [
{
type: 'tool-call',
name: 'mcp__linear__list_issues',
input: {},
mcpIdentity: { server: 'linear', tool: 'list_issues' }
}
]
expect(toolRunSummaryMembers(blocks)[0]?.mcpIdentity).toEqual({
server: 'linear',
tool: 'list_issues'
})
})
it('reports a blank argument rather than standing raw JSON in for one', () => {
const blocks: NativeChatBlock[] = [{ type: 'tool-call', name: 'Bash', input: { command: '' } }]
expect(toolRunSummaryMembers(blocks)).toEqual([
{ name: 'Bash', arg: '', mcpIdentity: undefined }
])
})
})
describe('summarizeToolRun', () => {
it('caps the run summary and skips nameless calls', () => {
const blocks: NativeChatBlock[] = [
+24 -6
View File
@@ -1,3 +1,4 @@
import type { NativeChatMcpIdentity } from './native-chat-tool-identity'
import { isToolCallBlock, type NativeChatBlock } from './native-chat-types'
const MAX_PREVIEW_LENGTH = 80
@@ -260,8 +261,20 @@ function summarizePrimaryToolArg(input: unknown): string | null {
return null
}
export function summarizeToolRun(blocks: readonly NativeChatBlock[]): string {
const parts: string[] = []
/** One named call in a run header, kept apart rather than pre-joined so a
* surface can draw the boundary between members itself. */
export type ToolRunMember = {
name: string
/** Brief argument, or '' when the call has none worth showing. */
arg: string
mcpIdentity?: NativeChatMcpIdentity
}
/** The run header's leading calls. Capped at the same limit the joined string
* has always used, so the two can never disagree about which calls speak for
* a run. */
export function toolRunSummaryMembers(blocks: readonly NativeChatBlock[]): ToolRunMember[] {
const members: ToolRunMember[] = []
for (const block of blocks) {
if (!isToolCallBlock(block)) {
continue
@@ -270,13 +283,18 @@ export function summarizeToolRun(blocks: readonly NativeChatBlock[]): string {
if (!name) {
continue
}
const detail = briefToolArg(block.input)
parts.push(detail ? `${name} ${detail}` : name)
if (parts.length >= MAX_TOOL_RUN_SUMMARY_PARTS) {
members.push({ name, arg: briefToolArg(block.input), mcpIdentity: block.mcpIdentity })
if (members.length >= MAX_TOOL_RUN_SUMMARY_PARTS) {
break
}
}
return parts.join(' · ')
return members
}
export function summarizeToolRun(blocks: readonly NativeChatBlock[]): string {
return toolRunSummaryMembers(blocks)
.map((member) => (member.arg ? `${member.name} ${member.arg}` : member.name))
.join(' · ')
}
export function countToolCalls(blocks: readonly NativeChatBlock[]): number {