@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mobius-os/mobius",
3
- "version": "0.3.14",
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
  )
@@ -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.hiddenOlder > 0 || scrollBack > 0
152
- ? <Text dimColor> ↑ {fitted.hiddenOlder > 0 ? `还有 ${fitted.hiddenOlder} 条较早记录 · PageUp 向上翻页` : '已到最早记录 · PageDown 向下翻页'}</Text>
153
- : null}
154
- {fitted.entries.map((entry, index) => (
155
- <EntryAccum key={entry.__id ?? `entry-${fitted.startIndex + index}`} entry={entry} columns={terminal.columns} />
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 向下翻页 · 较新 {fitted.hiddenRecent} 条</Text>
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
- // 把文本按宽度硬切成最多 maxLines (超出则在末行加 …), 用于 compact 类的 ≤2 行硬约束.
369
- function clampLines(text: string, width: number, maxLines: number): string[] {
370
- if (!text) return ['']
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
- // Ink reports the terminal Backspace key (\x7f) as `key.delete`; handle both
668
- // as a backward delete so Backspace works at the end of the input.
669
- if (key.backspace || key.delete || (key.ctrl && (input === 'h' || input === 'w'))) {
670
- if (at > 0) {
671
- if (key.ctrl && input === 'w') {
672
- const before = current.slice(0, at)
673
- const match = before.match(/\S+\s*$/)
674
- const cut = match ? match[0].length : 0
675
- edit(current.slice(0, at - cut) + current.slice(at), at - cut)
676
- } else {
677
- const previous = previousCursorBoundary(current, at)
678
- edit(current.slice(0, previous) + current.slice(at), previous)
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
- // Visible-column width (CJK / emoji / fullwidth count as 2; combining marks as
979
- // 0), used to size the AIMUX status block so the web URL truncates to exactly
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
+ }