@mobius-os/mobius 0.3.15 → 0.3.24

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.24",
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,32 @@ 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 or /config: swap task+model (and optionally project for /config)
125
+ // and start a brand-new session. App owns `ready`, so it folds the result in
126
+ // and remounts Chat on the fresh session (resumeSessionId = the eagerly
127
+ // created session).
128
+ function onReconfigure(result: ConfigResult) {
129
+ if (!ready || !client) return
130
+ if (process.env.MOBIUS_TUI_DEBUG) console.error('[route] reconfigure', result.project?.id, result.issue.id, result.prefs.model, result.sessionId)
131
+ setReady({ project: result.project || ready.project, issue: result.issue, prefs: result.prefs })
132
+ setResumeSessionId(result.sessionId)
133
+ setChatKey(k => k + 1)
134
+ setRoute('chat')
135
+ }
136
+
110
137
  function onResumed(sid: string) {
111
138
  setResumeSessionId(sid)
112
139
  setChatKey(k => k + 1)
@@ -135,6 +162,8 @@ export function App() {
135
162
  onClear={onClear}
136
163
  onResume={onResume}
137
164
  onQuit={onQuit}
165
+ onReconfigure={onReconfigure}
166
+ onConfigCancel={onConfigCancel}
138
167
  aimuxStatus={aimuxStatus}
139
168
  />
140
169
  )
@@ -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, ReconfigFlow, 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,25 @@ 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: '重新选择项目、任务和模型' },
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
+ const [reconfigOpen, setReconfigOpen] = useState(false)
71
+ // Ink's useInput keeps whatever handler was registered at subscription time;
72
+ // reading mutable refs (updated every render) keeps the callback from acting
73
+ // on a stale `configOpen`/sessionId closure after the config flow opens.
74
+ const handlerRef = useRef<{ configOpen: boolean; reconfigOpen: boolean; sessionId: string | null }>({ configOpen: false, reconfigOpen: false, sessionId: null })
75
+ handlerRef.current = { configOpen, reconfigOpen, sessionId: chat.sessionId }
57
76
  const terminal = useTerminalSize()
58
77
 
