@mobius-os/mobius 0.3.31 → 0.3.38

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.
@@ -21,7 +21,7 @@
21
21
  */
22
22
  import React, { useEffect, useRef, useState } from 'react'
23
23
  import { Box, Text } from 'ink'
24
- import { Select, TextInput, Spinner, type SelectItem } from './primitives.js'
24
+ import { isEscapeKeypress, Select, TextInput, Spinner, useStableInput, type SelectItem } from './primitives.js'
25
25
  import { MobiusClient } from '../api.js'
26
26
  import {
27
27
  bindCwdToProject, cwd, getCwdPreference, loadDir2Project, loadProjectsCache,
@@ -118,8 +118,8 @@ export function ConfigFlow({ client, issue, onDone }: {
118
118
  : <Select
119
119
  items={models.map(o => ({
120
120
  label: `${o.label}${o.key === defaultKey ? ' (默认)' : ''}`,
121
- value: o.key,
122
121
  desc: o.sub,
122
+ value: o.key,
123
123
  }))}
124
124
  onSelect={key => void pickModel(key)}
125
125
  />}
@@ -134,9 +134,10 @@ export function ConfigFlow({ client, issue, onDone }: {
134
134
 
135
135
  type ReconfigStep = 'projects' | 'issues' | 'models' | 'creating'
136
136
 
137
- export function ReconfigFlow({ client, onDone }: {
137
+ export function ReconfigFlow({ client, onDone, onCancel }: {
138
138
  client: MobiusClient
139
139
  onDone: (r: ConfigResult) => void
140
+ onCancel: () => void
140
141
  }) {
141
142
  const [step, setStep] = useState<ReconfigStep>('projects')
142
143
  const [projects, setProjects] = useState<Project[] | null>(null)
@@ -151,6 +152,29 @@ export function ReconfigFlow({ client, onDone }: {
151
152
  const doneRef = useRef(false)
152
153
  const thisCwd = cwd()
153
154
 
155
+ function goBack() {
156
+ // The focused TextInput owns Esc while a create form is open. Returning
157
+ // here prevents the same raw key from also navigating the underlying step.
158
+ if (createMode !== null) return
159
+ if (step === 'models') {
160
+ setIssue(null)
161
+ setStep('issues')
162
+ return
163
+ }
164
+ if (step === 'issues') {
165
+ setProject(null)
166
+ setIssue(null)
167
+ setStep('projects')
168
+ return
169
+ }
170
+ if (step === 'projects') onCancel()
171
+ }
172
+
173
+ // Own Esc across both loaded lists and their async loading frames.
174
+ useStableInput((input, key) => {
175
+ if (isEscapeKeypress(input, key)) goBack()
176
+ })
177
+
154
178
  useEffect(() => () => { doneRef.current = true }, [])
155
179
 
156
180
  // Load projects on mount.
@@ -320,7 +344,7 @@ export function ReconfigFlow({ client, onDone }: {
320
344
  : <Select
321
345
  items={[
322
346
  { label: '➕ 创建新项目', value: '__create__' },
323
- ...projects.map(p => ({ label: p.name, value: p.id, desc: p.description })),
347
+ ...projects.map(p => ({ label: p.name, desc: p.description, value: p.id })),
324
348
  ]}
325
349
  initialActive={projects.length > 0 ? 1 : 0}
326
350
  onSelect={v => v === '__create__' ? setCreateMode('project') : pickProject(projects!.find(p => p.id === v)!)}
@@ -344,7 +368,7 @@ export function ReconfigFlow({ client, onDone }: {
344
368
  : <Select
345
369
  items={[
346
370
  { label: '➕ 创建新任务', value: '__create__' },
347
- ...issues.map(i => ({ label: i.title, value: i.id, desc: i.description })),
371
+ ...issues.map(i => ({ label: i.title, desc: i.description, value: i.id })),
348
372
  ]}
349
373
  initialActive={1}
350
374
  onSelect={v => v === '__create__' ? setCreateMode('issue') : pickIssue(issues!.find(i => i.id === v)!)} />}
@@ -365,8 +389,8 @@ export function ReconfigFlow({ client, onDone }: {
365
389
  : <Select
366
390
  items={models.map(o => ({
367
391
  label: `${o.label}${o.key === defaultKey ? ' (默认)' : ''}`,
368
- value: o.key,
369
392
  desc: o.sub,
393
+ value: o.key,
370
394
  }))}
371
395
  onSelect={key => void pickModel(key)}
372
396
  />}
@@ -15,12 +15,14 @@ import { saveLogin, type LoginRecord } from '../config.js'
15
15
 
16
16
  const DEFAULT_SERVER = ''
17
17
 
18
- export function LoginScreen({ onSuccess, onError }: {
18
+ export function LoginScreen({ onSuccess, onError, initialServer = DEFAULT_SERVER, initialUsername = '' }: {
19
19
  onSuccess: (rec: LoginRecord) => void
20
20
  onError?: (msg: string) => void
21
+ initialServer?: string
22
+ initialUsername?: string
21
23
  }) {
22
- const [server, setServer] = useState(DEFAULT_SERVER)
23
- const [username, setUsername] = useState('')
24
+ const [server, setServer] = useState(initialServer)
25
+ const [username, setUsername] = useState(initialUsername)
24
26
  const [password, setPassword] = useState('')
25
27
  const [pwdRequired, setPwdRequired] = useState<boolean | null>(null)
26
28
  const [focus, setFocus] = useState(0) // 0 server, 1 user, 2 password
@@ -230,10 +230,75 @@ function toItems(arr: { id: string; name: string; description?: string }[]): Sel
230
230
  return arr.map(s => ({ label: s.name, value: s.id, desc: s.description }))
231
231
  }
232
232
 
233
- // 把可能含换行的描述压成单行:换行 可见符号 ⏎,避免列表项跨行。
234
- function flattenDesc(s?: string): string {
235
- if (!s) return ''
236
- return s.replace(/\s*\n\s*/g, ' ').replace(/[ \t]+/g, ' ').trim()
233
+ // Search-first picker used by the project and issue screens. The search field
234
+ // owns the initial focus so a user can type immediately; Down hands control to
235
+ // the normal Select for keyboard navigation. Enter in the search field chooses
236
+ // the first matching row, which keeps the common "type a unique name, Enter"
237
+ // workflow to one step.
238
+ function SearchableSelect({
239
+ items,
240
+ createItem,
241
+ title,
242
+ placeholder,
243
+ onSelect,
244
+ onCreate,
245
+ onQuit,
246
+ }: {
247
+ items: SelectItem[]
248
+ createItem?: SelectItem
249
+ title: string
250
+ placeholder: string
251
+ onSelect: (value: string) => void
252
+ onCreate?: () => void
253
+ onQuit?: () => void
254
+ }) {
255
+ const [query, setQuery] = useState('')
256
+ const [focus, setFocus] = useState<'search' | 'list'>('search')
257
+ const needle = query.trim().toLocaleLowerCase()
258
+ const matches = (item: SelectItem) => {
259
+ if (!needle) return true
260
+ return `${item.label}\n${item.desc ?? ''}\n${item.value}`.toLocaleLowerCase().includes(needle)
261
+ }
262
+ const filtered = items.filter(matches)
263
+ const createMatches = createItem && matches(createItem)
264
+ const visibleItems = createItem && (!needle || createMatches)
265
+ ? [createItem, ...filtered]
266
+ : filtered
267
+
268
+ function choose(value: string) {
269
+ if (value === createItem?.value) onCreate?.()
270
+ else onSelect(value)
271
+ }
272
+
273
+ return (
274
+ <Box flexDirection="column">
275
+ <Text bold color="cyan">{title}</Text>
276
+ <Box marginTop={1} flexDirection="column">
277
+ <TextInput
278
+ value={query}
279
+ onChange={setQuery}
280
+ focused={focus === 'search'}
281
+ prompt="搜索:"
282
+ placeholder={placeholder}
283
+ onArrowDown={() => setFocus('list')}
284
+ onArrowUp={() => setFocus('list')}
285
+ onSubmit={() => { if (visibleItems.length) choose(visibleItems[0].value) }}
286
+ onEscape={() => onQuit?.()}
287
+ />
288
+ {query.trim() && !filtered.length
289
+ ? <Text color="yellow">没有匹配的项目,请修改搜索</Text>
290
+ : focus === 'search' && query.trim() ? <Text color="gray">匹配 {filtered.length} 项 · ↓进入列表</Text> : null}
291
+ {focus === 'list' && !visibleItems.length
292
+ ? <Text color="yellow">没有匹配的项目,请按 Esc 修改搜索</Text>
293
+ : <Select
294
+ items={visibleItems}
295
+ focused={focus === 'list'}
296
+ onBack={() => setFocus('search')}
297
+ onSelect={choose}
298
+ />}
299
+ </Box>
300
+ </Box>
301
+ )
237
302
  }
238
303
 
239
304
  // ── Project picker ───────────────────────────────────────────────────────────
@@ -264,22 +329,25 @@ function ProjectPicker({ cwd, projects, statusMsg, onPick, onCreate, onQuit }: {
264
329
  )
265
330
  }
266
331
 
267
- const items: SelectItem[] = [
268
- { label: '➕ 创建新项目', value: '__create__', desc: '绑定到当前路径' },
269
- ...projects.map(p => {
270
- const desc = flattenDesc(p.description)
271
- return { label: desc ? `${p.name} — ${desc}` : p.name, value: p.id }
272
- }),
273
- ]
332
+ const items: SelectItem[] = projects.map(p => ({
333
+ label: p.name,
334
+ desc: p.description,
335
+ value: p.id,
336
+ }))
274
337
  return (
275
338
  <Box flexDirection="column" paddingX={2} paddingY={1}>
276
- <Text bold color="cyan">选择当前路径的绑定项目</Text>
277
339
  <Text color="gray">{cwd}</Text>
278
- <Box marginTop={1}>
279
- <Select items={items} onBack={onQuit} onSelect={v => v === '__create__' ? setMode('create') : onPick(projects.find(p => p.id === v)!)} />
280
- </Box>
340
+ <SearchableSelect
341
+ title="选择当前路径的绑定项目"
342
+ placeholder="输入项目名或描述"
343
+ items={items}
344
+ createItem={{ label: '➕ 创建新项目', value: '__create__', desc: '绑定到当前路径' }}
345
+ onSelect={v => onPick(projects.find(p => p.id === v)!)}
346
+ onCreate={() => setMode('create')}
347
+ onQuit={onQuit}
348
+ />
281
349
  {statusMsg ? <Text color="yellow">{statusMsg}</Text> : null}
282
- <Text color="gray">↑↓ 选择 · 回车确认 · Esc 退出</Text>
350
+ <Text color="gray">输入关键词筛选 · ↓进入列表 · Esc 退出</Text>
283
351
  </Box>
284
352
  )
285
353
  }
@@ -306,18 +374,23 @@ function IssuePicker({ issues, onPick, onCreate }: {
306
374
  </Box>
307
375
  )
308
376
  }
309
- const items: SelectItem[] = [
310
- { label: '➕ 创建新任务', value: '__create__' },
311
- ...issues.map(i => ({ label: i.title, value: i.id, desc: i.description })),
312
- ]
377
+ const items: SelectItem[] = issues.map(i => ({
378
+ label: i.title,
379
+ desc: i.description,
380
+ value: i.id,
381
+ }))
313
382
  return (
314
383
  <Box flexDirection="column">
315
- <Text bold color="cyan">选择任务(Issue)</Text>
316
384
  <Text color="gray">偏好设置将保存在所选任务内部</Text>
317
385
  <Box marginTop={1}>
318
- {issues.length === 0 && mode === 'list'
319
- ? <Select items={[{ label: '➕ 创建新任务(尚无任务)', value: '__create__' }]} onSelect={() => setMode('create-name')} />
320
- : <Select items={items} onSelect={v => v === '__create__' ? setMode('create-name') : onPick(issues.find(i => i.id === v)!)} />}
386
+ <SearchableSelect
387
+ title="选择任务(Issue)"
388
+ placeholder="输入任务标题或描述"
389
+ items={items}
390
+ createItem={{ label: issues.length ? '➕ 创建新任务' : '➕ 创建新任务(尚无任务)', value: '__create__' }}
391
+ onSelect={v => onPick(issues.find(i => i.id === v)!)}
392
+ onCreate={() => setMode('create-name')}
393
+ />
321
394
  </Box>
322
395
  </Box>
323
396
  )
@@ -332,8 +405,8 @@ function ModelPicker({ options, defaultKey, onSelect }: {
332
405
  if (!options.length) return <Text color="gray">加载模型列表…</Text>
333
406
  const items: SelectItem[] = options.map(o => ({
334
407
  label: `${o.label}${o.key === defaultKey ? ' (默认)' : ''}`,
335
- value: o.key,
336
408
  desc: o.sub,
409
+ value: o.key,
337
410
  }))
338
411
  return (
339
412
  <Box flexDirection="column">
@@ -4,7 +4,8 @@
4
4
  */
5
5
  import React, { useEffect, useRef, useState } from 'react'
6
6
  import { Box, Text, useInput, useStdout, useStdin, type Key } from 'ink'
7
- import { useDeleteKeyCapture, applyDeleteIntent } from '../lib/delete-keys.js'
7
+ import { useDeleteKeyCapture, applyDeleteIntent, clampCursor, previousWordBoundary, nextWordBoundary } from '../lib/delete-keys.js'
8
+ import { useCursorKeyCapture } from '../lib/cursor-keys.js'
8
9
 
9
10
  /** Return false when a mounted listener deliberately did not consume the input. */
10
11
  type InputHandler = (input: string, key: Key) => void | false
@@ -192,6 +193,26 @@ export function parseMouseEvents(input: string): MouseEventInfo[] {
192
193
  return out
193
194
  }
194
195
 
196
+ /** Collapse each contiguous wheel burst into one delta without reordering clicks. */
197
+ export function coalesceMouseEvents(events: MouseEventInfo[]): MouseEventInfo[] {
198
+ const out: MouseEventInfo[] = []
199
+ let wheelDelta = 0
200
+ const flushWheel = () => {
201
+ if (wheelDelta !== 0) out.push({ kind: 'wheel', delta: wheelDelta })
202
+ wheelDelta = 0
203
+ }
204
+ for (const event of events) {
205
+ if (event.kind === 'wheel') {
206
+ wheelDelta += event.delta
207
+ } else {
208
+ flushWheel()
209
+ out.push(event)
210
+ }
211
+ }
212
+ flushWheel()
213
+ return out
214
+ }
215
+
195
216
  /**
196
217
  * Enables terminal mouse tracking for the lifetime of the calling component and
197
218
  * forwards mouse events (wheel + left-button press/motion/release) to the given
@@ -228,7 +249,7 @@ export function useMouseEvents(handlers: {
228
249
  // A single read() chunk may carry several events and a sequence may be
229
250
  // split across chunks, so accumulate and re-scan.
230
251
  buf += String(chunk)
231
- for (const e of parseMouseEvents(buf)) {
252
+ for (const e of coalesceMouseEvents(parseMouseEvents(buf))) {
232
253
  if (e.kind === 'wheel') refs.current.onWheel?.(e.delta)
233
254
  else if (e.kind === 'press') refs.current.onPress?.(e.row, e.col)
234
255
  else if (e.kind === 'motion') refs.current.onMotion?.(e.row, e.col)
@@ -298,6 +319,14 @@ export function TextInput(props: TextInputProps) {
298
319
  const { text, cursor: nextCursor } = applyDeleteIntent(valueRef.current, cursorRef.current, intent)
299
320
  edit(text, nextCursor)
300
321
  })
322
+ useCursorKeyCapture(focused, (intent) => {
323
+ const current = valueRef.current
324
+ const at = clampCursor(current, cursorRef.current)
325
+ const next = intent === 'home' ? 0 : intent === 'end' ? current.length
326
+ : intent === 'backward-word' ? previousWordBoundary(current, at) : nextWordBoundary(current, at)
327
+ cursorRef.current = next
328
+ setCursor(next)
329
+ })
301
330
 
302
331
  useStableInput((input, key) => {
303
332
  if (isMouseInput(input)) return
@@ -323,6 +352,7 @@ export function TextInput(props: TextInputProps) {
323
352
  edit(text, nextCursor)
324
353
  return
325
354
  }
355
+ if (key.ctrl && (key.leftArrow || key.rightArrow)) return
326
356
  if (key.leftArrow) { setCursor(c => Math.max(0, c - 1)); return }
327
357
  if (key.rightArrow) { setCursor(c => Math.min(value.length, c + 1)); return }
328
358
  if (key.ctrl && input === 'a') { setCursor(0); return }
@@ -401,6 +431,12 @@ export interface SelectItem {
401
431
  desc?: string
402
432
  }
403
433
 
434
+ /** Keep a picker explanation on the item's main row; Select truncates that row. */
435
+ export function inlineSelectLabel(label: string, detail?: string): string {
436
+ const oneLine = detail?.replace(/\s*\n\s*/g, ' ⏎ ').replace(/[ \t]+/g, ' ').trim()
437
+ return oneLine ? `${label} - ${oneLine}` : label
438
+ }
439
+
404
440
  export interface SelectProps {
405
441
  items: SelectItem[]
406
442
  mode?: 'single' | 'multi'
@@ -471,17 +507,20 @@ export function Select(props: SelectProps) {
471
507
  const isActive = realIdx === active
472
508
  const checked = mode === 'multi' ? selectedSet.has(it.value) : false
473
509
  const marker = mode === 'multi' ? (checked ? '☑' : '☐') : isActive ? '❯' : ' '
510
+ // Keep picker rows compact: descriptions belong on the highlighted
511
+ // row only. Unfocused rows show just their label so a long list does
512
+ // not turn every item into a multi-line block.
513
+ const rowLabel = isActive ? inlineSelectLabel(it.label, it.desc) : it.label
474
514
  return (
475
- <Box key={it.value} flexDirection="column">
515
+ <Box key={it.value}>
476
516
  <Text
477
517
  color={isActive ? 'black' : undefined}
478
518
  backgroundColor={isActive ? 'cyan' : undefined}
479
519
  bold={isActive}
480
520
  wrap="truncate-end"
481
521
  >
482
- {marker} {it.label}
522
+ {marker} {rowLabel}
483
523
  </Text>
484
- {isActive && it.desc ? <Text color="gray" wrap="truncate-end"> {it.desc}</Text> : null}
485
524
  </Box>
486
525
  )
487
526
  })}
@@ -0,0 +1,70 @@
1
+ /** Raw terminal cursor-key capture for keys Ink does not expose in `Key`. */
2
+
3
+ import { useEffect, useRef } from 'react'
4
+ import { useStdin } from 'ink'
5
+
6
+ export type CursorIntent = 'home' | 'end' | 'backward-word' | 'forward-word'
7
+
8
+ // Home/End vary by terminal/application mode. Ctrl+Left/Right are the xterm
9
+ // CSI modifier-5 forms; the shorter 5D/5C forms are emitted by some tmux and
10
+ // ConPTY bridges. Longest sequences come first so prefixes cannot win early.
11
+ const CURSOR_SEQUENCES: ReadonlyArray<readonly [string, CursorIntent]> = [
12
+ ['\x1b[1;5D', 'backward-word'],
13
+ ['\x1b[1;5C', 'forward-word'],
14
+ ['\x1b[5D', 'backward-word'],
15
+ ['\x1b[5C', 'forward-word'],
16
+ ['\x1b[1~', 'home'],
17
+ ['\x1b[7~', 'home'],
18
+ ['\x1b[4~', 'end'],
19
+ ['\x1b[8~', 'end'],
20
+ ['\x1b[H', 'home'],
21
+ ['\x1b[F', 'end'],
22
+ ['\x1bOH', 'home'],
23
+ ['\x1bOF', 'end'],
24
+ ]
25
+
26
+ export function classifyCursorSequence(raw: string): { intent: CursorIntent; length: number } | null {
27
+ for (const [sequence, intent] of CURSOR_SEQUENCES) {
28
+ if (raw.startsWith(sequence)) return { intent, length: sequence.length }
29
+ }
30
+ return null
31
+ }
32
+
33
+ function isCursorPrefix(raw: string): boolean {
34
+ return CURSOR_SEQUENCES.some(([sequence]) => sequence.startsWith(raw))
35
+ }
36
+
37
+ /** Capture physical Home/End and Ctrl+arrow bytes without relying on Ink's lossy key object. */
38
+ export function useCursorKeyCapture(
39
+ enabled: boolean,
40
+ onCursor: (intent: CursorIntent) => void,
41
+ ): void {
42
+ const { internal_eventEmitter } = useStdin()
43
+ const enabledRef = useRef(enabled)
44
+ const onCursorRef = useRef(onCursor)
45
+ enabledRef.current = enabled
46
+ onCursorRef.current = onCursor
47
+
48
+ useEffect(() => {
49
+ if (!internal_eventEmitter) return
50
+ let buf = ''
51
+ const handler = (chunk: unknown) => {
52
+ buf += String(chunk)
53
+ while (buf) {
54
+ const match = classifyCursorSequence(buf)
55
+ if (match) {
56
+ buf = buf.slice(match.length)
57
+ if (enabledRef.current) onCursorRef.current(match.intent)
58
+ continue
59
+ }
60
+ if (isCursorPrefix(buf)) break
61
+ // Ordinary text is handled by Ink's useInput; only retain a possible
62
+ // incomplete escape prefix so split terminal sequences still match.
63
+ const nextEsc = buf.indexOf('\x1b', buf.startsWith('\x1b') ? 1 : 0)
64
+ buf = nextEsc >= 0 ? buf.slice(nextEsc) : ''
65
+ }
66
+ }
67
+ internal_eventEmitter.on('input', handler)
68
+ return () => { internal_eventEmitter.off('input', handler) }
69
+ }, [internal_eventEmitter])
70
+ }
@@ -59,7 +59,7 @@ export function nextCursorBoundary(text: string, cursor: number): number {
59
59
  }
60
60
 
61
61
  /** Backward-word boundary: skip trailing whitespace, then the word before it. */
62
- function backwardWordBoundary(text: string, at: number): number {
62
+ export function previousWordBoundary(text: string, at: number): number {
63
63
  let i = at
64
64
  while (i > 0 && /\s/.test(text[i - 1])) i--
65
65
  while (i > 0 && !/\s/.test(text[i - 1])) i--
@@ -67,7 +67,7 @@ function backwardWordBoundary(text: string, at: number): number {
67
67
  }
68
68
 
69
69
  /** Forward-word boundary: skip leading whitespace, then the word after it. */
70
- function forwardWordBoundary(text: string, at: number): number {
70
+ export function nextWordBoundary(text: string, at: number): number {
71
71
  let i = at
72
72
  while (i < text.length && /\s/.test(text[i])) i++
73
73
  while (i < text.length && !/\s/.test(text[i])) i++
@@ -93,12 +93,12 @@ export function applyDeleteIntent(
93
93
  return { text: text.slice(0, at) + text.slice(next), cursor: at }
94
94
  }
95
95
  case 'backward-word': {
96
- const start = backwardWordBoundary(text, at)
96
+ const start = previousWordBoundary(text, at)
97
97
  if (start === at) return { text, cursor: at }
98
98
  return { text: text.slice(0, start) + text.slice(at), cursor: start }
99
99
  }
100
100
  case 'forward-word': {
101
- const end = forwardWordBoundary(text, at)
101
+ const end = nextWordBoundary(text, at)
102
102
  if (end === at) return { text, cursor: at }
103
103
  return { text: text.slice(0, at) + text.slice(end), cursor: at }
104
104
  }
@@ -638,18 +638,18 @@ export function toolLabel(name: string): string {
638
638
  // agent 输出, 则视为同一次输入的重复入口 → 丢弃, 避免 TUI 把同一条提问显示多次.
639
639
  export function userTextOf(e: AnyEntry): string {
640
640
  if (e?.type === 'event_msg' && e?.payload?.type === 'user_message') {
641
- return String(e?.payload?.message || '').trim()
641
+ return canonicalUserText(String(e?.payload?.message || ''))
642
642
  }
643
643
  if (e?.type === 'response_item' && e?.payload?.type === 'message' && e?.payload?.role === 'user') {
644
644
  const c = e?.payload?.content
645
- if (typeof c === 'string') return c.trim()
646
- if (Array.isArray(c)) return c.map((b: any) => b?.text || b?.input_text || '').filter(Boolean).join('\n').trim()
645
+ if (typeof c === 'string') return canonicalUserText(c)
646
+ if (Array.isArray(c)) return canonicalUserText(c.map((b: any) => b?.text || b?.input_text || '').filter(Boolean).join('\n'))
647
647
  return ''
648
648
  }
649
649
  if (e?.type === 'user') {
650
650
  const c = e?.message?.content
651
- if (typeof c === 'string') return c.trim()
652
- if (Array.isArray(c)) return c.filter((b: any) => b?.type === 'text').map((b: any) => b?.text || '').join('\n').trim()
651
+ if (typeof c === 'string') return canonicalUserText(c)
652
+ if (Array.isArray(c)) return canonicalUserText(c.filter((b: any) => b?.type === 'text').map((b: any) => b?.text || '').join('\n'))
653
653
  return ''
654
654
  }
655
655
  return ''
@@ -669,6 +669,11 @@ export function stripUserFraming(text: string): string {
669
669
  return after || text
670
670
  }
671
671
 
672
+ /** Canonical user text for duplicate event identities (framed vs plain). */
673
+ function canonicalUserText(text: string): string {
674
+ return stripUserFraming(text).replace(/\s+/g, ' ').trim()
675
+ }
676
+
672
677
  export function isAssistantOutput(e: AnyEntry): boolean {
673
678
  if (e?.type === 'assistant') return true
674
679
  if (e?.type === 'event_msg' && e?.payload?.type === 'agent_message') return true
@@ -13,7 +13,6 @@
13
13
  import wrapAnsi from 'wrap-ansi'
14
14
  import { renderMarkdownLines } from '../markdown.js'
15
15
  import { toolLabel, viewsForEntry, type EntryView } from './entry-view.js'
16
- import type { AnyEntry } from '../types.js'
17
16
 
18
17
  // ── shared text helpers (mirrored from Chat.tsx, kept here to avoid a cycle) ─
19
18
  export function displayWidth(str: string): number {
@@ -221,11 +220,6 @@ export function entryScreenRows(views: EntryView[], columns: number): ScreenRow[
221
220
  return lines
222
221
  }
223
222
 
224
- /** Plain projection used by fitting, geometry, hit-testing, and copying. */
225
- export function entryScreenLines(views: EntryView[], columns: number): string[] {
226
- return entryScreenRows(views, columns).map(row => row.plain)
227
- }
228
-
229
223
  // ── vertical geometry ────────────────────────────────────────────────────────
230
224
  export interface TranscriptGeometry {
231
225
  /** Screen row where the transcript box's top edge sits. */
@@ -240,30 +234,6 @@ export interface TranscriptGeometry {
240
234
  * (`flexShrink=0`); the middle column holds header + hint + transcript (flexGrow)
241
235
  * + tip + help. Margins that render as extra rows are counted explicitly.
242
236
  */
243
- export function computeTranscriptGeometry(opts: {
244
- viewportRows: number
245
- composerRows: number
246
- statusRows: number
247
- activityRows: number
248
- helpRows: number
249
- showWelcome: boolean
250
- welcomeRows: number
251
- olderHintShown: boolean
252
- tipShown: boolean
253
- }): TranscriptGeometry {
254
- // The composer's reported height already includes its marginTop; the status
255
- // area and working indicator rows are already folded into statusRows and
256
- // activityRows. No extra +1 here — calibrated against the rendered frame.
257
- const bottomH = opts.activityRows + opts.composerRows + opts.statusRows
258
- const midH = opts.viewportRows - bottomH
259
- const headerH = opts.showWelcome ? opts.welcomeRows : 1
260
- const hintH = opts.olderHintShown ? 1 : 0
261
- const tipH = opts.tipShown ? 2 : 0 // marginTop 1 + content 1
262
- const helpH = opts.helpRows > 0 ? opts.helpRows + 1 : 0 // +1 marginTop
263
- const boxTop = headerH + hintH + tipH + helpH
264
- return { boxTop, boxH: Math.max(0, midH - boxTop) }
265
- }
266
-
267
237
  // ── selection mapping ────────────────────────────────────────────────────────
268
238
  export interface SelPoint {
269
239
  entry: number // index into the fitted entries
@@ -276,11 +246,6 @@ export interface TranscriptModel {
276
246
  totalRows: number
277
247
  }
278
248
 
279
- export function buildTranscriptModel(fittedEntries: AnyEntry[], columns: number): TranscriptModel {
280
- const entries = fittedEntries.map((e) => entryScreenLines(viewsForEntry(e), columns))
281
- return { entries, totalRows: entries.reduce((sum, l) => sum + l.length, 0) }
282
- }
283
-
284
249
  /** Convert a screen (row, col) into a SelPoint, or null if outside the transcript. */
285
250
  export function screenToSelPoint(
286
251
  screenRow: number,