mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 08:02:35 +00:00
Merge remote-tracking branch 'origin/main' into OrcaWin/neil-editor-dep-updates
This commit is contained in:
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+35
-35
@@ -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
@@ -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",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user