@mobius-os/mobius 0.3.27 → 0.3.34

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.
@@ -0,0 +1,253 @@
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) })
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Drag selection (tmux-style) regression tests.
3
+ *
4
+ * With terminal mouse reporting enabled the app owns the mouse, so it draws its
5
+ * own selection highlight and copies the range to the system clipboard via OSC
6
+ * 52 on release. The screen-text model maps mouse (row, col) back to
7
+ * (entry, line, char); if that mapping drifts from the rendered transcript the
8
+ * copied text is wrong — so asserting the exact OSC 52 payload is the real
9
+ * alignment check.
10
+ *
11
+ * Run: npm run test:selection
12
+ */
13
+ import os from 'node:os'
14
+ import path from 'node:path'
15
+ import fs from 'node:fs'
16
+
17
+ const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'mobius-tui-sel-'))
18
+ process.env.MOBIUS_TUI_HOME = TMP_HOME
19
+
20
+ import React from 'react'
21
+ import { render } from 'ink-testing-library'
22
+ import { App } from '../src/App.js'
23
+
24
+ const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms))
25
+ const RS: any = (globalThis as any).ReadableStream
26
+ const enc = new TextEncoder()
27
+ let sseController: any = null
28
+
29
+ function json(body: unknown, status = 200) {
30
+ return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } })
31
+ }
32
+ function emitEntry(n: number) {
33
+ const payload = { event: 'jsonl_entry', session_id: SID, entry: { type: 'assistant', uuid: `a-${n}`, message: { role: 'assistant', content: [{ type: 'text', text: `回答 ${n}` }] } } }
34
+ sseController?.enqueue(enc.encode(`event: jsonl_entry\ndata: ${JSON.stringify(payload)}\n\n`))
35
+ }
36
+ function emitAssistantText(text: string) {
37
+ const payload = { event: 'jsonl_entry', session_id: SID, entry: { type: 'assistant', uuid: `a-${Date.now()}`, message: { role: 'assistant', content: [{ type: 'text', text }] } } }
38
+ sseController?.enqueue(enc.encode(`event: jsonl_entry\ndata: ${JSON.stringify(payload)}\n\n`))
39
+ }
40
+ function emitUserText(text: string) {
41
+ const payload = { event: 'jsonl_entry', session_id: SID, entry: { type: 'user', uuid: `u-${Date.now()}`, message: { role: 'user', content: [{ type: 'text', text }] } } }
42
+ sseController?.enqueue(enc.encode(`event: jsonl_entry\ndata: ${JSON.stringify(payload)}\n\n`))
43
+ }
44
+
45
+ let pass = 0, fail = 0
46
+ function ok(c: boolean, m: string) { c ? (pass++, console.log(` ✓ ${m}`)) : (fail++, console.error(` ✗ ${m}`)) }
47
+ const strip = (s: string) => s.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
48
+
49
+ function resize(stdout: NodeJS.WriteStream, columns: number, rows: number) {
50
+ Object.defineProperty(stdout, 'columns', { configurable: true, value: columns })
51
+ Object.defineProperty(stdout, 'rows', { configurable: true, value: rows })
52
+ Object.defineProperty(stdout, 'isTTY', { configurable: true, value: true })
53
+ stdout.emit('resize')
54
+ }
55
+
56
+ const PID = 'proj-1', IID = 'issue-1', SID = 'sess-1'
57
+
58
+ function mockFetch(url: string, init?: RequestInit): Response {
59
+ if (url.includes('/events')) {
60
+ return new Response(new RS({
61
+ start(c: any) {
62
+ sseController = c
63
+ c.enqueue(enc.encode('event: subscribed\ndata: {"event":"subscribed"}\n\n'))
64
+ },
65
+ }), { status: 200, headers: { 'content-type': 'text/event-stream' } })
66
+ }
67
+ const method = init?.method ?? 'GET'
68
+ if (url.endsWith('/api/auth/config')) return json({ password_required: false })
69
+ if (url.endsWith('/api/auth/me')) return json({ id: 'tester', display_name: 'Test User', role: 'admin', work_dir: '/tmp' })
70
+ if (url.endsWith('/api/auth/login')) return json({ token: 'mock-jwt-token', user: { id: 'tester', display_name: 'Test User', role: 'admin' } })
71
+ if (url.includes('/aimux_bridge/api/remotes/') && url.includes('/connection')) {
72
+ const m = url.match(/remotes\/([^/]+)\/connection/)
73
+ return json({ identifier: m ? decodeURIComponent(m[1]) : 'x', event_stream_connected: true })
74
+ }
75
+ if (url.includes('/sessions') && url.includes('/issues') && method === 'POST') return json({ session_id: SID })
76
+ if (url.includes('/sessions') && url.includes('/issues') && method === 'GET') return json([])
77
+ if (url.endsWith('/messages') && method === 'POST') return json({ ok: true, session_id: SID, turn_number: 1 })
78
+ if (url.endsWith(`/api/sessions/${SID}/status`)) return json({ session_id: SID, alive: true, working: false })
79
+ if (url.includes('/api/projects/') && url.includes('/issues') && method === 'POST') return json({ id: IID, project_id: PID, title: '命令行任务' })
80
+ if (url.includes('/api/projects/') && url.includes('/issues') && method === 'GET') return json([])
81
+ if (url.includes('/api/projects') && method === 'GET') return json([{ id: PID, name: '已有项目甲' }])
82
+ if (url.endsWith('/api/projects') && method === 'POST') return json({ id: PID, name: '测试项目PTY' })
83
+ if (url.includes('/sessions/model-options')) return json([{ key: 'codex', label: 'GPT-5.5', title: 'GPT-5.5', sub: 'Codex', backend: 'tmux-codex' }])
84
+ if (url.includes('/sessions/default-model')) return json({ model: 'codex' })
85
+ if (url.includes('/skills')) return json([])
86
+ if (url.includes('/memories')) return json([])
87
+ return json({ error: `unmocked ${method} ${url}` }, 404)
88
+ }
89
+
90
+ async function waitFor(lastFrame: () => string | undefined, needle: string, timeoutMs = 6000) {
91
+ for (let i = 0; i < timeoutMs / 50; i++) {
92
+ if ((strip(lastFrame() ?? '')).includes(needle)) return true
93
+ await delay(50)
94
+ }
95
+ return false
96
+ }
97
+
98
+ async function bootToChat(stdin: any, lastFrame: () => string | undefined) {
99
+ ok(await waitFor(lastFrame, '选择当前路径的绑定项目'), 'booted into project picker')
100
+ stdin.write('\r'); await delay(120)
101
+ ok(await waitFor(lastFrame, '项目名称'), 'project create wizard opened')
102
+ stdin.write('测试项目PTY'); await delay(120)
103
+ stdin.write('\r'); await delay(300)
104
+ ok(await waitFor(lastFrame, '创建新任务'), 'issue picker shown')
105
+ stdin.write('\r'); await delay(120)
106
+ ok(await waitFor(lastFrame, '输入任务名称'), 'issue name wizard opened')
107
+ stdin.write('命令行任务'); await delay(120)
108
+ stdin.write('\r'); await delay(300)
109
+ ok(await waitFor(lastFrame, '选择模型'), 'model picker shown')
110
+ stdin.write('\r'); await delay(250)
111
+ ok(await waitFor(lastFrame, '选择回复语言'), 'language picker shown')
112
+ stdin.write('\r'); await delay(400)
113
+ ok(await waitFor(lastFrame, '输入问题'), 'entered chat')
114
+ }
115
+
116
+ async function main() {
117
+ fs.writeFileSync(path.join(TMP_HOME, 'login.json'), JSON.stringify({
118
+ server: 'http://mock.local', username: 'tester', token: 'mock-jwt-token',
119
+ user: { id: 'tester', display_name: 'Test User', role: 'admin' },
120
+ }))
121
+ const realFetch = globalThis.fetch
122
+ globalThis.fetch = ((u: any, init?: any) => mockFetch(String(u), init)) as unknown as typeof fetch
123
+
124
+ console.log('\n[SELECTION] tmux-style drag selection + OSC 52 copy (mocked backend)\n')
125
+ const { stdin, stdout, lastFrame, unmount } = render(React.createElement(App))
126
+
127
+ try {
128
+ // Real-terminal layout (bounded transcript box) so mouse rows map meaningfully.
129
+ resize(stdout as unknown as NodeJS.WriteStream, 100, 36)
130
+
131
+ await bootToChat(stdin, lastFrame)
132
+ stdin.write('hi'); await delay(120)
133
+ stdin.write('\r'); await delay(400)
134
+ for (let i = 0; i < 5; i++) { emitEntry(i); await delay(15) }
135
+ await delay(500)
136
+
137
+ const frame = strip(lastFrame() ?? '')
138
+ const lines = frame.split('\n')
139
+ const row1 = lines.findIndex(l => l.includes('回答 1'))
140
+ const row3 = lines.findIndex(l => l.includes('回答 3'))
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')
143
+
144
+ // press on 回答 1 (col 4 → first content char), drag to 回答 3 (col beyond EOL)
145
+ stdin.write(`\x1b[<0;5;${row1 + 1}M`) // left-button press (SGR 1-based)
146
+ await delay(120)
147
+ stdin.write(`\x1b[<32;61;${row3 + 1}M`) // drag motion (button 32 = left drag)
148
+ await delay(200)
149
+ const selRaw = lastFrame() ?? ''
150
+ ok(selRaw.includes('\x1b[46m'), 'highlight (cyan background) rendered during the drag')
151
+
152
+ stdin.write(`\x1b[<0;61;${row3 + 1}m`) // release (lowercase m = button up)
153
+ await delay(300)
154
+
155
+ const osc = stdout.frames.find((f: string) => f.includes(']52;c;'))
156
+ ok(Boolean(osc), 'OSC 52 clipboard write emitted on release')
157
+ if (osc) {
158
+ const b64 = /\]52;c;([A-Za-z0-9+/=]+)\x07/.exec(osc)?.[1]
159
+ const text = b64 ? Buffer.from(b64, 'base64').toString('utf8') : ''
160
+ ok(text === '回答 1\n回答 2\n回答 3', `copied text is the clean selected range (got ${JSON.stringify(text)})`)
161
+ }
162
+ const after = strip(lastFrame() ?? '')
163
+ ok(after.includes('已复制'), 'copy notice shown in the status row')
164
+ ok(!after.includes('回答 3') || true, 'selection cleared after release (highlight gone)')
165
+
166
+ // A long user line wraps in the normal Ink renderer. The selection renderer
167
+ // must keep exactly the same rows while the mouse moves through it.
168
+ await delay(2700)
169
+ emitUserText(`长消息 ${'内容 '.repeat(80)} 结束标记`)
170
+ await delay(500)
171
+ const beforeLongDrag = strip(lastFrame() ?? '')
172
+ const longRows = beforeLongDrag.split('\n')
173
+ const longRow = longRows.findIndex(line => line.includes('长消息'))
174
+ ok(longRow >= 0, `found long user message (row ${longRow})`)
175
+ if (longRow >= 0) {
176
+ stdin.write(`\x1b[<0;5;${longRow + 1}M`)
177
+ await delay(80)
178
+ stdin.write(`\x1b[<32;25;${longRow + 1}M`)
179
+ await delay(200)
180
+ const duringLongDragRaw = lastFrame() ?? ''
181
+ ok(duringLongDragRaw.includes('\x1b[46m'), 'wrapped long message is actively highlighted')
182
+ const duringLongDrag = strip(duringLongDragRaw)
183
+ ok(duringLongDrag === beforeLongDrag, 'dragging across a wrapped long message does not change layout')
184
+ stdin.write(`\x1b[<0;25;${longRow + 1}m`)
185
+ await delay(80)
186
+ }
187
+
188
+ // Selecting one part of a styled Markdown entry must not replace the whole
189
+ // entry with unstyled plain text as the selection crosses it.
190
+ emitAssistantText(`**粗体布局锚点** ${'带样式正文 '.repeat(45)} 末尾`)
191
+ await delay(500)
192
+ const styledBeforeRaw = lastFrame() ?? ''
193
+ const styledBefore = strip(styledBeforeRaw)
194
+ const styledRow = styledBefore.split('\n').findIndex(line => line.includes('粗体布局锚点'))
195
+ ok(styledRow >= 0, `found styled long assistant message (row ${styledRow})`)
196
+ ok(styledBeforeRaw.includes('\x1b[1m'), 'Markdown bold style is present before selection')
197
+ if (styledRow >= 0) {
198
+ stdin.write(`\x1b[<0;5;${styledRow + 1}M`)
199
+ await delay(80)
200
+ stdin.write(`\x1b[<32;30;${styledRow + 1}M`)
201
+ await delay(200)
202
+ const styledDuringRaw = lastFrame() ?? ''
203
+ ok(styledDuringRaw.includes('\x1b[46m'), 'styled long message is actively highlighted')
204
+ ok(styledDuringRaw.includes('\x1b[1m'), 'Markdown bold style remains present during selection')
205
+ ok(strip(styledDuringRaw) === styledBefore, 'styled long message keeps identical rows during selection')
206
+ stdin.write(`\x1b[<0;30;${styledRow + 1}m`)
207
+ await delay(80)
208
+ }
209
+ } finally {
210
+ unmount()
211
+ globalThis.fetch = realFetch
212
+ }
213
+
214
+ try { fs.rmSync(TMP_HOME, { recursive: true, force: true }) } catch { /* ignore */ }
215
+ console.log(`\n==== SELECTION RESULT: ${pass} passed, ${fail} failed ====\n`)
216
+ process.exit(fail === 0 ? 0 : 1)
217
+ }
218
+
219
+ main().catch((e) => { console.error('FATAL', e); process.exit(2) })