@mobius-os/mobius 0.3.31 → 0.3.38
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/scripts/build-python-bundles.sh +2 -2
- package/src/App.tsx +18 -2
- package/src/aimux.ts +17 -8
- package/src/components/Chat.tsx +118 -197
- package/src/components/ConfigFlow.tsx +30 -6
- package/src/components/Login.tsx +5 -3
- package/src/components/PrepScreen.tsx +98 -25
- package/src/components/primitives.tsx +44 -5
- package/src/lib/cursor-keys.ts +70 -0
- package/src/lib/delete-keys.ts +4 -4
- 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/markdown.ts +34 -10
- package/src/version.ts +21 -0
- package/tests/aimux.test.tsx +17 -6
- package/tests/flow.test.tsx +25 -4
- 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 +141 -10
- 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.38",
|
|
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/App.tsx
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import React, { useEffect, useState } from 'react'
|
|
13
13
|
import { Box, Text } from 'ink'
|
|
14
14
|
import { MobiusClient, getMe, login, ApiError } from './api.js'
|
|
15
|
-
import { loadLogin, saveLogin, type LoginRecord } from './config.js'
|
|
15
|
+
import { clearLogin, loadLogin, saveLogin, type LoginRecord } from './config.js'
|
|
16
16
|
import { LoginScreen } from './components/Login.js'
|
|
17
17
|
import { PrepScreen, type ReadyState } from './components/PrepScreen.js'
|
|
18
18
|
import { ChatScreen } from './components/Chat.js'
|
|
@@ -150,6 +150,21 @@ export function App() {
|
|
|
150
150
|
void stopAimuxConnection().finally(() => process.exit(0))
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
async function onLogout() {
|
|
154
|
+
if (process.env.MOBIUS_TUI_DEBUG) console.error('[route] logout')
|
|
155
|
+
try {
|
|
156
|
+
await clearLogin()
|
|
157
|
+
await stopAimuxConnection()
|
|
158
|
+
} finally {
|
|
159
|
+
setClient(null)
|
|
160
|
+
setUserId(null)
|
|
161
|
+
setReady(null)
|
|
162
|
+
setResumeSessionId(null)
|
|
163
|
+
setAimuxStatus({ state: 'stopped', phase: 'idle', detail: '登录后自动连接' })
|
|
164
|
+
setRoute('login')
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
153
168
|
// ── render ─────────────────────────────────────────────────────────────────
|
|
154
169
|
// The chat screen already pins itself to the terminal height, so render it
|
|
155
170
|
// bare — a <Screen> wrapper would clip its transcript in short terminals and
|
|
@@ -168,6 +183,7 @@ export function App() {
|
|
|
168
183
|
onClear={onClear}
|
|
169
184
|
onResume={onResume}
|
|
170
185
|
onQuit={onQuit}
|
|
186
|
+
onLogout={() => { void onLogout() }}
|
|
171
187
|
onReconfigure={onReconfigure}
|
|
172
188
|
onConfigCancel={onConfigCancel}
|
|
173
189
|
aimuxStatus={aimuxStatus}
|
|
@@ -178,7 +194,7 @@ export function App() {
|
|
|
178
194
|
if (route === 'boot') {
|
|
179
195
|
node = <Box paddingX={2} paddingY={1}><Text color="cyan">{bootMsg}</Text></Box>
|
|
180
196
|
} else if (route === 'login' || !client) {
|
|
181
|
-
node = <LoginScreen onSuccess={onLoginSuccess} />
|
|
197
|
+
node = <LoginScreen onSuccess={onLoginSuccess} initialServer={prefill.server} initialUsername={prefill.username} />
|
|
182
198
|
} else if (route === 'resume' && ready) {
|
|
183
199
|
node = <Box flexDirection="column"><AimuxStatusLine status={aimuxStatus} compact /><ResumePicker client={client} project={ready.project} onPick={onResumed} onBack={() => setRoute('chat')} /></Box>
|
|
184
200
|
} else {
|
package/src/aimux.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* currently authenticated Mobius server. Nothing is started before login.
|
|
7
7
|
*/
|
|
8
8
|
import { spawn, spawnSync, type ChildProcess } from 'node:child_process'
|
|
9
|
+
import { createHash } from 'node:crypto'
|
|
9
10
|
import { promises as fs, existsSync, createWriteStream } from 'node:fs'
|
|
10
11
|
import os from 'node:os'
|
|
11
12
|
import path from 'node:path'
|
|
@@ -88,8 +89,8 @@ async function pythonForAimux(onProgress?: (p: InstallProgress) => void): Promis
|
|
|
88
89
|
// 解压到 ~/.mobius/python-bundle/ 后用 `<python> -m aimux` 运行,彻底绕开宿主机
|
|
89
90
|
// 系统 python(如被精简掉 ensurepip 的容器镜像)。aimux 全部依赖为纯 Python,
|
|
90
91
|
// 故三平台可共用同一套打包产物,分别按 arch 发布到 CDN。
|
|
91
|
-
const BUNDLE_VER = '
|
|
92
|
-
const BUNDLE_AIMUX_VERSION = '0.1.
|
|
92
|
+
const BUNDLE_VER = '3'
|
|
93
|
+
const BUNDLE_AIMUX_VERSION = '0.1.23'
|
|
93
94
|
const bundleDir = () => path.join(mobiusHome(), 'python-bundle')
|
|
94
95
|
const bundlePython = () => WIN
|
|
95
96
|
? path.join(bundleDir(), 'python', 'python.exe')
|
|
@@ -300,16 +301,24 @@ export async function ensureAimux(onProgress?: (p: InstallProgress) => void): Pr
|
|
|
300
301
|
return { ok: false, error: `${venvError};内置运行时也失败: ${bundle.error}` }
|
|
301
302
|
}
|
|
302
303
|
|
|
303
|
-
export function tuiAimuxIdentifier(): string {
|
|
304
|
-
const host =
|
|
305
|
-
|
|
304
|
+
export function tuiAimuxIdentifier(hostname = os.hostname(), cwd = process.cwd()): string {
|
|
305
|
+
const host = hostname.toLowerCase().replace(/[^a-z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32)
|
|
306
|
+
// One machine may run several Mobius TUIs for different projects. A
|
|
307
|
+
// hostname-only identifier makes every reverse client register with the
|
|
308
|
+
// same name and --replace continuously evicts its siblings ("client
|
|
309
|
+
// replaced"). The normalized cwd hash is stable across restarts/resume but
|
|
310
|
+
// unique for the common multi-project case.
|
|
311
|
+
const workspace = createHash('sha256').update(path.resolve(cwd)).digest('hex').slice(0, 10)
|
|
312
|
+
return `tui-${host || 'pc'}-${workspace}`
|
|
306
313
|
}
|
|
307
314
|
|
|
308
315
|
/**
|
|
309
316
|
* Build the reverse-connect command in one place. The TUI can launch AIMUX
|
|
310
317
|
* through either a venv executable or bundled Python; both paths must request
|
|
311
|
-
*
|
|
312
|
-
* keyboard focus from the TUI.
|
|
318
|
+
* a fully headless Windows shell or every remote command flashes a console and
|
|
319
|
+
* steals keyboard focus from the TUI. Keep the old --silent-shell path
|
|
320
|
+
* available for older AIMUX bundles; current bundles use the no-console v2
|
|
321
|
+
* implementation (the historical spelling --slient-v2 is intentional).
|
|
313
322
|
*/
|
|
314
323
|
export function reverseConnectArgs(
|
|
315
324
|
server: string,
|
|
@@ -322,7 +331,7 @@ export function reverseConnectArgs(
|
|
|
322
331
|
'--identifier', identifier,
|
|
323
332
|
'--token', token,
|
|
324
333
|
'--replace',
|
|
325
|
-
...(platform === 'win32' ? ['--
|
|
334
|
+
...(platform === 'win32' ? ['--slient-v2'] : []),
|
|
326
335
|
]
|
|
327
336
|
}
|
|
328
337
|
|
package/src/components/Chat.tsx
CHANGED
|
@@ -13,18 +13,22 @@ 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'
|
|
24
27
|
import type { AimuxStatus } from '../aimux.js'
|
|
25
28
|
import { AimuxStatusLine, aimuxStatusText } from './AimuxStatus.js'
|
|
26
29
|
import { isEscapeKeypress, isMouseInput, useMouseEvents, useStableInput } from './primitives.js'
|
|
27
|
-
import { useDeleteKeyCapture, applyDeleteIntent, clampCursor, previousCursorBoundary, nextCursorBoundary } from '../lib/delete-keys.js'
|
|
30
|
+
import { useDeleteKeyCapture, applyDeleteIntent, clampCursor, previousCursorBoundary, nextCursorBoundary, previousWordBoundary, nextWordBoundary } from '../lib/delete-keys.js'
|
|
31
|
+
import { useCursorKeyCapture } from '../lib/cursor-keys.js'
|
|
28
32
|
|
|
29
33
|
interface ChatProps {
|
|
30
34
|
client: MobiusClient
|
|
@@ -34,6 +38,7 @@ interface ChatProps {
|
|
|
34
38
|
onClear: () => void
|
|
35
39
|
onResume: () => void
|
|
36
40
|
onQuit: () => void
|
|
41
|
+
onLogout: () => void
|
|
37
42
|
onReconfigure: (result: ConfigResult) => void
|
|
38
43
|
onConfigCancel: (sessionId: string | null) => void
|
|
39
44
|
aimuxStatus?: AimuxStatus
|
|
@@ -45,8 +50,7 @@ interface TerminalSize {
|
|
|
45
50
|
isTty: boolean
|
|
46
51
|
}
|
|
47
52
|
|
|
48
|
-
import {
|
|
49
|
-
const VERSION = createRequire(import.meta.url)('../../package.json').version
|
|
53
|
+
import { TUI_VERSION } from '../version.js'
|
|
50
54
|
const DEFAULT_COMPOSER_ROWS = 5
|
|
51
55
|
const STATUS_ROWS = 3
|
|
52
56
|
|
|
@@ -55,14 +59,17 @@ const SLASH_COMMANDS = [
|
|
|
55
59
|
{ cmd: '/resume', desc: '恢复一个历史会话' },
|
|
56
60
|
{ cmd: '/model', desc: '更换模型并开启新会话(保留当前任务)' },
|
|
57
61
|
{ cmd: '/config', desc: '重新选择项目、任务和模型' },
|
|
62
|
+
{ cmd: '/logout', desc: '断开当前连接并返回登录界面' },
|
|
58
63
|
{ cmd: '/help', desc: '显示帮助' },
|
|
59
64
|
{ cmd: '/quit', desc: '退出 TUI' },
|
|
60
65
|
]
|
|
61
66
|
|
|
62
|
-
export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear, onResume, onQuit, onReconfigure, onConfigCancel, aimuxStatus }: ChatProps) {
|
|
67
|
+
export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear, onResume, onQuit, onLogout, onReconfigure, onConfigCancel, aimuxStatus }: ChatProps) {
|
|
63
68
|
const chat = useChat({ client, ready, resumeSessionId })
|
|
64
69
|
const [showHelp, setShowHelp] = useState(false)
|
|
65
|
-
|
|
70
|
+
// null means "follow the tail". A concrete anchor identifies the exact row
|
|
71
|
+
// at the top of the viewport while the user browses history.
|
|
72
|
+
const [rowAnchor, setRowAnchor] = useState<RowAnchor | null>(null)
|
|
66
73
|
const [composerRows, setComposerRows] = useState(DEFAULT_COMPOSER_ROWS)
|
|
67
74
|
const [modelLabel, setModelLabel] = useState<string | null>(null)
|
|
68
75
|
const [configOpen, setConfigOpen] = useState(false)
|
|
@@ -87,10 +94,11 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
87
94
|
case '/help': setShowHelp(s => !s); return true
|
|
88
95
|
case '/model': setConfigOpen(true); return true
|
|
89
96
|
case '/config': setReconfigOpen(true); return true
|
|
97
|
+
case '/logout': onLogout(); return true
|
|
90
98
|
case '/quit': case '/exit': onQuit(); return true
|
|
91
99
|
default: return false
|
|
92
100
|
}
|
|
93
|
-
}, [onClear, onResume, onQuit])
|
|
101
|
+
}, [onClear, onResume, onQuit, onLogout])
|
|
94
102
|
|
|
95
103
|
const onSubmit = useCallback((text: string) => {
|
|
96
104
|
const t = text.trim()
|
|
@@ -100,7 +108,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
100
108
|
return
|
|
101
109
|
}
|
|
102
110
|
setShowHelp(false)
|
|
103
|
-
|
|
111
|
+
setRowAnchor(null)
|
|
104
112
|
void chat.send(t)
|
|
105
113
|
}, [chat, runSlash])
|
|
106
114
|
|
|
@@ -116,39 +124,66 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
116
124
|
// (type:user / response_item.message[user] / event_msg.user_message) 合并成 1 条,
|
|
117
125
|
// 避免在累积视图里把同一条提问显示多次.
|
|
118
126
|
const dedupedEntries = useMemo(() => dedupeUserEntries(chat.entries), [chat.entries])
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
127
|
+
const pendingEntry = useMemo<AnyEntry | null>(() => chat.pendingUser === null ? null : ({
|
|
128
|
+
type: 'user',
|
|
129
|
+
__id: '__pending-user__',
|
|
130
|
+
message: { role: 'user', content: chat.pendingUser },
|
|
131
|
+
}), [chat.pendingUser])
|
|
132
|
+
const transcriptEntries = useMemo(
|
|
133
|
+
() => pendingEntry ? [...dedupedEntries, pendingEntry] : dedupedEntries,
|
|
134
|
+
[dedupedEntries, pendingEntry],
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
// Markdown parsing and wrapping are paid once per entry/terminal width. Keep
|
|
138
|
+
// the two most recent widths so resize-back does not immediately reparse the
|
|
139
|
+
// whole visible history, while bounding cache growth during repeated resizes.
|
|
140
|
+
const rowCache = useRef<WeakMap<AnyEntry, Map<number, ScreenRow[]>>>(new WeakMap())
|
|
141
|
+
const rowsForEntry = useCallback((entry: AnyEntry): readonly ScreenRow[] => {
|
|
142
|
+
let widths = rowCache.current.get(entry)
|
|
143
|
+
if (!widths) {
|
|
144
|
+
widths = new Map()
|
|
145
|
+
rowCache.current.set(entry, widths)
|
|
146
|
+
}
|
|
147
|
+
const cached = widths.get(terminal.columns)
|
|
148
|
+
if (cached) return cached
|
|
149
|
+
const rows = entryScreenRows(viewsForEntry(entry), terminal.columns)
|
|
150
|
+
if (widths.size >= 2) widths.delete(widths.keys().next().value!)
|
|
151
|
+
widths.set(terminal.columns, rows)
|
|
152
|
+
return rows
|
|
153
|
+
}, [terminal.columns])
|
|
154
|
+
const rowAccess = useMemo(() => createRowAccess(
|
|
155
|
+
transcriptEntries,
|
|
156
|
+
// UUID is stable across SSE history replay; __id is only the local fallback
|
|
157
|
+
// for entries that do not carry a backend identity (notably the optimistic
|
|
158
|
+
// pending user row).
|
|
159
|
+
(entry, index) => String(entry.uuid ?? entry.__id ?? `entry-${index}`),
|
|
160
|
+
(entry) => rowsForEntry(entry),
|
|
161
|
+
), [transcriptEntries, rowsForEntry])
|
|
129
162
|
const viewportRows = terminal.isTty ? Math.max(9, terminal.rows - 1) : terminal.rows
|
|
130
163
|
const activityRows = (chat.typing ? 2 : 0) + (chat.error ? 1 : 0)
|
|
131
164
|
const helpRows = showHelp ? SLASH_COMMANDS.length + 3 : 0
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
165
|
+
// Conversation chrome is exactly two rows: compact header + navigation.
|
|
166
|
+
const transcriptRows = Math.max(1, viewportRows - composerRows - STATUS_ROWS - activityRows - helpRows - 2)
|
|
167
|
+
const tail = useMemo(() => tailAnchor(rowAccess, transcriptRows), [rowAccess, transcriptRows])
|
|
168
|
+
const effectiveAnchor = rowAnchor ?? tail
|
|
169
|
+
const viewport = useMemo(
|
|
170
|
+
() => sliceViewport(rowAccess, effectiveAnchor, transcriptRows),
|
|
171
|
+
[rowAccess, effectiveAnchor, transcriptRows],
|
|
136
172
|
)
|
|
137
|
-
const showWelcome =
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}, [dedupedEntries.length, scrollBack])
|
|
173
|
+
const showWelcome = transcriptEntries.length === 0
|
|
174
|
+
const pageRows = Math.max(1, transcriptRows - 1)
|
|
175
|
+
|
|
176
|
+
const scrollRows = useCallback((deltaRows: number) => {
|
|
177
|
+
if (deltaRows === 0) return
|
|
178
|
+
selState.current = null
|
|
179
|
+
setSel(null)
|
|
180
|
+
setRowAnchor(previous => {
|
|
181
|
+
const start = previous ?? tailAnchor(rowAccess, transcriptRows)
|
|
182
|
+
const next = moveAnchorByRows(rowAccess, start, deltaRows)
|
|
183
|
+
if (deltaRows > 0 && !sliceViewport(rowAccess, next, transcriptRows).hasNewer) return null
|
|
184
|
+
return next
|
|
185
|
+
})
|
|
186
|
+
}, [rowAccess, transcriptRows])
|
|
152
187
|
|
|
153
188
|
// Show the model's friendly label (e.g. "GPT-5.6-Sol") in the header/status
|
|
154
189
|
// instead of its opaque key (e.g. "codex:mobiusdefaultaabb").
|
|
@@ -170,20 +205,18 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
170
205
|
// the keypress). configOpen/reconfigOpen/sessionId are read from handlerRef
|
|
171
206
|
// (see above) because Ink keeps the originally-registered callback and would
|
|
172
207
|
// otherwise see a stale closure.
|
|
173
|
-
if (handlerRef.current.configOpen
|
|
208
|
+
if (handlerRef.current.configOpen) {
|
|
174
209
|
if (isEscapeKeypress(_input, key)) onConfigCancel(handlerRef.current.sessionId)
|
|
175
210
|
return
|
|
176
211
|
}
|
|
177
|
-
|
|
178
|
-
if (key.pageUp)
|
|
179
|
-
else if (key.pageDown)
|
|
212
|
+
if (handlerRef.current.reconfigOpen) return // ReconfigFlow owns hierarchical Esc navigation.
|
|
213
|
+
if (key.pageUp) scrollRows(-pageRows)
|
|
214
|
+
else if (key.pageDown) scrollRows(pageRows)
|
|
180
215
|
}, { interactive: false })
|
|
181
216
|
|
|
182
|
-
const
|
|
183
|
-
?
|
|
184
|
-
|
|
185
|
-
: '已到最早记录 · 滚轮/PageDown 翻页 · 拖动选中文本'
|
|
186
|
-
: null
|
|
217
|
+
const navigationHint = viewport.hasOlder
|
|
218
|
+
? `${viewport.hasNewer ? '↑ 较早内容 · ↓ 较新内容' : '↑ 还有较早内容'} · 滚轮 3 行 · PageUp/PageDown ${pageRows} 行 · 拖动选中文本`
|
|
219
|
+
: `${viewport.hasNewer ? '已到最早 · ↓ 还有较新内容' : '全部内容'} · 滚轮 3 行 · PageUp/PageDown ${pageRows} 行 · 拖动选中文本`
|
|
187
220
|
|
|
188
221
|
// Mouse: wheel pages through history in small fixed steps, and a left-button
|
|
189
222
|
// drag selects transcript text (tmux-style: the app owns the mouse, draws its
|
|
@@ -195,23 +228,16 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
195
228
|
const [sel, setSel] = useState<{ anchor: SelPoint; end: SelPoint; active: boolean } | null>(null)
|
|
196
229
|
const [copyNotice, setCopyNotice] = useState<string | null>(null)
|
|
197
230
|
|
|
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])
|
|
231
|
+
// Selection uses the exact virtual rows mounted below. There is no separate
|
|
232
|
+
// fitting/geometry pass, so hit-testing, rendering and clipboard extraction
|
|
233
|
+
// cannot disagree about which rows are on screen.
|
|
234
|
+
const geometry: TranscriptGeometry = useMemo(
|
|
235
|
+
() => ({ boxTop: 2, boxH: transcriptRows }),
|
|
236
|
+
[transcriptRows],
|
|
237
|
+
)
|
|
212
238
|
const transcriptModel: TranscriptModel = useMemo(
|
|
213
|
-
() =>
|
|
214
|
-
[
|
|
239
|
+
() => ({ entries: viewport.rows.map(item => [item.row.plain]), totalRows: viewport.rows.length }),
|
|
240
|
+
[viewport.rows],
|
|
215
241
|
)
|
|
216
242
|
const selMap = useMemo(
|
|
217
243
|
() => (sel?.active ? buildSelectionMap(transcriptModel, sel.anchor, sel.end) : null),
|
|
@@ -227,25 +253,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
227
253
|
|
|
228
254
|
useMouseEvents({
|
|
229
255
|
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)))
|
|
256
|
+
scrollRows(delta * -3)
|
|
249
257
|
},
|
|
250
258
|
onPress: (row, col) => {
|
|
251
259
|
const p = screenToSelPoint(row, col, transcriptModel, geometry)
|
|
@@ -307,6 +315,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
307
315
|
<ReconfigFlow
|
|
308
316
|
client={client}
|
|
309
317
|
onDone={(result) => onReconfigure(result)}
|
|
318
|
+
onCancel={() => onConfigCancel(chat.sessionId)}
|
|
310
319
|
/>
|
|
311
320
|
</Box>
|
|
312
321
|
)
|
|
@@ -325,34 +334,25 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
325
334
|
? <WelcomeCard ready={ready} columns={terminal.columns} resumed={Boolean(resumeSessionId)} modelDisplay={modelDisplay} />
|
|
326
335
|
: <CompactHeader ready={ready} sessionId={chat.sessionId} columns={terminal.columns} />}
|
|
327
336
|
|
|
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>
|
|
337
|
+
{!showWelcome
|
|
338
|
+
? <Box width="100%" flexShrink={0}><Text dimColor wrap="truncate-end"> {navigationHint}</Text></Box>
|
|
333
339
|
: null}
|
|
334
340
|
|
|
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}
|
|
341
|
+
<Box
|
|
342
|
+
height={showWelcome ? undefined : transcriptRows}
|
|
343
|
+
flexGrow={showWelcome ? 1 : 0}
|
|
344
|
+
flexShrink={showWelcome ? 1 : 0}
|
|
345
|
+
flexDirection="column"
|
|
346
|
+
justifyContent="flex-end"
|
|
347
|
+
overflowY="hidden"
|
|
348
|
+
>
|
|
349
|
+
{!showWelcome ? viewport.rows.map((item, index) => {
|
|
350
|
+
const range = selMap?.get(index)?.get(0)
|
|
351
|
+
const text = range && range.start < range.end
|
|
352
|
+
? highlightScreenRow(item.row.styled, range.start, range.end)
|
|
353
|
+
: item.row.styled
|
|
354
|
+
return <ScreenText key={`${item.entryId}:${item.rowIndex}`} row={item.row} text={text || ' '} />
|
|
355
|
+
}) : null}
|
|
356
356
|
</Box>
|
|
357
357
|
|
|
358
358
|
{dedupedEntries.length === 0 && chat.pendingUser === null && !showHelp
|
|
@@ -417,7 +417,7 @@ function WelcomeCard({ ready, columns, resumed, modelDisplay }: { ready: ReadySt
|
|
|
417
417
|
<Text>
|
|
418
418
|
<Text dimColor>{'>_ '}</Text>
|
|
419
419
|
<Text bold>Mobius</Text>
|
|
420
|
-
<Text dimColor> (v{
|
|
420
|
+
<Text dimColor> (v{TUI_VERSION})</Text>
|
|
421
421
|
</Text>
|
|
422
422
|
<Text> </Text>
|
|
423
423
|
<MetaRow label="model:" value={modelDisplay} hint="/help 查看命令" labelWidth={labelWidth} />
|
|
@@ -452,29 +452,6 @@ function CompactHeader({ ready, sessionId, columns }: { ready: ReadyState; sessi
|
|
|
452
452
|
)
|
|
453
453
|
}
|
|
454
454
|
|
|
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
455
|
function ScreenText({ row, text }: { row: ScreenRow; text: string }) {
|
|
479
456
|
const tone = row.tone
|
|
480
457
|
const color = tone === 'tool' ? 'cyan'
|
|
@@ -529,13 +506,6 @@ function highlightScreenRow(styled: string, start: number, end: number): string
|
|
|
529
506
|
return out
|
|
530
507
|
}
|
|
531
508
|
|
|
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
509
|
function WorkingIndicator({ firstQuery }: { firstQuery: boolean }) {
|
|
540
510
|
const startedAt = useRef(Date.now())
|
|
541
511
|
const [animationFrame, setAnimationFrame] = useState(0)
|
|
@@ -625,6 +595,13 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
|
|
|
625
595
|
const { text, cursor: nextCursor } = applyDeleteIntent(valueRef.current, cursorRef.current, intent)
|
|
626
596
|
edit(text, nextCursor)
|
|
627
597
|
})
|
|
598
|
+
useCursorKeyCapture(true, (intent) => {
|
|
599
|
+
const current = valueRef.current
|
|
600
|
+
const at = clampCursor(current, cursorRef.current)
|
|
601
|
+
const next = intent === 'home' ? 0 : intent === 'end' ? current.length
|
|
602
|
+
: intent === 'backward-word' ? previousWordBoundary(current, at) : nextWordBoundary(current, at)
|
|
603
|
+
moveCursor(next)
|
|
604
|
+
})
|
|
628
605
|
|
|
629
606
|
const filtered = useMemo(() => {
|
|
630
607
|
const match = /^(\w*)$/.exec(value.slice(1))
|
|
@@ -826,6 +803,7 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
|
|
|
826
803
|
edit(text, nextCursor)
|
|
827
804
|
return
|
|
828
805
|
}
|
|
806
|
+
if (key.ctrl && (key.leftArrow || key.rightArrow)) return
|
|
829
807
|
if (key.leftArrow) { moveCursor(previousCursorBoundary(current, at)); return }
|
|
830
808
|
if (key.rightArrow) { moveCursor(nextCursorBoundary(current, at)); return }
|
|
831
809
|
|
|
@@ -921,7 +899,7 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
|
|
|
921
899
|
})}
|
|
922
900
|
</Box>
|
|
923
901
|
<Box justifyContent="space-between">
|
|
924
|
-
<Text dimColor>{(stdout.columns ?? 80) >=
|
|
902
|
+
<Text dimColor>{(stdout.columns ?? 80) >= 72 ? 'Enter 发送 · Shift+Enter / Alt+Enter / Ctrl+J 换行' : 'Enter 发送 · Alt+Enter / Ctrl+J 换行'}</Text>
|
|
925
903
|
<Text dimColor>{wrapped.length > maxRows ? `${visualCursor + 1}/${wrapped.length} 行` : `${wrapped.length} 行`}</Text>
|
|
926
904
|
</Box>
|
|
927
905
|
</Box>
|
|
@@ -1104,60 +1082,3 @@ function clickableUrl(url: string, maxLen?: number): string {
|
|
|
1104
1082
|
|
|
1105
1083
|
// displayWidth is imported from src/lib/screen-text.ts (CJK/emoji-aware), used
|
|
1106
1084
|
// 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
|
-
}
|