@mobius-os/mobius 0.3.38 → 0.3.43
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 +7 -24
- package/src/aimux.ts +62 -9
- package/src/components/Chat.tsx +80 -9
- package/src/lib/entry-view.ts +40 -0
- package/src/lib/paint-flush.ts +223 -0
- package/install.ps1 +0 -265
- package/scripts/build-python-bundles.sh +0 -129
- package/tests/aimux.test.tsx +0 -221
- package/tests/flow.test.tsx +0 -232
- package/tests/integration.test.ts +0 -114
- package/tests/preview.tsx +0 -104
- package/tests/reconnect.test.tsx +0 -170
- package/tests/resume.test.tsx +0 -73
- package/tests/screen.test.tsx +0 -118
- package/tests/scroll.test.tsx +0 -253
- package/tests/selection.test.tsx +0 -219
- package/tests/ui.test.tsx +0 -1020
- package/tests/viewport.test.ts +0 -83
- package/tsconfig.json +0 -19
- package/uninstall.ps1 +0 -144
package/tests/resume.test.tsx
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Regression test: when ~/.mobius already has a bound project + an issue whose
|
|
3
|
-
* preferences are all configured, the app must boot STRAIGHT into chat (not get
|
|
4
|
-
* stuck on a bare prep header, and not need to re-run the wizard).
|
|
5
|
-
*
|
|
6
|
-
* Run: npm run test:resume
|
|
7
|
-
*/
|
|
8
|
-
import os from 'node:os'
|
|
9
|
-
import path from 'node:path'
|
|
10
|
-
import fs from 'node:fs'
|
|
11
|
-
|
|
12
|
-
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'mobius-tui-resume-'))
|
|
13
|
-
process.env.MOBIUS_TUI_HOME = TMP_HOME
|
|
14
|
-
|
|
15
|
-
import React from 'react'
|
|
16
|
-
import { render } from 'ink-testing-library'
|
|
17
|
-
import { App } from '../src/App.js'
|
|
18
|
-
|
|
19
|
-
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms))
|
|
20
|
-
const CWD = process.cwd()
|
|
21
|
-
const PID = 'proj-r', IID = 'issue-r'
|
|
22
|
-
let pass = 0, fail = 0
|
|
23
|
-
function ok(c: boolean, m: string) { c ? (pass++, console.log(` ✓ ${m}`)) : (fail++, console.error(` ✗ ${m}`)) }
|
|
24
|
-
|
|
25
|
-
function json(body: unknown, status = 200) {
|
|
26
|
-
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } })
|
|
27
|
-
}
|
|
28
|
-
function mockFetch(url: string, init?: RequestInit): Response {
|
|
29
|
-
const method = init?.method ?? 'GET'
|
|
30
|
-
if (url.endsWith('/api/auth/me')) return json({ id: 'tester', display_name: 'x', role: 'admin' })
|
|
31
|
-
if (url.includes('/api/projects') && method === 'GET' && !url.includes('issues')) return json([{ id: PID, name: '绑定项目' }])
|
|
32
|
-
if (url.includes('/issues') && method === 'GET') return json([{ id: IID, project_id: PID, title: '已配置任务' }])
|
|
33
|
-
if (url.includes('/sessions/model-options')) return json([{ key: 'codex', label: 'GPT', title: 'GPT', sub: '', backend: 'x' }])
|
|
34
|
-
return json({ error: `unmocked ${method} ${url}` }, 404)
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
async function main() {
|
|
38
|
-
// Pre-seed: token + cwd→project binding + issue with all prefs done.
|
|
39
|
-
fs.writeFileSync(path.join(TMP_HOME, 'login.json'), JSON.stringify({
|
|
40
|
-
server: 'http://mock.local', username: 'tester', token: 'tok', user: { id: 'tester', display_name: 'x', role: 'admin' },
|
|
41
|
-
}))
|
|
42
|
-
fs.writeFileSync(path.join(TMP_HOME, 'dir2project.json'), JSON.stringify({ [CWD]: PID }))
|
|
43
|
-
fs.writeFileSync(path.join(TMP_HOME, 'dir2project_preference.json'), JSON.stringify({
|
|
44
|
-
[CWD]: { issueId: IID, issueTitle: '已配置任务', prefs: { [IID]: {
|
|
45
|
-
model: 'codex', language: 'zh', excluded_skill_ids: [], excluded_memory_ids: [],
|
|
46
|
-
done: ['model', 'language', 'skills', 'memories'],
|
|
47
|
-
} } },
|
|
48
|
-
}))
|
|
49
|
-
|
|
50
|
-
const realFetch = globalThis.fetch
|
|
51
|
-
globalThis.fetch = ((u: any, init?: any) => mockFetch(String(u), init)) as unknown as typeof fetch
|
|
52
|
-
|
|
53
|
-
console.log('\n[RESUME] boot with saved prefs → straight to chat\n')
|
|
54
|
-
let reachedChat = false
|
|
55
|
-
const { lastFrame, unmount } = render(React.createElement(App))
|
|
56
|
-
for (let i = 0; i < 80; i++) { // up to ~4s
|
|
57
|
-
if ((lastFrame() ?? '').includes('输入问题')) { reachedChat = true; break }
|
|
58
|
-
await delay(50)
|
|
59
|
-
}
|
|
60
|
-
const frame = lastFrame() ?? ''
|
|
61
|
-
unmount()
|
|
62
|
-
globalThis.fetch = realFetch
|
|
63
|
-
|
|
64
|
-
ok(reachedChat, 'booted straight into chat (skipped wizard)')
|
|
65
|
-
ok(frame.includes('绑定项目') && frame.includes('已配置任务'), 'chat header shows the saved project + issue')
|
|
66
|
-
ok(!frame.includes('选择当前路径的绑定项目') && !frame.includes('选择模型'), 'did NOT show project/model picker')
|
|
67
|
-
|
|
68
|
-
try { fs.rmSync(TMP_HOME, { recursive: true, force: true }) } catch { /* ignore */ }
|
|
69
|
-
console.log(`\n==== RESUME RESULT: ${pass} passed, ${fail} failed ====\n`)
|
|
70
|
-
process.exit(fail === 0 ? 0 : 1)
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
main().catch((e) => { console.error('FATAL', e); process.exit(2) })
|
package/tests/screen.test.tsx
DELETED
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Screen / picker residue regression — "选择项目/Issue/会话时屏幕不残留其他东西".
|
|
3
|
-
*
|
|
4
|
-
* Root cause: Ink renders inline and, per frame, erases only as many lines as the
|
|
5
|
-
* previous frame occupied. A frame taller than the terminal window scrolls, so
|
|
6
|
-
* Ink can no longer reach the real top of that frame to erase it — stale lines
|
|
7
|
-
* from the previous screen stay behind as residue (worst when a tall list picker
|
|
8
|
-
* gives way to a shorter screen).
|
|
9
|
-
*
|
|
10
|
-
* The fix has two halves, both asserted here:
|
|
11
|
-
* 1. <Screen> pins every route to exactly the terminal height (overflow:hidden)
|
|
12
|
-
* so no frame ever scrolls → no residue on any transition.
|
|
13
|
-
* 2. Select reserves enough chrome rows that a long list window never exceeds
|
|
14
|
-
* the terminal, so nothing is clipped/garbled at the bottom.
|
|
15
|
-
*
|
|
16
|
-
* Run: npm run test:screen
|
|
17
|
-
*/
|
|
18
|
-
import React from 'react'
|
|
19
|
-
import { Box, Text } from 'ink'
|
|
20
|
-
import { render } from 'ink-testing-library'
|
|
21
|
-
import { Screen } from '../src/components/Screen.js'
|
|
22
|
-
import { Select } from '../src/components/primitives.js'
|
|
23
|
-
import { entryScreenRows } from '../src/lib/screen-text.js'
|
|
24
|
-
import { viewsForEntry } from '../src/lib/entry-view.js'
|
|
25
|
-
import { createRowAccess, sliceViewport, tailAnchor } from '../src/lib/transcript-viewport.js'
|
|
26
|
-
|
|
27
|
-
const delay = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
|
|
28
|
-
const strip = (s: string) => s.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
|
|
29
|
-
const lineCount = (s: string) => s.split('\n').length
|
|
30
|
-
|
|
31
|
-
let pass = 0, fail = 0
|
|
32
|
-
function ok(c: boolean, m: string) { c ? (pass++, console.log(` ✓ ${m}`)) : (fail++, console.error(` ✗ ${m}`)) }
|
|
33
|
-
|
|
34
|
-
// ink-testing-library's stdout reports no `rows`, so Screen falls back to 24.
|
|
35
|
-
const ROWS = 24
|
|
36
|
-
|
|
37
|
-
async function main() {
|
|
38
|
-
console.log('\n[SCREEN] no-residue picker transitions\n')
|
|
39
|
-
|
|
40
|
-
// A partial message is now represented by the same styled ScreenRow as a
|
|
41
|
-
// complete message. Virtual slicing must preserve its ANSI foreground bytes.
|
|
42
|
-
const styledEntries = [
|
|
43
|
-
{ type: 'assistant', uuid: 'styled-old', message: { role: 'assistant', content: [{ type: 'text', text: '[彩色链接](https://example.com)' }] } },
|
|
44
|
-
{ type: 'assistant', uuid: 'styled-new', message: { role: 'assistant', content: [{ type: 'text', text: '最新消息' }] } },
|
|
45
|
-
]
|
|
46
|
-
const rowAccess = createRowAccess(styledEntries, entry => entry.uuid, entry => entryScreenRows(viewsForEntry(entry), 80))
|
|
47
|
-
const viewport = sliceViewport(rowAccess, tailAnchor(rowAccess, 3), 3)
|
|
48
|
-
const partial = viewport.rows.find(item => item.entryId === 'styled-old')?.row
|
|
49
|
-
ok(partial !== undefined, 'small viewport exposes a row from the partial older message')
|
|
50
|
-
ok(!!partial?.styled.includes('\x1b[') && partial.styled.includes('彩色链接'), 'partial older row keeps its ANSI foreground styling')
|
|
51
|
-
|
|
52
|
-
// ── 1. Without Screen, a tall frame overflows the terminal (the bug). ───────
|
|
53
|
-
const tall = render(
|
|
54
|
-
<Box flexDirection="column">
|
|
55
|
-
{Array.from({ length: 40 }, (_, i) => <Text key={i}>item {i}</Text>)}
|
|
56
|
-
</Box>,
|
|
57
|
-
)
|
|
58
|
-
await delay(30)
|
|
59
|
-
const tallLines = lineCount(tall.lastFrame() ?? '')
|
|
60
|
-
ok(tallLines > ROWS, `uncapped tall frame overflows terminal (rendered ${tallLines} > ${ROWS})`)
|
|
61
|
-
|
|
62
|
-
// ── 2. Screen caps the same tall content to exactly the terminal height. ───
|
|
63
|
-
const capped = render(
|
|
64
|
-
<Screen>
|
|
65
|
-
<Box flexDirection="column">
|
|
66
|
-
{Array.from({ length: 40 }, (_, i) => <Text key={i}>item {i}</Text>)}
|
|
67
|
-
</Box>
|
|
68
|
-
</Screen>,
|
|
69
|
-
)
|
|
70
|
-
await delay(30)
|
|
71
|
-
const capFrame = capped.lastFrame() ?? ''
|
|
72
|
-
ok(lineCount(capFrame) === ROWS, `Screen caps frame to terminal height (${lineCount(capFrame)} === ${ROWS})`)
|
|
73
|
-
|
|
74
|
-
// ── 3. Realistic picker: AIMUX line + header + a 40-item Select + footer,
|
|
75
|
-
// wrapped in Screen, must fit within the terminal with the footer
|
|
76
|
-
// visible and the list windowed (overflow items hidden, not spilled). ─
|
|
77
|
-
const items = Array.from({ length: 40 }, (_, i) => ({ label: `项目 ${i}`, value: `p${i}`, desc: `desc ${i}` }))
|
|
78
|
-
const picker = render(
|
|
79
|
-
<Screen>
|
|
80
|
-
<Box flexDirection="column" paddingX={2} paddingY={1}>
|
|
81
|
-
<Text dimColor>AIMUX · 已连接</Text>
|
|
82
|
-
<Text bold color="cyan">选择当前路径的绑定项目</Text>
|
|
83
|
-
<Text color="gray">/some/path</Text>
|
|
84
|
-
<Box marginTop={1}>
|
|
85
|
-
<Select items={items} />
|
|
86
|
-
</Box>
|
|
87
|
-
<Text color="gray">↑↓ 选择 · 回车确认 · Esc 退出</Text>
|
|
88
|
-
</Box>
|
|
89
|
-
</Screen>,
|
|
90
|
-
)
|
|
91
|
-
await delay(30)
|
|
92
|
-
const pf = strip(picker.lastFrame() ?? '')
|
|
93
|
-
ok(lineCount(picker.lastFrame() ?? '') === ROWS, `picker frame pinned to terminal height (${lineCount(picker.lastFrame() ?? '')} === ${ROWS})`)
|
|
94
|
-
ok(pf.includes('Esc 退出'), 'picker footer visible (list did not push it off / clip it)')
|
|
95
|
-
ok(!pf.includes('项目 39'), 'list is windowed — tail item not spilled onto screen')
|
|
96
|
-
ok(/还有 \d+ 项/.test(pf), 'windowed overflow shows a "还有 N 项" hint')
|
|
97
|
-
|
|
98
|
-
// ── 4. Transition tall-picker → short screen leaves no residue: the new frame
|
|
99
|
-
// is still exactly terminal height (constant → Ink erase realigns) and
|
|
100
|
-
// contains none of the old picker's lines. ─────────────────────────────
|
|
101
|
-
picker.rerender(
|
|
102
|
-
<Screen>
|
|
103
|
-
<Box paddingX={2} paddingY={1}>
|
|
104
|
-
<Text color="green">准备就绪,进入对话…</Text>
|
|
105
|
-
</Box>
|
|
106
|
-
</Screen>,
|
|
107
|
-
)
|
|
108
|
-
await delay(30)
|
|
109
|
-
const after = strip(picker.lastFrame() ?? '')
|
|
110
|
-
ok(lineCount(picker.lastFrame() ?? '') === ROWS, `post-transition frame still terminal height (${lineCount(picker.lastFrame() ?? '')} === ${ROWS})`)
|
|
111
|
-
ok(!after.includes('选择当前路径'), 'previous picker heading gone after transition (no residue)')
|
|
112
|
-
ok(after.includes('准备就绪'), 'new screen content rendered')
|
|
113
|
-
|
|
114
|
-
console.log(`\n==== SCREEN RESULT: ${pass} passed, ${fail} failed ====\n`)
|
|
115
|
-
process.exit(fail === 0 ? 0 : 1)
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
main().catch(e => { console.error('FATAL', e); process.exit(2) })
|
package/tests/scroll.test.tsx
DELETED
|
@@ -1,253 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Chat viewport regression: history paging and live terminal resizing.
|
|
3
|
-
*
|
|
4
|
-
* The chat caps the transcript to the terminal height and (because Ink redraws
|
|
5
|
-
* only the live frame) the terminal's own scrollback holds no past turns, so
|
|
6
|
-
* older messages used to be unreachable. The fix is an in-app pager: PageUp
|
|
7
|
-
* scrolls back through history, PageDown forward, with a "stick to latest"
|
|
8
|
-
* rule so the conversation auto-follows again once you page back to the bottom.
|
|
9
|
-
*
|
|
10
|
-
* It also emits real stdout resize events after a long transcript is present.
|
|
11
|
-
* The dynamic tree must refit the visible records without duplicating or
|
|
12
|
-
* corrupting the fixed header, composer, and status rows.
|
|
13
|
-
*
|
|
14
|
-
* Run: npm run test:scroll
|
|
15
|
-
*/
|
|
16
|
-
import os from 'node:os'
|
|
17
|
-
import path from 'node:path'
|
|
18
|
-
import fs from 'node:fs'
|
|
19
|
-
|
|
20
|
-
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'mobius-tui-scroll-'))
|
|
21
|
-
process.env.MOBIUS_TUI_HOME = TMP_HOME
|
|
22
|
-
|
|
23
|
-
import React from 'react'
|
|
24
|
-
import { render } from 'ink-testing-library'
|
|
25
|
-
import { App } from '../src/App.js'
|
|
26
|
-
|
|
27
|
-
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms))
|
|
28
|
-
const RS: any = (globalThis as any).ReadableStream
|
|
29
|
-
const enc = new TextEncoder()
|
|
30
|
-
let sseController: any = null
|
|
31
|
-
|
|
32
|
-
function json(body: unknown, status = 200) {
|
|
33
|
-
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } })
|
|
34
|
-
}
|
|
35
|
-
function emitEntry(n: number) {
|
|
36
|
-
// distinct uuid per entry so useChat's de-dup keeps every one
|
|
37
|
-
const payload = { event: 'jsonl_entry', session_id: SID, entry: { type: 'assistant', uuid: `a-${n}`, message: { role: 'assistant', content: [{ type: 'text', text: `回答 ${n}` }] } } }
|
|
38
|
-
sseController?.enqueue(enc.encode(`event: jsonl_entry\ndata: ${JSON.stringify(payload)}\n\n`))
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
let pass = 0, fail = 0
|
|
42
|
-
function ok(c: boolean, m: string) { c ? (pass++, console.log(` ✓ ${m}`)) : (fail++, console.error(` ✗ ${m}`)) }
|
|
43
|
-
const strip = (s: string) => s.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
|
|
44
|
-
const answerCount = (s: string) => (strip(s).match(/回答 \d+/g) ?? []).length
|
|
45
|
-
|
|
46
|
-
function resize(stdout: NodeJS.WriteStream, columns: number, rows: number) {
|
|
47
|
-
Object.defineProperty(stdout, 'columns', { configurable: true, value: columns })
|
|
48
|
-
Object.defineProperty(stdout, 'rows', { configurable: true, value: rows })
|
|
49
|
-
Object.defineProperty(stdout, 'isTTY', { configurable: true, value: true })
|
|
50
|
-
stdout.emit('resize')
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const PID = 'proj-1', IID = 'issue-1', SID = 'sess-1'
|
|
54
|
-
|
|
55
|
-
function mockFetch(url: string, init?: RequestInit): Response {
|
|
56
|
-
if (url.includes('/events')) {
|
|
57
|
-
return new Response(new RS({
|
|
58
|
-
start(c: any) {
|
|
59
|
-
sseController = c
|
|
60
|
-
c.enqueue(enc.encode('event: subscribed\ndata: {"event":"subscribed"}\n\n'))
|
|
61
|
-
},
|
|
62
|
-
}), { status: 200, headers: { 'content-type': 'text/event-stream' } })
|
|
63
|
-
}
|
|
64
|
-
const method = init?.method ?? 'GET'
|
|
65
|
-
if (url.endsWith('/api/auth/config')) return json({ password_required: false })
|
|
66
|
-
if (url.endsWith('/api/auth/me')) return json({ id: 'tester', display_name: 'Test User', role: 'admin', work_dir: '/tmp' })
|
|
67
|
-
if (url.endsWith('/api/auth/login')) return json({ token: 'mock-jwt-token', user: { id: 'tester', display_name: 'Test User', role: 'admin' } })
|
|
68
|
-
if (url.includes('/aimux_bridge/api/remotes/') && url.includes('/connection')) {
|
|
69
|
-
const m = url.match(/remotes\/([^/]+)\/connection/)
|
|
70
|
-
return json({ identifier: m ? decodeURIComponent(m[1]) : 'x', event_stream_connected: true })
|
|
71
|
-
}
|
|
72
|
-
if (url.includes('/sessions') && url.includes('/issues') && method === 'POST') return json({ session_id: SID })
|
|
73
|
-
if (url.includes('/sessions') && url.includes('/issues') && method === 'GET') return json([])
|
|
74
|
-
if (url.endsWith('/messages') && method === 'POST') return json({ ok: true, session_id: SID, turn_number: 1 }) // keep SSE alive
|
|
75
|
-
if (url.endsWith(`/api/sessions/${SID}/status`)) return json({ session_id: SID, alive: true, working: false })
|
|
76
|
-
if (url.includes('/api/projects/') && url.includes('/issues') && method === 'POST') return json({ id: IID, project_id: PID, title: '命令行任务' })
|
|
77
|
-
if (url.includes('/api/projects/') && url.includes('/issues') && method === 'GET') return json([])
|
|
78
|
-
if (url.includes('/api/projects') && method === 'GET') return json([{ id: PID, name: '已有项目甲' }])
|
|
79
|
-
if (url.endsWith('/api/projects') && method === 'POST') return json({ id: PID, name: '测试项目PTY' })
|
|
80
|
-
if (url.includes('/sessions/model-options')) return json([{ key: 'codex', label: 'GPT-5.5', title: 'GPT-5.5', sub: 'Codex', backend: 'tmux-codex' }])
|
|
81
|
-
if (url.includes('/sessions/default-model')) return json({ model: 'codex' })
|
|
82
|
-
if (url.includes('/skills')) return json([])
|
|
83
|
-
if (url.includes('/memories')) return json([])
|
|
84
|
-
return json({ error: `unmocked ${method} ${url}` }, 404)
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
async function waitFor(lastFrame: () => string | undefined, needle: string, timeoutMs = 6000) {
|
|
88
|
-
for (let i = 0; i < timeoutMs / 50; i++) {
|
|
89
|
-
if ((strip(lastFrame() ?? '')).includes(needle)) return true
|
|
90
|
-
await delay(50)
|
|
91
|
-
}
|
|
92
|
-
return false
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Walk the prep wizard (project → issue → model → language) into the chat.
|
|
96
|
-
async function bootToChat(stdin: any, lastFrame: () => string | undefined) {
|
|
97
|
-
ok(await waitFor(lastFrame, '选择当前路径的绑定项目'), 'booted into project picker')
|
|
98
|
-
stdin.write('\r'); await delay(120)
|
|
99
|
-
ok(await waitFor(lastFrame, '项目名称'), 'project create wizard opened')
|
|
100
|
-
stdin.write('测试项目PTY'); await delay(120)
|
|
101
|
-
stdin.write('\r'); await delay(300)
|
|
102
|
-
ok(await waitFor(lastFrame, '创建新任务'), 'issue picker shown')
|
|
103
|
-
stdin.write('\r'); await delay(120)
|
|
104
|
-
ok(await waitFor(lastFrame, '输入任务名称'), 'issue name wizard opened')
|
|
105
|
-
stdin.write('命令行任务'); await delay(120)
|
|
106
|
-
stdin.write('\r'); await delay(300)
|
|
107
|
-
ok(await waitFor(lastFrame, '选择模型'), 'model picker shown')
|
|
108
|
-
stdin.write('\r'); await delay(250)
|
|
109
|
-
ok(await waitFor(lastFrame, '选择回复语言'), 'language picker shown')
|
|
110
|
-
stdin.write('\r'); await delay(400)
|
|
111
|
-
ok(await waitFor(lastFrame, '输入问题'), 'entered chat')
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
async function populateTranscript(stdin: any, emit: (n: number) => void, count = 25) {
|
|
115
|
-
stdin.write('hi'); await delay(120)
|
|
116
|
-
stdin.write('\r'); await delay(400) // creates session → SSE connects
|
|
117
|
-
for (let i = 0; i < count; i++) { emit(i); await delay(15) }
|
|
118
|
-
await delay(500)
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
async function main() {
|
|
122
|
-
fs.writeFileSync(path.join(TMP_HOME, 'login.json'), JSON.stringify({
|
|
123
|
-
server: 'http://mock.local', username: 'tester', token: 'mock-jwt-token',
|
|
124
|
-
user: { id: 'tester', display_name: 'Test User', role: 'admin' },
|
|
125
|
-
}))
|
|
126
|
-
const realFetch = globalThis.fetch
|
|
127
|
-
globalThis.fetch = ((u: any, init?: any) => mockFetch(String(u), init)) as unknown as typeof fetch
|
|
128
|
-
|
|
129
|
-
console.log('\n[SCROLL] in-app history pager (mocked backend)\n')
|
|
130
|
-
const { stdin, stdout, lastFrame, unmount } = render(React.createElement(App))
|
|
131
|
-
|
|
132
|
-
try {
|
|
133
|
-
// ── boot through the prep wizard into chat ────────────────────────────────
|
|
134
|
-
await bootToChat(stdin, lastFrame)
|
|
135
|
-
|
|
136
|
-
// ── populate a long transcript ────────────────────────────────────────────
|
|
137
|
-
await populateTranscript(stdin, emitEntry)
|
|
138
|
-
const tailFrame = strip(lastFrame() ?? '')
|
|
139
|
-
|
|
140
|
-
ok(tailFrame.includes('回答 24'), 'latest entry visible at tail (not hidden)')
|
|
141
|
-
ok(tailFrame.includes('↑ 还有较早内容') && tailFrame.includes('滚轮 3 行'), 'navigation reports older content and the exact wheel step')
|
|
142
|
-
|
|
143
|
-
// ── live resize: refit one dynamic frame, never retain old-width output ──
|
|
144
|
-
resize(stdout as unknown as NodeJS.WriteStream, 52, 18)
|
|
145
|
-
await delay(300)
|
|
146
|
-
const narrowFrame = strip(lastFrame() ?? '')
|
|
147
|
-
const narrowAnswers = answerCount(narrowFrame)
|
|
148
|
-
ok(narrowFrame.includes('回答 24'), 'narrow resize keeps the latest reply visible')
|
|
149
|
-
ok(narrowFrame.includes('Mobius') && narrowFrame.includes('输入问题或 / 命令') && narrowFrame.includes('web ·'), 'narrow resize preserves header, composer, and status')
|
|
150
|
-
ok((narrowFrame.match(/>_ Mobius/g) ?? []).length === 1, 'narrow resize leaves exactly one dynamic header')
|
|
151
|
-
|
|
152
|
-
resize(stdout as unknown as NodeJS.WriteStream, 100, 36)
|
|
153
|
-
await delay(300)
|
|
154
|
-
const tallFrame = strip(lastFrame() ?? '')
|
|
155
|
-
const tallAnswers = answerCount(tallFrame)
|
|
156
|
-
ok(tallFrame.includes('回答 24'), 'larger resize keeps the latest reply visible')
|
|
157
|
-
ok(tallAnswers > narrowAnswers, 'larger resize reveals more history in the same viewport')
|
|
158
|
-
ok((tallFrame.match(/>_ Mobius/g) ?? []).length === 1, 'larger resize still has one dynamic header')
|
|
159
|
-
|
|
160
|
-
// Navigation is a fixed one-row part of the conversation chrome. It must be
|
|
161
|
-
// the FIRST line below the header and state the exact PageUp/PageDown step.
|
|
162
|
-
const tallLines = tallFrame.split('\n')
|
|
163
|
-
const hintIdx = tallLines.findIndex(l => l.includes('滚轮 3 行'))
|
|
164
|
-
ok(hintIdx === 1, `navigation is the first line under the header (line ${hintIdx}, expected 1)`)
|
|
165
|
-
ok(/PageUp\/PageDown \d+ 行/.test(tallLines[hintIdx] ?? ''), 'navigation exposes the deterministic page size')
|
|
166
|
-
const messageRows = tallLines.slice(hintIdx + 1).filter(line => line.trim())
|
|
167
|
-
ok(messageRows.length > 0 && messageRows[0].includes('回答'), 'a real virtualized message row follows navigation without a synthetic peek row')
|
|
168
|
-
|
|
169
|
-
// ── PageUp: viewport scrolls back over history ────────────────────────────
|
|
170
|
-
stdin.write('\x1b[5~') // PageUp
|
|
171
|
-
await delay(300)
|
|
172
|
-
const upFrame = strip(lastFrame() ?? '')
|
|
173
|
-
ok(upFrame.includes('↓ 较新内容'), 'after PageUp: navigation reports newer content below')
|
|
174
|
-
ok(!upFrame.includes('回答 24'), 'after PageUp: latest entry paged out of view')
|
|
175
|
-
ok(/回答 \d+/.test(upFrame), 'after PageUp: an older entry is visible')
|
|
176
|
-
|
|
177
|
-
// ── PageDown: snaps back to the latest ────────────────────────────────────
|
|
178
|
-
stdin.write('\x1b[6~') // PageDown
|
|
179
|
-
await delay(300)
|
|
180
|
-
const downFrame = strip(lastFrame() ?? '')
|
|
181
|
-
ok(downFrame.includes('回答 24'), 'after PageDown: latest entry back in view')
|
|
182
|
-
|
|
183
|
-
// ── Mouse wheel: SGR wheel events drive the same pager ────────────────────
|
|
184
|
-
stdin.write('\x1b[<64;5;5M') // wheel up = scroll back
|
|
185
|
-
await delay(300)
|
|
186
|
-
const wheelUp = strip(lastFrame() ?? '')
|
|
187
|
-
ok(wheelUp.includes('↓ 较新内容'), 'wheel up: navigation reports newer content below')
|
|
188
|
-
ok(!wheelUp.includes('回答 24'), 'wheel up: latest entry paged out of view')
|
|
189
|
-
ok(/回答 \d+/.test(wheelUp), 'wheel up: an older entry is visible')
|
|
190
|
-
|
|
191
|
-
stdin.write('\x1b[<65;5;5M') // wheel down = scroll forward
|
|
192
|
-
await delay(300)
|
|
193
|
-
const wheelDown = strip(lastFrame() ?? '')
|
|
194
|
-
ok(wheelDown.includes('回答 24'), 'wheel down: latest entry back in view')
|
|
195
|
-
|
|
196
|
-
// ── Mouse wheel (legacy X10 encoding, terminals without SGR 1006) ────────
|
|
197
|
-
// wheel up: ESC [ M Cb Cx Cy, Cb = button + 32 → 0x60 (96); coords at 18,18
|
|
198
|
-
stdin.write('\x1b[M' + String.fromCharCode(96, 50, 50))
|
|
199
|
-
await delay(300)
|
|
200
|
-
const legacyUp = strip(lastFrame() ?? '')
|
|
201
|
-
ok(legacyUp.includes('↓ 较新内容'), 'legacy wheel up: navigation reports newer content below')
|
|
202
|
-
ok(!legacyUp.includes('回答 24'), 'legacy wheel up: latest entry paged out of view')
|
|
203
|
-
|
|
204
|
-
stdin.write('\x1b[M' + String.fromCharCode(97, 50, 50)) // wheel down Cb = 0x61
|
|
205
|
-
await delay(300)
|
|
206
|
-
const legacyDown = strip(lastFrame() ?? '')
|
|
207
|
-
ok(legacyDown.includes('回答 24'), 'legacy wheel down: latest entry back in view')
|
|
208
|
-
} finally {
|
|
209
|
-
unmount()
|
|
210
|
-
globalThis.fetch = realFetch
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// ── Phase 2: MOBIUS_TUI_DISABLE_MOUSE=1 opts out of wheel mode ─────────────
|
|
214
|
-
// Mouse reporting hands the terminal mouse to the app, which disables native
|
|
215
|
-
// drag-select. The env flag is the escape hatch: wheel stops, selection is
|
|
216
|
-
// free again. Here we assert wheel events no longer scroll the pager. A fresh
|
|
217
|
-
// MOBIUS_TUI_HOME is used because phase 1 persisted a dir→project binding.
|
|
218
|
-
const TMP_HOME2 = fs.mkdtempSync(path.join(os.tmpdir(), 'mobius-tui-scroll2-'))
|
|
219
|
-
process.env.MOBIUS_TUI_DISABLE_MOUSE = '1'
|
|
220
|
-
process.env.MOBIUS_TUI_HOME = TMP_HOME2
|
|
221
|
-
fs.writeFileSync(path.join(TMP_HOME2, 'login.json'), JSON.stringify({
|
|
222
|
-
server: 'http://mock.local', username: 'tester', token: 'mock-jwt-token',
|
|
223
|
-
user: { id: 'tester', display_name: 'Test User', role: 'admin' },
|
|
224
|
-
}))
|
|
225
|
-
globalThis.fetch = ((u: any, init?: any) => mockFetch(String(u), init)) as unknown as typeof fetch
|
|
226
|
-
const second = render(React.createElement(App))
|
|
227
|
-
try {
|
|
228
|
-
await bootToChat(second.stdin, second.lastFrame)
|
|
229
|
-
await populateTranscript(second.stdin, emitEntry)
|
|
230
|
-
const before = strip(second.lastFrame() ?? '')
|
|
231
|
-
const beforeAnswers = before.match(/回答 \d+/g) ?? []
|
|
232
|
-
ok(before.includes('回答 24'), 'disable-mouse: latest entry visible before wheel')
|
|
233
|
-
|
|
234
|
-
second.stdin.write('\x1b[<64;5;5M') // wheel up — must be ignored
|
|
235
|
-
await delay(300)
|
|
236
|
-
const after = strip(second.lastFrame() ?? '')
|
|
237
|
-
const afterAnswers = after.match(/回答 \d+/g) ?? []
|
|
238
|
-
ok(after.includes('回答 24'), 'disable-mouse: wheel up leaves latest entry in view')
|
|
239
|
-
ok(JSON.stringify(afterAnswers) === JSON.stringify(beforeAnswers), 'disable-mouse: wheel up leaves the visible row window unchanged')
|
|
240
|
-
} finally {
|
|
241
|
-
second.unmount()
|
|
242
|
-
delete process.env.MOBIUS_TUI_DISABLE_MOUSE
|
|
243
|
-
delete process.env.MOBIUS_TUI_HOME
|
|
244
|
-
globalThis.fetch = realFetch
|
|
245
|
-
try { fs.rmSync(TMP_HOME2, { recursive: true, force: true }) } catch { /* ignore */ }
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
try { fs.rmSync(TMP_HOME, { recursive: true, force: true }) } catch { /* ignore */ }
|
|
249
|
-
console.log(`\n==== SCROLL RESULT: ${pass} passed, ${fail} failed ====\n`)
|
|
250
|
-
process.exit(fail === 0 ? 0 : 1)
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
main().catch((e) => { console.error('FATAL', e); process.exit(2) })
|