59
78
  const runSlash = useCallback((raw: string) => {
@@ -62,6 +81,8 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
62
81
  case '/clear': onClear(); return true
63
82
  case '/resume': onResume(); return true
64
83
  case '/help': setShowHelp(s => !s); return true
84
+ case '/model': setConfigOpen(true); return true
85
+ case '/config': setReconfigOpen(true); return true
65
86
  case '/quit': case '/exit': onQuit(); return true
66
87
  default: return false
67
88
  }
@@ -129,27 +150,138 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
129
150
  const modelDisplay = modelLabel ?? ready.prefs.model ?? 'default'
130
151
 
131
152
  useInput((_input, key) => {
153
+ // While a config/reconfig flow is open, this ChatScreen-level handler owns
154
+ // Esc so cancel is reliable even mid-list-loading (a per-component
155
+ // EscToCancel could be unmounted by the loading→loaded transition and drop
156
+ // the keypress). configOpen/reconfigOpen/sessionId are read from handlerRef
157
+ // (see above) because Ink keeps the originally-registered callback and would
158
+ // otherwise see a stale closure.
159
+ if (handlerRef.current.configOpen || handlerRef.current.reconfigOpen) {
160
+ if (isEscapeKeypress(_input, key)) onConfigCancel(handlerRef.current.sessionId)
161
+ return
162
+ }
132
163
  const step = Math.max(1, fitted.entries.length)
133
164
  if (key.pageUp) setScrollBack(value => Math.min(dedupedEntries.length, value + step))
134
165
  else if (key.pageDown) setScrollBack(value => Math.max(0, value - step))
135
166
  })
136
167
 
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
168
  const olderHint = !showWelcome && (fitted.hiddenOlder > 0 || scrollBack > 0)
148
169
  ? fitted.hiddenOlder > 0
149
- ? `↑ 还有 ${fitted.hiddenOlder} 条较早记录 · 滚轮/PageUp 向上翻页`
150
- : '已到最早记录 · 滚轮/PageDown 向下翻页'
170
+ ? `↑ 还有 ${fitted.hiddenOlder} 条较早记录 · 滚轮/PageUp 翻页 · 拖动选中文本`
171
+ : '已到最早记录 · 滚轮/PageDown 翻页 · 拖动选中文本'
151
172
  : null
152
173
 
174
+ // Mouse: wheel pages through history in small fixed steps, and a left-button
175
+ // drag selects transcript text (tmux-style: the app owns the mouse, draws its
176
+ // own highlight, and copies the range via OSC 52 on release). Handled on the
177
+ // Ink event emitter (not useInput) so sequences can be buffered across read()
178
+ // chunks; the Composer guards against inserting mouse bytes as typed text.
179
+ const { stdout } = useStdout()
180
+ const selState = useRef<{ anchor: SelPoint; end: SelPoint; active: boolean } | null>(null)
181
+ const [sel, setSel] = useState<{ anchor: SelPoint; end: SelPoint; active: boolean } | null>(null)
182
+ const [copyNotice, setCopyNotice] = useState<string | null>(null)
183
+
184
+ // Geometry + text model must mirror the rendered transcript so a screen
185
+ // (row, col) maps to the right entry/line/char. Recompute with the fitted view.
186
+ const tipShown = dedupedEntries.length === 0 && chat.pendingUser === null && !showHelp
187
+ const geometry: TranscriptGeometry = useMemo(() => computeTranscriptGeometry({
188
+ viewportRows,
189
+ composerRows,
190
+ statusRows: STATUS_ROWS,
191
+ activityRows,
192
+ helpRows,
193
+ showWelcome,
194
+ welcomeRows: 10,
195
+ olderHintShown: olderHint !== null,
196
+ tipShown,
197
+ }), [viewportRows, composerRows, activityRows, helpRows, showWelcome, olderHint, tipShown])
198
+ const transcriptModel: TranscriptModel = useMemo(
199
+ () => buildTranscriptModel(fitted.entries, terminal.columns),
200
+ [fitted.entries, terminal.columns],
201
+ )
202
+ const selMap = useMemo(
203
+ () => (sel?.active ? buildSelectionMap(transcriptModel, sel.anchor, sel.end) : null),
204
+ [sel, transcriptModel],
205
+ )
206
+
207
+ const commitCopy = useCallback((anchor: SelPoint, end: SelPoint) => {
208
+ const text = buildSelectionText(transcriptModel, anchor, end)
209
+ if (!text) return
210
+ stdout.write(osc52(text))
211
+ setCopyNotice(`已复制 ${Array.from(text).length} 字符`)
212
+ }, [transcriptModel, stdout])
213
+
214
+ useMouseEvents({
215
+ onWheel: (delta) => {
216
+ if (delta === 0) return
217
+ const step = 3
218
+ setScrollBack(value => Math.min(dedupedEntries.length, Math.max(0, value + delta * step)))
219
+ },
220
+ onPress: (row, col) => {
221
+ const p = screenToSelPoint(row, col, transcriptModel, geometry)
222
+ if (p) { selState.current = { anchor: p, end: p, active: true }; setSel(selState.current) }
223
+ },
224
+ onMotion: (row, col) => {
225
+ const s = selState.current
226
+ if (!s?.active) return
227
+ const p = screenToSelPoint(row, col, transcriptModel, geometry)
228
+ if (p) { selState.current = { ...s, end: p }; setSel(selState.current) }
229
+ },
230
+ onRelease: () => {
231
+ const s = selState.current
232
+ selState.current = null
233
+ setSel(null)
234
+ if (s?.active && compareSel(s.anchor, s.end) !== 0) commitCopy(s.anchor, s.end)
235
+ },
236
+ })
237
+
238
+ // Transient "已复制 N 字符" notice in the status row, then it clears itself.
239
+ useEffect(() => {
240
+ if (!copyNotice) return
241
+ const id = setTimeout(() => setCopyNotice(null), 2500)
242
+ return () => clearTimeout(id)
243
+ }, [copyNotice])
244
+
245
+ if (configOpen) {
246
+ // Keep the SAME height-pinned root box the chat uses, so the frame stays
247
+ // exactly terminal-height whether the flow is open or the conversation is
248
+ // shown. Rendering the flow at its natural (shorter) height and then
249
+ // re-painting the tall chat on Esc made Ink's frame accounting go blank in
250
+ // the harness (and looked like a glitch in real terminals too).
251
+ return (
252
+ <Box
253
+ flexDirection="column"
254
+ width={terminal.isTty ? terminal.columns : undefined}
255
+ height={terminal.isTty ? viewportRows : undefined}
256
+ paddingX={1}
257
+ overflowY="hidden"
258
+ >
259
+ <ConfigFlow
260
+ client={client}
261
+ issue={ready.issue}
262
+ onDone={(result) => onReconfigure(result)}
263
+ />
264
+ </Box>
265
+ )
266
+ }
267
+
268
+ if (reconfigOpen) {
269
+ return (
270
+ <Box
271
+ flexDirection="column"
272
+ width={terminal.isTty ? terminal.columns : undefined}
273
+ height={terminal.isTty ? viewportRows : undefined}
274
+ paddingX={1}
275
+ overflowY="hidden"
276
+ >
277
+ <ReconfigFlow
278
+ client={client}
279
+ onDone={(result) => onReconfigure(result)}
280
+ />
281
+ </Box>
282
+ )
283
+ }
284
+
153
285
  return (
154
286
  <Box
155
287
  flexDirection="column"
@@ -167,16 +299,27 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
167
299
  always the first line of the transcript, spanning the full width,
168
300
  instead of floating mid-screen when the transcript has spare rows. */}
169
301
  {olderHint !== null
170
- ? <Box width="100%" flexShrink={0}><Text dimColor> {olderHint}</Text></Box>
302
+ ? <Box width="100%" flexShrink={0}><Text dimColor wrap="truncate-end"> {olderHint}</Text></Box>
171
303
  : null}
172
304
 
173
- <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
- ))}
305
+ <Box flexGrow={1} flexShrink={1} flexDirection="column" justifyContent={showWelcome || fitted.hiddenOlder > 0 ? 'flex-start' : 'flex-end'} overflowY="hidden">
306
+ {fitted.peekLines.length > 0
307
+ ? <Box width="100%" flexShrink={0} flexDirection="column">
308
+ {fitted.peekLines.map((line, index) => (
309
+ <Text key={`peek-${index}`} dimColor wrap="truncate-end">{index === 0 ? ' ⋯ ' : ' '}{line}</Text>
310
+ ))}
311
+ </Box>
312
+ : null}
313
+ {fitted.entries.map((entry, index) => {
314
+ const entrySel = selMap?.get(index)
315
+ const key = entry.__id ?? `entry-${fitted.startIndex + index}`
316
+ return entrySel
317
+ ? <EntryScreenWithSelection key={key} entry={entry} columns={terminal.columns} sel={entrySel} />
318
+ : <EntryAccum key={key} entry={entry} columns={terminal.columns} />
319
+ })}
177
320
  {chat.pendingUser !== null ? <UserLine text={chat.pendingUser} /> : null}
