@xmoon76/dsh-pi-tui 0.3.4 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +648 -0
- package/README.md +557 -466
- package/dist/builtins.mjs +1 -13
- package/dist/extension/advanced.d.mts +1 -1
- package/dist/extension/unstable.d.mts +1 -1
- package/dist/extensions.d.mts +9 -6
- package/dist/extensions.mjs +1 -1
- package/dist/index.d.mts +2 -12
- package/dist/index.mjs +2 -2
- package/dist/{public-types-CZz5_arj.d.mts → public-types-_pe9ZH00.d.mts} +74 -8
- package/dist/{src-B1uP5j0d.mjs → src-B93JMbsv.mjs} +13497 -2012
- package/package.json +11 -6
- package/README.zh-CN.md +0 -469
- package/scripts/advanced-plugin-smoke.mjs +0 -314
- package/scripts/bench.mts +0 -302
- package/scripts/client-boundary-baseline.json +0 -82
- package/scripts/client-boundary-gate.mjs +0 -165
- package/scripts/e2e-session-lease.sh +0 -388
- package/scripts/examples-plugin-smoke.mjs +0 -320
- package/scripts/extension-fixture-smoke.mjs +0 -219
- package/scripts/naming-gate.mjs +0 -158
- package/scripts/phase4-plugin-smoke.mjs +0 -318
- package/scripts/pre-push-stages.mjs +0 -116
- package/scripts/preview-prompt.mts +0 -62
- package/scripts/preview-role-styles.mts +0 -97
- package/scripts/tarball-smoke.mjs +0 -393
- package/scripts/unstable-plugin-smoke.mjs +0 -300
- package/scripts/vim-plugin-smoke.mjs +0 -315
- /package/dist/{theme-registry-CjbtRmhd.d.mts → keybinding-registry-CjbtRmhd.d.mts} +0 -0
|
@@ -1,314 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* advanced-plugin-smoke — verify the PACKED ADVANCED extension surface end
|
|
4
|
-
* to end with a real third-party-shaped plugin (Phase 2, plan §15):
|
|
5
|
-
*
|
|
6
|
-
* 1. the fixture imports ONLY the public `@xmoon76/dsh-pi-tui/
|
|
7
|
-
* extensions/advanced` (and the stable `extensions` entry for the
|
|
8
|
-
* service type) subpaths — importing `@xmoon76/pi-tui`, `src/tui-app`
|
|
9
|
-
* or any repository-relative internal path FAILS the gate (static
|
|
10
|
-
* imports, dynamic import() AND require() are all scanned);
|
|
11
|
-
* 2. the fixture typechecks against the packed `.d.mts`;
|
|
12
|
-
* 3. the fixture COMPILES to JS and RUNTIME-LOADS against the packed
|
|
13
|
-
* bytes (apply() registers through a mock service without throwing);
|
|
14
|
-
* 4. the fixture exercises the ADVANCED surface: ADVANCED_API_LEVEL === 1,
|
|
15
|
-
* normalized input capture (observe + capture), the interactive
|
|
16
|
-
* overlay lease, and the advanced editor controls;
|
|
17
|
-
* 5. the fixture's module shape (name/inject/apply) matches the Loader;
|
|
18
|
-
* 6. no duplicate dsh runtime in the fixture tree.
|
|
19
|
-
*
|
|
20
|
-
* The probe mirrors the host install with SYMLINKS (no npm install — the
|
|
21
|
-
* gate is offline-safe; the packed package's runtime deps resolve from
|
|
22
|
-
* the repo's node_modules exactly like a real profile).
|
|
23
|
-
*
|
|
24
|
-
* Usage: node scripts/advanced-plugin-smoke.mjs [path-to-tgz]
|
|
25
|
-
* @module advanced-plugin-smoke
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
import { spawnSync } from 'node:child_process'
|
|
29
|
-
import {
|
|
30
|
-
existsSync,
|
|
31
|
-
mkdirSync,
|
|
32
|
-
mkdtempSync,
|
|
33
|
-
readdirSync,
|
|
34
|
-
readFileSync,
|
|
35
|
-
realpathSync,
|
|
36
|
-
rmSync,
|
|
37
|
-
statSync,
|
|
38
|
-
lstatSync,
|
|
39
|
-
symlinkSync,
|
|
40
|
-
writeFileSync,
|
|
41
|
-
} from 'node:fs'
|
|
42
|
-
import { tmpdir } from 'node:os'
|
|
43
|
-
import { dirname, join, resolve } from 'node:path'
|
|
44
|
-
import { fileURLToPath } from 'node:url'
|
|
45
|
-
|
|
46
|
-
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
|
|
47
|
-
const PACKAGE_ROOT = join(SCRIPT_DIR, '..')
|
|
48
|
-
const FIXTURE_ROOT = join(PACKAGE_ROOT, 'test', 'fixtures', 'advanced-plugin')
|
|
49
|
-
|
|
50
|
-
const failures = []
|
|
51
|
-
const checks = []
|
|
52
|
-
|
|
53
|
-
function check(name, ok, detail = '') {
|
|
54
|
-
checks.push(`${ok ? 'ok ' : 'FAIL'} ${name}${detail === '' ? '' : ` — ${detail}`}`)
|
|
55
|
-
if (!ok) failures.push(`${name}${detail === '' ? '' : `: ${detail}`}`)
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function run(command, args, options = {}) {
|
|
59
|
-
return spawnSync(command, args, { encoding: 'utf8', ...options })
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function resolveTarball(explicit) {
|
|
63
|
-
if (explicit !== undefined) {
|
|
64
|
-
const absolute = resolve(explicit)
|
|
65
|
-
if (!existsSync(absolute)) throw new Error(`tarball not found: ${explicit}`)
|
|
66
|
-
return absolute
|
|
67
|
-
}
|
|
68
|
-
const dir = PACKAGE_ROOT
|
|
69
|
-
const candidates = readdirSync(dir)
|
|
70
|
-
.filter(name => /xmoon76-dsh-pi-tui-.*\.tgz$/.test(name))
|
|
71
|
-
.map(name => join(dir, name))
|
|
72
|
-
.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)
|
|
73
|
-
if (candidates.length === 0) {
|
|
74
|
-
throw new Error(`no xmoon76-dsh-pi-tui-*.tgz in ${dir}; run pnpm pack:release first`)
|
|
75
|
-
}
|
|
76
|
-
return candidates[0]
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function main() {
|
|
80
|
-
const tarball = resolveTarball(process.argv[2])
|
|
81
|
-
const workDir = mkdtempSync(join(tmpdir(), 'advanced-plugin-'))
|
|
82
|
-
try {
|
|
83
|
-
// 1. Extract the packed package.
|
|
84
|
-
const extractedDir = join(workDir, 'pkg')
|
|
85
|
-
mkdirSync(extractedDir)
|
|
86
|
-
const extract = run('tar', ['-xzf', tarball, '-C', extractedDir])
|
|
87
|
-
check('tarball extracts', extract.status === 0, extract.stderr)
|
|
88
|
-
const pkgDir = join(extractedDir, 'package')
|
|
89
|
-
check('packed package has dist/extension/advanced.mjs', existsSync(join(pkgDir, 'dist', 'extension', 'advanced.mjs')))
|
|
90
|
-
check('packed package has dist/extension/advanced.d.mts', existsSync(join(pkgDir, 'dist', 'extension', 'advanced.d.mts')))
|
|
91
|
-
|
|
92
|
-
// 2. IMPORT BAN (plan §15 CI gate): the fixture's IMPORT STATEMENTS
|
|
93
|
-
// (multi-line blocks), dynamic import() and require() specifiers
|
|
94
|
-
// must never reference private internals.
|
|
95
|
-
const fixtureSrc = readFileSync(join(FIXTURE_ROOT, 'src', 'index.ts'), 'utf8')
|
|
96
|
-
const lines = fixtureSrc.split('\n')
|
|
97
|
-
const importStatements = []
|
|
98
|
-
for (let index = 0; index < lines.length; index++) {
|
|
99
|
-
if (/^\s*import\b/.test(lines[index] ?? '')) {
|
|
100
|
-
let statement = lines[index] ?? ''
|
|
101
|
-
while (!statement.includes(';') && index + 1 < lines.length) {
|
|
102
|
-
index += 1
|
|
103
|
-
statement += lines[index]
|
|
104
|
-
}
|
|
105
|
-
importStatements.push(statement)
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
const importLines = importStatements.join('\n')
|
|
109
|
-
const dynamicSpecifiers = [...fixtureSrc.matchAll(/import\s*\(\s*['"]([^'"]+)['"]\s*\)/g)]
|
|
110
|
-
.map(match => match[1])
|
|
111
|
-
.concat([...fixtureSrc.matchAll(/require\s*\(\s*['"]([^'"]+)['"]\s*\)/g)]
|
|
112
|
-
.map(match => match[1]))
|
|
113
|
-
const banned = [
|
|
114
|
-
// Word-boundary: `@xmoon76/pi-tui` must NOT match
|
|
115
|
-
// `@xmoon76/dsh-pi-tui` (a substring of the public package name).
|
|
116
|
-
['@xmoon76/pi-tui (the private fork, word-bounded)', /@xmoon76\/pi-tui(?![-a-z])/g],
|
|
117
|
-
['@moonshot-ai/pi-tui', /@moonshot-ai\/pi-tui/g],
|
|
118
|
-
['src/tui-app', /src\/tui-app/g],
|
|
119
|
-
['TuiApp', /\bTuiApp\b/g],
|
|
120
|
-
['TuiMainScreen', /\bTuiMainScreen\b/g],
|
|
121
|
-
['TuiAltScreen', /\bTuiAltScreen\b/g],
|
|
122
|
-
['repository-relative internal path', /\.\.\/\.\.\/src/g],
|
|
123
|
-
]
|
|
124
|
-
for (const [name, pattern] of banned) {
|
|
125
|
-
const staticMatches = importLines.match(pattern) ?? []
|
|
126
|
-
const dynamicMatches = dynamicSpecifiers.filter(specifier => pattern.test(specifier))
|
|
127
|
-
check(`fixture does not import ${name}`, staticMatches.length === 0 && dynamicMatches.length === 0,
|
|
128
|
-
[...staticMatches, ...dynamicMatches].join(', '))
|
|
129
|
-
}
|
|
130
|
-
// The ONLY @xmoon76/dsh-pi-tui imports are the public subpaths
|
|
131
|
-
// (extensions/advanced + extensions for the service type; cordis is a
|
|
132
|
-
// legitimate peer for the plugin's apply signature).
|
|
133
|
-
const dshImports = importLines.match(/@xmoon76\/dsh-pi-tui[^'"]*/g) ?? []
|
|
134
|
-
check('fixture imports ONLY the public extensions subpaths',
|
|
135
|
-
dshImports.length === 2
|
|
136
|
-
&& dshImports.includes('@xmoon76/dsh-pi-tui/extensions/advanced')
|
|
137
|
-
&& dshImports.includes('@xmoon76/dsh-pi-tui/extensions'),
|
|
138
|
-
dshImports.join(', '))
|
|
139
|
-
|
|
140
|
-
// 3. ADVANCED-SURFACE EXERCISE (plan §15 acceptance): the fixture
|
|
141
|
-
// exercises the Phase-2 advanced surface through the public entry.
|
|
142
|
-
const surface = [
|
|
143
|
-
['ADVANCED_API_LEVEL', 'tier level constant'],
|
|
144
|
-
['advanced(', 'facade builder'],
|
|
145
|
-
['ui.input.capture', 'normalized input capture'],
|
|
146
|
-
["mode: 'observe'", 'observe mode'],
|
|
147
|
-
["mode: 'capture'", 'capture mode'],
|
|
148
|
-
['showInteractiveOverlay', 'interactive overlay'],
|
|
149
|
-
['AdvancedInteractiveComponent', 'interactive component type'],
|
|
150
|
-
['ui.editor', 'advanced editor controls'],
|
|
151
|
-
]
|
|
152
|
-
for (const [api, label] of surface) {
|
|
153
|
-
check(`fixture exercises ${label} (${api})`, fixtureSrc.includes(api))
|
|
154
|
-
}
|
|
155
|
-
check('fixture never parses raw terminal bytes (no CSI-u / escape matching in the plugin)',
|
|
156
|
-
!/\\x1b\[/.test(fixtureSrc) && !/escape\s*\(/.test(fixtureSrc.replace(/handleInput/g, '')))
|
|
157
|
-
|
|
158
|
-
// 4. The fixture tree: symlink the packed package + the host's
|
|
159
|
-
// node_modules (the offline-safe mirror of a real profile install).
|
|
160
|
-
const fixtureDir = join(workDir, 'fixture')
|
|
161
|
-
mkdirSync(join(fixtureDir, 'node_modules', '@xmoon76'), { recursive: true })
|
|
162
|
-
symlinkSync(pkgDir, join(fixtureDir, 'node_modules', '@xmoon76', 'dsh-pi-tui'), 'dir')
|
|
163
|
-
const hostModules = join(PACKAGE_ROOT, 'node_modules')
|
|
164
|
-
mkdirSync(join(pkgDir, 'node_modules'), { recursive: true })
|
|
165
|
-
for (const entry of readdirSync(hostModules)) {
|
|
166
|
-
const target = join(pkgDir, 'node_modules', entry)
|
|
167
|
-
if (existsSync(target)) continue
|
|
168
|
-
symlinkSync(join(hostModules, entry), target, 'dir')
|
|
169
|
-
}
|
|
170
|
-
mkdirSync(join(fixtureDir, 'node_modules'), { recursive: true })
|
|
171
|
-
for (const entry of readdirSync(hostModules)) {
|
|
172
|
-
const target = join(fixtureDir, 'node_modules', entry)
|
|
173
|
-
if (existsSync(target)) continue
|
|
174
|
-
symlinkSync(join(hostModules, entry), target, 'dir')
|
|
175
|
-
}
|
|
176
|
-
mkdirSync(join(fixtureDir, 'src'), { recursive: true })
|
|
177
|
-
writeFileSync(join(fixtureDir, 'src', 'index.ts'), fixtureSrc)
|
|
178
|
-
writeFileSync(join(fixtureDir, 'tsconfig.json'), JSON.stringify({
|
|
179
|
-
compilerOptions: {
|
|
180
|
-
target: 'ES2022',
|
|
181
|
-
module: 'NodeNext',
|
|
182
|
-
moduleResolution: 'NodeNext',
|
|
183
|
-
strict: true,
|
|
184
|
-
noEmit: true,
|
|
185
|
-
skipLibCheck: true,
|
|
186
|
-
types: [],
|
|
187
|
-
},
|
|
188
|
-
include: ['src'],
|
|
189
|
-
}, null, 2))
|
|
190
|
-
writeFileSync(join(fixtureDir, 'package.json'), JSON.stringify({
|
|
191
|
-
name: 'dsh-pi-advanced-fixture',
|
|
192
|
-
private: true,
|
|
193
|
-
type: 'module',
|
|
194
|
-
}, null, 2))
|
|
195
|
-
|
|
196
|
-
// 5. Typecheck against the packed .d.mts (the repo's tsc — no
|
|
197
|
-
// registry fetch).
|
|
198
|
-
const tsc = run('node', [join(PACKAGE_ROOT, 'node_modules', 'typescript', 'bin', 'tsc'), '-p', join(fixtureDir, 'tsconfig.json')], { cwd: fixtureDir })
|
|
199
|
-
check('fixture typechecks against the packed .d.mts', tsc.status === 0,
|
|
200
|
-
tsc.status === 0 ? '' : (tsc.stdout + tsc.stderr).split('\n').slice(-6).join(' '))
|
|
201
|
-
|
|
202
|
-
// 6. Module shape.
|
|
203
|
-
const moduleShape = /export const name = ['"][^'"]+['"]/s.test(fixtureSrc)
|
|
204
|
-
&& /export const inject = \[/s.test(fixtureSrc)
|
|
205
|
-
&& /export function apply\(ctx: Context\)/s.test(fixtureSrc)
|
|
206
|
-
check('fixture module shape (name/inject/apply) matches the Loader', moduleShape)
|
|
207
|
-
|
|
208
|
-
// 7. RUNTIME LOAD: compile the fixture to JS and apply() it with a
|
|
209
|
-
// mock service — the registrations must not throw and must resolve
|
|
210
|
-
// the PACKED advanced module.
|
|
211
|
-
const emitDir = join(fixtureDir, 'dist-out')
|
|
212
|
-
const emit = run('node', [join(PACKAGE_ROOT, 'node_modules', 'typescript', 'bin', 'tsc'),
|
|
213
|
-
'-p', join(fixtureDir, 'tsconfig.json'), '--noEmit', 'false', '--outDir', emitDir], { cwd: fixtureDir })
|
|
214
|
-
check('fixture compiles to JS for the runtime probe', emit.status === 0,
|
|
215
|
-
emit.status === 0 ? '' : (emit.stdout + emit.stderr).split('\n').slice(-4).join(' '))
|
|
216
|
-
if (emit.status === 0) {
|
|
217
|
-
writeFileSync(join(fixtureDir, 'runtime-probe.mjs'), `
|
|
218
|
-
const context = {
|
|
219
|
-
get: (name) => name === 'piTuiExtensions' ? mockService() : undefined,
|
|
220
|
-
on: () => {},
|
|
221
|
-
}
|
|
222
|
-
function mockService() {
|
|
223
|
-
const inertLease = {
|
|
224
|
-
id: 'inert', active: false, focused: false,
|
|
225
|
-
focus: () => {}, blur: () => {}, invalidate: () => {},
|
|
226
|
-
close: () => {}, hide: () => {}, show: () => {},
|
|
227
|
-
}
|
|
228
|
-
const inertControls = {
|
|
229
|
-
getEditorState: () => ({ text: '', cursor: 0, focused: false, composing: false }),
|
|
230
|
-
setEditorText: () => {}, setEditorCursor: () => {},
|
|
231
|
-
insertEditorText: () => {}, pasteToEditor: () => {}, requestEditorFocus: () => {},
|
|
232
|
-
}
|
|
233
|
-
return {
|
|
234
|
-
api: () => ({
|
|
235
|
-
apiVersion: 1,
|
|
236
|
-
hostVersion: 'test',
|
|
237
|
-
capabilities: new Set(['advanced.input.capture', 'advanced.ui.interactive', 'advanced.editor.control']),
|
|
238
|
-
deprecations: new Map(),
|
|
239
|
-
}),
|
|
240
|
-
_advancedCaptureInput: (spec) => ({ id: spec.id, dispose: () => {} }),
|
|
241
|
-
_advancedShowInteractiveOverlay: () => inertLease,
|
|
242
|
-
_advancedEditorControls: () => inertControls,
|
|
243
|
-
_advancedUiSelect: () => Promise.resolve(undefined),
|
|
244
|
-
_advancedUiConfirm: () => Promise.resolve(false),
|
|
245
|
-
_advancedUiInput: () => Promise.resolve(undefined),
|
|
246
|
-
_advancedUiNotify: () => {},
|
|
247
|
-
_advancedUiCustom: () => Promise.resolve(undefined),
|
|
248
|
-
_advancedHostState: () => ({
|
|
249
|
-
getTheme: () => 'dark',
|
|
250
|
-
setTheme: () => {},
|
|
251
|
-
setTitle: () => {},
|
|
252
|
-
setWorkingMessage: () => {},
|
|
253
|
-
setToolsExpanded: () => {},
|
|
254
|
-
}),
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
const plugin = await import(${JSON.stringify(join(emitDir, 'index.js').replaceAll('\\', '/'))})
|
|
258
|
-
plugin.apply(context)
|
|
259
|
-
if (plugin.name !== 'dsh-pi-advanced-fixture') throw new Error('bad name: ' + plugin.name)
|
|
260
|
-
console.log('fixture-runtime-ok')
|
|
261
|
-
`)
|
|
262
|
-
const runtime = run(process.execPath, [join(fixtureDir, 'runtime-probe.mjs')], { cwd: fixtureDir })
|
|
263
|
-
check('fixture runtime-loads and apply() registers without throwing',
|
|
264
|
-
runtime.status === 0 && runtime.stdout.includes('fixture-runtime-ok'),
|
|
265
|
-
runtime.status === 0 ? '' : (runtime.stderr || runtime.stdout).split('\n').slice(-8).join(' '))
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// 8. No duplicate dsh runtime: every @deepseek-ai entry in BOTH the
|
|
269
|
-
// packed package tree AND the fixture tree must be a SYMLINK whose
|
|
270
|
-
// realpath resolves into the host module tree (the packed tarball
|
|
271
|
-
// itself carries no node_modules — the scaffolding symlinks them,
|
|
272
|
-
// exactly like a real profile resolves in-box packages from the
|
|
273
|
-
// dsh installation). A REAL directory is a duplicate runtime copy.
|
|
274
|
-
const probeReal = resolve(workDir)
|
|
275
|
-
const auditTrees = [
|
|
276
|
-
['packed package', join(pkgDir, 'node_modules', '@deepseek-ai')],
|
|
277
|
-
['fixture tree', join(fixtureDir, 'node_modules', '@deepseek-ai')],
|
|
278
|
-
]
|
|
279
|
-
const realDirs = []
|
|
280
|
-
for (const [label, nm] of auditTrees) {
|
|
281
|
-
const entries = existsSync(nm) ? readdirSync(nm) : []
|
|
282
|
-
for (const name of entries) {
|
|
283
|
-
const entry = join(nm, name)
|
|
284
|
-
let isSymlink = false
|
|
285
|
-
try { isSymlink = lstatSync(entry).isSymbolicLink() } catch { /* missing */ }
|
|
286
|
-
if (!isSymlink) {
|
|
287
|
-
realDirs.push(`${label}:${name} (real dir)`)
|
|
288
|
-
continue
|
|
289
|
-
}
|
|
290
|
-
try {
|
|
291
|
-
const target = resolve(realpathSync(entry))
|
|
292
|
-
if (target.startsWith(probeReal)) realDirs.push(`${label}:${name}->${target}`)
|
|
293
|
-
} catch {
|
|
294
|
-
realDirs.push(`${label}:${name} (dangling)`)
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
check('no duplicate @deepseek-ai dsh-runtime copy (symlinks only, both trees)',
|
|
299
|
-
realDirs.length === 0,
|
|
300
|
-
realDirs.join(', '))
|
|
301
|
-
} finally {
|
|
302
|
-
if (process.env.ADVANCED_SMOKE_KEEP !== '1') rmSync(workDir, { recursive: true, force: true })
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
for (const line of checks) console.log(line)
|
|
306
|
-
if (failures.length > 0) {
|
|
307
|
-
console.error(`\nadvanced-plugin smoke FAILED (${failures.length}):`)
|
|
308
|
-
for (const failure of failures) console.error(` - ${failure}`)
|
|
309
|
-
process.exit(1)
|
|
310
|
-
}
|
|
311
|
-
console.log('\nadvanced-plugin smoke passed')
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
main()
|
package/scripts/bench.mts
DELETED
|
@@ -1,302 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* @xmoon76/dsh-pi-tui/scripts/bench — non-default performance benchmark
|
|
4
|
-
* (run explicitly: `node scripts/bench.mjs`; never part of the test suite).
|
|
5
|
-
*
|
|
6
|
-
* Builds synthetic session logs (markdown, diffs, consecutive reads, tool
|
|
7
|
-
* calls, CJK/emoji) and measures, across widths and themes:
|
|
8
|
-
*
|
|
9
|
-
* - ingest: TranscriptFolder.apply() time per event count;
|
|
10
|
-
* - projection: messages() p50/p95/p99 (the incremental read-grouping);
|
|
11
|
-
* - rebuild: TuiApp.setTranscript cold (full markdown parse) vs warm
|
|
12
|
-
* (the stage-J per-message render cache) p50/p95/p99, including the
|
|
13
|
-
* 20 Hz streaming case (one message's text replaced per frame);
|
|
14
|
-
* - theme switch cost;
|
|
15
|
-
* - heap: growth per warm rebuild and the settled working set.
|
|
16
|
-
*
|
|
17
|
-
* Output is a plain baseline table; run twice to compare. Requires a
|
|
18
|
-
* Node version with native TypeScript stripping (>=22.6 with
|
|
19
|
-
* --experimental-strip-types, or 24+).
|
|
20
|
-
* @module bench
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import xterm from '@xterm/headless'
|
|
24
|
-
import { TuiApp } from '../src/tui-app.ts'
|
|
25
|
-
import { TranscriptFolder } from '../src/transcript.ts'
|
|
26
|
-
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
27
|
-
import type { TranscriptMessage } from '../src/transcript.ts'
|
|
28
|
-
|
|
29
|
-
const XtermTerminal = xterm.Terminal
|
|
30
|
-
|
|
31
|
-
/** A minimal headless terminal for TuiApp (render target only). */
|
|
32
|
-
class BenchTerminal {
|
|
33
|
-
private readonly xterm: InstanceType<typeof XtermTerminal>
|
|
34
|
-
constructor(columns: number, rows: number) {
|
|
35
|
-
this.xterm = new XtermTerminal({ cols: columns, rows, disableStdin: true, allowProposedApi: true })
|
|
36
|
-
}
|
|
37
|
-
start(): void {}
|
|
38
|
-
stop(): void {}
|
|
39
|
-
async drainInput(): Promise<void> {}
|
|
40
|
-
write(data: string): void { this.xterm.write(data) }
|
|
41
|
-
get columns(): number { return this.xterm.cols }
|
|
42
|
-
get rows(): number { return this.xterm.rows }
|
|
43
|
-
get kittyProtocolActive(): boolean { return false }
|
|
44
|
-
moveBy(): void {}
|
|
45
|
-
hideCursor(): void {}
|
|
46
|
-
showCursor(): void {}
|
|
47
|
-
clearLine(): void {}
|
|
48
|
-
clearFromCursor(): void {}
|
|
49
|
-
clearScreen(): void {}
|
|
50
|
-
setTitle(): void {}
|
|
51
|
-
setProgress(): void {}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// --- synthetic session content ---------------------------------------------
|
|
55
|
-
// NOTE: the CJK strings below are FUNCTIONAL benchmark fixtures, not
|
|
56
|
-
// user-facing copy: the wide-grapheme samples exercise the CJK width
|
|
57
|
-
// paths (get-east-asian-width, wrap + emoji) whose cost this script
|
|
58
|
-
// measures. The English-only rule applies to user-facing strings and
|
|
59
|
-
// comments; these samples are deliberately CJK — an English-only fixture
|
|
60
|
-
// would silently drop the wide-character code path from the benchmark.
|
|
61
|
-
|
|
62
|
-
const MARKDOWN_BLOCKS = [
|
|
63
|
-
'## Findings\n\nThe **cache** now hits for unchanged messages, so `markdown` is not re-parsed every frame.\n\n```ts\nconst hit = cached.text === message.text\n```\n\n- one\n- two\n- three',
|
|
64
|
-
'## Steps\n\n1. ingest events\n2. fold the projection\n3. render the tail\n\n> Only the visible window pays the expensive conversions.',
|
|
65
|
-
'## 结论(CJK + emoji)\n\n缓存命中后 🐋🐳 单帧成本不再随完整历史线性增长。`宽度 40/80/160` 与主题切换均按 key 失效。',
|
|
66
|
-
]
|
|
67
|
-
|
|
68
|
-
const DIFF_BODY = 'diff --git a/src/a.ts b/src/a.ts\nindex 111..222 100644\n--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1,3 +1,4 @@\n const a = 1\n+const b = 2\n-const old = 3\n // tail'
|
|
69
|
-
|
|
70
|
-
/** Build `turns` turns of events; each turn ≈ 9 events. */
|
|
71
|
-
function buildEvents(turns: number): SessionEvent[] {
|
|
72
|
-
const events: SessionEvent[] = []
|
|
73
|
-
let seq = 0
|
|
74
|
-
for (let turn = 0; turn < turns; turn += 1) {
|
|
75
|
-
events.push({ type: 'turn/start', seq: seq++, time: turn * 1000, data: { turn } } as SessionEvent)
|
|
76
|
-
events.push({
|
|
77
|
-
type: 'user/message', seq: seq++, time: turn * 1000 + 1, data: {
|
|
78
|
-
content: [{ type: 'text', text: `user prompt ${turn} with CJK 你好 and emoji 🐋` }],
|
|
79
|
-
source: { kind: 'user' },
|
|
80
|
-
},
|
|
81
|
-
} as SessionEvent)
|
|
82
|
-
for (let chunk = 0; chunk < 8; chunk += 1) {
|
|
83
|
-
events.push({
|
|
84
|
-
type: 'assistant/chunk', seq: seq++, time: turn * 1000 + 2 + chunk, data: {
|
|
85
|
-
turn, step: 0, index: chunk,
|
|
86
|
-
chunk: { type: 'text-delta', index: chunk, text: MARKDOWN_BLOCKS[turn % 3]!.slice(chunk * 40, chunk * 40 + 40) },
|
|
87
|
-
},
|
|
88
|
-
} as SessionEvent)
|
|
89
|
-
}
|
|
90
|
-
events.push({
|
|
91
|
-
type: 'assistant/message', seq: seq++, time: turn * 1000 + 11, data: {
|
|
92
|
-
turn, step: 0,
|
|
93
|
-
message: { id: `msg-${turn}`, role: 'assistant', content: [{ type: 'text', text: MARKDOWN_BLOCKS[turn % 3]! }], source: { kind: 'assistant' } },
|
|
94
|
-
},
|
|
95
|
-
} as SessionEvent)
|
|
96
|
-
events.push({
|
|
97
|
-
type: 'tool/call', seq: seq++, time: turn * 1000 + 12, data: {
|
|
98
|
-
turn, step: 0, callId: `r${turn}`, name: 'read', arguments: JSON.stringify({ file: `src/file-${turn}.ts` }),
|
|
99
|
-
},
|
|
100
|
-
} as SessionEvent)
|
|
101
|
-
events.push({
|
|
102
|
-
type: 'tool/result', seq: seq++, time: turn * 1000 + 13, data: {
|
|
103
|
-
turn, step: 0, message: {
|
|
104
|
-
id: `m-${turn}`, role: 'user',
|
|
105
|
-
content: [{ type: 'tool-result', toolCallId: `r${turn}`, content: [{ type: 'text', text: DIFF_BODY }] }],
|
|
106
|
-
source: { kind: 'tool', callId: `r${turn}` },
|
|
107
|
-
},
|
|
108
|
-
},
|
|
109
|
-
} as SessionEvent)
|
|
110
|
-
events.push({
|
|
111
|
-
type: 'tool/call', seq: seq++, time: turn * 1000 + 14, data: {
|
|
112
|
-
turn, step: 0, callId: `b${turn}`, name: 'bash', arguments: JSON.stringify({ command: 'ls -la' }),
|
|
113
|
-
},
|
|
114
|
-
} as SessionEvent)
|
|
115
|
-
events.push({
|
|
116
|
-
type: 'tool/result', seq: seq++, time: turn * 1000 + 15, data: {
|
|
117
|
-
turn, step: 0, message: {
|
|
118
|
-
id: `bm-${turn}`, role: 'user',
|
|
119
|
-
content: [{ type: 'tool-result', toolCallId: `b${turn}`, content: [{ type: 'text', text: 'total 8\ndrwxr-xr-x 2 user user 4096 Aug 15 00:00 .\n-rw-r--r-- 1 user user 123 src/a.ts' }] }],
|
|
120
|
-
source: { kind: 'tool', callId: `b${turn}` },
|
|
121
|
-
},
|
|
122
|
-
},
|
|
123
|
-
} as SessionEvent)
|
|
124
|
-
events.push({ type: 'turn/end', seq: seq++, time: turn * 1000 + 16, data: { turn, reason: { kind: 'completed' } } } as SessionEvent)
|
|
125
|
-
}
|
|
126
|
-
return events
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// --- measurement helpers ----------------------------------------------------
|
|
130
|
-
|
|
131
|
-
function percentile(sorted: number[], p: number): number {
|
|
132
|
-
if (sorted.length === 0) return 0
|
|
133
|
-
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1))
|
|
134
|
-
return sorted[index]!
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function stats(samples: number[]): { p50: number; p95: number; p99: number; mean: number } {
|
|
138
|
-
const sorted = [...samples].sort((a, b) => a - b)
|
|
139
|
-
return {
|
|
140
|
-
p50: percentile(sorted, 50),
|
|
141
|
-
p95: percentile(sorted, 95),
|
|
142
|
-
p99: percentile(sorted, 99),
|
|
143
|
-
mean: samples.reduce((sum, value) => sum + value, 0) / Math.max(1, samples.length),
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function fmt(st: { p50: number; p95: number; p99: number; mean: number }): string {
|
|
148
|
-
return `p50 ${st.p50.toFixed(2)}ms · p95 ${st.p95.toFixed(2)}ms · p99 ${st.p99.toFixed(2)}ms · mean ${st.mean.toFixed(2)}ms`
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function fmtMs(ms: number): string {
|
|
152
|
-
return `${ms.toFixed(2)}ms`
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/** Run a callback `n` times and return per-call wall times. */
|
|
156
|
-
function timeIt(n: number, run: () => void): number[] {
|
|
157
|
-
const samples: number[] = []
|
|
158
|
-
for (let i = 0; i < n; i += 1) {
|
|
159
|
-
const started = performance.now()
|
|
160
|
-
run()
|
|
161
|
-
samples.push(performance.now() - started)
|
|
162
|
-
}
|
|
163
|
-
return samples
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/** Iteration counts; BENCH_FAST=1 shrinks them for quick before/after sweeps. */
|
|
167
|
-
const FAST = process.env.BENCH_FAST === '1'
|
|
168
|
-
const PROJ_SAMPLES = FAST ? 50 : 200
|
|
169
|
-
const WARM_SAMPLES = FAST ? 50 : 200
|
|
170
|
-
const STREAM_SAMPLES = FAST ? 50 : 200
|
|
171
|
-
const FULL_SAMPLES = FAST ? 20 : 50
|
|
172
|
-
const HEAP_REBUILDS = FAST ? 300 : 2000
|
|
173
|
-
|
|
174
|
-
// --- the benchmark ----------------------------------------------------------
|
|
175
|
-
|
|
176
|
-
async function main(): Promise<void> {
|
|
177
|
-
const rows: string[] = []
|
|
178
|
-
const row = (label: string, value: string): void => { rows.push(`${label.padEnd(58)} ${value}`) }
|
|
179
|
-
|
|
180
|
-
row('scenario', 'value')
|
|
181
|
-
|
|
182
|
-
// 1. ingest + projection
|
|
183
|
-
for (const turns of [111, 1111, 5555]) {
|
|
184
|
-
const events = buildEvents(turns)
|
|
185
|
-
const folder = new TranscriptFolder()
|
|
186
|
-
const ingest = timeIt(3, () => folder.apply(events))
|
|
187
|
-
const projection = timeIt(PROJ_SAMPLES, () => folder.messages())
|
|
188
|
-
const st = stats(projection)
|
|
189
|
-
row(`ingest ${events.length} events (${turns} turns)`, fmtMs(ingest[0]!))
|
|
190
|
-
row(` messages() ×200 (${folder.messages().length} messages)`, fmt(st))
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// 2. rebuild (cold vs warm) across widths, plus the streaming case
|
|
194
|
-
const turns = 555
|
|
195
|
-
const folder = new TranscriptFolder()
|
|
196
|
-
folder.apply(buildEvents(turns))
|
|
197
|
-
const messages = folder.messages()
|
|
198
|
-
for (const width of [40, 80, 160]) {
|
|
199
|
-
// True cold: a fresh app whose FIRST setTranscript parses every message.
|
|
200
|
-
const coldApp = new TuiApp(new BenchTerminal(width, 24) as never, { onSubmit: () => {}, onExit: () => {} })
|
|
201
|
-
coldApp.start()
|
|
202
|
-
const cold = timeIt(1, () => coldApp.setTranscript(messages))
|
|
203
|
-
coldApp.stop()
|
|
204
|
-
const terminal = new BenchTerminal(width, 24)
|
|
205
|
-
const app = new TuiApp(terminal as never, { onSubmit: () => {}, onExit: () => {} })
|
|
206
|
-
app.start()
|
|
207
|
-
app.setTranscript(messages)
|
|
208
|
-
const warm = timeIt(WARM_SAMPLES, () => app.setTranscript(messages))
|
|
209
|
-
row(`rebuild ${messages.length} messages @${width} cols (cold, fresh app)`, fmtMs(cold[0]!))
|
|
210
|
-
row(` same content (warm cache)`, fmt(stats(warm)))
|
|
211
|
-
// 20 Hz streaming: one assistant message's text replaced per frame.
|
|
212
|
-
const streaming = [...messages]
|
|
213
|
-
const target = streaming.findIndex(message => message.kind === 'assistant')
|
|
214
|
-
const warmStream = timeIt(STREAM_SAMPLES, () => {
|
|
215
|
-
const entry = streaming[target]
|
|
216
|
-
if (entry !== undefined && entry.kind === 'assistant') entry.text += ' tail'
|
|
217
|
-
app.setTranscript(streaming)
|
|
218
|
-
})
|
|
219
|
-
row(` streaming 1 message/frame @${width}`, fmt(stats(warmStream)))
|
|
220
|
-
// Theme switch cost (once per switch).
|
|
221
|
-
const theme = timeIt(5, () => app.applyTheme('light'))
|
|
222
|
-
row(` theme dark→light @${width}`, fmtMs(theme[0]!))
|
|
223
|
-
app.stop()
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// 3. fullscreen rebuild
|
|
227
|
-
{
|
|
228
|
-
const terminal = new BenchTerminal(120, 24)
|
|
229
|
-
const app = new TuiApp(terminal as never, { onSubmit: () => {}, onExit: () => {} })
|
|
230
|
-
app.start()
|
|
231
|
-
app.setTranscript(messages)
|
|
232
|
-
app.setFullscreen(true)
|
|
233
|
-
const full = timeIt(FULL_SAMPLES, () => app.setTranscript(messages))
|
|
234
|
-
row(`fullscreen rebuild ${messages.length} messages @120`, fmt(stats(full)))
|
|
235
|
-
app.stop()
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// 4. heap (requires --expose-gc)
|
|
239
|
-
if (globalThis.gc !== undefined) {
|
|
240
|
-
const terminal = new BenchTerminal(120, 24)
|
|
241
|
-
const app = new TuiApp(terminal as never, { onSubmit: () => {}, onExit: () => {} })
|
|
242
|
-
app.start()
|
|
243
|
-
app.setTranscript(messages)
|
|
244
|
-
const gc = globalThis.gc as () => void
|
|
245
|
-
gc()
|
|
246
|
-
const before = process.memoryUsage().heapUsed
|
|
247
|
-
for (let i = 0; i < HEAP_REBUILDS; i += 1) app.setTranscript(messages)
|
|
248
|
-
gc()
|
|
249
|
-
const after = process.memoryUsage().heapUsed
|
|
250
|
-
row(`heap growth per warm rebuild (${HEAP_REBUILDS}×, gc)`, `${((after - before) / 2000).toFixed(1)} B/rebuild`)
|
|
251
|
-
gc()
|
|
252
|
-
const settled = process.memoryUsage().heapUsed
|
|
253
|
-
row(`settled heap for ${messages.length} messages @120`, `${(settled / 1024 / 1024).toFixed(1)} MiB`)
|
|
254
|
-
app.stop()
|
|
255
|
-
} else {
|
|
256
|
-
row('heap (rerun with --expose-gc)', 'skipped')
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// 5. extension plugin overhead (M11, plan §23): the frame cost with N
|
|
260
|
-
// widget/list contributions — no-plugin baseline vs 10 vs 50 — and a
|
|
261
|
-
// streaming-invalidation case (the batcher must coalesce bursts).
|
|
262
|
-
const { ExtensionLedger } = await import('../src/extension/internal/ledger.ts')
|
|
263
|
-
const { SurfaceHost } = await import('../src/extension/internal/surface-host.ts')
|
|
264
|
-
const { WidgetOutlet } = await import('../src/extension/internal/widget-outlet.ts')
|
|
265
|
-
for (const count of [0, 10, 50]) {
|
|
266
|
-
const ledger = new ExtensionLedger()
|
|
267
|
-
const host = new SurfaceHost(ledger, () => {})
|
|
268
|
-
let renders = 0
|
|
269
|
-
const outlet = new WidgetOutlet(ledger, { requestRender: () => { renders += 1 } }, 'input.widget.below')
|
|
270
|
-
for (let index = 0; index < count; index++) {
|
|
271
|
-
ledger.register('input.widget.below', { id: `w${index}`, order: index }, {
|
|
272
|
-
view: { kind: 'text', spans: [{ text: `widget ${index}` }] },
|
|
273
|
-
importance: index,
|
|
274
|
-
maxHeight: 1,
|
|
275
|
-
}, `owner-${index}`)
|
|
276
|
-
}
|
|
277
|
-
const t0 = process.hrtime.bigint()
|
|
278
|
-
for (let frame = 0; frame < 200; frame += 1) outlet.refresh(0, 120, 4)
|
|
279
|
-
const elapsed = Number(process.hrtime.bigint() - t0) / 200
|
|
280
|
-
row(`widget outlet refresh ×${count} contributions (200 frames)`, `${elapsed.toFixed(0)} ns/frame`)
|
|
281
|
-
void host
|
|
282
|
-
}
|
|
283
|
-
// Invalidation coalescing: 100 replaces in one tick → ONE flush.
|
|
284
|
-
const { InvalidateBatcher } = await import('../src/extension/internal/batcher.ts')
|
|
285
|
-
let flushCount = 0
|
|
286
|
-
const batcher = new InvalidateBatcher({ requestRender: () => { flushCount += 1 } })
|
|
287
|
-
for (let index = 0; index < 100; index += 1) batcher.invalidate()
|
|
288
|
-
await Promise.resolve()
|
|
289
|
-
row('invalidation burst coalescing (100 in one tick)', `${flushCount} flush(es)`)
|
|
290
|
-
|
|
291
|
-
console.log(rows.join('\n'))
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// Round-1 finding 2 (fixed in round 2 — the previous attempt silently
|
|
295
|
-
// failed to write): main() is async — dynamic imports and awaited stages
|
|
296
|
-
// reject ASYNCHRONOUSLY. A try/catch cannot observe a rejection; catch
|
|
297
|
-
// the PROMISE explicitly so any async failure exits non-zero (never an
|
|
298
|
-
// unhandled rejection with a zero exit code).
|
|
299
|
-
main().catch((error) => {
|
|
300
|
-
console.error(`bench: ${error instanceof Error ? error.message : String(error)}`)
|
|
301
|
-
process.exit(1)
|
|
302
|
-
})
|