dsh-code 1.0.1 → 1.0.3

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.
Files changed (44) hide show
  1. package/README.en.md +21 -13
  2. package/README.md +21 -13
  3. package/lib/index.mjs +1902 -1092
  4. package/lib/types/app.d.ts +4 -13
  5. package/lib/types/attachments.d.ts +1 -1
  6. package/lib/types/editor-keys.d.ts +105 -0
  7. package/lib/types/git-workflow.d.ts +6 -2
  8. package/lib/types/keyboard.d.ts +31 -0
  9. package/lib/types/mentions.d.ts +2 -0
  10. package/lib/types/model-capabilities.d.ts +82 -0
  11. package/lib/types/provider-settings.d.ts +7 -0
  12. package/lib/types/render/animations.d.ts +27 -11
  13. package/lib/types/render/editor.d.ts +32 -7
  14. package/lib/types/render/lines.d.ts +26 -1
  15. package/lib/types/render/markdown.d.ts +1 -1
  16. package/lib/types/render/projection.d.ts +15 -1
  17. package/lib/types/render/text.d.ts +15 -9
  18. package/lib/types/render/width.d.ts +29 -0
  19. package/lib/types/session-directory.d.ts +27 -0
  20. package/lib/types/settings-file.d.ts +33 -0
  21. package/lib/types/store.d.ts +10 -0
  22. package/lib/types/subagents.d.ts +13 -3
  23. package/package.json +159 -159
  24. package/src/app.ts +920 -764
  25. package/src/attachments.ts +7 -0
  26. package/src/editor-keys.ts +371 -0
  27. package/src/git-workflow.ts +10 -6
  28. package/src/index.ts +1637 -1523
  29. package/src/internals.ts +26 -9
  30. package/src/keyboard.ts +131 -7
  31. package/src/mentions.ts +6 -1
  32. package/src/model-capabilities.ts +318 -0
  33. package/src/provider-settings.ts +16 -0
  34. package/src/render/animations.ts +64 -17
  35. package/src/render/editor.ts +125 -25
  36. package/src/render/lines.ts +403 -342
  37. package/src/render/markdown.ts +4 -7
  38. package/src/render/projection.ts +63 -40
  39. package/src/render/text.ts +152 -150
  40. package/src/render/width.ts +189 -0
  41. package/src/session-directory.ts +56 -0
  42. package/src/settings-file.ts +56 -0
  43. package/src/store.ts +26 -7
  44. package/src/subagents.ts +39 -6
@@ -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
  }