178
321
  {fitted.hiddenRecent > 0
179
- ? <Box width="100%" flexShrink={0}><Text dimColor> ↓ 滚轮/PageDown 向下翻页 · 较新 {fitted.hiddenRecent} 条</Text></Box>
322
+ ? <Box width="100%" flexShrink={0}><Text dimColor wrap="truncate-end"> ↓ 滚轮/PageDown 翻页 · 较新 {fitted.hiddenRecent} 条</Text></Box>
180
323
  : null}
181
324
  </Box>
182
325
 
@@ -206,6 +349,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
206
349
  webUrl={buildWebUrl(client.server, webUserId, ready, chat.sessionId)}
207
350
  aimuxStatus={aimuxStatus}
208
351
  modelDisplay={modelDisplay}
352
+ copyNotice={copyNotice}
209
353
  />
210
354
  </Box>
211
355
  </Box>
@@ -284,6 +428,35 @@ function EntryAccum({ entry, columns }: { entry: AnyEntry; columns: number }) {
284
428
  )
285
429
  }
286
430
 
431
+ // While a drag-selection is active, the affected entries are re-rendered from the
432
+ // screen-text model (plain rows, no ANSI) so the selected char range can be
433
+ // painted with a background — the same rows the model produces, keeping the
434
+ // layout stable. Rows outside the selection keep their original text.
435
+ function EntryScreenWithSelection({ entry, columns, sel }: {
436
+ entry: AnyEntry
437
+ columns: number
438
+ sel: Map<number, { start: number; end: number }>
439
+ }) {
440
+ const lines = entryScreenLines(viewsForEntry(entry), columns)
441
+ return (
442
+ <Box flexDirection="column">
443
+ {lines.map((row, index) => {
444
+ const range = sel.get(index)
445
+ if (range && range.start < range.end) {
446
+ return (
447
+ <Text key={index} wrap="truncate-end">
448
+ <Text>{row.slice(0, range.start)}</Text>
449
+ <Text backgroundColor="cyan" color="black">{row.slice(range.start, range.end)}</Text>
450
+ <Text>{row.slice(range.end)}</Text>
451
+ </Text>
452
+ )
453
+ }
454
+ return <Text key={index} wrap="truncate-end">{row || ' '}</Text>
455
+ })}
456
+ </Box>
457
+ )
458
+ }
459
+
287
460
  function ViewLine({ view, columns }: { view: EntryView; columns: number }) {
288
461
  const width = Math.max(8, columns - 4)
289
462
  switch (view.kind) {
@@ -385,41 +558,9 @@ function WriteFileView({ view }: { view: { filePath: string; content: string } }
385
558
  )
386
559
  }
387
560
 
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
- }
561
+ // clampLines / headTailLines / displayWidth live in src/lib/screen-text.ts
562
+ // (mirrored, exported) and are imported above; they must match ViewLine exactly
563
+ // so the drag-selection text model aligns with the rendered rows.
423
564
 
