@mobius-os/mobius 0.3.31 → 0.3.38
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.md +7 -0
- package/package.json +2 -1
- package/scripts/build-python-bundles.sh +2 -2
- package/src/App.tsx +18 -2
- package/src/aimux.ts +17 -8
- package/src/components/Chat.tsx +118 -197
- package/src/components/ConfigFlow.tsx +30 -6
- package/src/components/Login.tsx +5 -3
- package/src/components/PrepScreen.tsx +98 -25
- package/src/components/primitives.tsx +44 -5
- package/src/lib/cursor-keys.ts +70 -0
- package/src/lib/delete-keys.ts +4 -4
- package/src/lib/entry-view.ts +10 -5
- package/src/lib/screen-text.ts +0 -35
- package/src/lib/transcript-viewport.ts +162 -0
- package/src/markdown.ts +34 -10
- package/src/version.ts +21 -0
- package/tests/aimux.test.tsx +17 -6
- package/tests/flow.test.tsx +25 -4
- package/tests/screen.test.tsx +11 -10
- package/tests/scroll.test.tsx +13 -12
- package/tests/selection.test.tsx +1 -1
- package/tests/ui.test.tsx +141 -10
- package/tests/viewport.test.ts +83 -0
package/tests/ui.test.tsx
CHANGED
|
@@ -22,7 +22,7 @@ import { PrepScreen } from '../src/components/PrepScreen.js'
|
|
|
22
22
|
import { Select, TextInput } from '../src/components/primitives.js'
|
|
23
23
|
import { MobiusClient } from '../src/api.js'
|
|
24
24
|
import { renderMarkdownLines } from '../src/markdown.js'
|
|
25
|
-
import { viewsForEntry, toolLabel } from '../src/lib/entry-view.js'
|
|
25
|
+
import { dedupeUserEntries, viewsForEntry, toolLabel } from '../src/lib/entry-view.js'
|
|
26
26
|
import { SseConnection } from '../src/sse.js'
|
|
27
27
|
import type { ReadyState } from '../src/components/PrepScreen.js'
|
|
28
28
|
|
|
@@ -162,7 +162,7 @@ async function testChat() {
|
|
|
162
162
|
})
|
|
163
163
|
try {
|
|
164
164
|
const { stdin, lastFrame, unmount } = render(
|
|
165
|
-
<ChatScreen client={client} ready={ready} webUserId="test-user" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />
|
|
165
|
+
<ChatScreen client={client} ready={ready} webUserId="test-user" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onLogout={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />
|
|
166
166
|
)
|
|
167
167
|
await delay(40)
|
|
168
168
|
const initialFrame = lastFrame() ?? ''
|
|
@@ -226,7 +226,7 @@ async function testResumedWorkingStatus() {
|
|
|
226
226
|
})
|
|
227
227
|
try {
|
|
228
228
|
const { stdin, lastFrame, unmount } = render(
|
|
229
|
-
<ChatScreen client={client} ready={ready} webUserId="test-user" resumeSessionId="s1" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />
|
|
229
|
+
<ChatScreen client={client} ready={ready} webUserId="test-user" resumeSessionId="s1" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onLogout={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />
|
|
230
230
|
)
|
|
231
231
|
await delay(120)
|
|
232
232
|
ok((lastFrame() ?? '').includes('Working ('), 'resuming an already-running session restores Working without a new typing event')
|
|
@@ -261,6 +261,27 @@ function testMarkdownCodeRendering() {
|
|
|
261
261
|
|
|
262
262
|
const unlabelled = renderMarkdownLines('```\necho $HOME\n```')
|
|
263
263
|
ok(unlabelled.length === 1 && unlabelled[0].text === 'echo $HOME' && unlabelled[0].code, 'unlabelled code stays plain instead of being guessed as bash')
|
|
264
|
+
|
|
265
|
+
// HTML entity decoding — marked's lexer encodes ', ", <, >, & even when
|
|
266
|
+
// only tokenising, so the TUI renderer must decode them back.
|
|
267
|
+
const entities = renderMarkdownLines("What's \"cool\"? 1 < 2 & 3 > 1")
|
|
268
|
+
const entityText = entities.map(r => r.text).join('\n')
|
|
269
|
+
ok(entityText.includes("What's"), "' decoded back to apostrophe")
|
|
270
|
+
ok(entityText.includes('"cool"'), "" decoded back to double-quote")
|
|
271
|
+
ok(entityText.includes('1 < 2'), "< decoded back to <")
|
|
272
|
+
ok(entityText.includes('3 > 1'), "> decoded back to >")
|
|
273
|
+
ok(entityText.includes('& 3'), "& decoded back to &")
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function testFirstUserEntryDedupe() {
|
|
277
|
+
console.log('\n[UI 4b] first user message event deduplication')
|
|
278
|
+
const framed = '上下文注入\n\n## 用户的问题\n\n你好,检查首条消息'
|
|
279
|
+
const entries = [
|
|
280
|
+
{ type: 'user', uuid: 'framed-user', message: { role: 'user', content: framed } },
|
|
281
|
+
{ type: 'event_msg', uuid: 'plain-user', payload: { type: 'user_message', message: '你好,检查首条消息' } },
|
|
282
|
+
]
|
|
283
|
+
const deduped = dedupeUserEntries(entries as any)
|
|
284
|
+
ok(deduped.length === 1, 'framed and plain first-turn user events render once')
|
|
264
285
|
}
|
|
265
286
|
|
|
266
287
|
// ════════════════════════════════════════════════════════════════════════════
|
|
@@ -286,11 +307,18 @@ async function testPrepRender() {
|
|
|
286
307
|
ok(frame.includes('选择当前路径的绑定项目'), 'project picker title shown')
|
|
287
308
|
ok(frame.includes('已有项目A') && frame.includes('已有项目B'), 'existing projects listed')
|
|
288
309
|
ok(frame.includes('创建新项目'), 'create-new option present')
|
|
289
|
-
//
|
|
290
|
-
|
|
291
|
-
ok(frame.includes('已有项目B
|
|
310
|
+
// Only the highlighted row carries its description; unfocused rows stay
|
|
311
|
+
// compact and show their names alone.
|
|
312
|
+
ok(!frame.includes('已有项目A - 第一行') && !frame.includes('已有项目B - 单行描述'), 'unfocused project rows omit their descriptions')
|
|
313
|
+
stdin.write('\x1b[B'); await delay(15) // move focus from search to the list
|
|
314
|
+
stdin.write('\x1b[B'); await delay(15) // highlight the first project
|
|
315
|
+
const selectedFrame = lastFrame() ?? ''
|
|
316
|
+
ok(selectedFrame.includes('已有项目A - 第一行 ⏎ 第二行'), 'selected multi-line description stays on the main row')
|
|
317
|
+
ok(!selectedFrame.includes('已有项目B - 单行描述'), 'unselected project description is omitted')
|
|
318
|
+
ok(!frame.includes('\n 第一行') && !frame.includes('\n 单行描述'), 'project explanations do not render as an additional row')
|
|
292
319
|
ok(!frame.includes('加载项目列表…'), 'completed project load does not leave a stale loading message')
|
|
293
320
|
|
|
321
|
+
stdin.write('\x1b[A'); await delay(15) // return to the create row
|
|
294
322
|
stdin.write('\r')
|
|
295
323
|
await delay(30)
|
|
296
324
|
const createFrame = lastFrame() ?? ''
|
|
@@ -302,6 +330,56 @@ async function testPrepRender() {
|
|
|
302
330
|
} finally { restoreFetch() }
|
|
303
331
|
}
|
|
304
332
|
|
|
333
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
334
|
+
// TEST 5b — Project and issue pickers filter by the search field
|
|
335
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
336
|
+
async function testPrepSearch() {
|
|
337
|
+
console.log('\n[UI 5b] Prep picker search filtering')
|
|
338
|
+
const client = new MobiusClient('http://mock.local', 'mock-jwt-token')
|
|
339
|
+
installMock((url) => {
|
|
340
|
+
if (url.includes('/api/projects') && !url.includes('/issues') && !url.includes('/skills') && !url.includes('/memories')) {
|
|
341
|
+
return jsonResponse([
|
|
342
|
+
{ id: 'p1', name: '前端平台', description: '用户界面与组件' },
|
|
343
|
+
{ id: 'p2', name: '数据管线', description: '批处理任务' },
|
|
344
|
+
])
|
|
345
|
+
}
|
|
346
|
+
if (url.includes('/api/projects/p2/issues')) {
|
|
347
|
+
return jsonResponse([
|
|
348
|
+
{ id: 'i1', project_id: 'p2', title: '修复导入超时', description: '处理批处理任务' },
|
|
349
|
+
{ id: 'i2', project_id: 'p2', title: '更新监控面板', description: '前端界面' },
|
|
350
|
+
])
|
|
351
|
+
}
|
|
352
|
+
if (url.includes('/sessions/model-options')) return jsonResponse([])
|
|
353
|
+
if (url.includes('/sessions/default-model')) return jsonResponse({ model: 'codex' })
|
|
354
|
+
if (url.includes('/skills') || url.includes('/memories')) return jsonResponse([])
|
|
355
|
+
return jsonResponse({ error: 'no mock' }, 404)
|
|
356
|
+
})
|
|
357
|
+
try {
|
|
358
|
+
const { lastFrame, stdin, unmount } = render(<PrepScreen client={client} onReady={() => {}} />)
|
|
359
|
+
await delay(120)
|
|
360
|
+
stdin.write('数据')
|
|
361
|
+
await delay(40)
|
|
362
|
+
let frame = lastFrame() ?? ''
|
|
363
|
+
ok(frame.includes('数据管线') && !frame.includes('前端平台'), 'project search keeps matching project and hides non-matches')
|
|
364
|
+
ok(!frame.includes('创建新项目'), 'project search hides the create row while searching')
|
|
365
|
+
stdin.write('\r')
|
|
366
|
+
await delay(160)
|
|
367
|
+
ok((lastFrame() ?? '').includes('选择任务(Issue)'), 'matching project opens its issue picker')
|
|
368
|
+
|
|
369
|
+
stdin.write('超时')
|
|
370
|
+
await delay(40)
|
|
371
|
+
frame = lastFrame() ?? ''
|
|
372
|
+
ok(frame.includes('修复导入超时') && !frame.includes('更新监控面板'), 'issue search matches title and hides other issues')
|
|
373
|
+
stdin.write('\r')
|
|
374
|
+
await delay(120)
|
|
375
|
+
ok((lastFrame() ?? '').includes('选择模型') || (lastFrame() ?? '').includes('加载模型列表'), 'matching issue is selected with Enter')
|
|
376
|
+
unmount()
|
|
377
|
+
// This test deliberately selects a project; remove its persisted cwd
|
|
378
|
+
// binding so later picker tests still start on the project screen.
|
|
379
|
+
try { fs.rmSync(path.join(TMP_HOME, 'dir2project.json'), { force: true }) } catch { /* ignore */ }
|
|
380
|
+
} finally { restoreFetch() }
|
|
381
|
+
}
|
|
382
|
+
|
|
305
383
|
// ════════════════════════════════════════════════════════════════════════════
|
|
306
384
|
// TEST 6 — Select viewport: a long list must not overflow the terminal
|
|
307
385
|
// ════════════════════════════════════════════════════════════════════════════
|
|
@@ -471,6 +549,56 @@ async function testComposerDeleteKeys() {
|
|
|
471
549
|
unmount()
|
|
472
550
|
}
|
|
473
551
|
|
|
552
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
553
|
+
// TEST 8d — Home/End and Ctrl+Left/Right cursor movement
|
|
554
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
555
|
+
async function testCursorNavigationKeys() {
|
|
556
|
+
console.log('\n[UI 8d] Home/End + Ctrl-arrow cursor navigation')
|
|
557
|
+
|
|
558
|
+
let textInputSubmitted = ''
|
|
559
|
+
function TextHarness() {
|
|
560
|
+
const [v, setV] = React.useState('alpha beta')
|
|
561
|
+
return <TextInput value={v} onChange={setV} focused onSubmit={() => { textInputSubmitted = v }} />
|
|
562
|
+
}
|
|
563
|
+
const textInput = render(<TextHarness />)
|
|
564
|
+
await delay(20)
|
|
565
|
+
textInput.stdin.write('\x1b[H'); await delay(15) // Home
|
|
566
|
+
textInput.stdin.write('^'); await delay(15)
|
|
567
|
+
textInput.stdin.write('\x1b[F'); await delay(15) // End
|
|
568
|
+
textInput.stdin.write('$'); await delay(15)
|
|
569
|
+
textInput.stdin.write('\x1b[1;5D'); await delay(15) // Ctrl+Left
|
|
570
|
+
textInput.stdin.write('|'); await delay(15)
|
|
571
|
+
textInput.stdin.write('\x1b[1;5C'); await delay(15) // Ctrl+Right
|
|
572
|
+
textInput.stdin.write('!'); await delay(15)
|
|
573
|
+
textInput.stdin.write('\r'); await delay(20)
|
|
574
|
+
textInput.unmount()
|
|
575
|
+
ok(textInputSubmitted === '^alpha |beta$!', `TextInput cursor keys edit at expected boundaries (got ${JSON.stringify(textInputSubmitted)})`)
|
|
576
|
+
|
|
577
|
+
const composerSubmitted: string[] = []
|
|
578
|
+
const composer = render(
|
|
579
|
+
<Composer
|
|
580
|
+
onSubmit={v => composerSubmitted.push(v)}
|
|
581
|
+
onStop={() => {}}
|
|
582
|
+
onQuit={() => {}}
|
|
583
|
+
typing={false}
|
|
584
|
+
commands={[]}
|
|
585
|
+
/>,
|
|
586
|
+
)
|
|
587
|
+
await delay(20)
|
|
588
|
+
composer.stdin.write('alpha beta'); await delay(20)
|
|
589
|
+
composer.stdin.write('\x1b[H'); await delay(15)
|
|
590
|
+
composer.stdin.write('^'); await delay(15)
|
|
591
|
+
composer.stdin.write('\x1b[F'); await delay(15)
|
|
592
|
+
composer.stdin.write('$'); await delay(15)
|
|
593
|
+
composer.stdin.write('\x1b[1;5D'); await delay(15)
|
|
594
|
+
composer.stdin.write('|'); await delay(15)
|
|
595
|
+
composer.stdin.write('\x1b[1;5C'); await delay(15)
|
|
596
|
+
composer.stdin.write('!'); await delay(15)
|
|
597
|
+
composer.stdin.write('\r'); await delay(30)
|
|
598
|
+
composer.unmount()
|
|
599
|
+
ok(composerSubmitted[0] === '^alpha |beta$!', `Composer cursor keys edit at expected boundaries (got ${JSON.stringify(composerSubmitted[0])})`)
|
|
600
|
+
}
|
|
601
|
+
|
|
474
602
|
// ════════════════════════════════════════════════════════════════════════════
|
|
475
603
|
// TEST 9 — Codex-style composer keeps multiline pastes intact and grows/shrinks
|
|
476
604
|
// ════════════════════════════════════════════════════════════════════════════
|
|
@@ -489,7 +617,7 @@ async function testComposerMultilinePaste() {
|
|
|
489
617
|
await delay(20)
|
|
490
618
|
const initial = lastFrame() ?? ''
|
|
491
619
|
ok(initial.includes('╭') && initial.includes('╰'), 'composer has a visible bordered input boundary')
|
|
492
|
-
ok(initial.includes('Enter 发送') && initial.includes('Ctrl+J 换行'), 'composer shows
|
|
620
|
+
ok(initial.includes('Enter 发送') && initial.includes('Shift+Enter / Alt+Enter / Ctrl+J 换行'), 'composer shows submit/newline hints')
|
|
493
621
|
|
|
494
622
|
stdin.write('\x1b[200~第一行\r\n第二行\r第三行\x1b[201~')
|
|
495
623
|
await delay(20)
|
|
@@ -736,7 +864,7 @@ async function testChatSseReconnects() {
|
|
|
736
864
|
})
|
|
737
865
|
try {
|
|
738
866
|
const { stdin, lastFrame, unmount } = render(
|
|
739
|
-
<ChatScreen client={client} ready={ready} webUserId="test-user" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />,
|
|
867
|
+
<ChatScreen client={client} ready={ready} webUserId="test-user" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onLogout={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />,
|
|
740
868
|
)
|
|
741
869
|
await delay(40)
|
|
742
870
|
// Ink's test stdin treats one chunk as one keypress. Send text and Enter as
|
|
@@ -808,7 +936,7 @@ async function testIdleCompletedSessionReopensSseOnSend() {
|
|
|
808
936
|
})
|
|
809
937
|
try {
|
|
810
938
|
const { stdin, lastFrame, unmount } = render(
|
|
811
|
-
<ChatScreen client={client} ready={ready} webUserId="test-user" resumeSessionId="s1" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />,
|
|
939
|
+
<ChatScreen client={client} ready={ready} webUserId="test-user" resumeSessionId="s1" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onLogout={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />,
|
|
812
940
|
)
|
|
813
941
|
await delay(180)
|
|
814
942
|
ok(sseCall === 1, 'completed idle session did not reconnect by itself')
|
|
@@ -848,7 +976,7 @@ async function testSendRetries502() {
|
|
|
848
976
|
})
|
|
849
977
|
try {
|
|
850
978
|
const { stdin, lastFrame, unmount } = render(
|
|
851
|
-
<ChatScreen client={client} ready={ready} webUserId="u" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />,
|
|
979
|
+
<ChatScreen client={client} ready={ready} webUserId="u" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onLogout={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />,
|
|
852
980
|
)
|
|
853
981
|
await delay(40)
|
|
854
982
|
stdin.write('hi'); await delay(30); stdin.write('\r')
|
|
@@ -865,12 +993,15 @@ async function main() {
|
|
|
865
993
|
await testChat()
|
|
866
994
|
await testResumedWorkingStatus()
|
|
867
995
|
testMarkdownCodeRendering()
|
|
996
|
+
testFirstUserEntryDedupe()
|
|
868
997
|
await testPrepRender()
|
|
998
|
+
await testPrepSearch()
|
|
869
999
|
await testSelectViewport()
|
|
870
1000
|
await testProjectPickerEscQuit()
|
|
871
1001
|
await testTextInputBackspace()
|
|
872
1002
|
await testTextInputDeleteKeys()
|
|
873
1003
|
await testComposerDeleteKeys()
|
|
1004
|
+
await testCursorNavigationKeys()
|
|
874
1005
|
await testComposerMultilinePaste()
|
|
875
1006
|
testWorkingShimmer()
|
|
876
1007
|
testReasoningViews()
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { performance } from 'node:perf_hooks'
|
|
2
|
+
import { coalesceMouseEvents, parseMouseEvents } from '../src/components/primitives.js'
|
|
3
|
+
import {
|
|
4
|
+
createRowAccess, moveAnchorByRows, sliceViewport, tailAnchor,
|
|
5
|
+
type RowAnchor,
|
|
6
|
+
} from '../src/lib/transcript-viewport.js'
|
|
7
|
+
|
|
8
|
+
interface Entry { id: string; rows: number[] }
|
|
9
|
+
|
|
10
|
+
let passed = 0
|
|
11
|
+
let failed = 0
|
|
12
|
+
function ok(condition: boolean, message: string): void {
|
|
13
|
+
if (condition) { passed += 1; console.log(` ✓ ${message}`) }
|
|
14
|
+
else { failed += 1; console.error(` ✗ ${message}`) }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function access(entries: Entry[]) {
|
|
18
|
+
return createRowAccess(entries, entry => entry.id, entry => entry.rows)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function sameAnchor(actual: RowAnchor | null, entryId: string, rowIndex: number): boolean {
|
|
22
|
+
return actual?.entryId === entryId && actual.rowIndex === rowIndex
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function main(): void {
|
|
26
|
+
console.log('\n[VIEWPORT] exact row-level transcript navigation\n')
|
|
27
|
+
|
|
28
|
+
const long = access([{ id: 'long', rows: Array.from({ length: 1000 }, (_, i) => i) }])
|
|
29
|
+
const tail = tailAnchor(long, 24)
|
|
30
|
+
ok(sameAnchor(tail, 'long', 976), '1000-row entry tails at row 976 in a 24-row viewport')
|
|
31
|
+
const pageUp = moveAnchorByRows(long, tail, -23)
|
|
32
|
+
ok(sameAnchor(pageUp, 'long', 953), 'PageUp moves exactly 23 rows inside one long entry')
|
|
33
|
+
ok(sameAnchor(moveAnchorByRows(long, pageUp, 23), 'long', 976), 'PageDown exactly reverses PageUp')
|
|
34
|
+
|
|
35
|
+
const mixed = access([
|
|
36
|
+
{ id: 'a', rows: [0, 1] },
|
|
37
|
+
{ id: 'b', rows: Array.from({ length: 50 }, (_, i) => i) },
|
|
38
|
+
{ id: 'c', rows: [0, 1, 2] },
|
|
39
|
+
{ id: 'd', rows: Array.from({ length: 1000 }, (_, i) => i) },
|
|
40
|
+
])
|
|
41
|
+
const crossed = moveAnchorByRows(mixed, { entryId: 'd', entryIndex: 3, rowIndex: 0 }, -4)
|
|
42
|
+
ok(sameAnchor(crossed, 'b', 49), 'row navigation crosses mixed-height entry boundaries exactly')
|
|
43
|
+
const mixedUp = moveAnchorByRows(mixed, tailAnchor(mixed, 24), -777)
|
|
44
|
+
ok(JSON.stringify(moveAnchorByRows(mixed, mixedUp, 777)) === JSON.stringify(tailAnchor(mixed, 24)), 'large mixed-height PageUp/PageDown movement is reversible')
|
|
45
|
+
|
|
46
|
+
const startSlice = sliceViewport(long, { entryId: 'long', entryIndex: 0, rowIndex: 0 }, 24)
|
|
47
|
+
const middleSlice = sliceViewport(long, { entryId: 'long', entryIndex: 0, rowIndex: 500 }, 24)
|
|
48
|
+
const endSlice = sliceViewport(long, tail, 24)
|
|
49
|
+
ok(startSlice.rows[0]?.row === 0, 'long entry start is directly accessible')
|
|
50
|
+
ok(middleSlice.rows[0]?.row === 500, 'long entry middle is directly accessible')
|
|
51
|
+
ok(endSlice.rows.at(-1)?.row === 999, 'long entry end is directly accessible')
|
|
52
|
+
ok(startSlice.rows.length === 24 && middleSlice.rows.length === 24 && endSlice.rows.length === 24, 'only viewport-height rows are materialized in each slice')
|
|
53
|
+
|
|
54
|
+
const resized = access([{ id: 'long', rows: Array.from({ length: 700 }, (_, i) => i) }])
|
|
55
|
+
const restored = sliceViewport(resized, { entryId: 'long', entryIndex: 0, rowIndex: 500 }, 10).anchor
|
|
56
|
+
ok(sameAnchor(restored, 'long', 500), 'resize preserves a historical entry/row anchor')
|
|
57
|
+
ok(sameAnchor(tailAnchor(resized, 10), 'long', 690), 'tail-follow mode recomputes against the resized layout')
|
|
58
|
+
|
|
59
|
+
const appended = access([
|
|
60
|
+
{ id: 'long', rows: Array.from({ length: 1000 }, (_, i) => i) },
|
|
61
|
+
{ id: 'new', rows: [0, 1, 2] },
|
|
62
|
+
])
|
|
63
|
+
const held = sliceViewport(appended, pageUp, 24).anchor
|
|
64
|
+
ok(sameAnchor(held, 'long', 953), 'new entries do not move a historical anchor')
|
|
65
|
+
ok(sameAnchor(tailAnchor(appended, 24), 'long', 979), 'tail-follow mode automatically includes newly appended rows')
|
|
66
|
+
|
|
67
|
+
const burst = '\x1b[<64;5;5M'.repeat(5)
|
|
68
|
+
const coalesced = coalesceMouseEvents(parseMouseEvents(burst))
|
|
69
|
+
ok(coalesced.length === 1 && coalesced[0]?.kind === 'wheel' && coalesced[0].delta === 5, 'five wheel events in one input chunk coalesce into one +5 action')
|
|
70
|
+
ok(sameAnchor(moveAnchorByRows(long, tail, -3 * (coalesced[0]?.kind === 'wheel' ? coalesced[0].delta : 0)), 'long', 961), 'five wheel notches move exactly 15 rows')
|
|
71
|
+
|
|
72
|
+
const thousand = access(Array.from({ length: 1000 }, (_, i) => ({ id: `e-${i}`, rows: [i] })))
|
|
73
|
+
for (let i = 0; i < 100; i++) sliceViewport(thousand, moveAnchorByRows(thousand, tailAnchor(thousand, 24), -i), 24)
|
|
74
|
+
const started = performance.now()
|
|
75
|
+
for (let i = 0; i < 1000; i++) sliceViewport(thousand, moveAnchorByRows(thousand, tailAnchor(thousand, 24), -(i % 900)), 24)
|
|
76
|
+
const averageMs = (performance.now() - started) / 1000
|
|
77
|
+
ok(averageMs < 5, `1000-entry viewport navigation averages under 5ms (${averageMs.toFixed(3)}ms)`)
|
|
78
|
+
|
|
79
|
+
console.log(`\n==== VIEWPORT RESULT: ${passed} passed, ${failed} failed ====\n`)
|
|
80
|
+
process.exit(failed === 0 ? 0 : 1)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
main()
|