@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mobius-os/mobius",
3
- "version": "0.3.15",
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, isMouseInput, useMouseWheel } 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,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
- <EntryAccum key={entry.__id ?? `entry-${fitted.startIndex + index}`} entry={entry} columns={terminal.columns} />
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 向下翻页 · 较新 {fitted.hiddenRecent} 条</Text></Box>
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
- // 把文本按宽度硬切成最多 maxLines (超出则在末行加 …), 用于 compact 类的 ≤2 行硬约束.
389
- function clampLines(text: string, width: number, maxLines: number): string[] {
390
- if (!text) return ['']
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
- // Ink reports the terminal Backspace key (\x7f) as `key.delete`; handle both
689
- // as a backward delete so Backspace works at the end of the input.
690
- if (key.backspace || key.delete || (key.ctrl && (input === 'h' || input === 'w'))) {
691
- if (at > 0) {
692
- if (key.ctrl && input === 'w') {
693
- const before = current.slice(0, at)
694
- const match = before.match(/\S+\s*$/)
695
- const cut = match ? match[0].length : 0
696
- edit(current.slice(0, at - cut) + current.slice(at), at - cut)
697
- } else {
698
- const previous = previousCursorBoundary(current, at)
699
- edit(current.slice(0, previous) + current.slice(at), previous)
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
- // Visible-column width (CJK / emoji / fullwidth count as 2; combining marks as
1000
- // 0), used to size the AIMUX status block so the web URL truncates to exactly
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
+ }