424
565
  function UserLine({ text }: { text: string }) {
425
566
  const lines = text.split('\n')
@@ -463,6 +604,9 @@ export function shimmerText(label: string, frame: number): React.ReactNode[] {
463
604
  }
464
605
 
465
606
  function HelpBlock({ commands }: { commands: { cmd: string; desc: string }[] }) {
607
+ const mouseNote = process.env.MOBIUS_TUI_DISABLE_MOUSE === '1'
608
+ ? '滚轮翻页已关闭 (MOBIUS_TUI_DISABLE_MOUSE=1),鼠标可用于直接选中文本。'
609
+ : '滚轮翻页 · 拖动选中文本,松开即经 OSC 52 复制到剪贴板。'
466
610
  return (
467
611
  <Box flexDirection="column" borderStyle="round" borderColor="gray" borderDimColor paddingX={1} marginTop={1}>
468
612
  {commands.map(command => (
@@ -471,6 +615,7 @@ function HelpBlock({ commands }: { commands: { cmd: string; desc: string }[] })
471
615
  <Text>{command.desc}</Text>
472
616
  </Text>
473
617
  ))}
618
+ <Text dimColor> {mouseNote}</Text>
474
619
  </Box>
475
620
  )
476
621
  }
@@ -504,6 +649,15 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
504
649
  })
