@mobius-os/mobius 0.3.34 → 0.3.42

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.
@@ -35,6 +35,7 @@ function ok(c: boolean, m: string) { c ? (pass++, console.log(` ✓ ${m}`)) : (
35
35
 
36
36
  // ── mocked backend (precise URL matchers — substring overlaps broke an earlier draft) ─
37
37
  const PID = 'proj-1', IID = 'issue-1', SID = 'sess-1'
38
+ let lastMessageBody: any = null
38
39
  function mockFetch(url: string, init?: RequestInit): Response {
39
40
  // SSE
40
41
  if (url.includes('/events')) {
@@ -56,6 +57,18 @@ function mockFetch(url: string, init?: RequestInit): Response {
56
57
  return json([{ session_id: SID, name: '历史会话一', last_active: new Date(Date.now() - 3600_000).toISOString(), message_count: 5, model: 'codex', issue_title: '命令行任务' }])
57
58
  }
58
59
  if (url.endsWith('/messages') && method === 'POST') {
60
+ lastMessageBody = JSON.parse(String(init?.body || '{}'))
61
+ // /compact turns come back as claude-code local-command artifacts (command
62
+ // echo + completion stdout) instead of an assistant reply.
63
+ if (String(lastMessageBody?.content || '').trim() === '/compact') {
64
+ setTimeout(() => {
65
+ emit('typing', { active: true })
66
+ emit('jsonl_entry', { session_id: SID, entry: { type: 'user', uuid: 'flow-cmd-echo', message: { role: 'user', content: '<command-name>/compact</command-name><command-message>compact</command-message><command-args></command-args><local-command-caveat>no need to respond</local-command-caveat>' } } })
67
+ emit('jsonl_entry', { session_id: SID, entry: { type: 'user', uuid: 'flow-cmd-done', message: { role: 'user', content: [{ type: 'text', text: '<local-command-stdout>Compacted. Your new context length is 8,840 tokens</local-command-stdout>' }] } } })
68
+ emit('typing', { active: false })
69
+ }, 200)
70
+ return json({ ok: true, session_id: SID, turn_number: 2 })
71
+ }
59
72
  setTimeout(() => {
60
73
  emit('typing', { active: true })
61
74
  emit('jsonl_entry', { session_id: SID, entry: { type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: '已收到,这是来自 TUI 的回复。' }] } } })
@@ -68,9 +81,9 @@ function mockFetch(url: string, init?: RequestInit): Response {
68
81
  }
69
82
  // issues
70
83
  if (url.includes('/api/projects/') && url.includes('/issues') && method === 'POST') return json({ id: IID, project_id: PID, title: '命令行任务' }) // create issue
71
- if (url.includes('/api/projects/') && url.includes('/issues') && method === 'GET') return json([{ id: IID, project_id: PID, title: '命令行任务' }]) // list issues
84
+ if (url.includes('/api/projects/') && url.includes('/issues') && method === 'GET') return json([{ id: IID, project_id: PID, title: '命令行任务', description: '任务说明' }]) // list issues
72
85
  // projects
73
- if (url.includes('/api/projects') && method === 'GET') return json([{ id: PID, name: '已有项目甲' }]) // list projects
86
+ if (url.includes('/api/projects') && method === 'GET') return json([{ id: PID, name: '已有项目甲', description: '项目说明' }]) // list projects
74
87
  if (url.endsWith('/api/projects') && method === 'POST') return json({ id: PID, name: '测试项目PTY' }) // create project (exact)
75
88
  // preference lookups
76
89
  if (url.includes('/sessions/model-options')) return json([{ key: 'codex', label: 'GPT-5.5', title: 'GPT-5.5', sub: 'Codex', backend: 'tmux-codex' }])
@@ -165,13 +178,26 @@ async function main() {
165
178
  stdin.write('\r')
166
179
  ok(await waitFor(lastFrame, '重新配置'), '/config opens the full reconfig flow')
167
180
  ok(await waitFor(lastFrame, '选择项目'), '/config shows project picker first')
168
- // Pick the first project (created above).
181
+ ok(await waitFor(lastFrame, '已有项目甲 - 项目说明'), '/config keeps the project explanation on its main row')
182
+ // Pick the first project (created above), then verify Esc walks back one
183
+ // level at a time instead of closing the entire config flow.
169
184
  stdin.write('\r'); await delay(400)
170
185
  ok(await waitFor(lastFrame, '选择任务'), '/config shows issue picker after project')
171
- // Pick the first issue.
186
+ ok((lastFrame() ?? '').includes('命令行任务 - 任务说明'), '/config keeps the issue explanation on its main row')
187
+ stdin.write('\x1b'); await delay(180)
188
+ ok(await waitFor(lastFrame, '选择项目'), 'Esc from issue selection returns to project selection')
189
+ stdin.write('\r'); await delay(400)
190
+ ok(await waitFor(lastFrame, '选择任务'), 'project selection can be re-entered after Esc')
191
+
192
+ // Pick the issue and verify the model step also returns to the issue step.
172
193
  stdin.write('\r'); await delay(400)
173
194
  ok(await waitFor(lastFrame, '选择模型'), '/config shows model picker after issue')
174
195
  ok(await waitFor(lastFrame, 'GPT-5.5'), '/config model list rendered')
196
+ ok((lastFrame() ?? '').includes('GPT-5.5 (默认) - Codex'), '/config keeps the model explanation on its main row')
197
+ stdin.write('\x1b'); await delay(180)
198
+ ok(await waitFor(lastFrame, '选择任务'), 'Esc from model selection returns to issue selection')
199
+ stdin.write('\r'); await delay(400)
200
+ ok(await waitFor(lastFrame, '选择模型'), 'issue selection can be re-entered after Esc')
175
201
  stdin.write('\r'); await delay(700) // pick codex → create session
176
202
  ok(await waitFor(lastFrame, '输入问题'), '/config creates a fresh session and returns to chat')
177
203
  ok((lastFrame() ?? '').includes('?session=sess-1'), 'reconfigured chat is attached to the new session')
@@ -191,6 +217,22 @@ async function main() {
191
217
  ok(await waitFor(lastFrame, '输入问题'), '/model creates a fresh session and returns to chat')
192
218
  ok((lastFrame() ?? '').includes('?session=sess-1'), '/model new session attached')
193
219
  snap('7-after-model', lastFrame() ?? '')
220
+
221
+ // ── /compact: dispatch the literal command on the live session ────────
222
+ await delay(400)
223
+ stdin.write('/compact'); await delay(200)
224
+ stdin.write('\r')
225
+ ok(await waitFor(lastFrame, '上下文已压缩', 6000), '/compact renders the compact completion system line')
226
+ ok(lastMessageBody?.content === '/compact', '/compact posts the literal command to the session (web parity)')
227
+ snap('7b-after-compact', lastFrame() ?? '')
228
+
229
+ // ── /logout ─────────────────────────────────────────────────────────────
230
+ await delay(400)
231
+ stdin.write('/logout'); await delay(150)
232
+ stdin.write('\r')
233
+ ok(await waitFor(lastFrame, 'Mobius 登录'), '/logout returns to the login form')
234
+ ok(!fs.existsSync(path.join(TMP_HOME, 'login.json')), '/logout clears the persisted login token')
235
+ ok((lastFrame() ?? '').includes('http://mock.local') && (lastFrame() ?? '').includes('tester'), '/logout keeps server and username available for the next login')
194
236
  snap('6-after-config', lastFrame() ?? '')
195
237
  } finally {
196
238
  unmount()
@@ -170,7 +170,7 @@ async function main() {
170
170
  stdin.write('\x1b[5~') // PageUp
171
171
  await delay(300)
172
172
  const upFrame = strip(lastFrame() ?? '')
173
- ok(upFrame.includes('↓ 较新内容'), 'after PageUp: navigation reports newer content below')
173
+ ok(upFrame.includes('↓ 有新内容'), 'after PageUp: navigation reports newer content below')
174
174
  ok(!upFrame.includes('回答 24'), 'after PageUp: latest entry paged out of view')
175
175
  ok(/回答 \d+/.test(upFrame), 'after PageUp: an older entry is visible')
176
176
 
@@ -184,7 +184,7 @@ async function main() {
184
184
  stdin.write('\x1b[<64;5;5M') // wheel up = scroll back
185
185
  await delay(300)
186
186
  const wheelUp = strip(lastFrame() ?? '')
187
- ok(wheelUp.includes('↓ 较新内容'), 'wheel up: navigation reports newer content below')
187
+ ok(wheelUp.includes('↓ 有新内容'), 'wheel up: navigation reports newer content below')
188
188
  ok(!wheelUp.includes('回答 24'), 'wheel up: latest entry paged out of view')
189
189
  ok(/回答 \d+/.test(wheelUp), 'wheel up: an older entry is visible')
190
190
 
@@ -198,7 +198,7 @@ async function main() {
198
198
  stdin.write('\x1b[M' + String.fromCharCode(96, 50, 50))
199
199
  await delay(300)
200
200
  const legacyUp = strip(lastFrame() ?? '')
201
- ok(legacyUp.includes('↓ 较新内容'), 'legacy wheel up: navigation reports newer content below')
201
+ ok(legacyUp.includes('↓ 有新内容'), 'legacy wheel up: navigation reports newer content below')
202
202
  ok(!legacyUp.includes('回答 24'), 'legacy wheel up: latest entry paged out of view')
203
203
 
204
204
  stdin.write('\x1b[M' + String.fromCharCode(97, 50, 50)) // wheel down Cb = 0x61
@@ -139,7 +139,7 @@ async function main() {
139
139
  const row1 = lines.findIndex(l => l.includes('回答 1'))
140
140
  const row3 = lines.findIndex(l => l.includes('回答 3'))
141
141
  ok(row1 >= 0 && row3 >= 0, `found 回答 1 (row ${row1}) and 回答 3 (row ${row3}) in the transcript`)
142
- ok(frame.includes('全部内容') && !frame.includes('↑ 较早内容') && !frame.includes('↓ 还有较新内容'), 'all entries fit — navigation reports the complete transcript')
142
+ ok(frame.includes('全部内容') && !frame.includes('↑ 较早内容') && !frame.includes('↓ 有新内容'), 'all entries fit — navigation reports the complete transcript')
143
143
 
144
144
  // press on 回答 1 (col 4 → first content char), drag to 回答 3 (col beyond EOL)
145
145
  stdin.write(`\x1b[<0;5;${row1 + 1}M`) // left-button press (SGR 1-based)
package/tests/ui.test.tsx CHANGED
@@ -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,16 @@ 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 &")
264
274
  }
265
275
 
266
276
  function testFirstUserEntryDedupe() {
@@ -297,11 +307,18 @@ async function testPrepRender() {
297
307
  ok(frame.includes('选择当前路径的绑定项目'), 'project picker title shown')
298
308
  ok(frame.includes('已有项目A') && frame.includes('已有项目B'), 'existing projects listed')
299
309
  ok(frame.includes('创建新项目'), 'create-new option present')
300
- // multi-line description must be flattened onto one line with ⏎ in place of \n
301
- ok(frame.includes('已有项目A 第一行 第二行'), 'multi-line description flattened to a single line')
302
- 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')
303
319
  ok(!frame.includes('加载项目列表…'), 'completed project load does not leave a stale loading message')
304
320
 
321
+ stdin.write('\x1b[A'); await delay(15) // return to the create row
305
322
  stdin.write('\r')
306
323
  await delay(30)
307
324
  const createFrame = lastFrame() ?? ''
@@ -313,6 +330,56 @@ async function testPrepRender() {
313
330
  } finally { restoreFetch() }
314
331
  }
315
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
+
316
383
  // ════════════════════════════════════════════════════════════════════════════
317
384
  // TEST 6 — Select viewport: a long list must not overflow the terminal
318
385
  // ════════════════════════════════════════════════════════════════════════════
@@ -482,6 +549,56 @@ async function testComposerDeleteKeys() {
482
549
  unmount()
483
550
  }
484
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
+
485
602
  // ════════════════════════════════════════════════════════════════════════════
486
603
  // TEST 9 — Codex-style composer keeps multiline pastes intact and grows/shrinks
487
604
  // ════════════════════════════════════════════════════════════════════════════
@@ -500,7 +617,7 @@ async function testComposerMultilinePaste() {
500
617
  await delay(20)
501
618
  const initial = lastFrame() ?? ''
502
619
  ok(initial.includes('╭') && initial.includes('╰'), 'composer has a visible bordered input boundary')
503
- 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')
504
621
 
505
622
  stdin.write('\x1b[200~第一行\r\n第二行\r第三行\x1b[201~')
506
623
  await delay(20)
@@ -560,6 +677,54 @@ async function testComposerMultilinePaste() {
560
677
  unmount()
561
678
  }
562
679
 
680
+ // ════════════════════════════════════════════════════════════════════════════
681
+ // TEST 9b — Idle Ctrl+C requires a second press to quit (guards accidental exit)
682
+ // ════════════════════════════════════════════════════════════════════════════
683
+ async function testComposerCtrlCConfirm() {
684
+ console.log('\n[UI 9b] idle Ctrl+C asks for a confirming second press')
685
+ let quitCalled = 0
686
+ const { stdin, lastFrame, unmount } = render(
687
+ <Composer
688
+ onSubmit={() => {}}
689
+ onStop={() => {}}
690
+ onQuit={() => { quitCalled++ }}
691
+ typing={false}
692
+ commands={[]}
693
+ />,
694
+ )
695
+ await delay(20)
696
+ stdin.write('\x03') // Ctrl+C
697
+ await delay(20)
698
+ ok(quitCalled === 0, 'first Ctrl+C does not quit')
699
+ ok((lastFrame() ?? '').includes('请再次按下Ctrl+C退出'), 'first Ctrl+C shows the confirm prompt')
700
+ stdin.write('\x03') // second Ctrl+C within the window
701
+ await delay(20)
702
+ ok(quitCalled === 1, 'second Ctrl+C quits')
703
+ unmount()
704
+
705
+ // The confirmation window expires: a Ctrl+C after 2s must arm, not quit.
706
+ let lateQuit = 0
707
+ const late = render(
708
+ <Composer
709
+ onSubmit={() => {}}
710
+ onStop={() => {}}
711
+ onQuit={() => { lateQuit++ }}
712
+ typing={false}
713
+ commands={[]}
714
+ />,
715
+ )
716
+ await delay(20)
717
+ late.stdin.write('\x03')
718
+ await delay(2100) // let the 2s window lapse
719
+ late.stdin.write('\x03')
720
+ await delay(20)
721
+ ok(lateQuit === 0, 'Ctrl+C after the window lapses re-arms instead of quitting')
722
+ late.stdin.write('\x03')
723
+ await delay(20)
724
+ ok(lateQuit === 1, 'the re-armed second Ctrl+C quits')
725
+ late.unmount()
726
+ }
727
+
563
728
  // ════════════════════════════════════════════════════════════════════════════
564
729
  // TEST 10 — Working text uses a moving multi-level brightness wave
565
730
  // ════════════════════════════════════════════════════════════════════════════
@@ -747,7 +912,7 @@ async function testChatSseReconnects() {
747
912
  })
748
913
  try {
749
914
  const { stdin, lastFrame, unmount } = render(
750
- <ChatScreen client={client} ready={ready} webUserId="test-user" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />,
915
+ <ChatScreen client={client} ready={ready} webUserId="test-user" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onLogout={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />,
751
916
  )
752
917
  await delay(40)
753
918
  // Ink's test stdin treats one chunk as one keypress. Send text and Enter as
@@ -819,7 +984,7 @@ async function testIdleCompletedSessionReopensSseOnSend() {
819
984
  })
820
985
  try {
821
986
  const { stdin, lastFrame, unmount } = render(
822
- <ChatScreen client={client} ready={ready} webUserId="test-user" resumeSessionId="s1" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />,
987
+ <ChatScreen client={client} ready={ready} webUserId="test-user" resumeSessionId="s1" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onLogout={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />,
823
988
  )
824
989
  await delay(180)
825
990
  ok(sseCall === 1, 'completed idle session did not reconnect by itself')
@@ -859,7 +1024,7 @@ async function testSendRetries502() {
859
1024
  })
860
1025
  try {
861
1026
  const { stdin, lastFrame, unmount } = render(
862
- <ChatScreen client={client} ready={ready} webUserId="u" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />,
1027
+ <ChatScreen client={client} ready={ready} webUserId="u" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onLogout={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />,
863
1028
  )
864
1029
  await delay(40)
865
1030
  stdin.write('hi'); await delay(30); stdin.write('\r')
@@ -871,6 +1036,99 @@ async function testSendRetries502() {
871
1036
  } finally { restoreFetch() }
872
1037
  }
873
1038
 
1039
+ // ════════════════════════════════════════════════════════════════════════════
1040
+ // TEST 16 — /compact slash command dispatch + compact artifact rendering
1041
+ // ════════════════════════════════════════════════════════════════════════════
1042
+ async function testCompactSlash() {
1043
+ console.log('\n[UI 16] /compact slash command')
1044
+ // (a) unit: claude-code compact artifacts in the jsonl project cleanly.
1045
+ const echo = viewsForEntry({
1046
+ type: 'user',
1047
+ message: { role: 'user', content: '<command-name>/compact</command-name><command-message>compact</command-message><command-args></command-args><local-command-caveat>claude-3-5 prompted</local-command-caveat>' },
1048
+ } as any)
1049
+ ok(echo.length === 1 && echo[0].kind === 'skip', 'compact command echo (tag soup) is skipped, not shown as user text')
1050
+ const done = viewsForEntry({
1051
+ type: 'user',
1052
+ message: { role: 'user', content: [{ type: 'text', text: '<local-command-stdout>Compacted. Your new context length is 9,241 tokens</local-command-stdout>' }] },
1053
+ } as any)
1054
+ ok(done.length === 1 && done[0].kind === 'system' && (done[0] as any).text.includes('上下文已压缩'), 'compact completion stdout renders as a 上下文已压缩 system line')
1055
+ ok(done[0].kind === 'system' && (done[0] as any).text.includes('9,241 tokens'), 'compact completion keeps the token count detail')
1056
+ const goal = viewsForEntry({
1057
+ type: 'user',
1058
+ message: { role: 'user', content: '<local-command-stdout>Goal set: ship the TUI</local-command-stdout>' },
1059
+ } as any)
1060
+ ok(goal.length === 1 && goal[0].kind === 'system' && (goal[0] as any).text === 'Goal set: ship the TUI' && !(goal[0] as any).text.includes('上下文已压缩'), 'non-compact local-command stdout shows its body without the compact marker')
1061
+
1062
+ const client = new MobiusClient('http://mock.local', 'mock-jwt-token')
1063
+ const ready: ReadyState = {
1064
+ project: { id: 'p1', name: '测试项目' },
1065
+ issue: { id: 'i1', project_id: 'p1', title: '测试任务' },
1066
+ prefs: { model: 'codex', language: 'zh', excluded_skill_ids: [], excluded_memory_ids: [] },
1067
+ }
1068
+
1069
+ // (b) no session yet → /compact refuses instead of creating an empty session.
1070
+ let posted: any = null
1071
+ installMock((url, init) => {
1072
+ if (url.includes('/events')) {
1073
+ return new Response(new RS({ start(c: any) { sseController = c; c.enqueue(enc.encode('event: subscribed\ndata: {"event":"subscribed","session":{}}\n\n')) } }), { status: 200, headers: { 'content-type': 'text/event-stream' } })
1074
+ }
1075
+ if (url.endsWith('/messages') && init?.method === 'POST') { posted = JSON.parse(String(init.body || '{}')); return jsonResponse({ ok: true, session_id: 's1', turn_number: 1 }) }
1076
+ if (url.includes('/sessions') && init?.method === 'POST') return jsonResponse({ session_id: 's1' })
1077
+ return jsonResponse({ error: 'no mock' }, 404)
1078
+ })
1079
+ try {
1080
+ const { stdin, lastFrame, unmount } = render(
1081
+ <ChatScreen client={client} ready={ready} webUserId="u" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onLogout={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />,
1082
+ )
1083
+ await delay(40)
1084
+ stdin.write('/comp'); await delay(60)
1085
+ ok((lastFrame() ?? '').includes('/compact') && (lastFrame() ?? '').includes('压缩当前会话上下文'), '/compact appears in the slash autocomplete list')
1086
+ stdin.write('act'); await delay(40)
1087
+ stdin.write('\r'); await delay(120)
1088
+ const refused = lastFrame() ?? ''
1089
+ unmount()
1090
+ ok(refused.includes('当前没有可发送指令的会话'), '/compact without a session shows the guidance error')
1091
+ ok(posted === null, '/compact without a session dispatches nothing')
1092
+ } finally { restoreFetch() }
1093
+
1094
+ // (c) with a live session → literal '/compact' is POSTed like the web client,
1095
+ // and the streamed compact artifacts render as one system line.
1096
+ posted = null
1097
+ installMock((url, init) => {
1098
+ if (url.includes('/events')) {
1099
+ return new Response(new RS({ start(c: any) { sseController = c; c.enqueue(enc.encode('event: subscribed\ndata: {"event":"subscribed","session":{}}\n\n')) } }), { status: 200, headers: { 'content-type': 'text/event-stream' } })
1100
+ }
1101
+ if (url.endsWith('/messages') && init?.method === 'POST') {
1102
+ posted = JSON.parse(String(init.body || '{}'))
1103
+ setTimeout(() => {
1104
+ emit('typing', { active: true })
1105
+ emit('jsonl_entry', { session_id: 's1', entry: { type: 'user', uuid: 'cmd-echo', message: { role: 'user', content: '<command-name>/compact</command-name><command-message>compact</command-message><command-args></command-args><local-command-caveat>caveat</local-command-caveat>' } } })
1106
+ emit('jsonl_entry', { session_id: 's1', entry: { type: 'user', uuid: 'cmd-done', message: { role: 'user', content: [{ type: 'text', text: '<local-command-stdout>Compacted. Your new context length is 8,840 tokens</local-command-stdout>' }] } } })
1107
+ emit('typing', { active: false })
1108
+ }, 120)
1109
+ return jsonResponse({ ok: true, session_id: 's1', turn_number: 2 })
1110
+ }
1111
+ if (url.endsWith('/api/sessions/s1/status')) return jsonResponse({ session_id: 's1', alive: true, working: false })
1112
+ return jsonResponse({ error: 'no mock' }, 404)
1113
+ })
1114
+ try {
1115
+ const { stdin, lastFrame, unmount } = render(
1116
+ <ChatScreen client={client} ready={ready} webUserId="u" resumeSessionId="s1" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onLogout={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />,
1117
+ )
1118
+ await delay(120)
1119
+ stdin.write('/compact'); await delay(40)
1120
+ stdin.write('\r')
1121
+ ok(await waitFor(lastFrame, '上下文已压缩', 4000), 'compact completion renders the 上下文已压缩 system line')
1122
+ await delay(80)
1123
+ const out = lastFrame() ?? ''
1124
+ unmount()
1125
+ ok(posted?.content === '/compact', 'the literal /compact command is POSTed to the session (web parity)')
1126
+ ok(out.includes('8,840 tokens'), 'compact system line keeps the token detail')
1127
+ ok(!out.includes('<command-name>'), 'raw local-command tags never leak into the transcript')
1128
+ ok(!out.includes('当前没有可发送指令的会话'), 'no error line once a session exists')
1129
+ } finally { restoreFetch() }
1130
+ }
1131
+
874
1132
  async function main() {
875
1133
  await testLogin()
876
1134
  await testChat()
@@ -878,12 +1136,15 @@ async function main() {
878
1136
  testMarkdownCodeRendering()
879
1137
  testFirstUserEntryDedupe()
880
1138
  await testPrepRender()
1139
+ await testPrepSearch()
881
1140
  await testSelectViewport()
882
1141
  await testProjectPickerEscQuit()
883
1142
  await testTextInputBackspace()
884
1143
  await testTextInputDeleteKeys()
885
1144
  await testComposerDeleteKeys()
1145
+ await testCursorNavigationKeys()
886
1146
  await testComposerMultilinePaste()
1147
+ await testComposerCtrlCConfirm()
887
1148
  testWorkingShimmer()
888
1149
  testReasoningViews()
889
1150
  testCustomToolCallViews()
@@ -892,6 +1153,7 @@ async function main() {
892
1153
  await testChatSseReconnects()
893
1154
  await testIdleCompletedSessionReopensSseOnSend()
894
1155
  await testSendRetries502()
1156
+ await testCompactSlash()
895
1157
  // cleanup temp home
896
1158
  try { fs.rmSync(TMP_HOME, { recursive: true, force: true }) } catch { /* ignore */ }
897
1159
  console.log(`\n==== UI RESULT: ${pass} passed, ${fail} failed ====\n`)