@mobius-os/mobius 0.3.15 → 0.3.20
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 +28 -0
- package/src/components/Chat.tsx +202 -119
- package/src/components/ConfigFlow.tsx +120 -0
- 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.20",
|
|
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,31 @@ 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 (= /config): swap task+model and start a brand-new session. App owns
|
|
125
|
+
// `ready`, so it folds the ConfigFlow result in and remounts Chat on the fresh
|
|
126
|
+
// session (resumeSessionId = the eagerly created session).
|
|
127
|
+
function onReconfigure(result: ConfigResult) {
|
|
128
|
+
if (!ready || !client) return
|
|
129
|
+
if (process.env.MOBIUS_TUI_DEBUG) console.error('[route] reconfigure', result.issue.id, result.prefs.model, result.sessionId)
|
|
130
|
+
setReady({ project: ready.project, issue: result.issue, prefs: result.prefs })
|
|
131
|
+
setResumeSessionId(result.sessionId)
|
|
132
|
+
setChatKey(k => k + 1)
|
|
133
|
+
setRoute('chat')
|
|
134
|
+
}
|
|
135
|
+
|
|
110
136
|
function onResumed(sid: string) {
|
|
111
137
|
setResumeSessionId(sid)
|
|
112
138
|
setChatKey(k => k + 1)
|
|
@@ -135,6 +161,8 @@ export function App() {
|
|
|
135
161
|
onClear={onClear}
|
|
136
162
|
onResume={onResume}
|
|
137
163
|
onQuit={onQuit}
|
|
164
|
+
onReconfigure={onReconfigure}
|
|
165
|
+
onConfigCancel={onConfigCancel}
|
|
138
166
|
aimuxStatus={aimuxStatus}
|
|
139
167
|
/>
|
|
140
168
|
)
|
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, 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,24 @@ 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: '更换模型(/model 的别名)' },
|
|
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
|
+
// Ink's useInput keeps whatever handler was registered at subscription time;
|
|
71
|
+
// reading mutable refs (updated every render) keeps the callback from acting
|
|
72
|
+
// on a stale `configOpen`/sessionId closure after the config flow opens.
|
|
73
|
+
const handlerRef = useRef<{ configOpen: boolean; sessionId: string | null }>({ configOpen: false, sessionId: null })
|
|
74
|
+
handlerRef.current = { configOpen, sessionId: chat.sessionId }
|
|
57
75
|
const terminal = useTerminalSize()
|
|
58
76
|
|
|
59
77
|
const runSlash = useCallback((raw: string) => {
|
|
@@ -62,6 +80,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
62
80
|
case '/clear': onClear(); return true
|
|
63
81
|
case '/resume': onResume(); return true
|
|
64
82
|
case '/help': setShowHelp(s => !s); return true
|
|
83
|
+
case '/model': case '/config': setConfigOpen(true); return true
|
|
65
84
|
case '/quit': case '/exit': onQuit(); return true
|
|
66
85
|
default: return false
|
|
67
86
|
}
|
|
@@ -129,27 +148,120 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
129
148
|
const modelDisplay = modelLabel ?? ready.prefs.model ?? 'default'
|
|
130
149
|
|
|
131
150
|
useInput((_input, key) => {
|
|
151
|
+
// While the config flow is open, this ChatScreen-level handler owns Esc so
|
|
152
|
+
// cancel is reliable even mid-list-loading (a per-component EscToCancel
|
|
153
|
+
// could be unmounted by the loading→loaded transition and drop the keypress).
|
|
154
|
+
// configOpen/sessionId are read from handlerRef (see above) because Ink keeps
|
|
155
|
+
// the originally-registered callback and would otherwise see a stale closure.
|
|
156
|
+
if (handlerRef.current.configOpen) {
|
|
157
|
+
if (isEscapeKeypress(_input, key)) onConfigCancel(handlerRef.current.sessionId)
|
|
158
|
+
return
|
|
159
|
+
}
|
|
132
160
|
const step = Math.max(1, fitted.entries.length)
|
|
133
161
|
if (key.pageUp) setScrollBack(value => Math.min(dedupedEntries.length, value + step))
|
|
134
162
|
else if (key.pageDown) setScrollBack(value => Math.max(0, value - step))
|
|
135
163
|
})
|
|
136
164
|
|
|
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
165
|
const olderHint = !showWelcome && (fitted.hiddenOlder > 0 || scrollBack > 0)
|
|
148
166
|
? fitted.hiddenOlder > 0
|
|
149
|
-
? `↑ 还有 ${fitted.hiddenOlder} 条较早记录 · 滚轮/PageUp
|
|
150
|
-
: '已到最早记录 · 滚轮/PageDown
|
|
167
|
+
? `↑ 还有 ${fitted.hiddenOlder} 条较早记录 · 滚轮/PageUp 翻页 · 拖动选中文本`
|
|
168
|
+
: '已到最早记录 · 滚轮/PageDown 翻页 · 拖动选中文本'
|
|
151
169
|
: null
|
|
152
170
|
|
|
171
|
+
// Mouse: wheel pages through history in small fixed steps, and a left-button
|
|
172
|
+
// drag selects transcript text (tmux-style: the app owns the mouse, draws its
|
|
173
|
+
// own highlight, and copies the range via OSC 52 on release). Handled on the
|
|
174
|
+
// Ink event emitter (not useInput) so sequences can be buffered across read()
|
|
175
|
+
// chunks; the Composer guards against inserting mouse bytes as typed text.
|
|
176
|
+
const { stdout } = useStdout()
|
|
177
|
+
const selState = useRef<{ anchor: SelPoint; end: SelPoint; active: boolean } | null>(null)
|
|
178
|
+
const [sel, setSel] = useState<{ anchor: SelPoint; end: SelPoint; active: boolean } | null>(null)
|
|
179
|
+
const [copyNotice, setCopyNotice] = useState<string | null>(null)
|
|
180
|
+
|
|
181
|
+
// Geometry + text model must mirror the rendered transcript so a screen
|
|
182
|
+
// (row, col) maps to the right entry/line/char. Recompute with the fitted view.
|
|
183
|
+
const tipShown = dedupedEntries.length === 0 && chat.pendingUser === null && !showHelp
|
|
184
|
+
const geometry: TranscriptGeometry = useMemo(() => computeTranscriptGeometry({
|
|
185
|
+
viewportRows,
|
|
186
|
+
composerRows,
|
|
187
|
+
statusRows: STATUS_ROWS,
|
|
188
|
+
activityRows,
|
|
189
|
+
helpRows,
|
|
190
|
+
showWelcome,
|
|
191
|
+
welcomeRows: 10,
|
|
192
|
+
olderHintShown: olderHint !== null,
|
|
193
|
+
tipShown,
|
|
194
|
+
}), [viewportRows, composerRows, activityRows, helpRows, showWelcome, olderHint, tipShown])
|
|
195
|
+
const transcriptModel: TranscriptModel = useMemo(
|
|
196
|
+
() => buildTranscriptModel(fitted.entries, terminal.columns),
|
|
197
|
+
[fitted.entries, terminal.columns],
|
|
198
|
+
)
|
|
199
|
+
const selMap = useMemo(
|
|
200
|
+
() => (sel?.active ? buildSelectionMap(transcriptModel, sel.anchor, sel.end) : null),
|
|
201
|
+
[sel, transcriptModel],
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
const commitCopy = useCallback((anchor: SelPoint, end: SelPoint) => {
|
|
205
|
+
const text = buildSelectionText(transcriptModel, anchor, end)
|
|
206
|
+
if (!text) return
|
|
207
|
+
stdout.write(osc52(text))
|
|
208
|
+
setCopyNotice(`已复制 ${Array.from(text).length} 字符`)
|
|
209
|
+
}, [transcriptModel, stdout])
|
|
210
|
+
|
|
211
|
+
useMouseEvents({
|
|
212
|
+
onWheel: (delta) => {
|
|
213
|
+
if (delta === 0) return
|
|
214
|
+
const step = 3
|
|
215
|
+
setScrollBack(value => Math.min(dedupedEntries.length, Math.max(0, value + delta * step)))
|
|
216
|
+
},
|
|
217
|
+
onPress: (row, col) => {
|
|
218
|
+
const p = screenToSelPoint(row, col, transcriptModel, geometry)
|
|
219
|
+
if (p) { selState.current = { anchor: p, end: p, active: true }; setSel(selState.current) }
|
|
220
|
+
},
|
|
221
|
+
onMotion: (row, col) => {
|
|
222
|
+
const s = selState.current
|
|
223
|
+
if (!s?.active) return
|
|
224
|
+
const p = screenToSelPoint(row, col, transcriptModel, geometry)
|
|
225
|
+
if (p) { selState.current = { ...s, end: p }; setSel(selState.current) }
|
|
226
|
+
},
|
|
227
|
+
onRelease: () => {
|
|
228
|
+
const s = selState.current
|
|
229
|
+
selState.current = null
|
|
230
|
+
setSel(null)
|
|
231
|
+
if (s?.active && compareSel(s.anchor, s.end) !== 0) commitCopy(s.anchor, s.end)
|
|
232
|
+
},
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
// Transient "已复制 N 字符" notice in the status row, then it clears itself.
|
|
236
|
+
useEffect(() => {
|
|
237
|
+
if (!copyNotice) return
|
|
238
|
+
const id = setTimeout(() => setCopyNotice(null), 2500)
|
|
239
|
+
return () => clearTimeout(id)
|
|
240
|
+
}, [copyNotice])
|
|
241
|
+
|
|
242
|
+
if (configOpen) {
|
|
243
|
+
// Keep the SAME height-pinned root box the chat uses, so the frame stays
|
|
244
|
+
// exactly terminal-height whether the flow is open or the conversation is
|
|
245
|
+
// shown. Rendering the flow at its natural (shorter) height and then
|
|
246
|
+
// re-painting the tall chat on Esc made Ink's frame accounting go blank in
|
|
247
|
+
// the harness (and looked like a glitch in real terminals too).
|
|
248
|
+
return (
|
|
249
|
+
<Box
|
|
250
|
+
flexDirection="column"
|
|
251
|
+
width={terminal.isTty ? terminal.columns : undefined}
|
|
252
|
+
height={terminal.isTty ? viewportRows : undefined}
|
|
253
|
+
paddingX={1}
|
|
254
|
+
overflowY="hidden"
|
|
255
|
+
>
|
|
256
|
+
<ConfigFlow
|
|
257
|
+
client={client}
|
|
258
|
+
issue={ready.issue}
|
|
259
|
+
onDone={(result) => onReconfigure(result)}
|
|
260
|
+
/>
|
|
261
|
+
</Box>
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
|
|
153
265
|
return (
|
|
154
266
|
<Box
|
|
155
267
|
flexDirection="column"
|
|
@@ -167,16 +279,20 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
167
279
|
always the first line of the transcript, spanning the full width,
|
|
168
280
|
instead of floating mid-screen when the transcript has spare rows. */}
|
|
169
281
|
{olderHint !== null
|
|
170
|
-
? <Box width="100%" flexShrink={0}><Text dimColor> {olderHint}</Text></Box>
|
|
282
|
+
? <Box width="100%" flexShrink={0}><Text dimColor wrap="truncate-end"> {olderHint}</Text></Box>
|
|
171
283
|
: null}
|
|
172
284
|
|
|
173
285
|
<Box flexGrow={1} flexShrink={1} flexDirection="column" justifyContent={showWelcome ? 'flex-start' : 'flex-end'} overflowY="hidden">
|
|
174
|
-
{fitted.entries.map((entry, index) =>
|
|
175
|
-
|
|
176
|
-
|
|
286
|
+
{fitted.entries.map((entry, index) => {
|
|
287
|
+
const entrySel = selMap?.get(index)
|
|
288
|
+
const key = entry.__id ?? `entry-${fitted.startIndex + index}`
|
|
289
|
+
return entrySel
|
|
290
|
+
? <EntryScreenWithSelection key={key} entry={entry} columns={terminal.columns} sel={entrySel} />
|
|
291
|
+
: <EntryAccum key={key} entry={entry} columns={terminal.columns} />
|
|
292
|
+
})}
|
|
177
293
|
{chat.pendingUser !== null ? <UserLine text={chat.pendingUser} /> : null}
|
|
178
294
|
{fitted.hiddenRecent > 0
|
|
179
|
-
? <Box width="100%" flexShrink={0}><Text dimColor> ↓ 滚轮/PageDown
|
|
295
|
+
? <Box width="100%" flexShrink={0}><Text dimColor wrap="truncate-end"> ↓ 滚轮/PageDown 翻页 · 较新 {fitted.hiddenRecent} 条</Text></Box>
|
|
180
296
|
: null}
|
|
181
297
|
</Box>
|
|
182
298
|
|
|
@@ -206,6 +322,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
206
322
|
webUrl={buildWebUrl(client.server, webUserId, ready, chat.sessionId)}
|
|
207
323
|
aimuxStatus={aimuxStatus}
|
|
208
324
|
modelDisplay={modelDisplay}
|
|
325
|
+
copyNotice={copyNotice}
|
|
209
326
|
/>
|
|
210
327
|
</Box>
|
|
211
328
|
</Box>
|
|
@@ -284,6 +401,35 @@ function EntryAccum({ entry, columns }: { entry: AnyEntry; columns: number }) {
|
|
|
284
401
|
)
|
|
285
402
|
}
|
|
286
403
|
|
|
404
|
+
// While a drag-selection is active, the affected entries are re-rendered from the
|
|
405
|
+
// screen-text model (plain rows, no ANSI) so the selected char range can be
|
|
406
|
+
// painted with a background — the same rows the model produces, keeping the
|
|
407
|
+
// layout stable. Rows outside the selection keep their original text.
|
|
408
|
+
function EntryScreenWithSelection({ entry, columns, sel }: {
|
|
409
|
+
entry: AnyEntry
|
|
410
|
+
columns: number
|
|
411
|
+
sel: Map<number, { start: number; end: number }>
|
|
412
|
+
}) {
|
|
413
|
+
const lines = entryScreenLines(viewsForEntry(entry), columns)
|
|
414
|
+
return (
|
|
415
|
+
<Box flexDirection="column">
|
|
416
|
+
{lines.map((row, index) => {
|
|
417
|
+
const range = sel.get(index)
|
|
418
|
+
if (range && range.start < range.end) {
|
|
419
|
+
return (
|
|
420
|
+
<Text key={index} wrap="truncate-end">
|
|
421
|
+
<Text>{row.slice(0, range.start)}</Text>
|
|
422
|
+
<Text backgroundColor="cyan" color="black">{row.slice(range.start, range.end)}</Text>
|
|
423
|
+
<Text>{row.slice(range.end)}</Text>
|
|
424
|
+
</Text>
|
|
425
|
+
)
|
|
426
|
+
}
|
|
427
|
+
return <Text key={index} wrap="truncate-end">{row || ' '}</Text>
|
|
428
|
+
})}
|
|
429
|
+
</Box>
|
|
430
|
+
)
|
|
431
|
+
}
|
|
432
|
+
|
|
287
433
|
function ViewLine({ view, columns }: { view: EntryView; columns: number }) {
|
|
288
434
|
const width = Math.max(8, columns - 4)
|
|
289
435
|
switch (view.kind) {
|
|
@@ -385,41 +531,9 @@ function WriteFileView({ view }: { view: { filePath: string; content: string } }
|
|
|
385
531
|
)
|
|
386
532
|
}
|
|
387
533
|
|
|
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
|
-
}
|
|
534
|
+
// clampLines / headTailLines / displayWidth live in src/lib/screen-text.ts
|
|
535
|
+
// (mirrored, exported) and are imported above; they must match ViewLine exactly
|
|
536
|
+
// so the drag-selection text model aligns with the rendered rows.
|
|
423
537
|
|
|
424
538
|
function UserLine({ text }: { text: string }) {
|
|
425
539
|
const lines = text.split('\n')
|
|
@@ -463,6 +577,9 @@ export function shimmerText(label: string, frame: number): React.ReactNode[] {
|
|
|
463
577
|
}
|
|
464
578
|
|
|
465
579
|
function HelpBlock({ commands }: { commands: { cmd: string; desc: string }[] }) {
|
|
580
|
+
const mouseNote = process.env.MOBIUS_TUI_DISABLE_MOUSE === '1'
|
|
581
|
+
? '滚轮翻页已关闭 (MOBIUS_TUI_DISABLE_MOUSE=1),鼠标可用于直接选中文本。'
|
|
582
|
+
: '滚轮翻页 · 拖动选中文本,松开即经 OSC 52 复制到剪贴板。'
|
|
466
583
|
return (
|
|
467
584
|
<Box flexDirection="column" borderStyle="round" borderColor="gray" borderDimColor paddingX={1} marginTop={1}>
|
|
468
585
|
{commands.map(command => (
|
|
@@ -471,6 +588,7 @@ function HelpBlock({ commands }: { commands: { cmd: string; desc: string }[] })
|
|
|
471
588
|
<Text>{command.desc}</Text>
|
|
472
589
|
</Text>
|
|
473
590
|
))}
|
|
591
|
+
<Text dimColor> {mouseNote}</Text>
|
|
474
592
|
</Box>
|
|
475
593
|
)
|
|
476
594
|
}
|
|
@@ -504,6 +622,15 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
|
|
|
504
622
|
})
|
|
505
623
|
const { stdout } = useStdout()
|
|
506
624
|
|
|
625
|
+
// Physical Backspace/Delete keys are owned by useDeleteKeyCapture from the
|
|
626
|
+
// raw stdin bytes — Ink reports the Backspace key (\x7f) and the Delete key
|
|
627
|
+
// (ESC[3~) both as `key.delete`, so handling `key.delete` in useInput would
|
|
628
|
+
// delete in the wrong direction. The composer is always active while mounted.
|
|
629
|
+
useDeleteKeyCapture(true, (intent) => {
|
|
630
|
+
const { text, cursor: nextCursor } = applyDeleteIntent(valueRef.current, cursorRef.current, intent)
|
|
631
|
+
edit(text, nextCursor)
|
|
632
|
+
})
|
|
633
|
+
|
|
507
634
|
const filtered = useMemo(() => {
|
|
508
635
|
const match = /^(\w*)$/.exec(value.slice(1))
|
|
509
636
|
if (!value.startsWith('/') || match === null) return []
|
|
@@ -685,20 +812,22 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
|
|
|
685
812
|
return
|
|
686
813
|
}
|
|
687
814
|
if (key.ctrl && input === 'c') { typing ? void onStop() : onQuit(); return }
|
|
688
|
-
//
|
|
689
|
-
//
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
815
|
+
// Physical Backspace/Delete keys are handled by useDeleteKeyCapture above
|
|
816
|
+
// (raw stdin bytes distinguish them; Ink maps both to `key.delete`). Only
|
|
817
|
+
// the unambiguous logical editing bindings stay here.
|
|
818
|
+
if (key.ctrl && input === 'w') {
|
|
819
|
+
const { text, cursor: nextCursor } = applyDeleteIntent(current, at, 'backward-word')
|
|
820
|
+
edit(text, nextCursor)
|
|
821
|
+
return
|
|
822
|
+
}
|
|
823
|
+
if (key.ctrl && input === 'h') {
|
|
824
|
+
const { text, cursor: nextCursor } = applyDeleteIntent(current, at, 'backward')
|
|
825
|
+
edit(text, nextCursor)
|
|
826
|
+
return
|
|
827
|
+
}
|
|
828
|
+
if (key.ctrl && input === 'd') {
|
|
829
|
+
const { text, cursor: nextCursor } = applyDeleteIntent(current, at, 'forward')
|
|
830
|
+
edit(text, nextCursor)
|
|
702
831
|
return
|
|
703
832
|
}
|
|
704
833
|
if (key.leftArrow) { moveCursor(previousCursorBoundary(current, at)); return }
|
|
@@ -840,26 +969,6 @@ function pasteMarkerLength(input: string, at: number, code: '200' | '201'): numb
|
|
|
840
969
|
return input.startsWith(`\x1b[${code}~`, at) ? 6 : 5
|
|
841
970
|
}
|
|
842
971
|
|
|
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
972
|
interface ComposerLine { text: string; start: number; end: number }
|
|
864
973
|
|
|
865
974
|
function wrapComposerLines(text: string, width: number): ComposerLine[] {
|
|
@@ -904,13 +1013,14 @@ function findComposerCursorLine(lines: ComposerLine[], cursor: number): number {
|
|
|
904
1013
|
return Math.max(0, lines.length - 1)
|
|
905
1014
|
}
|
|
906
1015
|
|
|
907
|
-
function StatusArea({ ready, sessionId, columns, webUrl, aimuxStatus, modelDisplay }: {
|
|
1016
|
+
function StatusArea({ ready, sessionId, columns, webUrl, aimuxStatus, modelDisplay, copyNotice }: {
|
|
908
1017
|
ready: ReadyState
|
|
909
1018
|
sessionId: string | null
|
|
910
1019
|
columns: number
|
|
911
1020
|
webUrl: string
|
|
912
1021
|
aimuxStatus?: AimuxStatus
|
|
913
1022
|
modelDisplay: string
|
|
1023
|
+
copyNotice?: string | null
|
|
914
1024
|
}) {
|
|
915
1025
|
const model = modelDisplay
|
|
916
1026
|
const language = ready.prefs.language === 'en' ? 'English' : '中文'
|
|
@@ -926,7 +1036,7 @@ function StatusArea({ ready, sessionId, columns, webUrl, aimuxStatus, modelDispl
|
|
|
926
1036
|
return (
|
|
927
1037
|
<Box flexDirection="column" marginTop={1}>
|
|
928
1038
|
<Box justifyContent="space-between">
|
|
929
|
-
<Text dimColor>{left}</Text>
|
|
1039
|
+
<Text dimColor color={copyNotice ? 'green' : undefined}>{copyNotice ?? left}</Text>
|
|
930
1040
|
{right ? <Text dimColor>{right}</Text> : null}
|
|
931
1041
|
</Box>
|
|
932
1042
|
{/* Merged connectivity row: AIMUX status sits left, the clickable web URL
|
|
@@ -996,35 +1106,8 @@ function clickableUrl(url: string, maxLen?: number): string {
|
|
|
996
1106
|
return `\u001B]8;;${url}\u0007${display}\u001B]8;;\u0007`
|
|
997
1107
|
}
|
|
998
1108
|
|
|
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
|
-
}
|
|
1109
|
+
// displayWidth is imported from src/lib/screen-text.ts (CJK/emoji-aware), used
|
|
1110
|
+
// here to size the AIMUX status block so the web URL truncates exactly.
|
|
1028
1111
|
|
|
1029
1112
|
function wrappedRows(text: string, width: number): number {
|
|
1030
1113
|
const safeWidth = Math.max(1, width)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /model (= /config) flow — pick a model, then create a brand-new session in the
|
|
3
|
+
* CURRENT task (Issue) with that model. Launched from inside the chat; Esc at any
|
|
4
|
+
* point before the session is created cancels back to the conversation untouched.
|
|
5
|
+
*
|
|
6
|
+
* The active Issue is intentionally NOT changed: /model only swaps the model and
|
|
7
|
+
* starts a fresh session, keeping the current project/task context. Esc-cancel is
|
|
8
|
+
* owned by ChatScreen (its useInput handles Esc while configOpen), so this
|
|
9
|
+
* component only ever reports a completed pick via onDone.
|
|
10
|
+
*
|
|
11
|
+
* Preferences are stored inside the current Issue (same model as PrepScreen):
|
|
12
|
+
* updateIssuePreference — persist the chosen model on the active issue
|
|
13
|
+
* The session body mirrors useChat.ensureSession() so the pc_client_metadata
|
|
14
|
+
* (is_tui, aimux_id, local_path) matches lazily-created sessions exactly.
|
|
15
|
+
*/
|
|
16
|
+
import React, { useEffect, useRef, useState } from 'react'
|
|
17
|
+
import { Box, Text } from 'ink'
|
|
18
|
+
import { Select, Spinner } from './primitives.js'
|
|
19
|
+
import { MobiusClient } from '../api.js'
|
|
20
|
+
import { cwd, updateIssuePreference, type IssuePreference } from '../config.js'
|
|
21
|
+
import { tuiAimuxIdentifier } from '../aimux.js'
|
|
22
|
+
import type { Issue, SessionModelOption } from '../types.js'
|
|
23
|
+
|
|
24
|
+
export interface ConfigResult {
|
|
25
|
+
issue: Issue
|
|
26
|
+
prefs: IssuePreference
|
|
27
|
+
sessionId: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function ConfigFlow({ client, issue, onDone }: {
|
|
31
|
+
client: MobiusClient
|
|
32
|
+
issue: Issue
|
|
33
|
+
onDone: (r: ConfigResult) => void
|
|
34
|
+
}) {
|
|
35
|
+
const [step, setStep] = useState<'models' | 'creating'>('models')
|
|
36
|
+
const [models, setModels] = useState<SessionModelOption[] | null>(null)
|
|
37
|
+
const [defaultKey, setDefaultKey] = useState<string | null>(null)
|
|
38
|
+
const [status, setStatus] = useState('')
|
|
39
|
+
const doneRef = useRef(false)
|
|
40
|
+
|
|
41
|
+
// Guard against a setState after App has already remounted Chat (onDone fires
|
|
42
|
+
// a synchronous route change that unmounts us); also avoids double onDone.
|
|
43
|
+
useEffect(() => () => { doneRef.current = true }, [])
|
|
44
|
+
|
|
45
|
+
// Load the model list + default on mount (no issue step — the current Issue is used).
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
Promise.all([
|
|
48
|
+
client.modelOptions().catch(() => [] as SessionModelOption[]),
|
|
49
|
+
client.defaultModel().then(r => r.model).catch(() => null),
|
|
50
|
+
]).then(([opts, def]) => {
|
|
51
|
+
if (doneRef.current) return
|
|
52
|
+
setModels(opts)
|
|
53
|
+
setDefaultKey(def)
|
|
54
|
+
})
|
|
55
|
+
}, [client])
|
|
56
|
+
|
|
57
|
+
async function pickModel(model: string) {
|
|
58
|
+
setStep('creating')
|
|
59
|
+
try {
|
|
60
|
+
const prefs = await updateIssuePreference(cwd(), issue.id, { model })
|
|
61
|
+
if (doneRef.current) return
|
|
62
|
+
const s = await client.createSession(issue.id, {
|
|
63
|
+
name: `TUI ${new Date().toISOString().slice(5, 16).replace('T', ' ')}`,
|
|
64
|
+
model,
|
|
65
|
+
language: prefs.language,
|
|
66
|
+
excluded_skill_ids: prefs.excluded_skill_ids,
|
|
67
|
+
excluded_memory_ids: prefs.excluded_memory_ids,
|
|
68
|
+
pc_client_metadata: {
|
|
69
|
+
work_mode: 'pc',
|
|
70
|
+
aimux_id: tuiAimuxIdentifier(),
|
|
71
|
+
local_path: process.cwd(),
|
|
72
|
+
is_tui: true,
|
|
73
|
+
add_remote_aimux_mcp: true,
|
|
74
|
+
},
|
|
75
|
+
})
|
|
76
|
+
if (doneRef.current) return
|
|
77
|
+
onDone({ issue, prefs, sessionId: s.session_id })
|
|
78
|
+
} catch (e: any) {
|
|
79
|
+
if (doneRef.current) return
|
|
80
|
+
setStatus(`创建新会话失败: ${e?.message ?? e}`)
|
|
81
|
+
setStep('models')
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (step === 'creating') {
|
|
86
|
+
return (
|
|
87
|
+
<Box paddingX={2} paddingY={1}>
|
|
88
|
+
<Spinner label="正在创建新会话…" />
|
|
89
|
+
</Box>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<Box flexDirection="column" paddingX={2} paddingY={1}>
|
|
95
|
+
<Text bold color="cyan">更换模型</Text>
|
|
96
|
+
<Text color="gray">当前任务: {issue.title}</Text>
|
|
97
|
+
{status ? <Text color="yellow">{status}</Text> : null}
|
|
98
|
+
|
|
99
|
+
<Box flexDirection="column">
|
|
100
|
+
<Text bold color="cyan">选择模型</Text>
|
|
101
|
+
<Text color="gray">确认后创建新会话(保留当前任务)</Text>
|
|
102
|
+
<Box marginTop={1}>
|
|
103
|
+
{models === null
|
|
104
|
+
? <Text color="cyan">加载模型列表…</Text>
|
|
105
|
+
: models.length === 0
|
|
106
|
+
? <Text color="gray">(无可用模型)</Text>
|
|
107
|
+
: <Select
|
|
108
|
+
items={models.map(o => ({
|
|
109
|
+
label: `${o.label}${o.key === defaultKey ? ' (默认)' : ''}`,
|
|
110
|
+
value: o.key,
|
|
111
|
+
desc: o.sub,
|
|
112
|
+
}))}
|
|
113
|
+
onSelect={key => void pickModel(key)}
|
|
114
|
+
/>}
|
|
115
|
+
</Box>
|
|
116
|
+
<Text color="gray">↑↓ 选择 · 回车确认 · Esc 取消</Text>
|
|
117
|
+
</Box>
|
|
118
|
+
</Box>
|
|
119
|
+
)
|
|
120
|
+
}
|