@mobius-os/mobius 0.3.15 → 0.3.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/src/App.tsx +29 -0
- package/src/components/Chat.tsx +270 -127
- package/src/components/ConfigFlow.tsx +377 -0
- package/src/components/PrepScreen.tsx +8 -1
- package/src/components/primitives.tsx +99 -44
- package/src/lib/delete-keys.ts +152 -0
- package/src/lib/screen-text.ts +339 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mobius-os/mobius",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.24",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Mobius terminal client. Reuses Mobius frontend TypeScript types and jsonl entry shapes.",
|
|
6
6
|
"bin": {
|
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
"ink": "5.2.0",
|
|
23
23
|
"marked": "12.0.2",
|
|
24
24
|
"react": "18.3.1",
|
|
25
|
-
"tsx": "4.19.2"
|
|
25
|
+
"tsx": "4.19.2",
|
|
26
|
+
"wrap-ansi": "^9.0.0"
|
|
26
27
|
},
|
|
27
28
|
"engines": {
|
|
28
29
|
"node": ">=18"
|
package/src/App.tsx
CHANGED
|
@@ -16,6 +16,7 @@ import { 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'
|
|
19
|
+
import type { ConfigResult } from './components/ConfigFlow.js'
|
|
19
20
|
import { ResumePicker } from './components/ResumePicker.js'
|
|
20
21
|
import { Screen } from './components/Screen.js'
|
|
21
22
|
import { startAimuxConnection, stopAimuxConnection, type AimuxStatus } from './aimux.js'
|
|
@@ -107,6 +108,32 @@ export function App() {
|
|
|
107
108
|
|
|
108
109
|
function onResume() { if (process.env.MOBIUS_TUI_DEBUG) console.error('[route] resume'); setRoute('resume') }
|
|
109
110
|
|
|
111
|
+
// /model (= /config) Esc-cancel: go back to the conversation. Remounting Chat
|
|
112
|
+
// (rather than re-rendering it in place) is deliberate — toggling the config
|
|
113
|
+
// flow off and on re-mounted the whole chat subtree, which left Ink's frame
|
|
114
|
+
// blank in the harness. Remount via chatKey is the same path /clear and /resume
|
|
115
|
+
// use; the live sessionId (if any) is carried as resumeSessionId so the
|
|
116
|
+
// conversation is reconnected intact.
|
|
117
|
+
function onConfigCancel(sessionId: string | null) {
|
|
118
|
+
if (process.env.MOBIUS_TUI_DEBUG) console.error('[route] config-cancel', sessionId)
|
|
119
|
+
setResumeSessionId(sessionId)
|
|
120
|
+
setChatKey(k => k + 1)
|
|
121
|
+
setRoute('chat')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// /model or /config: swap task+model (and optionally project for /config)
|
|
125
|
+
// and start a brand-new session. App owns `ready`, so it folds the result in
|
|
126
|
+
// and remounts Chat on the fresh session (resumeSessionId = the eagerly
|
|
127
|
+
// created session).
|
|
128
|
+
function onReconfigure(result: ConfigResult) {
|
|
129
|
+
if (!ready || !client) return
|
|
130
|
+
if (process.env.MOBIUS_TUI_DEBUG) console.error('[route] reconfigure', result.project?.id, result.issue.id, result.prefs.model, result.sessionId)
|
|
131
|
+
setReady({ project: result.project || ready.project, issue: result.issue, prefs: result.prefs })
|
|
132
|
+
setResumeSessionId(result.sessionId)
|
|
133
|
+
setChatKey(k => k + 1)
|
|
134
|
+
setRoute('chat')
|
|
135
|
+
}
|
|
136
|
+
|
|
110
137
|
function onResumed(sid: string) {
|
|
111
138
|
setResumeSessionId(sid)
|
|
112
139
|
setChatKey(k => k + 1)
|
|
@@ -135,6 +162,8 @@ export function App() {
|
|
|
135
162
|
onClear={onClear}
|
|
136
163
|
onResume={onResume}
|
|
137
164
|
onQuit={onQuit}
|
|
165
|
+
onReconfigure={onReconfigure}
|
|
166
|
+
onConfigCancel={onConfigCancel}
|
|
138
167
|
aimuxStatus={aimuxStatus}
|
|
139
168
|
/>
|
|
140
169
|
)
|
package/src/components/Chat.tsx
CHANGED
|
@@ -13,11 +13,19 @@ import { useChat } from '../hooks/useChat.js'
|
|
|
13
13
|
import { MobiusClient } from '../api.js'
|
|
14
14
|
import { renderMarkdownLines } from '../markdown.js'
|
|
15
15
|
import { viewsForEntry, dedupeUserEntries, toolLabel, isAssistantOutput, type EntryView } from '../lib/entry-view.js'
|
|
16
|
+
import {
|
|
17
|
+
clampLines, headTailLines, displayWidth, compareSel, entryScreenLines,
|
|
18
|
+
buildTranscriptModel, computeTranscriptGeometry, screenToSelPoint,
|
|
19
|
+
buildSelectionMap, buildSelectionText, osc52,
|
|
20
|
+
type TranscriptModel, type TranscriptGeometry, type SelPoint,
|
|
21
|
+
} from '../lib/screen-text.js'
|
|
16
22
|
import type { ReadyState } from './PrepScreen.js'
|
|
17
23
|
import type { AnyEntry } from '../types.js'
|
|
24
|
+
import { ConfigFlow, ReconfigFlow, type ConfigResult } from './ConfigFlow.js'
|
|
18
25
|
import type { AimuxStatus } from '../aimux.js'
|
|
19
26
|
import { AimuxStatusLine, aimuxStatusText } from './AimuxStatus.js'
|
|
20
|
-
import { isEscapeKeypress, isMouseInput,
|
|
27
|
+
import { isEscapeKeypress, isMouseInput, useMouseEvents } from './primitives.js'
|
|
28
|
+
import { useDeleteKeyCapture, applyDeleteIntent, clampCursor, previousCursorBoundary, nextCursorBoundary } from '../lib/delete-keys.js'
|
|
21
29
|
|
|
22
30
|
interface ChatProps {
|
|
23
31
|
client: MobiusClient
|
|
@@ -27,6 +35,8 @@ interface ChatProps {
|
|
|
27
35
|
onClear: () => void
|
|
28
36
|
onResume: () => void
|
|
29
37
|
onQuit: () => void
|
|
38
|
+
onReconfigure: (result: ConfigResult) => void
|
|
39
|
+
onConfigCancel: (sessionId: string | null) => void
|
|
30
40
|
aimuxStatus?: AimuxStatus
|
|
31
41
|
}
|
|
32
42
|
|
|
@@ -44,16 +54,25 @@ const STATUS_ROWS = 3
|
|
|
44
54
|
const SLASH_COMMANDS = [
|
|
45
55
|
{ cmd: '/clear', desc: '清空当前对话,开启新会话' },
|
|
46
56
|
{ cmd: '/resume', desc: '恢复一个历史会话' },
|
|
57
|
+
{ cmd: '/model', desc: '更换模型并开启新会话(保留当前任务)' },
|
|
58
|
+
{ cmd: '/config', desc: '重新选择项目、任务和模型' },
|
|
47
59
|
{ cmd: '/help', desc: '显示帮助' },
|
|
48
60
|
{ cmd: '/quit', desc: '退出 TUI' },
|
|
49
61
|
]
|
|
50
62
|
|
|
51
|
-
export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear, onResume, onQuit, aimuxStatus }: ChatProps) {
|
|
63
|
+
export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear, onResume, onQuit, onReconfigure, onConfigCancel, aimuxStatus }: ChatProps) {
|
|
52
64
|
const chat = useChat({ client, ready, resumeSessionId })
|
|
53
65
|
const [showHelp, setShowHelp] = useState(false)
|
|
54
66
|
const [scrollBack, setScrollBack] = useState(0)
|
|
55
67
|
const [composerRows, setComposerRows] = useState(DEFAULT_COMPOSER_ROWS)
|
|
56
68
|
const [modelLabel, setModelLabel] = useState<string | null>(null)
|
|
69
|
+
const [configOpen, setConfigOpen] = useState(false)
|
|
70
|
+
const [reconfigOpen, setReconfigOpen] = useState(false)
|
|
71
|
+
// Ink's useInput keeps whatever handler was registered at subscription time;
|
|
72
|
+
// reading mutable refs (updated every render) keeps the callback from acting
|
|
73
|
+
// on a stale `configOpen`/sessionId closure after the config flow opens.
|
|
74
|
+
const handlerRef = useRef<{ configOpen: boolean; reconfigOpen: boolean; sessionId: string | null }>({ configOpen: false, reconfigOpen: false, sessionId: null })
|
|
75
|
+
handlerRef.current = { configOpen, reconfigOpen, sessionId: chat.sessionId }
|
|
57
76
|
const terminal = useTerminalSize()
|
|
58
77
|
|
|
59
78
|
const runSlash = useCallback((raw: string) => {
|
|
@@ -62,6 +81,8 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
62
81
|
case '/clear': onClear(); return true
|
|
63
82
|
case '/resume': onResume(); return true
|
|
64
83
|
case '/help': setShowHelp(s => !s); return true
|
|
84
|
+
case '/model': setConfigOpen(true); return true
|
|
85
|
+
case '/config': setReconfigOpen(true); return true
|
|
65
86
|
case '/quit': case '/exit': onQuit(); return true
|
|
66
87
|
default: return false
|
|
67
88
|
}
|
|
@@ -129,27 +150,138 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
129
150
|
const modelDisplay = modelLabel ?? ready.prefs.model ?? 'default'
|
|
130
151
|
|
|
131
152
|
useInput((_input, key) => {
|
|
153
|
+
// While a config/reconfig flow is open, this ChatScreen-level handler owns
|
|
154
|
+
// Esc so cancel is reliable even mid-list-loading (a per-component
|
|
155
|
+
// EscToCancel could be unmounted by the loading→loaded transition and drop
|
|
156
|
+
// the keypress). configOpen/reconfigOpen/sessionId are read from handlerRef
|
|
157
|
+
// (see above) because Ink keeps the originally-registered callback and would
|
|
158
|
+
// otherwise see a stale closure.
|
|
159
|
+
if (handlerRef.current.configOpen || handlerRef.current.reconfigOpen) {
|
|
160
|
+
if (isEscapeKeypress(_input, key)) onConfigCancel(handlerRef.current.sessionId)
|
|
161
|
+
return
|
|
162
|
+
}
|
|
132
163
|
const step = Math.max(1, fitted.entries.length)
|
|
133
164
|
if (key.pageUp) setScrollBack(value => Math.min(dedupedEntries.length, value + step))
|
|
134
165
|
else if (key.pageDown) setScrollBack(value => Math.max(0, value - step))
|
|
135
166
|
})
|
|
136
167
|
|
|
137
|
-
// Mouse wheel: up scrolls back through history, down returns toward the
|
|
138
|
-
// latest, mirroring PageUp/PageDown but in small fixed steps. Handled on the
|
|
139
|
-
// Ink event emitter (not useInput) so the sequence can be buffered across
|
|
140
|
-
// read() chunks; the Composer guards against inserting mouse bytes as text.
|
|
141
|
-
useMouseWheel((delta) => {
|
|
142
|
-
if (delta === 0) return
|
|
143
|
-
const step = 3
|
|
144
|
-
setScrollBack(value => Math.min(dedupedEntries.length, Math.max(0, value + delta * step)))
|
|
145
|
-
})
|
|
146
|
-
|
|
147
168
|
const olderHint = !showWelcome && (fitted.hiddenOlder > 0 || scrollBack > 0)
|
|
148
169
|
? fitted.hiddenOlder > 0
|
|
149
|
-
? `↑ 还有 ${fitted.hiddenOlder} 条较早记录 · 滚轮/PageUp
|
|
150
|
-
: '已到最早记录 · 滚轮/PageDown
|
|
170
|
+
? `↑ 还有 ${fitted.hiddenOlder} 条较早记录 · 滚轮/PageUp 翻页 · 拖动选中文本`
|
|
171
|
+
: '已到最早记录 · 滚轮/PageDown 翻页 · 拖动选中文本'
|
|
151
172
|
: null
|
|
152
173
|
|
|
174
|
+
// Mouse: wheel pages through history in small fixed steps, and a left-button
|
|
175
|
+
// drag selects transcript text (tmux-style: the app owns the mouse, draws its
|
|
176
|
+
// own highlight, and copies the range via OSC 52 on release). Handled on the
|
|
177
|
+
// Ink event emitter (not useInput) so sequences can be buffered across read()
|
|
178
|
+
// chunks; the Composer guards against inserting mouse bytes as typed text.
|
|
179
|
+
const { stdout } = useStdout()
|
|
180
|
+
const selState = useRef<{ anchor: SelPoint; end: SelPoint; active: boolean } | null>(null)
|
|
181
|
+
const [sel, setSel] = useState<{ anchor: SelPoint; end: SelPoint; active: boolean } | null>(null)
|
|
182
|
+
const [copyNotice, setCopyNotice] = useState<string | null>(null)
|
|
183
|
+
|
|
184
|
+
// Geometry + text model must mirror the rendered transcript so a screen
|
|
185
|
+
// (row, col) maps to the right entry/line/char. Recompute with the fitted view.
|
|
186
|
+
const tipShown = dedupedEntries.length === 0 && chat.pendingUser === null && !showHelp
|
|
187
|
+
const geometry: TranscriptGeometry = useMemo(() => computeTranscriptGeometry({
|
|
188
|
+
viewportRows,
|
|
189
|
+
composerRows,
|
|
190
|
+
statusRows: STATUS_ROWS,
|
|
191
|
+
activityRows,
|
|
192
|
+
helpRows,
|
|
193
|
+
showWelcome,
|
|
194
|
+
welcomeRows: 10,
|
|
195
|
+
olderHintShown: olderHint !== null,
|
|
196
|
+
tipShown,
|
|
197
|
+
}), [viewportRows, composerRows, activityRows, helpRows, showWelcome, olderHint, tipShown])
|
|
198
|
+
const transcriptModel: TranscriptModel = useMemo(
|
|
199
|
+
() => buildTranscriptModel(fitted.entries, terminal.columns),
|
|
200
|
+
[fitted.entries, terminal.columns],
|
|
201
|
+
)
|
|
202
|
+
const selMap = useMemo(
|
|
203
|
+
() => (sel?.active ? buildSelectionMap(transcriptModel, sel.anchor, sel.end) : null),
|
|
204
|
+
[sel, transcriptModel],
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
const commitCopy = useCallback((anchor: SelPoint, end: SelPoint) => {
|
|
208
|
+
const text = buildSelectionText(transcriptModel, anchor, end)
|
|
209
|
+
if (!text) return
|
|
210
|
+
stdout.write(osc52(text))
|
|
211
|
+
setCopyNotice(`已复制 ${Array.from(text).length} 字符`)
|
|
212
|
+
}, [transcriptModel, stdout])
|
|
213
|
+
|
|
214
|
+
useMouseEvents({
|
|
215
|
+
onWheel: (delta) => {
|
|
216
|
+
if (delta === 0) return
|
|
217
|
+
const step = 3
|
|
218
|
+
setScrollBack(value => Math.min(dedupedEntries.length, Math.max(0, value + delta * step)))
|
|
219
|
+
},
|
|
220
|
+
onPress: (row, col) => {
|
|
221
|
+
const p = screenToSelPoint(row, col, transcriptModel, geometry)
|
|
222
|
+
if (p) { selState.current = { anchor: p, end: p, active: true }; setSel(selState.current) }
|
|
223
|
+
},
|
|
224
|
+
onMotion: (row, col) => {
|
|
225
|
+
const s = selState.current
|
|
226
|
+
if (!s?.active) return
|
|
227
|
+
const p = screenToSelPoint(row, col, transcriptModel, geometry)
|
|
228
|
+
if (p) { selState.current = { ...s, end: p }; setSel(selState.current) }
|
|
229
|
+
},
|
|
230
|
+
onRelease: () => {
|
|
231
|
+
const s = selState.current
|
|
232
|
+
selState.current = null
|
|
233
|
+
setSel(null)
|
|
234
|
+
if (s?.active && compareSel(s.anchor, s.end) !== 0) commitCopy(s.anchor, s.end)
|
|
235
|
+
},
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
// Transient "已复制 N 字符" notice in the status row, then it clears itself.
|
|
239
|
+
useEffect(() => {
|
|
240
|
+
if (!copyNotice) return
|
|
241
|
+
const id = setTimeout(() => setCopyNotice(null), 2500)
|
|
242
|
+
return () => clearTimeout(id)
|
|
243
|
+
}, [copyNotice])
|
|
244
|
+
|
|
245
|
+
if (configOpen) {
|
|
246
|
+
// Keep the SAME height-pinned root box the chat uses, so the frame stays
|
|
247
|
+
// exactly terminal-height whether the flow is open or the conversation is
|
|
248
|
+
// shown. Rendering the flow at its natural (shorter) height and then
|
|
249
|
+
// re-painting the tall chat on Esc made Ink's frame accounting go blank in
|
|
250
|
+
// the harness (and looked like a glitch in real terminals too).
|
|
251
|
+
return (
|
|
252
|
+
<Box
|
|
253
|
+
flexDirection="column"
|
|
254
|
+
width={terminal.isTty ? terminal.columns : undefined}
|
|
255
|
+
height={terminal.isTty ? viewportRows : undefined}
|
|
256
|
+
paddingX={1}
|
|
257
|
+
overflowY="hidden"
|
|
258
|
+
>
|
|
259
|
+
<ConfigFlow
|
|
260
|
+
client={client}
|
|
261
|
+
issue={ready.issue}
|
|
262
|
+
onDone={(result) => onReconfigure(result)}
|
|
263
|
+
/>
|
|
264
|
+
</Box>
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (reconfigOpen) {
|
|
269
|
+
return (
|
|
270
|
+
<Box
|
|
271
|
+
flexDirection="column"
|
|
272
|
+
width={terminal.isTty ? terminal.columns : undefined}
|
|
273
|
+
height={terminal.isTty ? viewportRows : undefined}
|
|
274
|
+
paddingX={1}
|
|
275
|
+
overflowY="hidden"
|
|
276
|
+
>
|
|
277
|
+
<ReconfigFlow
|
|
278
|
+
client={client}
|
|
279
|
+
onDone={(result) => onReconfigure(result)}
|
|
280
|
+
/>
|
|
281
|
+
</Box>
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
|
|
153
285
|
return (
|
|
154
286
|
<Box
|
|
155
287
|
flexDirection="column"
|
|
@@ -167,16 +299,27 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
167
299
|
always the first line of the transcript, spanning the full width,
|
|
168
300
|
instead of floating mid-screen when the transcript has spare rows. */}
|
|
169
301
|
{olderHint !== null
|
|
170
|
-
? <Box width="100%" flexShrink={0}><Text dimColor> {olderHint}</Text></Box>
|
|
302
|
+
? <Box width="100%" flexShrink={0}><Text dimColor wrap="truncate-end"> {olderHint}</Text></Box>
|
|
171
303
|
: null}
|
|
172
304
|
|
|
173
|
-
<Box flexGrow={1} flexShrink={1} flexDirection="column" justifyContent={showWelcome ? 'flex-start' : 'flex-end'} overflowY="hidden">
|
|
174
|
-
{fitted.
|
|
175
|
-
<
|
|
176
|
-
|
|
305
|
+
<Box flexGrow={1} flexShrink={1} flexDirection="column" justifyContent={showWelcome || fitted.hiddenOlder > 0 ? 'flex-start' : 'flex-end'} overflowY="hidden">
|
|
306
|
+
{fitted.peekLines.length > 0
|
|
307
|
+
? <Box width="100%" flexShrink={0} flexDirection="column">
|
|
308
|
+
{fitted.peekLines.map((line, index) => (
|
|
309
|
+
<Text key={`peek-${index}`} dimColor wrap="truncate-end">{index === 0 ? ' ⋯ ' : ' '}{line}</Text>
|
|
310
|
+
))}
|
|
311
|
+
</Box>
|
|
312
|
+
: null}
|
|
313
|
+
{fitted.entries.map((entry, index) => {
|
|
314
|
+
const entrySel = selMap?.get(index)
|
|
315
|
+
const key = entry.__id ?? `entry-${fitted.startIndex + index}`
|
|
316
|
+
return entrySel
|
|
317
|
+
? <EntryScreenWithSelection key={key} entry={entry} columns={terminal.columns} sel={entrySel} />
|
|
318
|
+
: <EntryAccum key={key} entry={entry} columns={terminal.columns} />
|
|
319
|
+
})}
|
|
177
320
|
{chat.pendingUser !== null ? <UserLine text={chat.pendingUser} /> : null}
|
|
178
321
|
{fitted.hiddenRecent > 0
|
|
179
|
-
? <Box width="100%" flexShrink={0}><Text dimColor> ↓ 滚轮/PageDown
|
|
322
|
+
? <Box width="100%" flexShrink={0}><Text dimColor wrap="truncate-end"> ↓ 滚轮/PageDown 翻页 · 较新 {fitted.hiddenRecent} 条</Text></Box>
|
|
180
323
|
: null}
|
|
181
324
|
</Box>
|
|
182
325
|
|
|
@@ -206,6 +349,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
206
349
|
webUrl={buildWebUrl(client.server, webUserId, ready, chat.sessionId)}
|
|
207
350
|
aimuxStatus={aimuxStatus}
|
|
208
351
|
modelDisplay={modelDisplay}
|
|
352
|
+
copyNotice={copyNotice}
|
|
209
353
|
/>
|
|
210
354
|
</Box>
|
|
211
355
|
</Box>
|
|
@@ -284,6 +428,35 @@ function EntryAccum({ entry, columns }: { entry: AnyEntry; columns: number }) {
|
|
|
284
428
|
)
|
|
285
429
|
}
|
|
286
430
|
|
|
431
|
+
// While a drag-selection is active, the affected entries are re-rendered from the
|
|
432
|
+
// screen-text model (plain rows, no ANSI) so the selected char range can be
|
|
433
|
+
// painted with a background — the same rows the model produces, keeping the
|
|
434
|
+
// layout stable. Rows outside the selection keep their original text.
|
|
435
|
+
function EntryScreenWithSelection({ entry, columns, sel }: {
|
|
436
|
+
entry: AnyEntry
|
|
437
|
+
columns: number
|
|
438
|
+
sel: Map<number, { start: number; end: number }>
|
|
439
|
+
}) {
|
|
440
|
+
const lines = entryScreenLines(viewsForEntry(entry), columns)
|
|
441
|
+
return (
|
|
442
|
+
<Box flexDirection="column">
|
|
443
|
+
{lines.map((row, index) => {
|
|
444
|
+
const range = sel.get(index)
|
|
445
|
+
if (range && range.start < range.end) {
|
|
446
|
+
return (
|
|
447
|
+
<Text key={index} wrap="truncate-end">
|
|
448
|
+
<Text>{row.slice(0, range.start)}</Text>
|
|
449
|
+
<Text backgroundColor="cyan" color="black">{row.slice(range.start, range.end)}</Text>
|
|
450
|
+
<Text>{row.slice(range.end)}</Text>
|
|
451
|
+
</Text>
|
|
452
|
+
)
|
|
453
|
+
}
|
|
454
|
+
return <Text key={index} wrap="truncate-end">{row || ' '}</Text>
|
|
455
|
+
})}
|
|
456
|
+
</Box>
|
|
457
|
+
)
|
|
458
|
+
}
|
|
459
|
+
|
|
287
460
|
function ViewLine({ view, columns }: { view: EntryView; columns: number }) {
|
|
288
461
|
const width = Math.max(8, columns - 4)
|
|
289
462
|
switch (view.kind) {
|
|
@@ -385,41 +558,9 @@ function WriteFileView({ view }: { view: { filePath: string; content: string } }
|
|
|
385
558
|
)
|
|
386
559
|
}
|
|
387
560
|
|
|
388
|
-
//
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
const paras = text.replace(/\r\n/g, '\n').split('\n')
|
|
392
|
-
const wrapped: string[] = []
|
|
393
|
-
for (const para of paras) {
|
|
394
|
-
if (para === '') { wrapped.push(''); continue }
|
|
395
|
-
for (let i = 0; i < para.length; i += width) wrapped.push(para.slice(i, i + width))
|
|
396
|
-
}
|
|
397
|
-
if (wrapped.length <= maxLines) return wrapped
|
|
398
|
-
const trimmed = wrapped.slice(0, maxLines)
|
|
399
|
-
const last = trimmed[maxLines - 1]
|
|
400
|
-
trimmed[maxLines - 1] = last.length >= width ? last.slice(0, width - 1) + '…' : last + '…'
|
|
401
|
-
return trimmed
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
// codex 式 head + ellipsis + tail 截断 (参考 codex-rs/tui/src/exec_cell/render.rs
|
|
405
|
-
// 的 truncate_lines_middle): 保留输出头尾, 中间省略并报告省略行数. 长输出既能看
|
|
406
|
-
// 到结论 (成功/失败常在尾), 又不刷屏. maxLines 含省略行 (如 5 = 头2 + 省1 + 尾2).
|
|
407
|
-
function headTailLines(text: string, width: number, maxLines: number): string[] {
|
|
408
|
-
if (!text) return ['']
|
|
409
|
-
const paras = text.replace(/\r\n/g, '\n').split('\n')
|
|
410
|
-
const wrapped: string[] = []
|
|
411
|
-
for (const para of paras) {
|
|
412
|
-
if (para === '') { wrapped.push(''); continue }
|
|
413
|
-
for (let i = 0; i < para.length; i += width) wrapped.push(para.slice(i, i + width))
|
|
414
|
-
}
|
|
415
|
-
if (wrapped.length <= maxLines) return wrapped.slice(0, maxLines)
|
|
416
|
-
const budget = maxLines - 1 // 留 1 行给省略标记
|
|
417
|
-
const head = Math.max(1, Math.ceil(budget / 2))
|
|
418
|
-
const tail = Math.max(1, budget - head)
|
|
419
|
-
const omitted = wrapped.length - head - tail
|
|
420
|
-
if (omitted <= 0) return wrapped.slice(0, maxLines)
|
|
421
|
-
return [...wrapped.slice(0, head), `… +${omitted} 行`, ...wrapped.slice(wrapped.length - tail)]
|
|
422
|
-
}
|
|
561
|
+
// clampLines / headTailLines / displayWidth live in src/lib/screen-text.ts
|
|
562
|
+
// (mirrored, exported) and are imported above; they must match ViewLine exactly
|
|
563
|
+
// so the drag-selection text model aligns with the rendered rows.
|
|
423
564
|
|
|
424
565
|
function UserLine({ text }: { text: string }) {
|
|
425
566
|
const lines = text.split('\n')
|
|
@@ -463,6 +604,9 @@ export function shimmerText(label: string, frame: number): React.ReactNode[] {
|
|
|
463
604
|
}
|
|
464
605
|
|
|
465
606
|
function HelpBlock({ commands }: { commands: { cmd: string; desc: string }[] }) {
|
|
607
|
+
const mouseNote = process.env.MOBIUS_TUI_DISABLE_MOUSE === '1'
|
|
608
|
+
? '滚轮翻页已关闭 (MOBIUS_TUI_DISABLE_MOUSE=1),鼠标可用于直接选中文本。'
|
|
609
|
+
: '滚轮翻页 · 拖动选中文本,松开即经 OSC 52 复制到剪贴板。'
|
|
466
610
|
return (
|
|
467
611
|
<Box flexDirection="column" borderStyle="round" borderColor="gray" borderDimColor paddingX={1} marginTop={1}>
|
|
468
612
|
{commands.map(command => (
|
|
@@ -471,6 +615,7 @@ function HelpBlock({ commands }: { commands: { cmd: string; desc: string }[] })
|
|
|
471
615
|
<Text>{command.desc}</Text>
|
|
472
616
|
</Text>
|
|
473
617
|
))}
|
|
618
|
+
<Text dimColor> {mouseNote}</Text>
|
|
474
619
|
</Box>
|
|
475
620
|
)
|
|
476
621
|
}
|
|
@@ -504,6 +649,15 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
|
|
|
504
649
|
})
|
|
505
650
|
const { stdout } = useStdout()
|
|
506
651
|
|
|
652
|
+
// Physical Backspace/Delete keys are owned by useDeleteKeyCapture from the
|
|
653
|
+
// raw stdin bytes — Ink reports the Backspace key (\x7f) and the Delete key
|
|
654
|
+
// (ESC[3~) both as `key.delete`, so handling `key.delete` in useInput would
|
|
655
|
+
// delete in the wrong direction. The composer is always active while mounted.
|
|
656
|
+
useDeleteKeyCapture(true, (intent) => {
|
|
657
|
+
const { text, cursor: nextCursor } = applyDeleteIntent(valueRef.current, cursorRef.current, intent)
|
|
658
|
+
edit(text, nextCursor)
|
|
659
|
+
})
|
|
660
|
+
|
|
507
661
|
const filtered = useMemo(() => {
|
|
508
662
|
const match = /^(\w*)$/.exec(value.slice(1))
|
|
509
663
|
if (!value.startsWith('/') || match === null) return []
|
|
@@ -685,20 +839,22 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
|
|
|
685
839
|
return
|
|
686
840
|
}
|
|
687
841
|
if (key.ctrl && input === 'c') { typing ? void onStop() : onQuit(); return }
|
|
688
|
-
//
|
|
689
|
-
//
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
842
|
+
// Physical Backspace/Delete keys are handled by useDeleteKeyCapture above
|
|
843
|
+
// (raw stdin bytes distinguish them; Ink maps both to `key.delete`). Only
|
|
844
|
+
// the unambiguous logical editing bindings stay here.
|
|
845
|
+
if (key.ctrl && input === 'w') {
|
|
846
|
+
const { text, cursor: nextCursor } = applyDeleteIntent(current, at, 'backward-word')
|
|
847
|
+
edit(text, nextCursor)
|
|
848
|
+
return
|
|
849
|
+
}
|
|
850
|
+
if (key.ctrl && input === 'h') {
|
|
851
|
+
const { text, cursor: nextCursor } = applyDeleteIntent(current, at, 'backward')
|
|
852
|
+
edit(text, nextCursor)
|
|
853
|
+
return
|
|
854
|
+
}
|
|
855
|
+
if (key.ctrl && input === 'd') {
|
|
856
|
+
const { text, cursor: nextCursor } = applyDeleteIntent(current, at, 'forward')
|
|
857
|
+
edit(text, nextCursor)
|
|
702
858
|
return
|
|
703
859
|
}
|
|
704
860
|
if (key.leftArrow) { moveCursor(previousCursorBoundary(current, at)); return }
|
|
@@ -840,26 +996,6 @@ function pasteMarkerLength(input: string, at: number, code: '200' | '201'): numb
|
|
|
840
996
|
return input.startsWith(`\x1b[${code}~`, at) ? 6 : 5
|
|
841
997
|
}
|
|
842
998
|
|
|
843
|
-
function clampCursor(text: string, cursor: number): number {
|
|
844
|
-
let at = Math.max(0, Math.min(text.length, cursor))
|
|
845
|
-
while (at > 0 && at < text.length && /[\uDC00-\uDFFF]/.test(text[at])) at--
|
|
846
|
-
return at
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
function previousCursorBoundary(text: string, cursor: number): number {
|
|
850
|
-
const at = clampCursor(text, cursor)
|
|
851
|
-
if (at <= 0) return 0
|
|
852
|
-
const code = text.charCodeAt(at - 1)
|
|
853
|
-
return at - (code >= 0xDC00 && code <= 0xDFFF ? 2 : 1)
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
function nextCursorBoundary(text: string, cursor: number): number {
|
|
857
|
-
const at = clampCursor(text, cursor)
|
|
858
|
-
if (at >= text.length) return text.length
|
|
859
|
-
const code = text.charCodeAt(at)
|
|
860
|
-
return at + (code >= 0xD800 && code <= 0xDBFF ? 2 : 1)
|
|
861
|
-
}
|
|
862
|
-
|
|
863
999
|
interface ComposerLine { text: string; start: number; end: number }
|
|
864
1000
|
|
|
865
1001
|
function wrapComposerLines(text: string, width: number): ComposerLine[] {
|
|
@@ -904,13 +1040,14 @@ function findComposerCursorLine(lines: ComposerLine[], cursor: number): number {
|
|
|
904
1040
|
return Math.max(0, lines.length - 1)
|
|
905
1041
|
}
|
|
906
1042
|
|
|
907
|
-
function StatusArea({ ready, sessionId, columns, webUrl, aimuxStatus, modelDisplay }: {
|
|
1043
|
+
function StatusArea({ ready, sessionId, columns, webUrl, aimuxStatus, modelDisplay, copyNotice }: {
|
|
908
1044
|
ready: ReadyState
|
|
909
1045
|
sessionId: string | null
|
|
910
1046
|
columns: number
|
|
911
1047
|
webUrl: string
|
|
912
1048
|
aimuxStatus?: AimuxStatus
|
|
913
1049
|
modelDisplay: string
|
|
1050
|
+
copyNotice?: string | null
|
|
914
1051
|
}) {
|
|
915
1052
|
const model = modelDisplay
|
|
916
1053
|
const language = ready.prefs.language === 'en' ? 'English' : '中文'
|
|
@@ -926,7 +1063,7 @@ function StatusArea({ ready, sessionId, columns, webUrl, aimuxStatus, modelDispl
|
|
|
926
1063
|
return (
|
|
927
1064
|
<Box flexDirection="column" marginTop={1}>
|
|
928
1065
|
<Box justifyContent="space-between">
|
|
929
|
-
<Text dimColor>{left}</Text>
|
|
1066
|
+
<Text dimColor color={copyNotice ? 'green' : undefined}>{copyNotice ?? left}</Text>
|
|
930
1067
|
{right ? <Text dimColor>{right}</Text> : null}
|
|
931
1068
|
</Box>
|
|
932
1069
|
{/* Merged connectivity row: AIMUX status sits left, the clickable web URL
|
|
@@ -996,35 +1133,8 @@ function clickableUrl(url: string, maxLen?: number): string {
|
|
|
996
1133
|
return `\u001B]8;;${url}\u0007${display}\u001B]8;;\u0007`
|
|
997
1134
|
}
|
|
998
1135
|
|
|
999
|
-
//
|
|
1000
|
-
//
|
|
1001
|
-
// the remaining width without overflowing the row.
|
|
1002
|
-
function displayWidth(str: string): number {
|
|
1003
|
-
let w = 0
|
|
1004
|
-
for (const ch of str) {
|
|
1005
|
-
const code = ch.codePointAt(0) ?? 0
|
|
1006
|
-
if (code >= 0x0300 && code <= 0x036F) continue // combining diacriticals: 0 cols
|
|
1007
|
-
w += isWideCodepoint(code) ? 2 : 1
|
|
1008
|
-
}
|
|
1009
|
-
return w
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
function isWideCodepoint(code: number): boolean {
|
|
1013
|
-
return (
|
|
1014
|
-
(code >= 0x1100 && code <= 0x115F) || // Hangul Jamo
|
|
1015
|
-
(code >= 0x2E80 && code <= 0x303E) || // CJK radicals / punctuation
|
|
1016
|
-
(code >= 0x3041 && code <= 0x33FF) || // Hiragana / Katakana / CJK compat
|
|
1017
|
-
(code >= 0x3400 && code <= 0x4DBF) || // CJK Unified Extension A
|
|
1018
|
-
(code >= 0x4E00 && code <= 0x9FFF) || // CJK Unified Ideographs (心跳正常 …)
|
|
1019
|
-
(code >= 0xA000 && code <= 0xA4CF) || // Yi
|
|
1020
|
-
(code >= 0xAC00 && code <= 0xD7A3) || // Hangul syllables
|
|
1021
|
-
(code >= 0xF900 && code <= 0xFAFF) || // CJK compatibility ideographs
|
|
1022
|
-
(code >= 0xFE30 && code <= 0xFE4F) || // CJK compatibility forms
|
|
1023
|
-
(code >= 0xFF00 && code <= 0xFF60) || // Fullwidth ASCII
|
|
1024
|
-
(code >= 0xFFE0 && code <= 0xFFE6) || // Fullwidth signs
|
|
1025
|
-
(code >= 0x1F300 && code <= 0x1FAFF) // Emoji / symbols
|
|
1026
|
-
)
|
|
1027
|
-
}
|
|
1136
|
+
// displayWidth is imported from src/lib/screen-text.ts (CJK/emoji-aware), used
|
|
1137
|
+
// here to size the AIMUX status block so the web URL truncates exactly.
|
|
1028
1138
|
|
|
1029
1139
|
function wrappedRows(text: string, width: number): number {
|
|
1030
1140
|
const safeWidth = Math.max(1, width)
|
|
@@ -1061,22 +1171,55 @@ function entryRows(entry: AnyEntry, columns: number): number {
|
|
|
1061
1171
|
|
|
1062
1172
|
function fitTranscript(entries: AnyEntry[], rowBudget: number, columns: number, scrollBack = 0): {
|
|
1063
1173
|
entries: AnyEntry[]
|
|
1174
|
+
/** Tail rows of the next older entry, used to fill spare space above the viewport. */
|
|
1175
|
+
peekLines: string[]
|
|
1064
1176
|
hiddenOlder: number
|
|
1065
1177
|
hiddenRecent: number
|
|
1066
1178
|
startIndex: number
|
|
1067
1179
|
} {
|
|
1068
1180
|
const tail = Math.max(0, entries.length - scrollBack)
|
|
1069
1181
|
const available = tail === 0 ? [] : entries.slice(0, tail)
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1182
|
+
const renderedRows = available.map((entry) => entryScreenLines(viewsForEntry(entry), columns))
|
|
1183
|
+
const fit = (budget: number) => {
|
|
1184
|
+
let rows = 0
|
|
1185
|
+
let first = available.length
|
|
1186
|
+
for (let index = available.length - 1; index >= 0; index--) {
|
|
1187
|
+
const nextRows = renderedRows[index].length
|
|
1188
|
+
if (first < available.length && rows + nextRows > budget) break
|
|
1189
|
+
rows += nextRows
|
|
1190
|
+
first = index
|
|
1191
|
+
}
|
|
1192
|
+
return { first, rows }
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
const base = fit(rowBudget)
|
|
1196
|
+
let fitted = base
|
|
1197
|
+
let first = fitted.first
|
|
1198
|
+
let peekLines: string[] = []
|
|
1199
|
+
// When older history exists, guarantee at least one row for the tail of the
|
|
1200
|
+
// next older message. If complete entries exactly consume the budget, refit
|
|
1201
|
+
// them with one fewer row; only the oldest complete entry can drop out, while
|
|
1202
|
+
// the latest content remains visible. A single oversized entry keeps its
|
|
1203
|
+
// original rendering because it cannot safely donate a row.
|
|
1204
|
+
if (first > 0 && fitted.rows <= rowBudget) {
|
|
1205
|
+
if (fitted.rows === rowBudget && rowBudget > 1) {
|
|
1206
|
+
const reduced = fit(rowBudget - 1)
|
|
1207
|
+
if (reduced.first > 0 && reduced.rows <= rowBudget - 1) {
|
|
1208
|
+
fitted = reduced
|
|
1209
|
+
first = reduced.first
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
const olderLines = renderedRows[first - 1].slice()
|
|
1213
|
+
while (olderLines.length > 0 && !olderLines[0].trim()) olderLines.shift()
|
|
1214
|
+
while (olderLines.length > 0 && !olderLines[olderLines.length - 1].trim()) olderLines.pop()
|
|
1215
|
+
const spare = rowBudget - fitted.rows
|
|
1216
|
+
if (spare > 0 && olderLines.length > 0) {
|
|
1217
|
+
peekLines = olderLines.slice(-spare)
|
|
1218
|
+
}
|
|
1077
1219
|
}
|
|
1078
1220
|
return {
|
|
1079
1221
|
entries: available.slice(first),
|
|
1222
|
+
peekLines,
|
|
1080
1223
|
hiddenOlder: first,
|
|
1081
1224
|
hiddenRecent: entries.length - tail,
|
|
1082
1225
|
startIndex: first,
|