@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.
- package/package.json +1 -1
- package/scripts/build-python-bundles.sh +2 -2
- package/src/App.tsx +18 -2
- package/src/aimux.ts +69 -11
- package/src/components/Chat.tsx +65 -11
- 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 +23 -4
- package/src/lib/cursor-keys.ts +70 -0
- package/src/lib/delete-keys.ts +4 -4
- package/src/lib/entry-view.ts +40 -0
- package/src/markdown.ts +34 -10
- package/tests/aimux.test.tsx +41 -10
- package/tests/flow.test.tsx +46 -4
- package/tests/scroll.test.tsx +3 -3
- package/tests/selection.test.tsx +1 -1
- package/tests/ui.test.tsx +271 -9
package/tests/flow.test.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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()
|
package/tests/scroll.test.tsx
CHANGED
|
@@ -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('↓
|
|
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('↓
|
|
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('↓
|
|
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
|
package/tests/selection.test.tsx
CHANGED
|
@@ -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('↓
|
|
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"), "' 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 &")
|
|
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
|
-
//
|
|
301
|
-
|
|
302
|
-
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')
|
|
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
|
|
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`)
|