@mobius-os/mobius 0.3.27 → 0.3.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/install.ps1 +265 -0
- package/package.json +24 -7
- package/scripts/build-python-bundles.sh +129 -0
- package/src/App.tsx +2 -2
- package/src/components/Chat.tsx +158 -341
- package/src/components/ConfigFlow.tsx +2 -0
- package/src/components/primitives.tsx +134 -7
- package/src/lib/entry-view.ts +10 -5
- package/src/lib/screen-text.ts +68 -57
- package/src/lib/transcript-viewport.ts +162 -0
- package/src/lib/windows-input.ts +186 -0
- package/src/main.tsx +5 -1
- package/src/version.ts +21 -0
- package/tests/aimux.test.tsx +210 -0
- package/tests/flow.test.tsx +211 -0
- package/tests/integration.test.ts +114 -0
- package/tests/preview.tsx +104 -0
- package/tests/reconnect.test.tsx +170 -0
- package/tests/resume.test.tsx +73 -0
- package/tests/screen.test.tsx +118 -0
- package/tests/scroll.test.tsx +253 -0
- package/tests/selection.test.tsx +219 -0
- package/tests/ui.test.tsx +901 -0
- package/tests/viewport.test.ts +83 -0
- package/tsconfig.json +19 -0
- package/uninstall.ps1 +144 -0
package/src/components/Chat.tsx
CHANGED
|
@@ -11,14 +11,16 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
|
11
11
|
import { Box, Text, useStdout } from 'ink'
|
|
12
12
|
import { useChat } from '../hooks/useChat.js'
|
|
13
13
|
import { MobiusClient } from '../api.js'
|
|
14
|
-
import {
|
|
15
|
-
import { viewsForEntry, dedupeUserEntries, toolLabel, isAssistantOutput, type EntryView } from '../lib/entry-view.js'
|
|
14
|
+
import { viewsForEntry, dedupeUserEntries, isAssistantOutput } from '../lib/entry-view.js'
|
|
16
15
|
import {
|
|
17
|
-
|
|
18
|
-
buildTranscriptModel, computeTranscriptGeometry, screenToSelPoint,
|
|
16
|
+
displayWidth, compareSel, entryScreenRows, screenToSelPoint,
|
|
19
17
|
buildSelectionMap, buildSelectionText, osc52,
|
|
20
|
-
type TranscriptModel, type TranscriptGeometry, type SelPoint,
|
|
18
|
+
type TranscriptModel, type TranscriptGeometry, type SelPoint, type ScreenRow,
|
|
21
19
|
} from '../lib/screen-text.js'
|
|
20
|
+
import {
|
|
21
|
+
createRowAccess, moveAnchorByRows, sliceViewport, tailAnchor,
|
|
22
|
+
type RowAnchor,
|
|
23
|
+
} from '../lib/transcript-viewport.js'
|
|
22
24
|
import type { ReadyState } from './PrepScreen.js'
|
|
23
25
|
import type { AnyEntry } from '../types.js'
|
|
24
26
|
import { ConfigFlow, ReconfigFlow, type ConfigResult } from './ConfigFlow.js'
|
|
@@ -46,8 +48,7 @@ interface TerminalSize {
|
|
|
46
48
|
isTty: boolean
|
|
47
49
|
}
|
|
48
50
|
|
|
49
|
-
import {
|
|
50
|
-
const VERSION = createRequire(import.meta.url)('../../package.json').version
|
|
51
|
+
import { TUI_VERSION } from '../version.js'
|
|
51
52
|
const DEFAULT_COMPOSER_ROWS = 5
|
|
52
53
|
const STATUS_ROWS = 3
|
|
53
54
|
|
|
@@ -63,11 +64,18 @@ const SLASH_COMMANDS = [
|
|
|
63
64
|
export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear, onResume, onQuit, onReconfigure, onConfigCancel, aimuxStatus }: ChatProps) {
|
|
64
65
|
const chat = useChat({ client, ready, resumeSessionId })
|
|
65
66
|
const [showHelp, setShowHelp] = useState(false)
|
|
66
|
-
|
|
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)
|
|
67
70
|
const [composerRows, setComposerRows] = useState(DEFAULT_COMPOSER_ROWS)
|
|
68
71
|
const [modelLabel, setModelLabel] = useState<string | null>(null)
|
|
69
72
|
const [configOpen, setConfigOpen] = useState(false)
|
|
70
73
|
const [reconfigOpen, setReconfigOpen] = useState(false)
|
|
74
|
+
// Ink may deliver one final event to Composer while an async config picker is
|
|
75
|
+
// replacing it. The shared ref lets that stale listener report "not handled"
|
|
76
|
+
// so App can replay the key after the new Select mounts.
|
|
77
|
+
const chatInputActiveRef = useRef(true)
|
|
78
|
+
chatInputActiveRef.current = !configOpen && !reconfigOpen
|
|
71
79
|
// Ink's useInput keeps whatever handler was registered at subscription time;
|
|
72
80
|
// reading mutable refs (updated every render) keeps the callback from acting
|
|
73
81
|
// on a stale `configOpen`/sessionId closure after the config flow opens.
|
|
@@ -96,7 +104,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
96
104
|
return
|
|
97
105
|
}
|
|
98
106
|
setShowHelp(false)
|
|
99
|
-
|
|
107
|
+
setRowAnchor(null)
|
|
100
108
|
void chat.send(t)
|
|
101
109
|
}, [chat, runSlash])
|
|
102
110
|
|
|
@@ -112,39 +120,66 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
112
120
|
// (type:user / response_item.message[user] / event_msg.user_message) 合并成 1 条,
|
|
113
121
|
// 避免在累积视图里把同一条提问显示多次.
|
|
114
122
|
const dedupedEntries = useMemo(() => dedupeUserEntries(chat.entries), [chat.entries])
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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])
|
|
125
158
|
const viewportRows = terminal.isTty ? Math.max(9, terminal.rows - 1) : terminal.rows
|
|
126
159
|
const activityRows = (chat.typing ? 2 : 0) + (chat.error ? 1 : 0)
|
|
127
160
|
const helpRows = showHelp ? SLASH_COMMANDS.length + 3 : 0
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
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],
|
|
132
168
|
)
|
|
133
|
-
const showWelcome =
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}, [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])
|
|
148
183
|
|
|
149
184
|
// Show the model's friendly label (e.g. "GPT-5.6-Sol") in the header/status
|
|
150
185
|
// instead of its opaque key (e.g. "codex:mobiusdefaultaabb").
|
|
@@ -170,16 +205,13 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
170
205
|
if (isEscapeKeypress(_input, key)) onConfigCancel(handlerRef.current.sessionId)
|
|
171
206
|
return
|
|
172
207
|
}
|
|
173
|
-
|
|
174
|
-
if (key.
|
|
175
|
-
|
|
176
|
-
})
|
|
208
|
+
if (key.pageUp) scrollRows(-pageRows)
|
|
209
|
+
else if (key.pageDown) scrollRows(pageRows)
|
|
210
|
+
}, { interactive: false })
|
|
177
211
|
|
|
178
|
-
const
|
|
179
|
-
?
|
|
180
|
-
|
|
181
|
-
: '已到最早记录 · 滚轮/PageDown 翻页 · 拖动选中文本'
|
|
182
|
-
: null
|
|
212
|
+
const navigationHint = viewport.hasOlder
|
|
213
|
+
? `${viewport.hasNewer ? '↑ 较早内容 · ↓ 较新内容' : '↑ 还有较早内容'} · 滚轮 3 行 · PageUp/PageDown ${pageRows} 行 · 拖动选中文本`
|
|
214
|
+
: `${viewport.hasNewer ? '已到最早 · ↓ 还有较新内容' : '全部内容'} · 滚轮 3 行 · PageUp/PageDown ${pageRows} 行 · 拖动选中文本`
|
|
183
215
|
|
|
184
216
|
// Mouse: wheel pages through history in small fixed steps, and a left-button
|
|
185
217
|
// drag selects transcript text (tmux-style: the app owns the mouse, draws its
|
|
@@ -191,23 +223,16 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
191
223
|
const [sel, setSel] = useState<{ anchor: SelPoint; end: SelPoint; active: boolean } | null>(null)
|
|
192
224
|
const [copyNotice, setCopyNotice] = useState<string | null>(null)
|
|
193
225
|
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
const geometry: TranscriptGeometry = useMemo(
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
activityRows,
|
|
202
|
-
helpRows,
|
|
203
|
-
showWelcome,
|
|
204
|
-
welcomeRows: 10,
|
|
205
|
-
olderHintShown: olderHint !== null,
|
|
206
|
-
tipShown,
|
|
207
|
-
}), [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
|
+
)
|
|
208
233
|
const transcriptModel: TranscriptModel = useMemo(
|
|
209
|
-
() =>
|
|
210
|
-
[
|
|
234
|
+
() => ({ entries: viewport.rows.map(item => [item.row.plain]), totalRows: viewport.rows.length }),
|
|
235
|
+
[viewport.rows],
|
|
211
236
|
)
|
|
212
237
|
const selMap = useMemo(
|
|
213
238
|
() => (sel?.active ? buildSelectionMap(transcriptModel, sel.anchor, sel.end) : null),
|
|
@@ -223,25 +248,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
223
248
|
|
|
224
249
|
useMouseEvents({
|
|
225
250
|
onWheel: (delta) => {
|
|
226
|
-
|
|
227
|
-
const targetLines = 2
|
|
228
|
-
let lines = 0
|
|
229
|
-
let next = scrollBack
|
|
230
|
-
const n = dedupedEntries.length
|
|
231
|
-
if (delta > 0) {
|
|
232
|
-
// scroll up (older): hide more entries from the tail
|
|
233
|
-
for (let i = n - 1 - next; i >= 0 && lines < targetLines; i--) {
|
|
234
|
-
lines += getEntryLines(i)
|
|
235
|
-
next++
|
|
236
|
-
}
|
|
237
|
-
} else {
|
|
238
|
-
// scroll down (newer): unhide entries from the tail
|
|
239
|
-
for (let i = n - next; i < n && lines < targetLines; i++) {
|
|
240
|
-
lines += getEntryLines(i)
|
|
241
|
-
next--
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
setScrollBack(Math.min(n, Math.max(0, next)))
|
|
251
|
+
scrollRows(delta * -3)
|
|
245
252
|
},
|
|
246
253
|
onPress: (row, col) => {
|
|
247
254
|
const p = screenToSelPoint(row, col, transcriptModel, geometry)
|
|
@@ -321,32 +328,25 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
321
328
|
? <WelcomeCard ready={ready} columns={terminal.columns} resumed={Boolean(resumeSessionId)} modelDisplay={modelDisplay} />
|
|
322
329
|
: <CompactHeader ready={ready} sessionId={chat.sessionId} columns={terminal.columns} />}
|
|
323
330
|
|
|
324
|
-
{
|
|
325
|
-
|
|
326
|
-
instead of floating mid-screen when the transcript has spare rows. */}
|
|
327
|
-
{olderHint !== null
|
|
328
|
-
? <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>
|
|
329
333
|
: null}
|
|
330
334
|
|
|
331
|
-
<Box
|
|
332
|
-
{
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
{
|
|
340
|
-
const
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
})}
|
|
346
|
-
{chat.pendingUser !== null ? <UserLine text={chat.pendingUser} /> : null}
|
|
347
|
-
{fitted.hiddenRecent > 0
|
|
348
|
-
? <Box width="100%" flexShrink={0}><Text dimColor wrap="truncate-end"> ↓ 滚轮/PageDown 翻页 · 较新 {fitted.hiddenRecent} 条</Text></Box>
|
|
349
|
-
: 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}
|
|
350
350
|
</Box>
|
|
351
351
|
|
|
352
352
|
{dedupedEntries.length === 0 && chat.pendingUser === null && !showHelp
|
|
@@ -367,6 +367,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
367
367
|
typing={chat.typing}
|
|
368
368
|
commands={SLASH_COMMANDS}
|
|
369
369
|
onHeightChange={setComposerRows}
|
|
370
|
+
inputActiveRef={chatInputActiveRef}
|
|
370
371
|
/>
|
|
371
372
|
<StatusArea
|
|
372
373
|
ready={ready}
|
|
@@ -410,7 +411,7 @@ function WelcomeCard({ ready, columns, resumed, modelDisplay }: { ready: ReadySt
|
|
|
410
411
|
<Text>
|
|
411
412
|
<Text dimColor>{'>_ '}</Text>
|
|
412
413
|
<Text bold>Mobius</Text>
|
|
413
|
-
<Text dimColor> (v{
|
|
414
|
+
<Text dimColor> (v{TUI_VERSION})</Text>
|
|
414
415
|
</Text>
|
|
415
416
|
<Text> </Text>
|
|
416
417
|
<MetaRow label="model:" value={modelDisplay} hint="/help 查看命令" labelWidth={labelWidth} />
|
|
@@ -445,154 +446,58 @@ function CompactHeader({ ready, sessionId, columns }: { ready: ReadyState; sessi
|
|
|
445
446
|
)
|
|
446
447
|
}
|
|
447
448
|
|
|
448
|
-
function
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
449
|
+
function ScreenText({ row, text }: { row: ScreenRow; text: string }) {
|
|
450
|
+
const tone = row.tone
|
|
451
|
+
const color = tone === 'tool' ? 'cyan'
|
|
452
|
+
: tone === 'tool_error' || tone === 'edit_old' || tone === 'error' ? 'red'
|
|
453
|
+
: tone === 'edit_header' || tone === 'reasoning' ? 'magenta'
|
|
454
|
+
: tone === 'edit_new' ? 'green'
|
|
455
|
+
: tone === 'system' ? 'yellow'
|
|
456
|
+
: undefined
|
|
457
|
+
const dimColor = tone === 'tool_result' || tone === 'tool_error' || tone === 'reasoning' || tone === 'system'
|
|
458
|
+
return <Text wrap="truncate-end" bold={tone === 'user'} dimColor={dimColor} color={color}>{text}</Text>
|
|
455
459
|
}
|
|
456
460
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
}
|
|
480
|
-
return <Text key={index} wrap="truncate-end">{row || ' '}</Text>
|
|
481
|
-
})}
|
|
482
|
-
</Box>
|
|
483
|
-
)
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
function ViewLine({ view, columns }: { view: EntryView; columns: number }) {
|
|
487
|
-
const width = Math.max(8, columns - 4)
|
|
488
|
-
switch (view.kind) {
|
|
489
|
-
case 'skip':
|
|
490
|
-
return null
|
|
491
|
-
case 'user':
|
|
492
|
-
return <UserLine text={view.text} />
|
|
493
|
-
case 'assistant': {
|
|
494
|
-
const lines = renderMarkdownLines(view.text)
|
|
495
|
-
return (
|
|
496
|
-
<Box marginTop={1} flexDirection="column">
|
|
497
|
-
{lines.map((line, index) => (
|
|
498
|
-
<Text key={index} wrap={line.code ? 'truncate-end' : 'wrap'}>
|
|
499
|
-
{index === 0 ? '• ' : ' '}{line.text || ' '}
|
|
500
|
-
</Text>
|
|
501
|
-
))}
|
|
502
|
-
</Box>
|
|
503
|
-
)
|
|
461
|
+
const ANSI_CSI_RE = /^\x1b\[[0-?]*[ -/]*[@-~]/
|
|
462
|
+
const SELECTION_BG = '\x1b[46m'
|
|
463
|
+
const SELECTION_BG_END = '\x1b[49m'
|
|
464
|
+
|
|
465
|
+
/** Add a background to visible UTF-16 offsets without stripping existing ANSI. */
|
|
466
|
+
function highlightScreenRow(styled: string, start: number, end: number): string {
|
|
467
|
+
if (start >= end) return styled
|
|
468
|
+
let out = ''
|
|
469
|
+
let raw = 0
|
|
470
|
+
let visible = 0
|
|
471
|
+
let highlighted = false
|
|
472
|
+
while (raw < styled.length) {
|
|
473
|
+
if (styled.charCodeAt(raw) === 0x1b) {
|
|
474
|
+
const match = ANSI_CSI_RE.exec(styled.slice(raw))
|
|
475
|
+
if (match) {
|
|
476
|
+
out += match[0]
|
|
477
|
+
raw += match[0].length
|
|
478
|
+
// A full SGR reset inside Markdown/syntax text also resets the injected
|
|
479
|
+
// background; immediately restore it while the selected span is active.
|
|
480
|
+
if (highlighted && /^\x1b\[(?:0)?m$/.test(match[0])) out += SELECTION_BG
|
|
481
|
+
continue
|
|
482
|
+
}
|
|
504
483
|
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
{view.result ? (
|
|
512
|
-
<Text dimColor color={view.result.isError ? 'red' : undefined}>
|
|
513
|
-
{' └ '}{clampLines(view.result.text, width - 4, 1)[0] || '(无输出)'}
|
|
514
|
-
</Text>
|
|
515
|
-
) : null}
|
|
516
|
-
</Box>
|
|
517
|
-
)
|
|
484
|
+
const codePoint = styled.codePointAt(raw)!
|
|
485
|
+
const char = String.fromCodePoint(codePoint)
|
|
486
|
+
const nextVisible = visible + char.length
|
|
487
|
+
if (!highlighted && nextVisible > start && visible < end) {
|
|
488
|
+
out += SELECTION_BG
|
|
489
|
+
highlighted = true
|
|
518
490
|
}
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
const lines = headTailLines(view.text, width - 4, 5)
|
|
523
|
-
return (
|
|
524
|
-
<Box flexDirection="column">
|
|
525
|
-
{lines.map((l, i) => (
|
|
526
|
-
<Text key={i} dimColor color={view.isError ? 'red' : undefined}>{i === 0 ? ' └ ' : ' '}{l}</Text>
|
|
527
|
-
))}
|
|
528
|
-
</Box>
|
|
529
|
-
)
|
|
491
|
+
if (highlighted && visible >= end) {
|
|
492
|
+
out += SELECTION_BG_END
|
|
493
|
+
highlighted = false
|
|
530
494
|
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
return <WriteFileView view={view} />
|
|
535
|
-
case 'reasoning': {
|
|
536
|
-
const lines = clampLines(view.text, width - 4, 2)
|
|
537
|
-
return (
|
|
538
|
-
<Box marginTop={1} flexDirection="column">
|
|
539
|
-
{lines.map((line, i) => (
|
|
540
|
-
<Text key={i} dimColor color="magenta">{i === 0 ? ' ◇ ' : ' '}{line}</Text>
|
|
541
|
-
))}
|
|
542
|
-
</Box>
|
|
543
|
-
)
|
|
544
|
-
}
|
|
545
|
-
case 'system':
|
|
546
|
-
return <Text dimColor color="yellow"> {clampLines(view.text, width - 2, 2)[0]}</Text>
|
|
547
|
-
case 'error':
|
|
548
|
-
return (
|
|
549
|
-
<Box marginTop={1} flexDirection="column">
|
|
550
|
-
{view.text.split('\n').map((line, i) => (
|
|
551
|
-
<Text key={i} color="red">{i === 0 ? '⚠ ' : ' '}{line}</Text>
|
|
552
|
-
))}
|
|
553
|
-
</Box>
|
|
554
|
-
)
|
|
555
|
-
default:
|
|
556
|
-
return null
|
|
495
|
+
out += char
|
|
496
|
+
raw += char.length
|
|
497
|
+
visible = nextVisible
|
|
557
498
|
}
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
// 代码修改 (Edit/StrReplace/apply_patch) — full: 完整展示 old(−)/new(+) 改动原文.
|
|
561
|
-
function CodeEditView({ view }: { view: { filePath: string; oldString: string; newString: string } }) {
|
|
562
|
-
return (
|
|
563
|
-
<Box marginTop={1} flexDirection="column">
|
|
564
|
-
<Text color="magenta">✎ 编辑 {view.filePath || '(未指定文件)'}</Text>
|
|
565
|
-
{view.oldString ? view.oldString.split('\n').map((line, i) => (
|
|
566
|
-
<Text key={`o${i}`} color="red">{' − '}{line}</Text>
|
|
567
|
-
)) : null}
|
|
568
|
-
{view.newString ? view.newString.split('\n').map((line, i) => (
|
|
569
|
-
<Text key={`n${i}`} color="green">{' + '}{line}</Text>
|
|
570
|
-
)) : null}
|
|
571
|
-
</Box>
|
|
572
|
-
)
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
// 文件写入 (Write/create_file) — full: 完整展示写入内容原文.
|
|
576
|
-
function WriteFileView({ view }: { view: { filePath: string; content: string } }) {
|
|
577
|
-
return (
|
|
578
|
-
<Box marginTop={1} flexDirection="column">
|
|
579
|
-
<Text color="magenta">✎ 写入 {view.filePath || '(未指定文件)'}</Text>
|
|
580
|
-
{view.content.split('\n').map((line, i) => (
|
|
581
|
-
<Text key={i} color="green">{' + '}{line}</Text>
|
|
582
|
-
))}
|
|
583
|
-
</Box>
|
|
584
|
-
)
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
// clampLines / headTailLines / displayWidth live in src/lib/screen-text.ts
|
|
588
|
-
// (mirrored, exported) and are imported above; they must match ViewLine exactly
|
|
589
|
-
// so the drag-selection text model aligns with the rendered rows.
|
|
590
|
-
|
|
591
|
-
function UserLine({ text }: { text: string }) {
|
|
592
|
-
const lines = text.split('\n')
|
|
593
|
-
if (lines[0] !== undefined) lines[0] = `› ${lines[0]}`
|
|
594
|
-
for (let i = 1; i < lines.length; i++) lines[i] = ` ${lines[i]}`
|
|
595
|
-
return <Box marginTop={1}><Text bold>{lines.join('\n')}</Text></Box>
|
|
499
|
+
if (highlighted) out += SELECTION_BG_END
|
|
500
|
+
return out
|
|
596
501
|
}
|
|
597
502
|
|
|
598
503
|
function WorkingIndicator({ firstQuery }: { firstQuery: boolean }) {
|
|
@@ -653,9 +558,10 @@ interface ComposerProps {
|
|
|
653
558
|
typing: boolean
|
|
654
559
|
commands: { cmd: string; desc: string }[]
|
|
655
560
|
onHeightChange?: (rows: number) => void
|
|
561
|
+
inputActiveRef?: React.RefObject<boolean>
|
|
656
562
|
}
|
|
657
563
|
|
|
658
|
-
export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightChange }: ComposerProps) {
|
|
564
|
+
export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightChange, inputActiveRef }: ComposerProps) {
|
|
659
565
|
const [value, setValue] = useState('')
|
|
660
566
|
const [cursor, setCursor] = useState(0)
|
|
661
567
|
const [popupIdx, setPopupIdx] = useState(0)
|
|
@@ -799,6 +705,7 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
|
|
|
799
705
|
useEffect(() => () => resetPasteBurst(), [])
|
|
800
706
|
|
|
801
707
|
useStableInput((input, key) => {
|
|
708
|
+
if (inputActiveRef?.current === false) return false
|
|
802
709
|
if (isMouseInput(input)) return // mouse events must never become typed text
|
|
803
710
|
const now = Date.now()
|
|
804
711
|
const escape = isEscapeKeypress(input, key)
|
|
@@ -1005,7 +912,7 @@ function normalizeComposerPaste(text: string): string {
|
|
|
1005
912
|
}
|
|
1006
913
|
|
|
1007
914
|
function isEnhancedNewlineInput(input: string): boolean {
|
|
1008
|
-
return /^\[(
|
|
915
|
+
return /^\[13;2u$/.test(input) || /^\[27;2;13~$/.test(input) || input === '\x1b\r'
|
|
1009
916
|
}
|
|
1010
917
|
|
|
1011
918
|
function findPasteMarker(input: string, code: '200' | '201', from = 0): number {
|
|
@@ -1161,93 +1068,3 @@ function clickableUrl(url: string, maxLen?: number): string {
|
|
|
1161
1068
|
|
|
1162
1069
|
// displayWidth is imported from src/lib/screen-text.ts (CJK/emoji-aware), used
|
|
1163
1070
|
// here to size the AIMUX status block so the web URL truncates exactly.
|
|
1164
|
-
|
|
1165
|
-
function wrappedRows(text: string, width: number): number {
|
|
1166
|
-
const safeWidth = Math.max(1, width)
|
|
1167
|
-
return text.split('\n').reduce((sum, line) => sum + Math.max(1, Math.ceil(displayWidth(line) / safeWidth)), 0)
|
|
1168
|
-
}
|
|
1169
|
-
|
|
1170
|
-
function entryRows(entry: AnyEntry, columns: number): number {
|
|
1171
|
-
const width = Math.max(8, columns - 4)
|
|
1172
|
-
return Math.max(1, viewsForEntry(entry).reduce((sum, view) => {
|
|
1173
|
-
switch (view.kind) {
|
|
1174
|
-
case 'skip': return sum
|
|
1175
|
-
case 'user': return sum + 1 + wrappedRows(view.text, width - 2)
|
|
1176
|
-
case 'assistant': {
|
|
1177
|
-
const rows = renderMarkdownLines(view.text).reduce((total, line) => {
|
|
1178
|
-
return total + (line.code ? 1 : wrappedRows(line.text || ' ', width - 2))
|
|
1179
|
-
}, 0)
|
|
1180
|
-
return sum + 1 + rows
|
|
1181
|
-
}
|
|
1182
|
-
case 'tool_call': return sum + 2 + (view.result ? 1 : 0)
|
|
1183
|
-
case 'tool_result': return sum + headTailLines(view.text, width - 4, 5).length
|
|
1184
|
-
case 'code_edit':
|
|
1185
|
-
return sum + 2 + wrappedRows(view.filePath || '(未指定文件)', width - 4)
|
|
1186
|
-
+ wrappedRows(view.oldString, width - 4) + wrappedRows(view.newString, width - 4)
|
|
1187
|
-
case 'write_file':
|
|
1188
|
-
return sum + 2 + wrappedRows(view.filePath || '(未指定文件)', width - 4)
|
|
1189
|
-
+ wrappedRows(view.content, width - 4)
|
|
1190
|
-
case 'reasoning': return sum + 1 + clampLines(view.text, width - 4, 2).length
|
|
1191
|
-
case 'system': return sum + 1
|
|
1192
|
-
case 'error': return sum + 1 + wrappedRows(view.text, width - 2)
|
|
1193
|
-
default: return sum
|
|
1194
|
-
}
|
|
1195
|
-
}, 0))
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
|
-
function fitTranscript(entries: AnyEntry[], rowBudget: number, columns: number, scrollBack = 0): {
|
|
1199
|
-
entries: AnyEntry[]
|
|
1200
|
-
/** Tail rows of the next older entry, used to fill spare space above the viewport. */
|
|
1201
|
-
peekLines: string[]
|
|
1202
|
-
hiddenOlder: number
|
|
1203
|
-
hiddenRecent: number
|
|
1204
|
-
startIndex: number
|
|
1205
|
-
} {
|
|
1206
|
-
const tail = Math.max(0, entries.length - scrollBack)
|
|
1207
|
-
const available = tail === 0 ? [] : entries.slice(0, tail)
|
|
1208
|
-
const renderedRows = available.map((entry) => entryScreenLines(viewsForEntry(entry), columns))
|
|
1209
|
-
const fit = (budget: number) => {
|
|
1210
|
-
let rows = 0
|
|
1211
|
-
let first = available.length
|
|
1212
|
-
for (let index = available.length - 1; index >= 0; index--) {
|
|
1213
|
-
const nextRows = renderedRows[index].length
|
|
1214
|
-
if (first < available.length && rows + nextRows > budget) break
|
|
1215
|
-
rows += nextRows
|
|
1216
|
-
first = index
|
|
1217
|
-
}
|
|
1218
|
-
return { first, rows }
|
|
1219
|
-
}
|
|
1220
|
-
|
|
1221
|
-
const base = fit(rowBudget)
|
|
1222
|
-
let fitted = base
|
|
1223
|
-
let first = fitted.first
|
|
1224
|
-
let peekLines: string[] = []
|
|
1225
|
-
// When older history exists, guarantee at least one row for the tail of the
|
|
1226
|
-
// next older message. If complete entries exactly consume the budget, refit
|
|
1227
|
-
// them with one fewer row; only the oldest complete entry can drop out, while
|
|
1228
|
-
// the latest content remains visible. A single oversized entry keeps its
|
|
1229
|
-
// original rendering because it cannot safely donate a row.
|
|
1230
|
-
if (first > 0 && fitted.rows <= rowBudget) {
|
|
1231
|
-
if (fitted.rows === rowBudget && rowBudget > 1) {
|
|
1232
|
-
const reduced = fit(rowBudget - 1)
|
|
1233
|
-
if (reduced.first > 0 && reduced.rows <= rowBudget - 1) {
|
|
1234
|
-
fitted = reduced
|
|
1235
|
-
first = reduced.first
|
|
1236
|
-
}
|
|
1237
|
-
}
|
|
1238
|
-
const olderLines = renderedRows[first - 1].slice()
|
|
1239
|
-
while (olderLines.length > 0 && !olderLines[0].trim()) olderLines.shift()
|
|
1240
|
-
while (olderLines.length > 0 && !olderLines[olderLines.length - 1].trim()) olderLines.pop()
|
|
1241
|
-
const spare = rowBudget - fitted.rows
|
|
1242
|
-
if (spare > 0 && olderLines.length > 0) {
|
|
1243
|
-
peekLines = olderLines.slice(-spare)
|
|
1244
|
-
}
|
|
1245
|
-
}
|
|
1246
|
-
return {
|
|
1247
|
-
entries: available.slice(first),
|
|
1248
|
-
peekLines,
|
|
1249
|
-
hiddenOlder: first,
|
|
1250
|
-
hiddenRecent: entries.length - tail,
|
|
1251
|
-
startIndex: first,
|
|
1252
|
-
}
|
|
1253
|
-
}
|
|
@@ -322,6 +322,7 @@ export function ReconfigFlow({ client, onDone }: {
|
|
|
322
322
|
{ label: '➕ 创建新项目', value: '__create__' },
|
|
323
323
|
...projects.map(p => ({ label: p.name, value: p.id, desc: p.description })),
|
|
324
324
|
]}
|
|
325
|
+
initialActive={projects.length > 0 ? 1 : 0}
|
|
325
326
|
onSelect={v => v === '__create__' ? setCreateMode('project') : pickProject(projects!.find(p => p.id === v)!)}
|
|
326
327
|
/>}
|
|
327
328
|
</Box>
|
|
@@ -345,6 +346,7 @@ export function ReconfigFlow({ client, onDone }: {
|
|
|
345
346
|
{ label: '➕ 创建新任务', value: '__create__' },
|
|
346
347
|
...issues.map(i => ({ label: i.title, value: i.id, desc: i.description })),
|
|
347
348
|
]}
|
|
349
|
+
initialActive={1}
|
|
348
350
|
onSelect={v => v === '__create__' ? setCreateMode('issue') : pickIssue(issues!.find(i => i.id === v)!)} />}
|
|
349
351
|
</Box>
|
|
350
352
|
<Text color="gray">↑↓ 选择 · 回车确认 · Esc 取消</Text>
|