@xmoon76/dsh-pi-tui 0.3.3 → 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.
@@ -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
- })