@mobius-os/mobius 0.3.14 → 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 +213 -109
- package/src/components/ConfigFlow.tsx +120 -0
- package/src/components/primitives.tsx +156 -16
- 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 } from './primitives.js'
|
|
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,11 +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
|
|
|
165
|
+
const olderHint = !showWelcome && (fitted.hiddenOlder > 0 || scrollBack > 0)
|
|
166
|
+
? fitted.hiddenOlder > 0
|
|
167
|
+
? `↑ 还有 ${fitted.hiddenOlder} 条较早记录 · 滚轮/PageUp 翻页 · 拖动选中文本`
|
|
168
|
+
: '已到最早记录 · 滚轮/PageDown 翻页 · 拖动选中文本'
|
|
169
|
+
: null
|
|
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
|
+
|
|
137
265
|
return (
|
|
138
266
|
<Box
|
|
139
267
|
flexDirection="column"
|
|
@@ -147,16 +275,24 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
147
275
|
? <WelcomeCard ready={ready} columns={terminal.columns} resumed={Boolean(resumeSessionId)} modelDisplay={modelDisplay} />
|
|
148
276
|
: <CompactHeader ready={ready} sessionId={chat.sessionId} columns={terminal.columns} />}
|
|
149
277
|
|
|
278
|
+
{/* Older-records hint is pinned OUTSIDE the flex-end scroll box so it is
|
|
279
|
+
always the first line of the transcript, spanning the full width,
|
|
280
|
+
instead of floating mid-screen when the transcript has spare rows. */}
|
|
281
|
+
{olderHint !== null
|
|
282
|
+
? <Box width="100%" flexShrink={0}><Text dimColor wrap="truncate-end"> {olderHint}</Text></Box>
|
|
283
|
+
: null}
|
|
284
|
+
|
|
150
285
|
<Box flexGrow={1} flexShrink={1} flexDirection="column" justifyContent={showWelcome ? 'flex-start' : 'flex-end'} overflowY="hidden">
|
|
151
|
-
{fitted.
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
+
})}
|
|
157
293
|
{chat.pendingUser !== null ? <UserLine text={chat.pendingUser} /> : null}
|
|
158
294
|
{fitted.hiddenRecent > 0
|
|
159
|
-
? <Text dimColor> ↓ PageDown
|
|
295
|
+
? <Box width="100%" flexShrink={0}><Text dimColor wrap="truncate-end"> ↓ 滚轮/PageDown 翻页 · 较新 {fitted.hiddenRecent} 条</Text></Box>
|
|
160
296
|
: null}
|
|
161
297
|
</Box>
|
|
162
298
|
|
|
@@ -186,6 +322,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
186
322
|
webUrl={buildWebUrl(client.server, webUserId, ready, chat.sessionId)}
|
|
187
323
|
aimuxStatus={aimuxStatus}
|
|
188
324
|
modelDisplay={modelDisplay}
|
|
325
|
+
copyNotice={copyNotice}
|
|
189
326
|
/>
|
|
190
327
|
</Box>
|
|
191
328
|
</Box>
|
|
@@ -264,6 +401,35 @@ function EntryAccum({ entry, columns }: { entry: AnyEntry; columns: number }) {
|
|
|
264
401
|
)
|
|
265
402
|
}
|
|
266
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
|
+
|
|
267
433
|
function ViewLine({ view, columns }: { view: EntryView; columns: number }) {
|
|
268
434
|
const width = Math.max(8, columns - 4)
|
|
269
435
|
switch (view.kind) {
|
|
@@ -365,41 +531,9 @@ function WriteFileView({ view }: { view: { filePath: string; content: string } }
|
|
|
365
531
|
)
|
|
366
532
|
}
|
|
367
533
|
|
|
368
|
-
//
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
const paras = text.replace(/\r\n/g, '\n').split('\n')
|
|
372
|
-
const wrapped: string[] = []
|
|
373
|
-
for (const para of paras) {
|
|
374
|
-
if (para === '') { wrapped.push(''); continue }
|
|
375
|
-
for (let i = 0; i < para.length; i += width) wrapped.push(para.slice(i, i + width))
|
|
376
|
-
}
|
|
377
|
-
if (wrapped.length <= maxLines) return wrapped
|
|
378
|
-
const trimmed = wrapped.slice(0, maxLines)
|
|
379
|
-
const last = trimmed[maxLines - 1]
|
|
380
|
-
trimmed[maxLines - 1] = last.length >= width ? last.slice(0, width - 1) + '…' : last + '…'
|
|
381
|
-
return trimmed
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
// codex 式 head + ellipsis + tail 截断 (参考 codex-rs/tui/src/exec_cell/render.rs
|
|
385
|
-
// 的 truncate_lines_middle): 保留输出头尾, 中间省略并报告省略行数. 长输出既能看
|
|
386
|
-
// 到结论 (成功/失败常在尾), 又不刷屏. maxLines 含省略行 (如 5 = 头2 + 省1 + 尾2).
|
|
387
|
-
function headTailLines(text: string, width: number, maxLines: number): string[] {
|
|
388
|
-
if (!text) return ['']
|
|
389
|
-
const paras = text.replace(/\r\n/g, '\n').split('\n')
|
|
390
|
-
const wrapped: string[] = []
|
|
391
|
-
for (const para of paras) {
|
|
392
|
-
if (para === '') { wrapped.push(''); continue }
|
|
393
|
-
for (let i = 0; i < para.length; i += width) wrapped.push(para.slice(i, i + width))
|
|
394
|
-
}
|
|
395
|
-
if (wrapped.length <= maxLines) return wrapped.slice(0, maxLines)
|
|
396
|
-
const budget = maxLines - 1 // 留 1 行给省略标记
|
|
397
|
-
const head = Math.max(1, Math.ceil(budget / 2))
|
|
398
|
-
const tail = Math.max(1, budget - head)
|
|
399
|
-
const omitted = wrapped.length - head - tail
|
|
400
|
-
if (omitted <= 0) return wrapped.slice(0, maxLines)
|
|
401
|
-
return [...wrapped.slice(0, head), `… +${omitted} 行`, ...wrapped.slice(wrapped.length - tail)]
|
|
402
|
-
}
|
|
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.
|
|
403
537
|
|
|
404
538
|
function UserLine({ text }: { text: string }) {
|
|
405
539
|
const lines = text.split('\n')
|
|
@@ -443,6 +577,9 @@ export function shimmerText(label: string, frame: number): React.ReactNode[] {
|
|
|
443
577
|
}
|
|
444
578
|
|
|
445
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 复制到剪贴板。'
|
|
446
583
|
return (
|
|
447
584
|
<Box flexDirection="column" borderStyle="round" borderColor="gray" borderDimColor paddingX={1} marginTop={1}>
|
|
448
585
|
{commands.map(command => (
|
|
@@ -451,6 +588,7 @@ function HelpBlock({ commands }: { commands: { cmd: string; desc: string }[] })
|
|
|
451
588
|
<Text>{command.desc}</Text>
|
|
452
589
|
</Text>
|
|
453
590
|
))}
|
|
591
|
+
<Text dimColor> {mouseNote}</Text>
|
|
454
592
|
</Box>
|
|
455
593
|
)
|
|
456
594
|
}
|
|
@@ -484,6 +622,15 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
|
|
|
484
622
|
})
|
|
485
623
|
const { stdout } = useStdout()
|
|
486
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
|
+
|
|
487
634
|
const filtered = useMemo(() => {
|
|
488
635
|
const match = /^(\w*)$/.exec(value.slice(1))
|
|
489
636
|
if (!value.startsWith('/') || match === null) return []
|
|
@@ -599,6 +746,7 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
|
|
|
599
746
|
useEffect(() => () => resetPasteBurst(), [])
|
|
600
747
|
|
|
601
748
|
useInput((input, key) => {
|
|
749
|
+
if (isMouseInput(input)) return // mouse events must never become typed text
|
|
602
750
|
const now = Date.now()
|
|
603
751
|
const escape = isEscapeKeypress(input, key)
|
|
604
752
|
if (typing && escape) { void onStop(); return }
|
|
@@ -664,20 +812,22 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
|
|
|
664
812
|
return
|
|
665
813
|
}
|
|
666
814
|
if (key.ctrl && input === 'c') { typing ? void onStop() : onQuit(); return }
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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)
|
|
681
831
|
return
|
|
682
832
|
}
|
|
683
833
|
if (key.leftArrow) { moveCursor(previousCursorBoundary(current, at)); return }
|
|
@@ -819,26 +969,6 @@ function pasteMarkerLength(input: string, at: number, code: '200' | '201'): numb
|
|
|
819
969
|
return input.startsWith(`\x1b[${code}~`, at) ? 6 : 5
|
|
820
970
|
}
|
|
821
971
|
|
|
822
|
-
function clampCursor(text: string, cursor: number): number {
|
|
823
|
-
let at = Math.max(0, Math.min(text.length, cursor))
|
|
824
|
-
while (at > 0 && at < text.length && /[\uDC00-\uDFFF]/.test(text[at])) at--
|
|
825
|
-
return at
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
function previousCursorBoundary(text: string, cursor: number): number {
|
|
829
|
-
const at = clampCursor(text, cursor)
|
|
830
|
-
if (at <= 0) return 0
|
|
831
|
-
const code = text.charCodeAt(at - 1)
|
|
832
|
-
return at - (code >= 0xDC00 && code <= 0xDFFF ? 2 : 1)
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
function nextCursorBoundary(text: string, cursor: number): number {
|
|
836
|
-
const at = clampCursor(text, cursor)
|
|
837
|
-
if (at >= text.length) return text.length
|
|
838
|
-
const code = text.charCodeAt(at)
|
|
839
|
-
return at + (code >= 0xD800 && code <= 0xDBFF ? 2 : 1)
|
|
840
|
-
}
|
|
841
|
-
|
|
842
972
|
interface ComposerLine { text: string; start: number; end: number }
|
|
843
973
|
|
|
844
974
|
function wrapComposerLines(text: string, width: number): ComposerLine[] {
|
|
@@ -883,13 +1013,14 @@ function findComposerCursorLine(lines: ComposerLine[], cursor: number): number {
|
|
|
883
1013
|
return Math.max(0, lines.length - 1)
|
|
884
1014
|
}
|
|
885
1015
|
|
|
886
|
-
function StatusArea({ ready, sessionId, columns, webUrl, aimuxStatus, modelDisplay }: {
|
|
1016
|
+
function StatusArea({ ready, sessionId, columns, webUrl, aimuxStatus, modelDisplay, copyNotice }: {
|
|
887
1017
|
ready: ReadyState
|
|
888
1018
|
sessionId: string | null
|
|
889
1019
|
columns: number
|
|
890
1020
|
webUrl: string
|
|
891
1021
|
aimuxStatus?: AimuxStatus
|
|
892
1022
|
modelDisplay: string
|
|
1023
|
+
copyNotice?: string | null
|
|
893
1024
|
}) {
|
|
894
1025
|
const model = modelDisplay
|
|
895
1026
|
const language = ready.prefs.language === 'en' ? 'English' : '中文'
|
|
@@ -905,7 +1036,7 @@ function StatusArea({ ready, sessionId, columns, webUrl, aimuxStatus, modelDispl
|
|
|
905
1036
|
return (
|
|
906
1037
|
<Box flexDirection="column" marginTop={1}>
|
|
907
1038
|
<Box justifyContent="space-between">
|
|
908
|
-
<Text dimColor>{left}</Text>
|
|
1039
|
+
<Text dimColor color={copyNotice ? 'green' : undefined}>{copyNotice ?? left}</Text>
|
|
909
1040
|
{right ? <Text dimColor>{right}</Text> : null}
|
|
910
1041
|
</Box>
|
|
911
1042
|
{/* Merged connectivity row: AIMUX status sits left, the clickable web URL
|
|
@@ -975,35 +1106,8 @@ function clickableUrl(url: string, maxLen?: number): string {
|
|
|
975
1106
|
return `\u001B]8;;${url}\u0007${display}\u001B]8;;\u0007`
|
|
976
1107
|
}
|
|
977
1108
|
|
|
978
|
-
//
|
|
979
|
-
//
|
|
980
|
-
// the remaining width without overflowing the row.
|
|
981
|
-
function displayWidth(str: string): number {
|
|
982
|
-
let w = 0
|
|
983
|
-
for (const ch of str) {
|
|
984
|
-
const code = ch.codePointAt(0) ?? 0
|
|
985
|
-
if (code >= 0x0300 && code <= 0x036F) continue // combining diacriticals: 0 cols
|
|
986
|
-
w += isWideCodepoint(code) ? 2 : 1
|
|
987
|
-
}
|
|
988
|
-
return w
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
function isWideCodepoint(code: number): boolean {
|
|
992
|
-
return (
|
|
993
|
-
(code >= 0x1100 && code <= 0x115F) || // Hangul Jamo
|
|
994
|
-
(code >= 0x2E80 && code <= 0x303E) || // CJK radicals / punctuation
|
|
995
|
-
(code >= 0x3041 && code <= 0x33FF) || // Hiragana / Katakana / CJK compat
|
|
996
|
-
(code >= 0x3400 && code <= 0x4DBF) || // CJK Unified Extension A
|
|
997
|
-
(code >= 0x4E00 && code <= 0x9FFF) || // CJK Unified Ideographs (心跳正常 …)
|
|
998
|
-
(code >= 0xA000 && code <= 0xA4CF) || // Yi
|
|
999
|
-
(code >= 0xAC00 && code <= 0xD7A3) || // Hangul syllables
|
|
1000
|
-
(code >= 0xF900 && code <= 0xFAFF) || // CJK compatibility ideographs
|
|
1001
|
-
(code >= 0xFE30 && code <= 0xFE4F) || // CJK compatibility forms
|
|
1002
|
-
(code >= 0xFF00 && code <= 0xFF60) || // Fullwidth ASCII
|
|
1003
|
-
(code >= 0xFFE0 && code <= 0xFFE6) || // Fullwidth signs
|
|
1004
|
-
(code >= 0x1F300 && code <= 0x1FAFF) // Emoji / symbols
|
|
1005
|
-
)
|
|
1006
|
-
}
|
|
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.
|
|
1007
1111
|
|
|
1008
1112
|
function wrappedRows(text: string, width: number): number {
|
|
1009
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
|
+
}
|