505
650
  const { stdout } = useStdout()
506
651
 
652
+ // Physical Backspace/Delete keys are owned by useDeleteKeyCapture from the
653
+ // raw stdin bytes — Ink reports the Backspace key (\x7f) and the Delete key
654
+ // (ESC[3~) both as `key.delete`, so handling `key.delete` in useInput would
655
+ // delete in the wrong direction. The composer is always active while mounted.
656
+ useDeleteKeyCapture(true, (intent) => {
657
+ const { text, cursor: nextCursor } = applyDeleteIntent(valueRef.current, cursorRef.current, intent)
658
+ edit(text, nextCursor)
659
+ })
660
+
507
661
  const filtered = useMemo(() => {
508
662
  const match = /^(\w*)$/.exec(value.slice(1))
509
663
  if (!value.startsWith('/') || match === null) return []
@@ -685,20 +839,22 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
685
839
  return
686
840
  }
687
841
  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
- }
842
+ // Physical Backspace/Delete keys are handled by useDeleteKeyCapture above
843
+ // (raw stdin bytes distinguish them; Ink maps both to `key.delete`). Only
844
+ // the unambiguous logical editing bindings stay here.
845
+ if (key.ctrl && input === 'w') {
846
+ const { text, cursor: nextCursor } = applyDeleteIntent(current, at, 'backward-word')
847
+ edit(text, nextCursor)
848
+ return
849
+ }
850
+ if (key.ctrl && input === 'h') {
851
+ const { text, cursor: nextCursor } = applyDeleteIntent(current, at, 'backward')
852
+ edit(text, nextCursor)
853
+ return
854
+ }
855
+ if (key.ctrl && input === 'd') {
856
+ const { text, cursor: nextCursor } = applyDeleteIntent(current, at, 'forward')
857
+ edit(text, nextCursor)
702
858
  return
703
859
  }
704
860
  if (key.leftArrow) { moveCursor(previousCursorBoundary(current, at)); return }
@@ -840,26 +996,6 @@ function pasteMarkerLength(input: string, at: number, code: '200' | '201'): numb
840
996
  return input.startsWith(`\x1b[${code}~`, at) ? 6 : 5
841
997
  }
842
998
 
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
999
  interface ComposerLine { text: string; start: number; end: number }
864
1000
 
