@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/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"), "&#39; decoded back to apostrophe")
270
+ ok(entityText.includes('"cool"'), "&quot; decoded back to double-quote")
271
+ ok(entityText.includes('1 < 2'), "&lt; decoded back to <")
272
+ ok(entityText.includes('3 > 1'), "&gt; decoded back to >")
273
+ ok(entityText.includes('& 3'), "&amp; 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
- // multi-line description must be flattened onto one line with ⏎ in place of \n
290
- ok(frame.includes('已有项目A 第一行 第二行'), 'multi-line description flattened to a single line')
291
- ok(frame.includes('已有项目B 单行描述'), 'single-line description kept as-is')
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 Codex-style submit/newline hints')
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()