dsh-code 1.0.1 → 1.0.2

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.
@@ -108,11 +108,16 @@ export async function inspectImagePaths(
108
108
  export async function saveImagePaths(
109
109
  paths: readonly string[],
110
110
  attachments: AttachmentStore | undefined,
111
+ signal?: AbortSignal,
111
112
  ): Promise<readonly ImageBlock[]> {
112
113
  if (paths.length === 0) return []
113
114
  if (attachments === undefined) throw new Error('image attachments are unavailable in this profile')
115
+ const checkCancelled = (): void => {
116
+ if (signal?.aborted === true) throw new Error('image submission cancelled')
117
+ }
114
118
  const inputs: SaveImageAttachment[] = []
115
119
  for (const path of paths) {
120
+ checkCancelled()
116
121
  let data: Uint8Array
117
122
  try {
118
123
  data = await readFile(path)
@@ -123,6 +128,8 @@ export async function saveImagePaths(
123
128
  if (mediaType === undefined) throw new Error(`unsupported image file "${path}" (expected PNG, JPEG, WebP, or GIF)`)
124
129
  inputs.push({ data, mediaType, name: basename(path) })
125
130
  }
131
+ checkCancelled()
126
132
  const refs = await attachments.saveImages(inputs)
133
+ checkCancelled()
127
134
  return refs.map(attachment => ({ type: 'image', attachment }))
128
135
  }
package/src/index.ts CHANGED
@@ -1405,7 +1405,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1405
1405
  copyTextValue: copyText,
1406
1406
  loadMentions: (query: string, signal?: AbortSignal) => mentions.candidates(query, signal),
1407
1407
  inspectImages: paths => inspectImagePaths(paths, ctx.get('attachments'), session?.header.cwd ?? cwd),
1408
- prepareImages: paths => saveImagePaths(paths, ctx.get('attachments')),
1408
+ prepareImages: (paths, signal) => saveImagePaths(paths, ctx.get('attachments'), signal),
1409
1409
  cyclePermission,
1410
1410
  setPermission: setPermissionAction,
1411
1411
  selectModel,
package/src/internals.ts CHANGED
@@ -8,7 +8,16 @@
8
8
 
9
9
  import { render } from 'ink'
10
10
  import type { ReactElement } from 'react'
11
- import { BRACKETED_PASTE_DISABLE, BRACKETED_PASTE_ENABLE, KEYBOARD_ENHANCE_DISABLE, KEYBOARD_ENHANCE_ENABLE } from './keyboard.ts'
11
+ import {
12
+ BRACKETED_PASTE_DISABLE,
13
+ BRACKETED_PASTE_ENABLE,
14
+ KEYBOARD_ENHANCE_DISABLE,
15
+ KEYBOARD_ENHANCE_ENABLE,
16
+ TERMINAL_FOCUS_REPORT_DISABLE,
17
+ TERMINAL_FOCUS_REPORT_ENABLE,
18
+ isVsCodeTerminalEnv,
19
+ shouldEnableKeyboardEnhancement,
20
+ } from './keyboard.ts'
12
21
 
13
22
  /** A mounted terminal app instance; the runner owns unmount ordering. */
14
23
  export interface TuiMount {
@@ -29,11 +38,16 @@ export const internals: {
29
38
  stderr: { write(chunk: string): unknown }
30
39
  } = {
31
40
  mount: (element: ReactElement): TuiMount => {
32
- // Codex keyboard_modes parity: push the kitty keyboard protocol so
33
- // modified controls remain distinguishable, and enable bracketed paste so
34
- // pasted newlines insert instead of submitting. Both are inert in
35
- // terminals without support.
36
- process.stdout.write(KEYBOARD_ENHANCE_ENABLE + BRACKETED_PASTE_ENABLE)
41
+ // VS Code's integrated terminal can route Tab to the workbench when Kitty
42
+ // enhancement is enabled. Keep bracketed paste everywhere, but only push
43
+ // the keyboard protocol on terminals that can safely own those key events.
44
+ const keyboardEnhanced = shouldEnableKeyboardEnhancement()
45
+ const focusReporting = isVsCodeTerminalEnv()
46
+ process.stdout.write(
47
+ (keyboardEnhanced ? KEYBOARD_ENHANCE_ENABLE : '')
48
+ + BRACKETED_PASTE_ENABLE
49
+ + (focusReporting ? TERMINAL_FOCUS_REPORT_ENABLE : ''),
50
+ )
37
51
  // App owns Ctrl+C's deliberate three-state contract (interrupt, clear
38
52
  // draft, quit). Ink's default `exitOnCtrlC: true` would intercept the
39
53
  // normalized control byte first, unmount only its renderer, and leave the
@@ -45,9 +59,12 @@ export const internals: {
45
59
  },
46
60
  unmount(): void {
47
61
  instance.unmount()
48
- // Pop the stack so the parent shell does not inherit enhanced key
49
- // reporting (Codex resets even harder on forced exit).
50
- process.stdout.write(KEYBOARD_ENHANCE_DISABLE + BRACKETED_PASTE_DISABLE)
62
+ // Pop only a stack this mount pushed, then disable bracketed paste.
63
+ process.stdout.write(
64
+ (keyboardEnhanced ? KEYBOARD_ENHANCE_DISABLE : '')
65
+ + BRACKETED_PASTE_DISABLE
66
+ + (focusReporting ? TERMINAL_FOCUS_REPORT_DISABLE : ''),
67
+ )
51
68
  },
52
69
  }
53
70
  },
package/src/keyboard.ts CHANGED
@@ -20,13 +20,56 @@
20
20
  export const KEYBOARD_ENHANCE_ENABLE = '\x1b[>4;0m\x1b[>5u'
21
21
 
22
22
  /** Pop the enhancement stack and reset modifyOtherKeys (exit path). */
23
- export const KEYBOARD_ENHANCE_DISABLE = '\x1b[<u\x1b[>4;0m'
24
-
25
- /** Enable bracketed paste reporting. */
26
- export const BRACKETED_PASTE_ENABLE = '\x1b[?2004h'
23
+ export const KEYBOARD_ENHANCE_DISABLE = '\x1b[<u\x1b[>4;0m'
24
+
25
+ /** Explicit environment overrides for terminal keyboard enhancement. */
26
+ export const DSH_DISABLE_KEYBOARD_ENHANCEMENT = 'DSH_DISABLE_KEYBOARD_ENHANCEMENT'
27
+ export const DSH_ENABLE_KEYBOARD_ENHANCEMENT = 'DSH_ENABLE_KEYBOARD_ENHANCEMENT'
28
+
29
+ function parseBooleanEnv(value: string | undefined): boolean | undefined {
30
+ if (value === undefined) return undefined
31
+ const normalized = value.trim().toLowerCase()
32
+ if (normalized === '1' || normalized === 'true' || normalized === 'yes') return true
33
+ if (normalized === '0' || normalized === 'false' || normalized === 'no') return false
34
+ return undefined
35
+ }
36
+
37
+ /** True when the process is running inside the VS Code integrated terminal. */
38
+ export function isVsCodeTerminalEnv(env: NodeJS.ProcessEnv = process.env): boolean {
39
+ return env.TERM_PROGRAM?.trim().toLowerCase() === 'vscode' || env.VSCODE_INJECTION === '1'
40
+ }
41
+
42
+ /** Whether to push Kitty keyboard enhancement for the current terminal. */
43
+ export function shouldEnableKeyboardEnhancement(env: NodeJS.ProcessEnv = process.env): boolean {
44
+ const explicitDisable = parseBooleanEnv(env[DSH_DISABLE_KEYBOARD_ENHANCEMENT])
45
+ if (explicitDisable === true) return false
46
+ const explicitEnable = parseBooleanEnv(env[DSH_ENABLE_KEYBOARD_ENHANCEMENT])
47
+ if (explicitEnable !== undefined) return explicitEnable
48
+ return !isVsCodeTerminalEnv(env)
49
+ }
27
50
 
28
- /** Disable bracketed paste reporting. */
29
- export const BRACKETED_PASTE_DISABLE = '\x1b[?2004l'
51
+ /** Enable bracketed paste reporting. */
52
+ export const BRACKETED_PASTE_ENABLE = '\x1b[?2004h'
53
+
54
+ /** Disable bracketed paste reporting. */
55
+ export const BRACKETED_PASTE_DISABLE = '\x1b[?2004l'
56
+
57
+ /** Enable terminal focus-in/focus-out reporting (xterm focus protocol). */
58
+ export const TERMINAL_FOCUS_REPORT_ENABLE = '\x1b[?1004h'
59
+
60
+ /** Disable terminal focus-in/focus-out reporting. */
61
+ export const TERMINAL_FOCUS_REPORT_DISABLE = '\x1b[?1004l'
62
+
63
+ /**
64
+ * Remove xterm focus reports from one input chunk and update the caller's
65
+ * focus state. Focus reports are terminal protocol, not composer text.
66
+ */
67
+ export function stripTerminalFocusEvents(chunk: string, onFocus: (focused: boolean) => void): string {
68
+ return chunk.replace(/\x1b\[(I|O)/gu, (_whole, event: string) => {
69
+ onFocus(event === 'I')
70
+ return ''
71
+ })
72
+ }
30
73
 
31
74
  /** Bracketed paste markers as Ink delivers them (it strips the leading ESC). */
32
75
  export const PASTE_START_MARKER = '[200~'
@@ -123,4 +166,85 @@ export function normalizeKeyboardChunk(chunk: string): string {
123
166
  })
124
167
  return legacy ?? whole
125
168
  })
126
- }
169
+ }
170
+
171
+ /** Editor actions Ink cannot distinguish reliably when terminal bytes batch. */
172
+ export type RawEditorToken =
173
+ | { readonly kind: 'text'; readonly text: string }
174
+ | { readonly kind: 'home' | 'end' | 'delete-backward' | 'delete-word-backward' | 'delete-forward' | 'delete-word-forward' }
175
+
176
+ const HOME_SEQUENCES = ['\x1b[H', '\x1b[1~', '\x1b[7~', '\x1bOH'] as const
177
+ const END_SEQUENCES = ['\x1b[F', '\x1b[4~', '\x1b[8~', '\x1bOF'] as const
178
+
179
+ /** Parse one CSI functional-key sequence at `offset`. */
180
+ function functionalToken(chunk: string, offset: number): { token: RawEditorToken; length: number } | undefined {
181
+ const tail = chunk.slice(offset)
182
+ for (const sequence of HOME_SEQUENCES) {
183
+ if (tail.startsWith(sequence)) return { token: { kind: 'home' }, length: sequence.length }
184
+ }
185
+ for (const sequence of END_SEQUENCES) {
186
+ if (tail.startsWith(sequence)) return { token: { kind: 'end' }, length: sequence.length }
187
+ }
188
+ const modifiedHome = /^\x1b\[1;(\d+)H/u.exec(tail)
189
+ if (modifiedHome !== null) return { token: { kind: 'home' }, length: modifiedHome[0].length }
190
+ const modifiedEnd = /^\x1b\[1;(\d+)F/u.exec(tail)
191
+ if (modifiedEnd !== null) return { token: { kind: 'end' }, length: modifiedEnd[0].length }
192
+ const modifiedDelete = /^\x1b\[3(?:;(\d+))?~/u.exec(tail)
193
+ if (modifiedDelete !== null) {
194
+ const modifiers = Number.parseInt(modifiedDelete[1] ?? '1', 10) - 1
195
+ const byWord = (modifiers & 2) !== 0 || (modifiers & 4) !== 0
196
+ return {
197
+ token: { kind: byWord ? 'delete-word-forward' : 'delete-forward' },
198
+ length: modifiedDelete[0].length,
199
+ }
200
+ }
201
+ return undefined
202
+ }
203
+
204
+ /**
205
+ * Tokenize a stdin chunk containing at least one editor-only key. Ink calls
206
+ * `useInput` once for a pasted/batched chunk, so preserving each action here
207
+ * prevents repeated Backspace/Home/End/Delete presses from collapsing into
208
+ * one blurred key event. Unknown escape sequences return undefined and stay
209
+ * under Ink's ownership.
210
+ */
211
+ export function tokenizeRawEditorChunk(chunk: string): readonly RawEditorToken[] | undefined {
212
+ const tokens: RawEditorToken[] = []
213
+ let text = ''
214
+ let special = false
215
+ const flushText = (): void => {
216
+ if (text === '') return
217
+ tokens.push({ kind: 'text', text })
218
+ text = ''
219
+ }
220
+ for (let offset = 0; offset < chunk.length;) {
221
+ if (chunk.startsWith('\x1b\x7f', offset) || chunk.startsWith('\x1b\b', offset)) {
222
+ flushText()
223
+ tokens.push({ kind: 'delete-word-backward' })
224
+ special = true
225
+ offset += 2
226
+ continue
227
+ }
228
+ const functional = functionalToken(chunk, offset)
229
+ if (functional !== undefined) {
230
+ flushText()
231
+ tokens.push(functional.token)
232
+ special = true
233
+ offset += functional.length
234
+ continue
235
+ }
236
+ const char = chunk[offset]!
237
+ if (char === '\x7f' || char === '\b') {
238
+ flushText()
239
+ tokens.push({ kind: 'delete-backward' })
240
+ special = true
241
+ offset += 1
242
+ continue
243
+ }
244
+ if (char === '\x1b') return undefined
245
+ text += char
246
+ offset += 1
247
+ }
248
+ flushText()
249
+ return special ? tokens : undefined
250
+ }
package/src/mentions.ts CHANGED
@@ -75,6 +75,11 @@ interface FileReferenceServiceLike {
75
75
  /** Menu cap on file rows; the service owns ranking and default rows. */
76
76
  const MAX_FILE_ROWS = 20
77
77
 
78
+ /** Whether a mention token is already navigating a filesystem path. */
79
+ export function isPathLikeMentionQuery(query: string): boolean {
80
+ return /[\\/]/u.test(query)
81
+ }
82
+
78
83
  /** The mention API the input editor and the runner share. */
79
84
  export interface MentionsApi {
80
85
  /** Ranked menu candidates for the typed `@` query. */
@@ -136,7 +141,7 @@ export function createMentions(ctx: Context, agent: Agent | undefined, cwd: stri
136
141
  : agent === undefined
137
142
  ? preSessionFiles(needle, signal).catch(() => [] as readonly ServiceFileCandidate[])
138
143
  : Promise.resolve([] as readonly ServiceFileCandidate[]),
139
- sessionCapable && needle !== '' && agent !== undefined
144
+ sessionCapable && needle !== '' && !isPathLikeMentionQuery(needle) && agent !== undefined
140
145
  ? resolver!.listCandidates(agent, needle, 10, signal).catch(() => [] as readonly SessionReferenceCandidate[])
141
146
  : Promise.resolve([] as readonly SessionReferenceCandidate[]),
142
147
  ])
@@ -1,9 +1,8 @@
1
1
  /**
2
- * Terminal animation frame tables derived from the web design language:
3
- * the StateDot "ongoing" pixel chase (3×3 ring, 125ms flat-hold brightness
4
- * steps, 1s cycle) becomes the full-ring clockwise braille chase in
5
- * {@link BUSY_CHASE_FRAMES}, and the streaming caret blink is the
6
- * Claude-Code convention.
2
+ * Terminal animation helpers derived from the web design language:
3
+ * thinking uses Codex's slow shimmer sweep, the busy composer marker uses the
4
+ * original braille chase, and the streaming caret blink is the Claude-Code
5
+ * convention.
7
6
  *
8
7
  * The DeepSeek model-switch easter egg ports Codex's effort-ignition "Wave"
9
8
  * style (`codex-rs/tui/src/bottom_pane/effort_ignition_styles.rs`): switching
@@ -19,18 +18,66 @@
19
18
 
20
19
  import type { RgbTriple } from '../theme.ts'
21
20
 
22
- /**
23
- * The web StateDot "ongoing" chase in terminal form: three cells of the 3×3
24
- * ring trail clockwise around the eight outer positions, one braille glyph
25
- * per step 8 frames × 125ms = the web's 1s cycle.
26
- */
27
- export const BUSY_CHASE_FRAMES = ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'] as const
28
-
29
- /** Chase frame for a monotonic tick (the busy composer/Deep-diving marker). */
30
- export function busyChaseFrame(tick: number): string {
31
- return BUSY_CHASE_FRAMES[tick % BUSY_CHASE_FRAMES.length] ?? BUSY_CHASE_FRAMES[0]
32
- }
33
-
21
+ /** Cadence for the original busy braille chase (8 frames × 125ms = 1s). */
22
+ export const BUSY_CHASE_TICK_MS = 125
23
+
24
+ /** Original terminal StateDot chase frames. */
25
+ export const BUSY_CHASE_FRAMES = ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'] as const
26
+
27
+ /** Chase frame for a monotonic tick. */
28
+ export function busyChaseFrame(tick: number): string {
29
+ return BUSY_CHASE_FRAMES[tick % BUSY_CHASE_FRAMES.length] ?? BUSY_CHASE_FRAMES[0]
30
+ }
31
+
32
+ /** Clock cadence for the Codex-style Deep diving shimmer. */
33
+ export const DEEP_DIVING_SHIMMER_TICK_MS = 33
34
+
35
+ /** Codex shimmer timing and geometry. */
36
+ export const DEEP_DIVING_SHIMMER_DURATION_MS = 2_000
37
+ export const DEEP_DIVING_SHIMMER_PADDING = 10
38
+ export const DEEP_DIVING_SHIMMER_HALF_WIDTH = 5
39
+ export const DEEP_DIVING_SPARK_BREATH_DURATION_MS = 2_000
40
+
41
+ /**
42
+ * Codex's 2-second shimmer sweep, expressed in terminal ticks. The sweep has
43
+ * ten virtual columns of padding on either side and a five-column cosine
44
+ * highlight band, so the text changes gently rather than cycling rapidly.
45
+ */
46
+ export function deepDivingShimmerIntensity(index: number, tick: number, graphemeCount: number): number {
47
+ if (graphemeCount <= 0) return 0
48
+ const period = graphemeCount + DEEP_DIVING_SHIMMER_PADDING * 2
49
+ const elapsed = ((tick * DEEP_DIVING_SHIMMER_TICK_MS) % DEEP_DIVING_SHIMMER_DURATION_MS + DEEP_DIVING_SHIMMER_DURATION_MS) % DEEP_DIVING_SHIMMER_DURATION_MS
50
+ const position = elapsed / DEEP_DIVING_SHIMMER_DURATION_MS * period
51
+ const distance = Math.abs(index + DEEP_DIVING_SHIMMER_PADDING - position)
52
+ if (distance > DEEP_DIVING_SHIMMER_HALF_WIDTH) return 0
53
+ const angle = Math.PI * distance / DEEP_DIVING_SHIMMER_HALF_WIDTH
54
+ return 0.5 * (1 + Math.cos(angle))
55
+ }
56
+
57
+ /** Blue RGB color for one grapheme in the Codex-style shimmer. */
58
+ export function deepDivingGradientColor(
59
+ index: number,
60
+ tick: number,
61
+ graphemeCount: number,
62
+ base: RgbTriple,
63
+ highlight: RgbTriple,
64
+ ): RgbTriple {
65
+ return blendRgb(highlight, base, deepDivingShimmerIntensity(index, tick, graphemeCount))
66
+ }
67
+
68
+ /** Smooth breathing intensity for the always-visible Deep diving sparkle. */
69
+ export function deepDivingSparkIntensity(tick: number): number {
70
+ const elapsed = ((tick * DEEP_DIVING_SHIMMER_TICK_MS) % DEEP_DIVING_SPARK_BREATH_DURATION_MS
71
+ + DEEP_DIVING_SPARK_BREATH_DURATION_MS) % DEEP_DIVING_SPARK_BREATH_DURATION_MS
72
+ const phase = elapsed / DEEP_DIVING_SPARK_BREATH_DURATION_MS
73
+ return 0.2 + 0.8 * (0.5 + 0.5 * Math.cos(Math.PI * 2 * phase))
74
+ }
75
+
76
+ /** Blue RGB color for the breathing Deep diving sparkle. */
77
+ export function deepDivingSparkColor(tick: number, base: RgbTriple, highlight: RgbTriple): RgbTriple {
78
+ return blendRgb(highlight, base, deepDivingSparkIntensity(tick))
79
+ }
80
+
34
81
  /** Caret visibility: half the ticks on, half off (530ms blink). */
35
82
  export function caretVisible(tick: number): boolean {
36
83
  return tick % 2 === 0
@@ -8,9 +8,8 @@
8
8
  * grapheme boundaries) so the React state stays two primitives
9
9
  * (value, cursor) and every operation here stays pure and testable.
10
10
  *
11
- * Word motion deviates from Codex's UAX#29 segmentation in one deliberate
12
- * way: a run of same-class characters is ONE piece, so a CJK run moves as a
13
- * single word (two hanzi are one Alt+B step, not two).
11
+ * Word motion follows Codex's piece semantics: whitespace separates runs,
12
+ * punctuation runs stay atomic, and each Han grapheme is its own boundary.
14
13
  *
15
14
  * @module @deepseek-ai/dsh-code/render/editor
16
15
  */
@@ -203,6 +202,31 @@ export interface CaretSite {
203
202
  column: number
204
203
  }
205
204
 
205
+ /** Text slices for rendering one physical row with at most one caret. */
206
+ export interface EditorRowParts {
207
+ readonly before: string
208
+ readonly caret: string
209
+ readonly after: string
210
+ readonly hasCaret: boolean
211
+ }
212
+
213
+ /** Split one row around the authoritative caret; every other row stays whole. */
214
+ export function editorRowParts(
215
+ row: EditorRowModel,
216
+ rowIndex: number,
217
+ caretRow: number,
218
+ cursor: number,
219
+ caretEnabled = true,
220
+ ): EditorRowParts {
221
+ if (!caretEnabled || rowIndex !== caretRow) return { before: '', caret: '', after: row.text, hasCaret: false }
222
+ const at = row.offsets.indexOf(cursor)
223
+ if (at < 0) return { before: '', caret: '', after: row.text, hasCaret: false }
224
+ const before = at > 0 ? row.text.slice(0, row.cuts[at]!) : ''
225
+ const caret = at < row.cuts.length - 1 ? row.text.slice(row.cuts[at]!, row.cuts[at + 1]!) : ' '
226
+ const after = at < row.cuts.length - 1 ? row.text.slice(row.cuts[at + 1]!) : ''
227
+ return { before, caret, after, hasCaret: true }
228
+ }
229
+
206
230
  /** Map a cursor offset to its caret site on the wrapped rows. */
207
231
  export function caretSite(model: EditorModel, offset: number): CaretSite {
208
232
  const target = Math.max(0, Math.min(model.length, offset))
@@ -223,7 +247,9 @@ export function caretSite(model: EditorModel, offset: number): CaretSite {
223
247
  export function moveCursorVertically(model: EditorModel, offset: number, preferredColumn: number, delta: number): number {
224
248
  const site = caretSite(model, offset)
225
249
  const target = site.row + delta
226
- if (target < 0 || target >= model.rows.length || delta === 0) return offset
250
+ if (delta === 0) return offset
251
+ if (target < 0) return 0
252
+ if (target >= model.rows.length) return model.length
227
253
  const row = model.rows[target]!
228
254
  const wanted = Math.max(0, Math.min(preferredColumn, row.columns[row.columns.length - 1]!))
229
255
  let best = 0
@@ -247,22 +273,26 @@ const WORD_SEPARATORS = new Set('`~!@#$%^&*()-=+[{]}\\|;:\'",.<>/?')
247
273
 
248
274
  type PieceClass = 'space' | 'punct' | 'word'
249
275
 
276
+ const HAN_GRAPHEME = /^\p{Script=Han}(?:\p{Mark}|\uFE0F)*$/u
277
+ const UNICODE_PUNCTUATION = /^\p{P}+$/u
278
+
250
279
  function classifyGrapheme(text: string): PieceClass {
251
280
  if (/^\s$/u.test(text)) return 'space'
252
- return WORD_SEPARATORS.has(text) ? 'punct' : 'word'
281
+ return WORD_SEPARATORS.has(text) || UNICODE_PUNCTUATION.test(text) ? 'punct' : 'word'
253
282
  }
254
283
 
255
- /** Maximal same-class runs of graphemes as [start, end) spans. */
256
- function pieceRuns(value: string): readonly { start: number; end: number; class: PieceClass }[] {
257
- const runs: { start: number; end: number; class: PieceClass }[] = []
258
- let current: { start: number; end: number; class: PieceClass } | undefined
284
+ /** Maximal same-class runs; Han graphemes deliberately stay one run each. */
285
+ function pieceRuns(value: string): readonly { start: number; end: number; class: PieceClass; atomic: boolean }[] {
286
+ const runs: { start: number; end: number; class: PieceClass; atomic: boolean }[] = []
287
+ let current: { start: number; end: number; class: PieceClass; atomic: boolean } | undefined
259
288
  for (const span of splitGraphemes(value)) {
260
289
  const klass = span.text === '\n' ? 'space' : classifyGrapheme(span.text)
261
- if (current !== undefined && current.class === klass) {
290
+ const atomic = klass === 'word' && HAN_GRAPHEME.test(span.text)
291
+ if (current !== undefined && current.class === klass && !current.atomic && !atomic) {
262
292
  current.end = span.end
263
293
  continue
264
294
  }
265
- current = { start: span.start, end: span.end, class: klass }
295
+ current = { start: span.start, end: span.end, class: klass, atomic }
266
296
  runs.push(current)
267
297
  }
268
298
  return runs
@@ -273,11 +303,11 @@ function pieceRuns(value: string): readonly { start: number; end: number; class:
273
303
  * START of the trailing non-space piece (extending over separator pieces).
274
304
  */
275
305
  export function moveWordLeft(value: string, offset: number): number {
276
- const cursor = Math.max(0, Math.min(value.length, offset))
306
+ const cursor = clampCursor(value, offset)
277
307
  const runs = pieceRuns(value)
278
- // Index of the last run that ends at or before the cursor.
279
- let index = runs.length - 1
280
- while (index >= 0 && runs[index]!.end > cursor) index -= 1
308
+ // The last run with content before the cursor includes the current word's
309
+ // left-hand fragment, instead of skipping the whole containing run.
310
+ let index = runs.findLastIndex(run => run.start < cursor)
281
311
  if (index < 0) return 0
282
312
  if (runs[index]!.class === 'space') {
283
313
  index -= 1
@@ -293,10 +323,11 @@ export function moveWordLeft(value: string, offset: number): number {
293
323
  * the leading non-space piece (extending over separator pieces).
294
324
  */
295
325
  export function moveWordRight(value: string, offset: number): number {
296
- const cursor = Math.max(0, Math.min(value.length, offset))
326
+ const cursor = clampCursor(value, offset)
297
327
  const runs = pieceRuns(value)
298
- let index = 0
299
- while (index < runs.length && runs[index]!.start < cursor) index += 1
328
+ // The first run with content after the cursor includes the current word's
329
+ // right-hand fragment, instead of jumping straight to the following word.
330
+ let index = runs.findIndex(run => run.end > cursor)
300
331
  if (index >= runs.length) return value.length
301
332
  if (runs[index]!.class === 'space') {
302
333
  index += 1
@@ -377,6 +408,75 @@ export function insertText(value: string, cursor: number, text: string): EditRes
377
408
  return { value: value.slice(0, cursor) + safe + value.slice(cursor), cursor: cursor + safe.length, killed: undefined }
378
409
  }
379
410
 
411
+ /** Ctrl+A: current logical line start, then the previous line start on repeat. */
412
+ export function moveToLineStart(value: string, cursor: number, crossOnRepeat: boolean): number {
413
+ const site = clampCursor(value, cursor)
414
+ const bounds = lineBounds(value, site)
415
+ if (!crossOnRepeat || site !== bounds.start || bounds.start === 0) return bounds.start
416
+ return lineBounds(value, bounds.start - 1).start
417
+ }
418
+
419
+ /** Ctrl+E: current logical line end, then the next line end on repeat. */
420
+ export function moveToLineEnd(value: string, cursor: number, crossOnRepeat: boolean): number {
421
+ const site = clampCursor(value, cursor)
422
+ const bounds = lineBounds(value, site)
423
+ if (!crossOnRepeat || site !== bounds.end || bounds.end >= value.length) return bounds.end
424
+ return lineBounds(value, bounds.end + 1).end
425
+ }
426
+
427
+ /** One stable text range captured before an asynchronous draft operation. */
428
+ export interface DraftRange {
429
+ readonly start: number
430
+ readonly end: number
431
+ }
432
+
433
+ /**
434
+ * Remap a captured range when all intervening edits are wholly before or
435
+ * wholly after it. An edit overlapping either boundary invalidates the
436
+ * anchor instead of guessing and inserting content at a surprising place.
437
+ */
438
+ export function remapStableRange(original: string, current: string, range: DraftRange): DraftRange | undefined {
439
+ const start = Math.max(0, Math.min(original.length, range.start))
440
+ const end = Math.max(start, Math.min(original.length, range.end))
441
+ if (original === current) return { start, end }
442
+ let prefix = 0
443
+ const shared = Math.min(original.length, current.length)
444
+ while (prefix < shared && original[prefix] === current[prefix]) prefix += 1
445
+ let suffix = 0
446
+ while (suffix < original.length - prefix
447
+ && suffix < current.length - prefix
448
+ && original[original.length - 1 - suffix] === current[current.length - 1 - suffix]) suffix += 1
449
+ const oldChangedEnd = original.length - suffix
450
+ if (prefix >= end) return { start, end }
451
+ if (oldChangedEnd <= start) {
452
+ const delta = current.length - original.length
453
+ return { start: start + delta, end: end + delta }
454
+ }
455
+ return undefined
456
+ }
457
+
458
+ /** Replace a current range while preserving a cursor moved after capture. */
459
+ export function replaceRangePreservingCursor(
460
+ value: string,
461
+ cursor: number,
462
+ range: DraftRange,
463
+ replacement: string,
464
+ ): EditResult {
465
+ const start = Math.max(0, Math.min(value.length, range.start))
466
+ const end = Math.max(start, Math.min(value.length, range.end))
467
+ const site = clampCursor(value, cursor)
468
+ const nextCursor = site <= start
469
+ ? site
470
+ : site >= end
471
+ ? site + replacement.length - (end - start)
472
+ : start + replacement.length
473
+ return {
474
+ value: value.slice(0, start) + replacement + value.slice(end),
475
+ cursor: nextCursor,
476
+ killed: undefined,
477
+ }
478
+ }
479
+
380
480
  /**
381
481
  * Composer editor row budget: the editor itself never grows past this many
382
482
  * physical rows; deeper drafts scroll internally to keep the caret visible.
@@ -387,12 +487,12 @@ export function composerMaxRows(terminalRows: number): number {
387
487
  }
388
488
 
389
489
  /**
390
- * Codex `should_handle_navigation`: Up/Down walk history only from an empty
391
- * draft, or from a boundary of a draft that still exactly matches the last
392
- * recalled entry. Any interior cursor position keeps vertical caret movement.
490
+ * History navigation starts with Up on an empty draft, or after visual
491
+ * movement has reached the directional text edge of an unchanged recalled
492
+ * entry. Every other position remains under textarea movement.
393
493
  */
394
- export function shouldRecallNavigate(value: string, cursor: number, lastRecalled: string | null): boolean {
395
- if (value === '') return true
396
- if (cursor !== 0 && cursor !== value.length) return false
397
- return lastRecalled === value
494
+ export function shouldRecallNavigate(value: string, cursor: number, lastRecalled: string | null, direction: -1 | 1): boolean {
495
+ if (value === '') return direction < 0
496
+ if (lastRecalled !== value) return false
497
+ return direction < 0 ? cursor === 0 : cursor === value.length
398
498
  }
@@ -236,6 +236,18 @@ function toolDetailLines(detail: ToolDetail, columns: number): readonly StyledLi
236
236
  }
237
237
  }
238
238
 
239
+ /** Default compact tool-card window used while the Ctrl+R fold is closed. */
240
+ const DEFAULT_TOOL_ROWS = 3
241
+
242
+ /** Keep the invocation visible while making hidden tool output discoverable. */
243
+ function compactToolLines(lines: readonly StyledLine[], columns: number): readonly StyledLine[] {
244
+ if (lines.length <= DEFAULT_TOOL_ROWS) return lines
245
+ return [
246
+ ...lines.slice(0, DEFAULT_TOOL_ROWS - 1),
247
+ ...textLines(' … output hidden · Ctrl+R', columns, 'dim').slice(0, 1),
248
+ ]
249
+ }
250
+
239
251
  /**
240
252
  * Convert one durable transcript entry to its complete scrollable row model.
241
253
  * The source entry stays intact; only the caller's visible slice is rendered.
@@ -247,6 +259,7 @@ export function transcriptEntryLines(
247
259
  columns: number,
248
260
  showReasoning = true,
249
261
  reasoningToggleHint = true,
262
+ showToolDetails = showReasoning,
250
263
  ): readonly StyledLine[] {
251
264
  const width = Math.max(1, Math.floor(columns))
252
265
  switch (entry.kind) {
@@ -278,7 +291,7 @@ export function transcriptEntryLines(
278
291
  const mark = entry.state === 'running' ? '●' : entry.state === 'error' ? '⨯' : '⏺'
279
292
  const markStyle: LineStyle = entry.state === 'running' ? 'brand' : entry.state === 'error' ? 'error' : 'success'
280
293
  const summaryStyle: LineStyle = entry.state === 'error' ? 'error' : 'dim'
281
- return [
294
+ const lines = [
282
295
  // The invocation row hangs wrapped previews under the call badge.
283
296
  ...hangingStyledLines([
284
297
  // Global call ordinal — the same number an error line references.
@@ -295,6 +308,7 @@ export function transcriptEntryLines(
295
308
  )),
296
309
  ...(entry.detail === undefined ? [] : toolDetailLines(entry.detail, width)),
297
310
  ]
311
+ return showToolDetails ? lines : compactToolLines(lines, width)
298
312
  }
299
313
  case 'command': {
300
314
  const mark = entry.state === 'running' ? '●' : entry.state === 'error' ? '⨯' : '⏺'
@@ -338,5 +352,5 @@ export function transcriptEntryLines(
338
352
 
339
353
  /** Settled-history variant carrying the Ctrl+R reasoning fold. */
340
354
  export function settledEntryLines(entry: TranscriptEntry, columns: number, showReasoning: boolean): readonly StyledLine[] {
341
- return transcriptEntryLines(entry, columns, showReasoning, false)
355
+ return transcriptEntryLines(entry, columns, showReasoning, false, showReasoning)
342
356
  }