865
1001
  function wrapComposerLines(text: string, width: number): ComposerLine[] {
@@ -904,13 +1040,14 @@ function findComposerCursorLine(lines: ComposerLine[], cursor: number): number {
904
1040
  return Math.max(0, lines.length - 1)
905
1041
  }
906
1042
 
907
- function StatusArea({ ready, sessionId, columns, webUrl, aimuxStatus, modelDisplay }: {
1043
+ function StatusArea({ ready, sessionId, columns, webUrl, aimuxStatus, modelDisplay, copyNotice }: {
908
1044
  ready: ReadyState
909
1045
  sessionId: string | null
910
1046
  columns: number
911
1047
  webUrl: string
912
1048
  aimuxStatus?: AimuxStatus
913
1049
  modelDisplay: string
1050
+ copyNotice?: string | null
914
1051
  }) {
915
1052
  const model = modelDisplay
916
1053
  const language = ready.prefs.language === 'en' ? 'English' : '中文'
@@ -926,7 +1063,7 @@ function StatusArea({ ready, sessionId, columns, webUrl, aimuxStatus, modelDispl
926
1063
  return (
927
1064
  <Box flexDirection="column" marginTop={1}>
928
1065
  <Box justifyContent="space-between">
929
- <Text dimColor>{left}</Text>
1066
+ <Text dimColor color={copyNotice ? 'green' : undefined}>{copyNotice ?? left}</Text>
930
1067
  {right ? <Text dimColor>{right}</Text> : null}
931
1068
  </Box>
932
1069
  {/* Merged connectivity row: AIMUX status sits left, the clickable web URL
@@ -996,35 +1133,8 @@ function clickableUrl(url: string, maxLen?: number): string {
996
1133
  return `\u001B]8;;${url}\u0007${display}\u001B]8;;\u0007`
997
1134
  }
998
1135
 
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
- }
1136
+ // displayWidth is imported from src/lib/screen-text.ts (CJK/emoji-aware), used
1137
+ // here to size the AIMUX status block so the web URL truncates exactly.
1028
1138
 
1029
1139
  function wrappedRows(text: string, width: number): number {
1030
1140
  const safeWidth = Math.max(1, width)
@@ -1061,22 +1171,55 @@ function entryRows(entry: AnyEntry, columns: number): number {
1061
1171
 
1062
1172
  function fitTranscript(entries: AnyEntry[], rowBudget: number, columns: number, scrollBack = 0): {
1063
1173
  entries: AnyEntry[]
1174
+ /** Tail rows of the next older entry, used to fill spare space above the viewport. */
1175
+ peekLines: string[]
1064
1176
  hiddenOlder: number
1065
1177
  hiddenRecent: number
1066
1178
  startIndex: number
1067
1179
  } {
1068
1180
  const tail = Math.max(0, entries.length - scrollBack)
1069
1181
  const available = tail === 0 ? [] : entries.slice(0, tail)
1070
- let rows = 0
1071
- let first = available.length
1072
- for (let index = available.length - 1; index >= 0; index--) {
1073
- const nextRows = entryRows(available[index], columns)
1074
- if (first < available.length && rows + nextRows > rowBudget) break
1075
- rows += nextRows
1076
- first = index
1182
+ const renderedRows = available.map((entry) => entryScreenLines(viewsForEntry(entry), columns))
1183
+ const fit = (budget: number) => {
1184
+ let rows = 0
1185
+ let first = available.length
1186
+ for (let index = available.length - 1; index >= 0; index--) {
1187
+ const nextRows = renderedRows[index].length
1188
+ if (first < available.length && rows + nextRows > budget) break
1189
+ rows += nextRows
1190
+ first = index
1191
+ }
1192
+ return { first, rows }
1193
+ }
1194
+
1195
+ const base = fit(rowBudget)
1196
+ let fitted = base
1197
+ let first = fitted.first
1198
+ let peekLines: string[] = []
1199
+ // When older history exists, guarantee at least one row for the tail of the
1200
+ // next older message. If complete entries exactly consume the budget, refit
1201
+ // them with one fewer row; only the oldest complete entry can drop out, while
1202
+ // the latest content remains visible. A single oversized entry keeps its
1203
+ // original rendering because it cannot safely donate a row.
1204
+ if (first > 0 && fitted.rows <= rowBudget) {
1205
+ if (fitted.rows === rowBudget && rowBudget > 1) {
1206
+ const reduced = fit(rowBudget - 1)
1207
+ if (reduced.first > 0 && reduced.rows <= rowBudget - 1) {
1208
+ fitted = reduced
1209
+ first = reduced.first
1210
+ }
1211
+ }
1212
+ const olderLines = renderedRows[first - 1].slice()
1213
+ while (olderLines.length > 0 && !olderLines[0].trim()) olderLines.shift()
1214
+ while (olderLines.length > 0 && !olderLines[olderLines.length - 1].trim()) olderLines.pop()
1215
+ const spare = rowBudget - fitted.rows
1216
+ if (spare > 0 && olderLines.length > 0) {
1217
+ peekLines = olderLines.slice(-spare)
1218
+ }
1077
1219
  }
1078
1220
  return {
1079
1221
  entries: available.slice(first),
1222
+ peekLines,
1080
1223
  hiddenOlder: first,
1081
1224
  hiddenRecent: entries.length - tail,
1082
1225
  startIndex: first,