@mobius-os/mobius 0.3.31 → 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.
- package/README.md +7 -0
- package/package.json +2 -1
- package/src/components/Chat.tsx +99 -192
- package/src/components/primitives.tsx +21 -1
- package/src/lib/entry-view.ts +10 -5
- package/src/lib/screen-text.ts +0 -35
- package/src/lib/transcript-viewport.ts +162 -0
- package/src/version.ts +21 -0
- package/tests/screen.test.tsx +11 -10
- package/tests/scroll.test.tsx +13 -12
- package/tests/selection.test.tsx +1 -1
- package/tests/ui.test.tsx +13 -1
- package/tests/viewport.test.ts +83 -0
package/README.md
CHANGED
|
@@ -130,6 +130,13 @@ npm test # all three
|
|
|
130
130
|
|
|
131
131
|
## Build an installable package
|
|
132
132
|
|
|
133
|
+
The TUI release version has one source of truth: `mobius/tui/package.json`.
|
|
134
|
+
The welcome screen, npm package metadata, artifact filename, and download
|
|
135
|
+
manifest all read that value. `package-lock.json` mirrors it as generated npm
|
|
136
|
+
metadata; do not edit it as a separate release setting. The AIMUX bundle
|
|
137
|
+
version (`BUNDLE_VER`) is an independent Python runtime cache version and is
|
|
138
|
+
not the TUI version.
|
|
139
|
+
|
|
133
140
|
From the repository root:
|
|
134
141
|
|
|
135
142
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mobius-os/mobius",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.34",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Mobius terminal client. Reuses Mobius frontend TypeScript types and jsonl entry shapes.",
|
|
6
6
|
"bin": {
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"test:reconnect": "tsx tests/reconnect.test.tsx",
|
|
20
20
|
"test:screen": "FORCE_COLOR=1 tsx tests/screen.test.tsx",
|
|
21
21
|
"test:scroll": "tsx tests/scroll.test.tsx",
|
|
22
|
+
"test:viewport": "tsx tests/viewport.test.ts",
|
|
22
23
|
"test:selection": "FORCE_COLOR=1 tsx tests/selection.test.tsx",
|
|
23
24
|
"test": "npm run typecheck && npm run test:ui && npm run test:integration"
|
|
24
25
|
},
|
package/src/components/Chat.tsx
CHANGED
|
@@ -13,11 +13,14 @@ import { useChat } from '../hooks/useChat.js'
|
|
|
13
13
|
import { MobiusClient } from '../api.js'
|
|
14
14
|
import { viewsForEntry, dedupeUserEntries, isAssistantOutput } from '../lib/entry-view.js'
|
|
15
15
|
import {
|
|
16
|
-
displayWidth, compareSel,
|
|
17
|
-
buildTranscriptModel, computeTranscriptGeometry, screenToSelPoint,
|
|
16
|
+
displayWidth, compareSel, entryScreenRows, screenToSelPoint,
|
|
18
17
|
buildSelectionMap, buildSelectionText, osc52,
|
|
19
18
|
type TranscriptModel, type TranscriptGeometry, type SelPoint, type ScreenRow,
|
|
20
19
|
} from '../lib/screen-text.js'
|
|
20
|
+
import {
|
|
21
|
+
createRowAccess, moveAnchorByRows, sliceViewport, tailAnchor,
|
|
22
|
+
type RowAnchor,
|
|
23
|
+
} from '../lib/transcript-viewport.js'
|
|
21
24
|
import type { ReadyState } from './PrepScreen.js'
|
|
22
25
|
import type { AnyEntry } from '../types.js'
|
|
23
26
|
import { ConfigFlow, ReconfigFlow, type ConfigResult } from './ConfigFlow.js'
|
|
@@ -45,8 +48,7 @@ interface TerminalSize {
|
|
|
45
48
|
isTty: boolean
|
|
46
49
|
}
|
|
47
50
|
|
|
48
|
-
import {
|
|
49
|
-
const VERSION = createRequire(import.meta.url)('../../package.json').version
|
|
51
|
+
import { TUI_VERSION } from '../version.js'
|
|
50
52
|
const DEFAULT_COMPOSER_ROWS = 5
|
|
51
53
|
const STATUS_ROWS = 3
|
|
52
54
|
|
|
@@ -62,7 +64,9 @@ const SLASH_COMMANDS = [
|
|
|
62
64
|
export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear, onResume, onQuit, onReconfigure, onConfigCancel, aimuxStatus }: ChatProps) {
|
|
63
65
|
const chat = useChat({ client, ready, resumeSessionId })
|
|
64
66
|
const [showHelp, setShowHelp] = useState(false)
|
|
65
|
-
|
|
67
|
+
// null means "follow the tail". A concrete anchor identifies the exact row
|
|
68
|
+
// at the top of the viewport while the user browses history.
|
|
69
|
+
const [rowAnchor, setRowAnchor] = useState<RowAnchor | null>(null)
|
|
66
70
|
const [composerRows, setComposerRows] = useState(DEFAULT_COMPOSER_ROWS)
|
|
67
71
|
const [modelLabel, setModelLabel] = useState<string | null>(null)
|
|
68
72
|
const [configOpen, setConfigOpen] = useState(false)
|
|
@@ -100,7 +104,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
100
104
|
return
|
|
101
105
|
}
|
|
102
106
|
setShowHelp(false)
|
|
103
|
-
|
|
107
|
+
setRowAnchor(null)
|
|
104
108
|
void chat.send(t)
|
|
105
109
|
}, [chat, runSlash])
|
|
106
110
|
|
|
@@ -116,39 +120,66 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
116
120
|
// (type:user / response_item.message[user] / event_msg.user_message) 合并成 1 条,
|
|
117
121
|
// 避免在累积视图里把同一条提问显示多次.
|
|
118
122
|
const dedupedEntries = useMemo(() => dedupeUserEntries(chat.entries), [chat.entries])
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
123
|
+
const pendingEntry = useMemo<AnyEntry | null>(() => chat.pendingUser === null ? null : ({
|
|
124
|
+
type: 'user',
|
|
125
|
+
__id: '__pending-user__',
|
|
126
|
+
message: { role: 'user', content: chat.pendingUser },
|
|
127
|
+
}), [chat.pendingUser])
|
|
128
|
+
const transcriptEntries = useMemo(
|
|
129
|
+
() => pendingEntry ? [...dedupedEntries, pendingEntry] : dedupedEntries,
|
|
130
|
+
[dedupedEntries, pendingEntry],
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
// Markdown parsing and wrapping are paid once per entry/terminal width. Keep
|
|
134
|
+
// the two most recent widths so resize-back does not immediately reparse the
|
|
135
|
+
// whole visible history, while bounding cache growth during repeated resizes.
|
|
136
|
+
const rowCache = useRef<WeakMap<AnyEntry, Map<number, ScreenRow[]>>>(new WeakMap())
|
|
137
|
+
const rowsForEntry = useCallback((entry: AnyEntry): readonly ScreenRow[] => {
|
|
138
|
+
let widths = rowCache.current.get(entry)
|
|
139
|
+
if (!widths) {
|
|
140
|
+
widths = new Map()
|
|
141
|
+
rowCache.current.set(entry, widths)
|
|
142
|
+
}
|
|
143
|
+
const cached = widths.get(terminal.columns)
|
|
144
|
+
if (cached) return cached
|
|
145
|
+
const rows = entryScreenRows(viewsForEntry(entry), terminal.columns)
|
|
146
|
+
if (widths.size >= 2) widths.delete(widths.keys().next().value!)
|
|
147
|
+
widths.set(terminal.columns, rows)
|
|
148
|
+
return rows
|
|
149
|
+
}, [terminal.columns])
|
|
150
|
+
const rowAccess = useMemo(() => createRowAccess(
|
|
151
|
+
transcriptEntries,
|
|
152
|
+
// UUID is stable across SSE history replay; __id is only the local fallback
|
|
153
|
+
// for entries that do not carry a backend identity (notably the optimistic
|
|
154
|
+
// pending user row).
|
|
155
|
+
(entry, index) => String(entry.uuid ?? entry.__id ?? `entry-${index}`),
|
|
156
|
+
(entry) => rowsForEntry(entry),
|
|
157
|
+
), [transcriptEntries, rowsForEntry])
|
|
129
158
|
const viewportRows = terminal.isTty ? Math.max(9, terminal.rows - 1) : terminal.rows
|
|
130
159
|
const activityRows = (chat.typing ? 2 : 0) + (chat.error ? 1 : 0)
|
|
131
160
|
const helpRows = showHelp ? SLASH_COMMANDS.length + 3 : 0
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
161
|
+
// Conversation chrome is exactly two rows: compact header + navigation.
|
|
162
|
+
const transcriptRows = Math.max(1, viewportRows - composerRows - STATUS_ROWS - activityRows - helpRows - 2)
|
|
163
|
+
const tail = useMemo(() => tailAnchor(rowAccess, transcriptRows), [rowAccess, transcriptRows])
|
|
164
|
+
const effectiveAnchor = rowAnchor ?? tail
|
|
165
|
+
const viewport = useMemo(
|
|
166
|
+
() => sliceViewport(rowAccess, effectiveAnchor, transcriptRows),
|
|
167
|
+
[rowAccess, effectiveAnchor, transcriptRows],
|
|
136
168
|
)
|
|
137
|
-
const showWelcome =
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}, [dedupedEntries.length, scrollBack])
|
|
169
|
+
const showWelcome = transcriptEntries.length === 0
|
|
170
|
+
const pageRows = Math.max(1, transcriptRows - 1)
|
|
171
|
+
|
|
172
|
+
const scrollRows = useCallback((deltaRows: number) => {
|
|
173
|
+
if (deltaRows === 0) return
|
|
174
|
+
selState.current = null
|
|
175
|
+
setSel(null)
|
|
176
|
+
setRowAnchor(previous => {
|
|
177
|
+
const start = previous ?? tailAnchor(rowAccess, transcriptRows)
|
|
178
|
+
const next = moveAnchorByRows(rowAccess, start, deltaRows)
|
|
179
|
+
if (deltaRows > 0 && !sliceViewport(rowAccess, next, transcriptRows).hasNewer) return null
|
|
180
|
+
return next
|
|
181
|
+
})
|
|
182
|
+
}, [rowAccess, transcriptRows])
|
|
152
183
|
|
|
153
184
|
// Show the model's friendly label (e.g. "GPT-5.6-Sol") in the header/status
|
|
154
185
|
// instead of its opaque key (e.g. "codex:mobiusdefaultaabb").
|
|
@@ -174,16 +205,13 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
174
205
|
if (isEscapeKeypress(_input, key)) onConfigCancel(handlerRef.current.sessionId)
|
|
175
206
|
return
|
|
176
207
|
}
|
|
177
|
-
|
|
178
|
-
if (key.
|
|
179
|
-
else if (key.pageDown) setScrollBack(value => Math.max(0, value - step))
|
|
208
|
+
if (key.pageUp) scrollRows(-pageRows)
|
|
209
|
+
else if (key.pageDown) scrollRows(pageRows)
|
|
180
210
|
}, { interactive: false })
|
|
181
211
|
|
|
182
|
-
const
|
|
183
|
-
?
|
|
184
|
-
|
|
185
|
-
: '已到最早记录 · 滚轮/PageDown 翻页 · 拖动选中文本'
|
|
186
|
-
: null
|
|
212
|
+
const navigationHint = viewport.hasOlder
|
|
213
|
+
? `${viewport.hasNewer ? '↑ 较早内容 · ↓ 较新内容' : '↑ 还有较早内容'} · 滚轮 3 行 · PageUp/PageDown ${pageRows} 行 · 拖动选中文本`
|
|
214
|
+
: `${viewport.hasNewer ? '已到最早 · ↓ 还有较新内容' : '全部内容'} · 滚轮 3 行 · PageUp/PageDown ${pageRows} 行 · 拖动选中文本`
|
|
187
215
|
|
|
188
216
|
// Mouse: wheel pages through history in small fixed steps, and a left-button
|
|
189
217
|
// drag selects transcript text (tmux-style: the app owns the mouse, draws its
|
|
@@ -195,23 +223,16 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
195
223
|
const [sel, setSel] = useState<{ anchor: SelPoint; end: SelPoint; active: boolean } | null>(null)
|
|
196
224
|
const [copyNotice, setCopyNotice] = useState<string | null>(null)
|
|
197
225
|
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
const geometry: TranscriptGeometry = useMemo(
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
activityRows,
|
|
206
|
-
helpRows,
|
|
207
|
-
showWelcome,
|
|
208
|
-
welcomeRows: 10,
|
|
209
|
-
olderHintShown: olderHint !== null,
|
|
210
|
-
tipShown,
|
|
211
|
-
}), [viewportRows, composerRows, activityRows, helpRows, showWelcome, olderHint, tipShown])
|
|
226
|
+
// Selection uses the exact virtual rows mounted below. There is no separate
|
|
227
|
+
// fitting/geometry pass, so hit-testing, rendering and clipboard extraction
|
|
228
|
+
// cannot disagree about which rows are on screen.
|
|
229
|
+
const geometry: TranscriptGeometry = useMemo(
|
|
230
|
+
() => ({ boxTop: 2, boxH: transcriptRows }),
|
|
231
|
+
[transcriptRows],
|
|
232
|
+
)
|
|
212
233
|
const transcriptModel: TranscriptModel = useMemo(
|
|
213
|
-
() =>
|
|
214
|
-
[
|
|
234
|
+
() => ({ entries: viewport.rows.map(item => [item.row.plain]), totalRows: viewport.rows.length }),
|
|
235
|
+
[viewport.rows],
|
|
215
236
|
)
|
|
216
237
|
const selMap = useMemo(
|
|
217
238
|
() => (sel?.active ? buildSelectionMap(transcriptModel, sel.anchor, sel.end) : null),
|
|
@@ -227,25 +248,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
227
248
|
|
|
228
249
|
useMouseEvents({
|
|
229
250
|
onWheel: (delta) => {
|
|
230
|
-
|
|
231
|
-
const targetLines = 2
|
|
232
|
-
let lines = 0
|
|
233
|
-
let next = scrollBack
|
|
234
|
-
const n = dedupedEntries.length
|
|
235
|
-
if (delta > 0) {
|
|
236
|
-
// scroll up (older): hide more entries from the tail
|
|
237
|
-
for (let i = n - 1 - next; i >= 0 && lines < targetLines; i--) {
|
|
238
|
-
lines += getEntryLines(i)
|
|
239
|
-
next++
|
|
240
|
-
}
|
|
241
|
-
} else {
|
|
242
|
-
// scroll down (newer): unhide entries from the tail
|
|
243
|
-
for (let i = n - next; i < n && lines < targetLines; i++) {
|
|
244
|
-
lines += getEntryLines(i)
|
|
245
|
-
next--
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
setScrollBack(Math.min(n, Math.max(0, next)))
|
|
251
|
+
scrollRows(delta * -3)
|
|
249
252
|
},
|
|
250
253
|
onPress: (row, col) => {
|
|
251
254
|
const p = screenToSelPoint(row, col, transcriptModel, geometry)
|
|
@@ -325,34 +328,25 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
325
328
|
? <WelcomeCard ready={ready} columns={terminal.columns} resumed={Boolean(resumeSessionId)} modelDisplay={modelDisplay} />
|
|
326
329
|
: <CompactHeader ready={ready} sessionId={chat.sessionId} columns={terminal.columns} />}
|
|
327
330
|
|
|
328
|
-
{
|
|
329
|
-
|
|
330
|
-
instead of floating mid-screen when the transcript has spare rows. */}
|
|
331
|
-
{olderHint !== null
|
|
332
|
-
? <Box width="100%" flexShrink={0}><Text dimColor wrap="truncate-end"> {olderHint}</Text></Box>
|
|
331
|
+
{!showWelcome
|
|
332
|
+
? <Box width="100%" flexShrink={0}><Text dimColor wrap="truncate-end"> {navigationHint}</Text></Box>
|
|
333
333
|
: null}
|
|
334
334
|
|
|
335
|
-
<Box
|
|
336
|
-
{
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
return <EntryScreen key={key} entry={entry} columns={terminal.columns} sel={entrySel} />
|
|
351
|
-
})}
|
|
352
|
-
{chat.pendingUser !== null ? <UserLine text={chat.pendingUser} /> : null}
|
|
353
|
-
{fitted.hiddenRecent > 0
|
|
354
|
-
? <Box width="100%" flexShrink={0}><Text dimColor wrap="truncate-end"> ↓ 滚轮/PageDown 翻页 · 较新 {fitted.hiddenRecent} 条</Text></Box>
|
|
355
|
-
: null}
|
|
335
|
+
<Box
|
|
336
|
+
height={showWelcome ? undefined : transcriptRows}
|
|
337
|
+
flexGrow={showWelcome ? 1 : 0}
|
|
338
|
+
flexShrink={showWelcome ? 1 : 0}
|
|
339
|
+
flexDirection="column"
|
|
340
|
+
justifyContent="flex-end"
|
|
341
|
+
overflowY="hidden"
|
|
342
|
+
>
|
|
343
|
+
{!showWelcome ? viewport.rows.map((item, index) => {
|
|
344
|
+
const range = selMap?.get(index)?.get(0)
|
|
345
|
+
const text = range && range.start < range.end
|
|
346
|
+
? highlightScreenRow(item.row.styled, range.start, range.end)
|
|
347
|
+
: item.row.styled
|
|
348
|
+
return <ScreenText key={`${item.entryId}:${item.rowIndex}`} row={item.row} text={text || ' '} />
|
|
349
|
+
}) : null}
|
|
356
350
|
</Box>
|
|
357
351
|
|
|
358
352
|
{dedupedEntries.length === 0 && chat.pendingUser === null && !showHelp
|
|
@@ -417,7 +411,7 @@ function WelcomeCard({ ready, columns, resumed, modelDisplay }: { ready: ReadySt
|
|
|
417
411
|
<Text>
|
|
418
412
|
<Text dimColor>{'>_ '}</Text>
|
|
419
413
|
<Text bold>Mobius</Text>
|
|
420
|
-
<Text dimColor> (v{
|
|
414
|
+
<Text dimColor> (v{TUI_VERSION})</Text>
|
|
421
415
|
</Text>
|
|
422
416
|
<Text> </Text>
|
|
423
417
|
<MetaRow label="model:" value={modelDisplay} hint="/help 查看命令" labelWidth={labelWidth} />
|
|
@@ -452,29 +446,6 @@ function CompactHeader({ ready, sessionId, columns }: { ready: ReadyState; sessi
|
|
|
452
446
|
)
|
|
453
447
|
}
|
|
454
448
|
|
|
455
|
-
// Normal and selected transcript rows use the exact same component tree. Mouse
|
|
456
|
-
// motion now changes only ANSI background bytes inside a row; it never swaps a
|
|
457
|
-
// rich Markdown entry for a structurally different plain-text entry, which used
|
|
458
|
-
// to make long/styled messages jump as the selection crossed entry boundaries.
|
|
459
|
-
function EntryScreen({ entry, columns, sel }: {
|
|
460
|
-
entry: AnyEntry
|
|
461
|
-
columns: number
|
|
462
|
-
sel?: Map<number, { start: number; end: number }>
|
|
463
|
-
}) {
|
|
464
|
-
const rows = entryScreenRows(viewsForEntry(entry), columns)
|
|
465
|
-
return (
|
|
466
|
-
<Box flexDirection="column">
|
|
467
|
-
{rows.map((row, index) => {
|
|
468
|
-
const range = sel?.get(index)
|
|
469
|
-
const text = range && range.start < range.end
|
|
470
|
-
? highlightScreenRow(row.styled, range.start, range.end)
|
|
471
|
-
: row.styled
|
|
472
|
-
return <ScreenText key={index} row={row} text={text || ' '} />
|
|
473
|
-
})}
|
|
474
|
-
</Box>
|
|
475
|
-
)
|
|
476
|
-
}
|
|
477
|
-
|
|
478
449
|
function ScreenText({ row, text }: { row: ScreenRow; text: string }) {
|
|
479
450
|
const tone = row.tone
|
|
480
451
|
const color = tone === 'tool' ? 'cyan'
|
|
@@ -529,13 +500,6 @@ function highlightScreenRow(styled: string, start: number, end: number): string
|
|
|
529
500
|
return out
|
|
530
501
|
}
|
|
531
502
|
|
|
532
|
-
function UserLine({ text }: { text: string }) {
|
|
533
|
-
const lines = text.split('\n')
|
|
534
|
-
if (lines[0] !== undefined) lines[0] = `› ${lines[0]}`
|
|
535
|
-
for (let i = 1; i < lines.length; i++) lines[i] = ` ${lines[i]}`
|
|
536
|
-
return <Box marginTop={1}><Text bold>{lines.join('\n')}</Text></Box>
|
|
537
|
-
}
|
|
538
|
-
|
|
539
503
|
function WorkingIndicator({ firstQuery }: { firstQuery: boolean }) {
|
|
540
504
|
const startedAt = useRef(Date.now())
|
|
541
505
|
const [animationFrame, setAnimationFrame] = useState(0)
|
|
@@ -1104,60 +1068,3 @@ function clickableUrl(url: string, maxLen?: number): string {
|
|
|
1104
1068
|
|
|
1105
1069
|
// displayWidth is imported from src/lib/screen-text.ts (CJK/emoji-aware), used
|
|
1106
1070
|
// here to size the AIMUX status block so the web URL truncates exactly.
|
|
1107
|
-
|
|
1108
|
-
export function fitTranscript(entries: AnyEntry[], rowBudget: number, columns: number, scrollBack = 0): {
|
|
1109
|
-
entries: AnyEntry[]
|
|
1110
|
-
/** Tail rows of the next older entry, used to fill spare space above the viewport. */
|
|
1111
|
-
peekRows: ScreenRow[]
|
|
1112
|
-
hiddenOlder: number
|
|
1113
|
-
hiddenRecent: number
|
|
1114
|
-
startIndex: number
|
|
1115
|
-
} {
|
|
1116
|
-
const tail = Math.max(0, entries.length - scrollBack)
|
|
1117
|
-
const available = tail === 0 ? [] : entries.slice(0, tail)
|
|
1118
|
-
const renderedRows = available.map((entry) => entryScreenRows(viewsForEntry(entry), columns))
|
|
1119
|
-
const fit = (budget: number) => {
|
|
1120
|
-
let rows = 0
|
|
1121
|
-
let first = available.length
|
|
1122
|
-
for (let index = available.length - 1; index >= 0; index--) {
|
|
1123
|
-
const nextRows = renderedRows[index].length
|
|
1124
|
-
if (first < available.length && rows + nextRows > budget) break
|
|
1125
|
-
rows += nextRows
|
|
1126
|
-
first = index
|
|
1127
|
-
}
|
|
1128
|
-
return { first, rows }
|
|
1129
|
-
}
|
|
1130
|
-
|
|
1131
|
-
const base = fit(rowBudget)
|
|
1132
|
-
let fitted = base
|
|
1133
|
-
let first = fitted.first
|
|
1134
|
-
let peekRows: ScreenRow[] = []
|
|
1135
|
-
// When older history exists, guarantee at least one row for the tail of the
|
|
1136
|
-
// next older message. If complete entries exactly consume the budget, refit
|
|
1137
|
-
// them with one fewer row; only the oldest complete entry can drop out, while
|
|
1138
|
-
// the latest content remains visible. A single oversized entry keeps its
|
|
1139
|
-
// original rendering because it cannot safely donate a row.
|
|
1140
|
-
if (first > 0 && fitted.rows <= rowBudget) {
|
|
1141
|
-
if (fitted.rows === rowBudget && rowBudget > 1) {
|
|
1142
|
-
const reduced = fit(rowBudget - 1)
|
|
1143
|
-
if (reduced.first > 0 && reduced.rows <= rowBudget - 1) {
|
|
1144
|
-
fitted = reduced
|
|
1145
|
-
first = reduced.first
|
|
1146
|
-
}
|
|
1147
|
-
}
|
|
1148
|
-
const olderRows = renderedRows[first - 1].slice()
|
|
1149
|
-
while (olderRows.length > 0 && !olderRows[0].plain.trim()) olderRows.shift()
|
|
1150
|
-
while (olderRows.length > 0 && !olderRows[olderRows.length - 1].plain.trim()) olderRows.pop()
|
|
1151
|
-
const spare = rowBudget - fitted.rows
|
|
1152
|
-
if (spare > 0 && olderRows.length > 0) {
|
|
1153
|
-
peekRows = olderRows.slice(-spare)
|
|
1154
|
-
}
|
|
1155
|
-
}
|
|
1156
|
-
return {
|
|
1157
|
-
entries: available.slice(first),
|
|
1158
|
-
peekRows,
|
|
1159
|
-
hiddenOlder: first,
|
|
1160
|
-
hiddenRecent: entries.length - tail,
|
|
1161
|
-
startIndex: first,
|
|
1162
|
-
}
|
|
1163
|
-
}
|
|
@@ -192,6 +192,26 @@ export function parseMouseEvents(input: string): MouseEventInfo[] {
|
|
|
192
192
|
return out
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
/** Collapse each contiguous wheel burst into one delta without reordering clicks. */
|
|
196
|
+
export function coalesceMouseEvents(events: MouseEventInfo[]): MouseEventInfo[] {
|
|
197
|
+
const out: MouseEventInfo[] = []
|
|
198
|
+
let wheelDelta = 0
|
|
199
|
+
const flushWheel = () => {
|
|
200
|
+
if (wheelDelta !== 0) out.push({ kind: 'wheel', delta: wheelDelta })
|
|
201
|
+
wheelDelta = 0
|
|
202
|
+
}
|
|
203
|
+
for (const event of events) {
|
|
204
|
+
if (event.kind === 'wheel') {
|
|
205
|
+
wheelDelta += event.delta
|
|
206
|
+
} else {
|
|
207
|
+
flushWheel()
|
|
208
|
+
out.push(event)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
flushWheel()
|
|
212
|
+
return out
|
|
213
|
+
}
|
|
214
|
+
|
|
195
215
|
/**
|
|
196
216
|
* Enables terminal mouse tracking for the lifetime of the calling component and
|
|
197
217
|
* forwards mouse events (wheel + left-button press/motion/release) to the given
|
|
@@ -228,7 +248,7 @@ export function useMouseEvents(handlers: {
|
|
|
228
248
|
// A single read() chunk may carry several events and a sequence may be
|
|
229
249
|
// split across chunks, so accumulate and re-scan.
|
|
230
250
|
buf += String(chunk)
|
|
231
|
-
for (const e of parseMouseEvents(buf)) {
|
|
251
|
+
for (const e of coalesceMouseEvents(parseMouseEvents(buf))) {
|
|
232
252
|
if (e.kind === 'wheel') refs.current.onWheel?.(e.delta)
|
|
233
253
|
else if (e.kind === 'press') refs.current.onPress?.(e.row, e.col)
|
|
234
254
|
else if (e.kind === 'motion') refs.current.onMotion?.(e.row, e.col)
|
package/src/lib/entry-view.ts
CHANGED
|
@@ -638,18 +638,18 @@ export function toolLabel(name: string): string {
|
|
|
638
638
|
// agent 输出, 则视为同一次输入的重复入口 → 丢弃, 避免 TUI 把同一条提问显示多次.
|
|
639
639
|
export function userTextOf(e: AnyEntry): string {
|
|
640
640
|
if (e?.type === 'event_msg' && e?.payload?.type === 'user_message') {
|
|
641
|
-
return String(e?.payload?.message || '')
|
|
641
|
+
return canonicalUserText(String(e?.payload?.message || ''))
|
|
642
642
|
}
|
|
643
643
|
if (e?.type === 'response_item' && e?.payload?.type === 'message' && e?.payload?.role === 'user') {
|
|
644
644
|
const c = e?.payload?.content
|
|
645
|
-
if (typeof c === 'string') return c
|
|
646
|
-
if (Array.isArray(c)) return c.map((b: any) => b?.text || b?.input_text || '').filter(Boolean).join('\n')
|
|
645
|
+
if (typeof c === 'string') return canonicalUserText(c)
|
|
646
|
+
if (Array.isArray(c)) return canonicalUserText(c.map((b: any) => b?.text || b?.input_text || '').filter(Boolean).join('\n'))
|
|
647
647
|
return ''
|
|
648
648
|
}
|
|
649
649
|
if (e?.type === 'user') {
|
|
650
650
|
const c = e?.message?.content
|
|
651
|
-
if (typeof c === 'string') return c
|
|
652
|
-
if (Array.isArray(c)) return c.filter((b: any) => b?.type === 'text').map((b: any) => b?.text || '').join('\n')
|
|
651
|
+
if (typeof c === 'string') return canonicalUserText(c)
|
|
652
|
+
if (Array.isArray(c)) return canonicalUserText(c.filter((b: any) => b?.type === 'text').map((b: any) => b?.text || '').join('\n'))
|
|
653
653
|
return ''
|
|
654
654
|
}
|
|
655
655
|
return ''
|
|
@@ -669,6 +669,11 @@ export function stripUserFraming(text: string): string {
|
|
|
669
669
|
return after || text
|
|
670
670
|
}
|
|
671
671
|
|
|
672
|
+
/** Canonical user text for duplicate event identities (framed vs plain). */
|
|
673
|
+
function canonicalUserText(text: string): string {
|
|
674
|
+
return stripUserFraming(text).replace(/\s+/g, ' ').trim()
|
|
675
|
+
}
|
|
676
|
+
|
|
672
677
|
export function isAssistantOutput(e: AnyEntry): boolean {
|
|
673
678
|
if (e?.type === 'assistant') return true
|
|
674
679
|
if (e?.type === 'event_msg' && e?.payload?.type === 'agent_message') return true
|
package/src/lib/screen-text.ts
CHANGED
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
import wrapAnsi from 'wrap-ansi'
|
|
14
14
|
import { renderMarkdownLines } from '../markdown.js'
|
|
15
15
|
import { toolLabel, viewsForEntry, type EntryView } from './entry-view.js'
|
|
16
|
-
import type { AnyEntry } from '../types.js'
|
|
17
16
|
|
|
18
17
|
// ── shared text helpers (mirrored from Chat.tsx, kept here to avoid a cycle) ─
|
|
19
18
|
export function displayWidth(str: string): number {
|
|
@@ -221,11 +220,6 @@ export function entryScreenRows(views: EntryView[], columns: number): ScreenRow[
|
|
|
221
220
|
return lines
|
|
222
221
|
}
|
|
223
222
|
|
|
224
|
-
/** Plain projection used by fitting, geometry, hit-testing, and copying. */
|
|
225
|
-
export function entryScreenLines(views: EntryView[], columns: number): string[] {
|
|
226
|
-
return entryScreenRows(views, columns).map(row => row.plain)
|
|
227
|
-
}
|
|
228
|
-
|
|
229
223
|
// ── vertical geometry ────────────────────────────────────────────────────────
|
|
230
224
|
export interface TranscriptGeometry {
|
|
231
225
|
/** Screen row where the transcript box's top edge sits. */
|
|
@@ -240,30 +234,6 @@ export interface TranscriptGeometry {
|
|
|
240
234
|
* (`flexShrink=0`); the middle column holds header + hint + transcript (flexGrow)
|
|
241
235
|
* + tip + help. Margins that render as extra rows are counted explicitly.
|
|
242
236
|
*/
|
|
243
|
-
export function computeTranscriptGeometry(opts: {
|
|
244
|
-
viewportRows: number
|
|
245
|
-
composerRows: number
|
|
246
|
-
statusRows: number
|
|
247
|
-
activityRows: number
|
|
248
|
-
helpRows: number
|
|
249
|
-
showWelcome: boolean
|
|
250
|
-
welcomeRows: number
|
|
251
|
-
olderHintShown: boolean
|
|
252
|
-
tipShown: boolean
|
|
253
|
-
}): TranscriptGeometry {
|
|
254
|
-
// The composer's reported height already includes its marginTop; the status
|
|
255
|
-
// area and working indicator rows are already folded into statusRows and
|
|
256
|
-
// activityRows. No extra +1 here — calibrated against the rendered frame.
|
|
257
|
-
const bottomH = opts.activityRows + opts.composerRows + opts.statusRows
|
|
258
|
-
const midH = opts.viewportRows - bottomH
|
|
259
|
-
const headerH = opts.showWelcome ? opts.welcomeRows : 1
|
|
260
|
-
const hintH = opts.olderHintShown ? 1 : 0
|
|
261
|
-
const tipH = opts.tipShown ? 2 : 0 // marginTop 1 + content 1
|
|
262
|
-
const helpH = opts.helpRows > 0 ? opts.helpRows + 1 : 0 // +1 marginTop
|
|
263
|
-
const boxTop = headerH + hintH + tipH + helpH
|
|
264
|
-
return { boxTop, boxH: Math.max(0, midH - boxTop) }
|
|
265
|
-
}
|
|
266
|
-
|
|
267
237
|
// ── selection mapping ────────────────────────────────────────────────────────
|
|
268
238
|
export interface SelPoint {
|
|
269
239
|
entry: number // index into the fitted entries
|
|
@@ -276,11 +246,6 @@ export interface TranscriptModel {
|
|
|
276
246
|
totalRows: number
|
|
277
247
|
}
|
|
278
248
|
|
|
279
|
-
export function buildTranscriptModel(fittedEntries: AnyEntry[], columns: number): TranscriptModel {
|
|
280
|
-
const entries = fittedEntries.map((e) => entryScreenLines(viewsForEntry(e), columns))
|
|
281
|
-
return { entries, totalRows: entries.reduce((sum, l) => sum + l.length, 0) }
|
|
282
|
-
}
|
|
283
|
-
|
|
284
249
|
/** Convert a screen (row, col) into a SelPoint, or null if outside the transcript. */
|
|
285
250
|
export function screenToSelPoint(
|
|
286
251
|
screenRow: number,
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure row-level transcript viewport.
|
|
3
|
+
*
|
|
4
|
+
* Entries are materialized lazily through RowAccess.rowsAt(). Navigation keeps
|
|
5
|
+
* an entry/row anchor, so moving by N rows is exact even when one entry is much
|
|
6
|
+
* taller than the terminal. No React or Ink dependency belongs in this file.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface RowAnchor {
|
|
10
|
+
entryId: string
|
|
11
|
+
entryIndex: number
|
|
12
|
+
rowIndex: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ViewportRow<T> {
|
|
16
|
+
entryId: string
|
|
17
|
+
entryIndex: number
|
|
18
|
+
rowIndex: number
|
|
19
|
+
row: T
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RowAccess<T> {
|
|
23
|
+
length: number
|
|
24
|
+
idAt: (index: number) => string
|
|
25
|
+
indexOf: (entryId: string) => number
|
|
26
|
+
rowsAt: (index: number) => readonly T[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ViewportSlice<T> {
|
|
30
|
+
anchor: RowAnchor | null
|
|
31
|
+
rows: ViewportRow<T>[]
|
|
32
|
+
hasOlder: boolean
|
|
33
|
+
hasNewer: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createRowAccess<E, T>(
|
|
37
|
+
entries: readonly E[],
|
|
38
|
+
getId: (entry: E, index: number) => string,
|
|
39
|
+
getRows: (entry: E, index: number) => readonly T[],
|
|
40
|
+
): RowAccess<T> {
|
|
41
|
+
const ids = entries.map(getId)
|
|
42
|
+
const indexById = new Map(ids.map((id, index) => [id, index]))
|
|
43
|
+
return {
|
|
44
|
+
length: entries.length,
|
|
45
|
+
idAt: (index) => ids[index] ?? '',
|
|
46
|
+
indexOf: (entryId) => indexById.get(entryId) ?? -1,
|
|
47
|
+
rowsAt: (index) => index >= 0 && index < entries.length ? getRows(entries[index], index) : [],
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function previousNonEmpty<T>(access: RowAccess<T>, from: number): number {
|
|
52
|
+
for (let index = from; index >= 0; index--) {
|
|
53
|
+
if (access.rowsAt(index).length > 0) return index
|
|
54
|
+
}
|
|
55
|
+
return -1
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function nextNonEmpty<T>(access: RowAccess<T>, from: number): number {
|
|
59
|
+
for (let index = from; index < access.length; index++) {
|
|
60
|
+
if (access.rowsAt(index).length > 0) return index
|
|
61
|
+
}
|
|
62
|
+
return -1
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function resolveAnchor<T>(access: RowAccess<T>, anchor: RowAnchor | null): RowAnchor | null {
|
|
66
|
+
if (!anchor || access.length === 0) return null
|
|
67
|
+
let entryIndex = access.indexOf(anchor.entryId)
|
|
68
|
+
if (entryIndex < 0) entryIndex = Math.max(0, Math.min(access.length - 1, anchor.entryIndex))
|
|
69
|
+
|
|
70
|
+
let rows = access.rowsAt(entryIndex)
|
|
71
|
+
if (rows.length === 0) {
|
|
72
|
+
const next = nextNonEmpty(access, entryIndex + 1)
|
|
73
|
+
const previous = previousNonEmpty(access, entryIndex - 1)
|
|
74
|
+
entryIndex = next >= 0 ? next : previous
|
|
75
|
+
if (entryIndex < 0) return null
|
|
76
|
+
rows = access.rowsAt(entryIndex)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
entryId: access.idAt(entryIndex),
|
|
81
|
+
entryIndex,
|
|
82
|
+
rowIndex: Math.max(0, Math.min(rows.length - 1, anchor.rowIndex)),
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function tailAnchor<T>(access: RowAccess<T>, viewportRows: number): RowAnchor | null {
|
|
87
|
+
let remaining = Math.max(1, Math.floor(viewportRows))
|
|
88
|
+
let first: RowAnchor | null = null
|
|
89
|
+
for (let entryIndex = access.length - 1; entryIndex >= 0; entryIndex--) {
|
|
90
|
+
const rows = access.rowsAt(entryIndex)
|
|
91
|
+
if (rows.length === 0) continue
|
|
92
|
+
first = { entryId: access.idAt(entryIndex), entryIndex, rowIndex: 0 }
|
|
93
|
+
if (rows.length >= remaining) {
|
|
94
|
+
return { entryId: access.idAt(entryIndex), entryIndex, rowIndex: rows.length - remaining }
|
|
95
|
+
}
|
|
96
|
+
remaining -= rows.length
|
|
97
|
+
}
|
|
98
|
+
return first
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Move the top-row anchor by an exact signed row delta. Positive means newer. */
|
|
102
|
+
export function moveAnchorByRows<T>(access: RowAccess<T>, anchor: RowAnchor | null, deltaRows: number): RowAnchor | null {
|
|
103
|
+
const resolved = resolveAnchor(access, anchor)
|
|
104
|
+
if (!resolved || deltaRows === 0) return resolved
|
|
105
|
+
|
|
106
|
+
let entryIndex = resolved.entryIndex
|
|
107
|
+
let rowIndex = resolved.rowIndex
|
|
108
|
+
let remaining = Math.abs(Math.trunc(deltaRows))
|
|
109
|
+
|
|
110
|
+
if (deltaRows > 0) {
|
|
111
|
+
while (remaining > 0) {
|
|
112
|
+
const rows = access.rowsAt(entryIndex)
|
|
113
|
+
const within = rows.length - 1 - rowIndex
|
|
114
|
+
if (remaining <= within) { rowIndex += remaining; remaining = 0; break }
|
|
115
|
+
remaining -= within
|
|
116
|
+
const next = nextNonEmpty(access, entryIndex + 1)
|
|
117
|
+
if (next < 0) { rowIndex = rows.length - 1; break }
|
|
118
|
+
entryIndex = next
|
|
119
|
+
rowIndex = 0
|
|
120
|
+
remaining -= 1
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
while (remaining > 0) {
|
|
124
|
+
if (remaining <= rowIndex) { rowIndex -= remaining; remaining = 0; break }
|
|
125
|
+
remaining -= rowIndex
|
|
126
|
+
const previous = previousNonEmpty(access, entryIndex - 1)
|
|
127
|
+
if (previous < 0) { rowIndex = 0; break }
|
|
128
|
+
entryIndex = previous
|
|
129
|
+
rowIndex = access.rowsAt(entryIndex).length - 1
|
|
130
|
+
remaining -= 1
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { entryId: access.idAt(entryIndex), entryIndex, rowIndex }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function sliceViewport<T>(access: RowAccess<T>, anchor: RowAnchor | null, viewportRows: number): ViewportSlice<T> {
|
|
138
|
+
const resolved = resolveAnchor(access, anchor)
|
|
139
|
+
const limit = Math.max(0, Math.floor(viewportRows))
|
|
140
|
+
if (!resolved || limit === 0) return { anchor: resolved, rows: [], hasOlder: false, hasNewer: false }
|
|
141
|
+
|
|
142
|
+
const visible: ViewportRow<T>[] = []
|
|
143
|
+
let entryIndex = resolved.entryIndex
|
|
144
|
+
let rowIndex = resolved.rowIndex
|
|
145
|
+
while (entryIndex < access.length && visible.length < limit) {
|
|
146
|
+
const rows = access.rowsAt(entryIndex)
|
|
147
|
+
for (; rowIndex < rows.length && visible.length < limit; rowIndex++) {
|
|
148
|
+
visible.push({ entryId: access.idAt(entryIndex), entryIndex, rowIndex, row: rows[rowIndex] })
|
|
149
|
+
}
|
|
150
|
+
entryIndex = nextNonEmpty(access, entryIndex + 1)
|
|
151
|
+
rowIndex = 0
|
|
152
|
+
if (entryIndex < 0) break
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const hasOlder = resolved.rowIndex > 0 || previousNonEmpty(access, resolved.entryIndex - 1) >= 0
|
|
156
|
+
const last = visible.at(-1)
|
|
157
|
+
const hasNewer = !!last && (
|
|
158
|
+
last.rowIndex < access.rowsAt(last.entryIndex).length - 1 ||
|
|
159
|
+
nextNonEmpty(access, last.entryIndex + 1) >= 0
|
|
160
|
+
)
|
|
161
|
+
return { anchor: resolved, rows: visible, hasOlder, hasNewer }
|
|
162
|
+
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The TUI package version has one source of truth: package.json.
|
|
3
|
+
*
|
|
4
|
+
* Keep all runtime/UI consumers behind this module so a release cannot show
|
|
5
|
+
* a stale hard-coded version while the npm package has already been bumped.
|
|
6
|
+
*/
|
|
7
|
+
import { createRequire } from 'node:module'
|
|
8
|
+
|
|
9
|
+
interface TuiPackageMetadata {
|
|
10
|
+
name?: string
|
|
11
|
+
version?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const packageJson = createRequire(import.meta.url)('../package.json') as TuiPackageMetadata
|
|
15
|
+
|
|
16
|
+
if (!packageJson.version) {
|
|
17
|
+
throw new Error('TUI package.json is missing a version')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const TUI_VERSION = packageJson.version
|
|
21
|
+
export const TUI_PACKAGE_NAME = packageJson.name ?? '@mobius-os/mobius'
|
package/tests/screen.test.tsx
CHANGED
|
@@ -20,8 +20,9 @@ import { Box, Text } from 'ink'
|
|
|
20
20
|
import { render } from 'ink-testing-library'
|
|
21
21
|
import { Screen } from '../src/components/Screen.js'
|
|
22
22
|
import { Select } from '../src/components/primitives.js'
|
|
23
|
-
import {
|
|
24
|
-
import
|
|
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'
|
|
25
26
|
|
|
26
27
|
const delay = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
|
|
27
28
|
const strip = (s: string) => s.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
|
|
@@ -36,17 +37,17 @@ const ROWS = 24
|
|
|
36
37
|
async function main() {
|
|
37
38
|
console.log('\n[SCREEN] no-residue picker transitions\n')
|
|
38
39
|
|
|
39
|
-
// A
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
// look gray even though the rest of the message is colored.
|
|
43
|
-
const styledEntries: AnyEntry[] = [
|
|
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 = [
|
|
44
43
|
{ type: 'assistant', uuid: 'styled-old', message: { role: 'assistant', content: [{ type: 'text', text: '[彩色链接](https://example.com)' }] } },
|
|
45
44
|
{ type: 'assistant', uuid: 'styled-new', message: { role: 'assistant', content: [{ type: 'text', text: '最新消息' }] } },
|
|
46
45
|
]
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
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')
|
|
50
51
|
|
|
51
52
|
// ── 1. Without Screen, a tall frame overflows the terminal (the bug). ───────
|
|
52
53
|
const tall = render(
|
package/tests/scroll.test.tsx
CHANGED
|
@@ -138,7 +138,7 @@ async function main() {
|
|
|
138
138
|
const tailFrame = strip(lastFrame() ?? '')
|
|
139
139
|
|
|
140
140
|
ok(tailFrame.includes('回答 24'), 'latest entry visible at tail (not hidden)')
|
|
141
|
-
ok(tailFrame.includes('
|
|
141
|
+
ok(tailFrame.includes('↑ 还有较早内容') && tailFrame.includes('滚轮 3 行'), 'navigation reports older content and the exact wheel step')
|
|
142
142
|
|
|
143
143
|
// ── live resize: refit one dynamic frame, never retain old-width output ──
|
|
144
144
|
resize(stdout as unknown as NodeJS.WriteStream, 52, 18)
|
|
@@ -157,21 +157,20 @@ async function main() {
|
|
|
157
157
|
ok(tallAnswers > narrowAnswers, 'larger resize reveals more history in the same viewport')
|
|
158
158
|
ok((tallFrame.match(/>_ Mobius/g) ?? []).length === 1, 'larger resize still has one dynamic header')
|
|
159
159
|
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
// viewport has spare rows (regression for real terminals, which bound the
|
|
163
|
-
// transcript box height via stdout.isTTY).
|
|
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.
|
|
164
162
|
const tallLines = tallFrame.split('\n')
|
|
165
|
-
const hintIdx = tallLines.findIndex(l => l.includes('
|
|
166
|
-
ok(hintIdx === 1, `
|
|
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')
|
|
167
166
|
const messageRows = tallLines.slice(hintIdx + 1).filter(line => line.trim())
|
|
168
|
-
ok(messageRows.length > 0 && messageRows[0].includes('
|
|
167
|
+
ok(messageRows.length > 0 && messageRows[0].includes('回答'), 'a real virtualized message row follows navigation without a synthetic peek row')
|
|
169
168
|
|
|
170
169
|
// ── PageUp: viewport scrolls back over history ────────────────────────────
|
|
171
170
|
stdin.write('\x1b[5~') // PageUp
|
|
172
171
|
await delay(300)
|
|
173
172
|
const upFrame = strip(lastFrame() ?? '')
|
|
174
|
-
ok(upFrame.includes('
|
|
173
|
+
ok(upFrame.includes('↓ 较新内容'), 'after PageUp: navigation reports newer content below')
|
|
175
174
|
ok(!upFrame.includes('回答 24'), 'after PageUp: latest entry paged out of view')
|
|
176
175
|
ok(/回答 \d+/.test(upFrame), 'after PageUp: an older entry is visible')
|
|
177
176
|
|
|
@@ -185,7 +184,7 @@ async function main() {
|
|
|
185
184
|
stdin.write('\x1b[<64;5;5M') // wheel up = scroll back
|
|
186
185
|
await delay(300)
|
|
187
186
|
const wheelUp = strip(lastFrame() ?? '')
|
|
188
|
-
ok(wheelUp.includes('
|
|
187
|
+
ok(wheelUp.includes('↓ 较新内容'), 'wheel up: navigation reports newer content below')
|
|
189
188
|
ok(!wheelUp.includes('回答 24'), 'wheel up: latest entry paged out of view')
|
|
190
189
|
ok(/回答 \d+/.test(wheelUp), 'wheel up: an older entry is visible')
|
|
191
190
|
|
|
@@ -199,7 +198,7 @@ async function main() {
|
|
|
199
198
|
stdin.write('\x1b[M' + String.fromCharCode(96, 50, 50))
|
|
200
199
|
await delay(300)
|
|
201
200
|
const legacyUp = strip(lastFrame() ?? '')
|
|
202
|
-
ok(legacyUp.includes('
|
|
201
|
+
ok(legacyUp.includes('↓ 较新内容'), 'legacy wheel up: navigation reports newer content below')
|
|
203
202
|
ok(!legacyUp.includes('回答 24'), 'legacy wheel up: latest entry paged out of view')
|
|
204
203
|
|
|
205
204
|
stdin.write('\x1b[M' + String.fromCharCode(97, 50, 50)) // wheel down Cb = 0x61
|
|
@@ -229,13 +228,15 @@ async function main() {
|
|
|
229
228
|
await bootToChat(second.stdin, second.lastFrame)
|
|
230
229
|
await populateTranscript(second.stdin, emitEntry)
|
|
231
230
|
const before = strip(second.lastFrame() ?? '')
|
|
231
|
+
const beforeAnswers = before.match(/回答 \d+/g) ?? []
|
|
232
232
|
ok(before.includes('回答 24'), 'disable-mouse: latest entry visible before wheel')
|
|
233
233
|
|
|
234
234
|
second.stdin.write('\x1b[<64;5;5M') // wheel up — must be ignored
|
|
235
235
|
await delay(300)
|
|
236
236
|
const after = strip(second.lastFrame() ?? '')
|
|
237
|
+
const afterAnswers = after.match(/回答 \d+/g) ?? []
|
|
237
238
|
ok(after.includes('回答 24'), 'disable-mouse: wheel up leaves latest entry in view')
|
|
238
|
-
ok(
|
|
239
|
+
ok(JSON.stringify(afterAnswers) === JSON.stringify(beforeAnswers), 'disable-mouse: wheel up leaves the visible row window unchanged')
|
|
239
240
|
} finally {
|
|
240
241
|
second.unmount()
|
|
241
242
|
delete process.env.MOBIUS_TUI_DISABLE_MOUSE
|
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('
|
|
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
|
@@ -22,7 +22,7 @@ import { PrepScreen } from '../src/components/PrepScreen.js'
|
|
|
22
22
|
import { Select, TextInput } from '../src/components/primitives.js'
|
|
23
23
|
import { MobiusClient } from '../src/api.js'
|
|
24
24
|
import { renderMarkdownLines } from '../src/markdown.js'
|
|
25
|
-
import { viewsForEntry, toolLabel } from '../src/lib/entry-view.js'
|
|
25
|
+
import { dedupeUserEntries, viewsForEntry, toolLabel } from '../src/lib/entry-view.js'
|
|
26
26
|
import { SseConnection } from '../src/sse.js'
|
|
27
27
|
import type { ReadyState } from '../src/components/PrepScreen.js'
|
|
28
28
|
|
|
@@ -263,6 +263,17 @@ function testMarkdownCodeRendering() {
|
|
|
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
264
|
}
|
|
265
265
|
|
|
266
|
+
function testFirstUserEntryDedupe() {
|
|
267
|
+
console.log('\n[UI 4b] first user message event deduplication')
|
|
268
|
+
const framed = '上下文注入\n\n## 用户的问题\n\n你好,检查首条消息'
|
|
269
|
+
const entries = [
|
|
270
|
+
{ type: 'user', uuid: 'framed-user', message: { role: 'user', content: framed } },
|
|
271
|
+
{ type: 'event_msg', uuid: 'plain-user', payload: { type: 'user_message', message: '你好,检查首条消息' } },
|
|
272
|
+
]
|
|
273
|
+
const deduped = dedupeUserEntries(entries as any)
|
|
274
|
+
ok(deduped.length === 1, 'framed and plain first-turn user events render once')
|
|
275
|
+
}
|
|
276
|
+
|
|
266
277
|
// ════════════════════════════════════════════════════════════════════════════
|
|
267
278
|
// TEST 5 — Prep screen renders the project picker when cwd is unbound
|
|
268
279
|
// ════════════════════════════════════════════════════════════════════════════
|
|
@@ -865,6 +876,7 @@ async function main() {
|
|
|
865
876
|
await testChat()
|
|
866
877
|
await testResumedWorkingStatus()
|
|
867
878
|
testMarkdownCodeRendering()
|
|
879
|
+
testFirstUserEntryDedupe()
|
|
868
880
|
await testPrepRender()
|
|
869
881
|
await testSelectViewport()
|
|
870
882
|
await testProjectPickerEscQuit()
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { performance } from 'node:perf_hooks'
|
|
2
|
+
import { coalesceMouseEvents, parseMouseEvents } from '../src/components/primitives.js'
|
|
3
|
+
import {
|
|
4
|
+
createRowAccess, moveAnchorByRows, sliceViewport, tailAnchor,
|
|
5
|
+
type RowAnchor,
|
|
6
|
+
} from '../src/lib/transcript-viewport.js'
|
|
7
|
+
|
|
8
|
+
interface Entry { id: string; rows: number[] }
|
|
9
|
+
|
|
10
|
+
let passed = 0
|
|
11
|
+
let failed = 0
|
|
12
|
+
function ok(condition: boolean, message: string): void {
|
|
13
|
+
if (condition) { passed += 1; console.log(` ✓ ${message}`) }
|
|
14
|
+
else { failed += 1; console.error(` ✗ ${message}`) }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function access(entries: Entry[]) {
|
|
18
|
+
return createRowAccess(entries, entry => entry.id, entry => entry.rows)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function sameAnchor(actual: RowAnchor | null, entryId: string, rowIndex: number): boolean {
|
|
22
|
+
return actual?.entryId === entryId && actual.rowIndex === rowIndex
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function main(): void {
|
|
26
|
+
console.log('\n[VIEWPORT] exact row-level transcript navigation\n')
|
|
27
|
+
|
|
28
|
+
const long = access([{ id: 'long', rows: Array.from({ length: 1000 }, (_, i) => i) }])
|
|
29
|
+
const tail = tailAnchor(long, 24)
|
|
30
|
+
ok(sameAnchor(tail, 'long', 976), '1000-row entry tails at row 976 in a 24-row viewport')
|
|
31
|
+
const pageUp = moveAnchorByRows(long, tail, -23)
|
|
32
|
+
ok(sameAnchor(pageUp, 'long', 953), 'PageUp moves exactly 23 rows inside one long entry')
|
|
33
|
+
ok(sameAnchor(moveAnchorByRows(long, pageUp, 23), 'long', 976), 'PageDown exactly reverses PageUp')
|
|
34
|
+
|
|
35
|
+
const mixed = access([
|
|
36
|
+
{ id: 'a', rows: [0, 1] },
|
|
37
|
+
{ id: 'b', rows: Array.from({ length: 50 }, (_, i) => i) },
|
|
38
|
+
{ id: 'c', rows: [0, 1, 2] },
|
|
39
|
+
{ id: 'd', rows: Array.from({ length: 1000 }, (_, i) => i) },
|
|
40
|
+
])
|
|
41
|
+
const crossed = moveAnchorByRows(mixed, { entryId: 'd', entryIndex: 3, rowIndex: 0 }, -4)
|
|
42
|
+
ok(sameAnchor(crossed, 'b', 49), 'row navigation crosses mixed-height entry boundaries exactly')
|
|
43
|
+
const mixedUp = moveAnchorByRows(mixed, tailAnchor(mixed, 24), -777)
|
|
44
|
+
ok(JSON.stringify(moveAnchorByRows(mixed, mixedUp, 777)) === JSON.stringify(tailAnchor(mixed, 24)), 'large mixed-height PageUp/PageDown movement is reversible')
|
|
45
|
+
|
|
46
|
+
const startSlice = sliceViewport(long, { entryId: 'long', entryIndex: 0, rowIndex: 0 }, 24)
|
|
47
|
+
const middleSlice = sliceViewport(long, { entryId: 'long', entryIndex: 0, rowIndex: 500 }, 24)
|
|
48
|
+
const endSlice = sliceViewport(long, tail, 24)
|
|
49
|
+
ok(startSlice.rows[0]?.row === 0, 'long entry start is directly accessible')
|
|
50
|
+
ok(middleSlice.rows[0]?.row === 500, 'long entry middle is directly accessible')
|
|
51
|
+
ok(endSlice.rows.at(-1)?.row === 999, 'long entry end is directly accessible')
|
|
52
|
+
ok(startSlice.rows.length === 24 && middleSlice.rows.length === 24 && endSlice.rows.length === 24, 'only viewport-height rows are materialized in each slice')
|
|
53
|
+
|
|
54
|
+
const resized = access([{ id: 'long', rows: Array.from({ length: 700 }, (_, i) => i) }])
|
|
55
|
+
const restored = sliceViewport(resized, { entryId: 'long', entryIndex: 0, rowIndex: 500 }, 10).anchor
|
|
56
|
+
ok(sameAnchor(restored, 'long', 500), 'resize preserves a historical entry/row anchor')
|
|
57
|
+
ok(sameAnchor(tailAnchor(resized, 10), 'long', 690), 'tail-follow mode recomputes against the resized layout')
|
|
58
|
+
|
|
59
|
+
const appended = access([
|
|
60
|
+
{ id: 'long', rows: Array.from({ length: 1000 }, (_, i) => i) },
|
|
61
|
+
{ id: 'new', rows: [0, 1, 2] },
|
|
62
|
+
])
|
|
63
|
+
const held = sliceViewport(appended, pageUp, 24).anchor
|
|
64
|
+
ok(sameAnchor(held, 'long', 953), 'new entries do not move a historical anchor')
|
|
65
|
+
ok(sameAnchor(tailAnchor(appended, 24), 'long', 979), 'tail-follow mode automatically includes newly appended rows')
|
|
66
|
+
|
|
67
|
+
const burst = '\x1b[<64;5;5M'.repeat(5)
|
|
68
|
+
const coalesced = coalesceMouseEvents(parseMouseEvents(burst))
|
|
69
|
+
ok(coalesced.length === 1 && coalesced[0]?.kind === 'wheel' && coalesced[0].delta === 5, 'five wheel events in one input chunk coalesce into one +5 action')
|
|
70
|
+
ok(sameAnchor(moveAnchorByRows(long, tail, -3 * (coalesced[0]?.kind === 'wheel' ? coalesced[0].delta : 0)), 'long', 961), 'five wheel notches move exactly 15 rows')
|
|
71
|
+
|
|
72
|
+
const thousand = access(Array.from({ length: 1000 }, (_, i) => ({ id: `e-${i}`, rows: [i] })))
|
|
73
|
+
for (let i = 0; i < 100; i++) sliceViewport(thousand, moveAnchorByRows(thousand, tailAnchor(thousand, 24), -i), 24)
|
|
74
|
+
const started = performance.now()
|
|
75
|
+
for (let i = 0; i < 1000; i++) sliceViewport(thousand, moveAnchorByRows(thousand, tailAnchor(thousand, 24), -(i % 900)), 24)
|
|
76
|
+
const averageMs = (performance.now() - started) / 1000
|
|
77
|
+
ok(averageMs < 5, `1000-entry viewport navigation averages under 5ms (${averageMs.toFixed(3)}ms)`)
|
|
78
|
+
|
|
79
|
+
console.log(`\n==== VIEWPORT RESULT: ${passed} passed, ${failed} failed ====\n`)
|
|
80
|
+
process.exit(failed === 0 ? 0 : 1)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
main()
|