dsh-code 1.0.0 → 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.
@@ -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
  }
@@ -336,9 +336,13 @@ function imagesOf(content: readonly ContentBlock[]): readonly ImageBlock['attach
336
336
  export function imageLabels(images: readonly ImageBlock['attachment'][] | undefined): string {
337
337
  if (images === undefined || images.length === 0) return ''
338
338
  return images.map((image, index) => {
339
- const rawName = image.name?.trim() || `image ${index + 1}`
340
- const name = rawName.length <= 80 ? rawName : `${rawName.slice(0, 79)}…`
341
- return `[image: ${name} · ${image.width}×${image.height} · ${image.bytes} B]`
339
+ const rawName = image.name?.trim() || `image ${index + 1}`
340
+ const name = rawName.length <= 80 ? rawName : `${rawName.slice(0, 79)}…`
341
+ const original = image.originalDimensions
342
+ const dimensions = original === undefined
343
+ ? `${image.width}×${image.height}`
344
+ : `${image.width}×${image.height} · original ${original.width}×${original.height}`
345
+ return `[image: ${name} · ${dimensions} · ${image.bytes} B]`
342
346
  }).join('\n')
343
347
  }
344
348
 
@@ -55,12 +55,12 @@ function formatRate(n: number): string {
55
55
  /**
56
56
  * Cache-hit share of billed prompt-side input.
57
57
  * @param usage - cumulative token totals.
58
- * @returns rounded integer percent, or null when no input was billed.
58
+ * @returns percent rounded to one decimal place, or null when no input was billed.
59
59
  */
60
60
  export function cacheHitPercent(usage: TranscriptStats['usage']): number | null {
61
61
  return usage.inputTokens === 0
62
62
  ? null
63
- : Math.round(usage.cacheReadTokens / usage.inputTokens * 100)
63
+ : Math.round(usage.cacheReadTokens / usage.inputTokens * 1_000) / 10
64
64
  }
65
65
 
66
66
  /**
@@ -162,7 +162,7 @@ export function mergeSessionTitles(
162
162
  const titles = new Map<string, string>()
163
163
  for (const observation of observations) {
164
164
  if (observation.status !== 'fulfilled') continue
165
- const title = observation.value?.title?.title ?? observation.value?.title?.text
165
+ const title = observation.value?.title?.title
166
166
  if (title !== undefined && title.trim() !== '') titles.set(observation.sessionId, title)
167
167
  }
168
168
  return rows.map(row => titles.has(row.id) ? { ...row, title: titles.get(row.id) } : row)