dsh-code 1.0.6 → 1.2.0

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 (86) hide show
  1. package/README.en.md +123 -26
  2. package/README.md +124 -27
  3. package/bin/deepseek.mjs +283 -35
  4. package/cordis.patch.yml +97 -0
  5. package/lib/index.mjs +5008 -881
  6. package/lib/session-query.mjs +150 -0
  7. package/lib/startup.mjs +4 -4
  8. package/lib/{theme-DCT8Y2xf.mjs → theme-7u5Qo3dF.mjs} +657 -20
  9. package/lib/types/app.d.ts +106 -62
  10. package/lib/types/authorization-panel.d.ts +3 -3
  11. package/lib/types/git-workflow.d.ts +91 -2
  12. package/lib/types/i18n.d.ts +39 -0
  13. package/lib/types/index.d.ts +100 -1
  14. package/lib/types/input-split.d.ts +1 -1
  15. package/lib/types/kernel-panels.d.ts +107 -29
  16. package/lib/types/language-panel.d.ts +12 -0
  17. package/lib/types/locales/en.d.ts +450 -0
  18. package/lib/types/locales/zh.d.ts +9 -0
  19. package/lib/types/mentions.d.ts +7 -3
  20. package/lib/types/models.d.ts +14 -0
  21. package/lib/types/panel-accent.d.ts +28 -0
  22. package/lib/types/rainbow.d.ts +69 -0
  23. package/lib/types/render/animations.d.ts +42 -0
  24. package/lib/types/render/editor.d.ts +4 -3
  25. package/lib/types/render/ime-cursor.d.ts +60 -0
  26. package/lib/types/render/inspector.d.ts +26 -0
  27. package/lib/types/render/lines.d.ts +21 -1
  28. package/lib/types/render/markdown.d.ts +1 -1
  29. package/lib/types/render/projection.d.ts +130 -4
  30. package/lib/types/render/status.d.ts +9 -9
  31. package/lib/types/render/text.d.ts +6 -0
  32. package/lib/types/render/usage.d.ts +113 -0
  33. package/lib/types/session-directory.d.ts +17 -0
  34. package/lib/types/session-query.d.ts +92 -0
  35. package/lib/types/startup.d.ts +1 -1
  36. package/lib/types/terminal-title.d.ts +66 -0
  37. package/lib/types/theme-panel.d.ts +2 -2
  38. package/lib/types/theme.d.ts +271 -52
  39. package/lib/types/update-panel.d.ts +49 -0
  40. package/lib/types/update.d.ts +75 -0
  41. package/lib/types/version.d.ts +4 -3
  42. package/package.json +246 -90
  43. package/src/app.ts +1369 -509
  44. package/src/approval.ts +166 -166
  45. package/src/authorization-panel.ts +19 -16
  46. package/src/editor-keys.ts +371 -371
  47. package/src/git-workflow.ts +229 -3
  48. package/src/i18n.ts +68 -0
  49. package/src/index.ts +534 -80
  50. package/src/input-split.ts +3 -3
  51. package/src/internals.ts +5 -0
  52. package/src/kernel-panels.ts +554 -86
  53. package/src/keyboard.ts +5 -4
  54. package/src/language-panel.ts +53 -0
  55. package/src/locales/en.ts +489 -0
  56. package/src/locales/zh.ts +488 -0
  57. package/src/mentions.ts +8 -4
  58. package/src/models.ts +264 -212
  59. package/src/panel-accent.ts +41 -0
  60. package/src/presets.ts +1 -1
  61. package/src/provider-settings.ts +1 -1
  62. package/src/rainbow.ts +208 -0
  63. package/src/render/animations.ts +104 -6
  64. package/src/render/editor.ts +25 -24
  65. package/src/render/export.ts +116 -95
  66. package/src/render/ime-cursor.ts +147 -0
  67. package/src/render/inspector.ts +42 -0
  68. package/src/render/lines.ts +628 -415
  69. package/src/render/markdown.ts +15 -3
  70. package/src/render/projection.ts +572 -21
  71. package/src/render/status.ts +59 -39
  72. package/src/render/text.ts +14 -0
  73. package/src/render/tool-preview.ts +77 -77
  74. package/src/render/usage.ts +430 -0
  75. package/src/render/width.ts +2 -2
  76. package/src/session-directory.ts +8 -6
  77. package/src/session-query.ts +239 -0
  78. package/src/startup.ts +3 -3
  79. package/src/subagents.ts +229 -229
  80. package/src/terminal-title.ts +190 -0
  81. package/src/theme-panel.ts +17 -21
  82. package/src/theme.ts +281 -33
  83. package/src/update-panel.ts +256 -0
  84. package/src/update.ts +126 -0
  85. package/src/version.ts +58 -20
  86. package/src/whale-glyph.ts +23 -23
package/src/rainbow.ts ADDED
@@ -0,0 +1,208 @@
1
+ /**
2
+ * The rainbow theme's per-launch roll: a seeded shuffle over a twelve-color
3
+ * bright-tier spectrum that fills every palette role, the status-bar tone map
4
+ * (adjacent tones in canonical on-screen order never share a color, the
5
+ * row1/row2 boundary included), the panel accent ring, and full-spectrum
6
+ * flow anchors with a random phase. The roll happens once per launch and
7
+ * stays stable for the whole run; setting RAINBOW_SEED=<uint32> pins it for
8
+ * reproduction (the /theme notification prints the seed of the current roll).
9
+ *
10
+ * All pool colors clear AA body text (≥4.5:1) on a black terminal by a wide
11
+ * margin (11-17:1); diff rows pair a bright hue with its own 22% dark tint
12
+ * so the foreground stays 7.7-10.2:1 on its background.
13
+ *
14
+ * @module @deepseek-ai/dsh-tui/rainbow
15
+ */
16
+
17
+ import type { StatusTone } from './render/status.ts'
18
+ import type { RgbTriple, ThemePalette } from './theme.ts'
19
+
20
+ /**
21
+ * The bright-tier spectrum: twelve Tailwind *-300 hues covering red through
22
+ * fuchsia, every one ≥11:1 on black. The roll samples this pool by shuffle;
23
+ * nothing outside it is ever painted.
24
+ */
25
+ export const RAINBOW_POOL: readonly RgbTriple[] = [
26
+ [252, 165, 165],
27
+ [253, 186, 116],
28
+ [253, 230, 138],
29
+ [253, 224, 71],
30
+ [190, 242, 100],
31
+ [134, 239, 172],
32
+ [94, 234, 212],
33
+ [103, 232, 249],
34
+ [125, 211, 252],
35
+ [147, 197, 253],
36
+ [196, 181, 253],
37
+ [240, 171, 252],
38
+ ]
39
+
40
+ /**
41
+ * Status tones in canonical on-screen order (row one left-to-right, then row
42
+ * two): consecutive entries here are the pairs the "adjacent spans differ"
43
+ * guarantee covers, including the accent→value step across the row boundary.
44
+ */
45
+ const TONE_ORDER: readonly StatusTone[] = [
46
+ 'live', 'model', 'path', 'branch', 'accent',
47
+ 'value', 'label', 'meta', 'ctxFill', 'success', 'plan', 'warn', 'error',
48
+ ]
49
+
50
+ /** One per-launch carnival roll: everything the rainbow theme paints from. */
51
+ export interface RainbowRoll {
52
+ /** The seed this roll came from (print it to reproduce). */
53
+ readonly seed: number
54
+ /** The full 15-token palette for this launch. */
55
+ readonly palette: ThemePalette
56
+ /** The four-color panel ring (random order and start). */
57
+ readonly ring: readonly RgbTriple[]
58
+ /** Status-tone → color: adjacent tones in {@link TONE_ORDER} never match. */
59
+ readonly toneColors: Readonly<Record<StatusTone, RgbTriple>>
60
+ /** Full-spectrum flow anchors in hue order (a different subset every launch). */
61
+ readonly flowAnchors: readonly RgbTriple[]
62
+ /** Random phase offset into the flow lap, milliseconds. */
63
+ readonly flowPhaseMs: number
64
+ }
65
+
66
+ /** Parse a RAINBOW_SEED value: a non-negative decimal uint32, else nothing. */
67
+ export function parseRainbowSeed(value: string | undefined): number | undefined {
68
+ if (value === undefined || !/^\d{1,10}$/.test(value.trim())) return undefined
69
+ const parsed = Number(value.trim())
70
+ return Number.isSafeInteger(parsed) ? parsed >>> 0 : undefined
71
+ }
72
+
73
+ /**
74
+ * One parsed `/rainbow` argument: empty means a fresh random roll; a
75
+ * decimal uint32 pins that seed; anything else is a usage error.
76
+ * @param argument - the raw text after `/rainbow`.
77
+ * @returns `{ seed }`, `'random'`, or `'usage'`.
78
+ */
79
+ export function parseRainbowArgument(argument: string): { seed: number } | 'random' | 'usage' {
80
+ const trimmed = argument.trim()
81
+ if (trimmed === '') return 'random'
82
+ const parsed = parseRainbowSeed(trimmed)
83
+ return parsed === undefined ? 'usage' : { seed: parsed }
84
+ }
85
+
86
+ /** Deterministic 32-bit RNG (mulberry32) — one roll, one stream. */
87
+ function mulberry32(seed: number): () => number {
88
+ let state = seed >>> 0
89
+ return () => {
90
+ state = (state + 0x6d2b79f5) | 0
91
+ let t = Math.imul(state ^ (state >>> 15), 1 | state)
92
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
93
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
94
+ }
95
+ }
96
+
97
+ /** Fisher–Yates shuffle over a copy, driven by the roll's RNG. */
98
+ function shuffle<T>(items: readonly T[], rng: () => number): T[] {
99
+ const out = [...items]
100
+ for (let i = out.length - 1; i > 0; i -= 1) {
101
+ const j = Math.floor(rng() * (i + 1))
102
+ ;[out[i], out[j]] = [out[j], out[i]]
103
+ }
104
+ return out
105
+ }
106
+
107
+ /** Approximate HSL hue of one RGB triple, degrees in [0, 360). */
108
+ function hueOf([r, g, b]: RgbTriple): number {
109
+ const max = Math.max(r, g, b)
110
+ const min = Math.min(r, g, b)
111
+ if (max === min) return 0
112
+ const span = max - min
113
+ if (max === r) return (60 * (g - b) / span + 360) % 360
114
+ if (max === g) return (60 * (b - r) / span + 120) % 360
115
+ return (60 * (r - g) / span + 240) % 360
116
+ }
117
+
118
+ /** A dark row tint for diff backgrounds: the hue at 22% strength over black. */
119
+ function darkTint(hue: RgbTriple): RgbTriple {
120
+ return [Math.round(hue[0] * 0.22), Math.round(hue[1] * 0.22), Math.round(hue[2] * 0.22)]
121
+ }
122
+
123
+ /** Readability anchors shared by every roll: captions, text, band stay calm. */
124
+ const DIM: RgbTriple = [166, 163, 184]
125
+ const TEXT: RgbTriple = [240, 238, 249]
126
+ const BAND: RgbTriple = [46, 46, 52]
127
+
128
+ /**
129
+ * Build the roll for one seed: pure and deterministic, so tests pin exact
130
+ * outcomes and RAINBOW_SEED reproduces a lucky launch exactly.
131
+ * @param seed - the uint32 seed to roll from.
132
+ * @returns the complete carnival roll.
133
+ */
134
+ export function rollRainbow(seed: number): RainbowRoll {
135
+ const rng = mulberry32(seed)
136
+ const pool = shuffle(RAINBOW_POOL, rng)
137
+ const [brand, brandBright, brandMid, brandDeep, code, success, error, warn, addHue, delHue] = pool
138
+ const palette: ThemePalette = {
139
+ brand: brand,
140
+ brandBright: brandBright,
141
+ brandMid: brandMid,
142
+ brandDeep: brandDeep,
143
+ dim: DIM,
144
+ success: success,
145
+ error: error,
146
+ warn: warn,
147
+ text: TEXT,
148
+ code: code,
149
+ composerBand: BAND,
150
+ diffAdd: darkTint(addHue),
151
+ diffDel: darkTint(delHue),
152
+ diffAddFg: addHue,
153
+ diffDelFg: delHue,
154
+ // Prompt-row bars ride the roll's own colors: the bright brand hue for an
155
+ // ordinary prompt, the rolled warning hue for a queued one, and the rolled
156
+ // code hue for a steered one. Each bar is that color over the surface.
157
+ surface: [0, 0, 0],
158
+ prompt: brandBright,
159
+ queued: warn,
160
+ steered: code,
161
+ }
162
+ const ring = shuffle(RAINBOW_POOL, rng).slice(0, 4)
163
+ // Adjacency by construction: consecutive tones take consecutive slots of a
164
+ // 12-distinct shuffle, so neighbors can never match (13 tones wrap the
165
+ // first color onto 'error', which is never adjacent to 'live' on screen).
166
+ const offset = Math.floor(rng() * RAINBOW_POOL.length)
167
+ const toneColors = {} as Record<StatusTone, RgbTriple>
168
+ TONE_ORDER.forEach((tone, index) => {
169
+ toneColors[tone] = pool[(offset + index) % pool.length]!
170
+ })
171
+ const flowAnchors = [...RAINBOW_POOL].sort((a, b) => hueOf(a) - hueOf(b))
172
+ const flowPhaseMs = Math.floor(rng() * 2400)
173
+ return { seed, palette, ring, toneColors, flowAnchors, flowPhaseMs }
174
+ }
175
+
176
+ /** The launch seed: RAINBOW_SEED when parseable, else a fresh random roll. */
177
+ function launchSeed(): number {
178
+ const pinned = parseRainbowSeed(process.env.RAINBOW_SEED)
179
+ if (pinned !== undefined) return pinned
180
+ return (Date.now() ^ Math.floor(Math.random() * 0x100000000)) >>> 0
181
+ }
182
+
183
+ let rolled: RainbowRoll | undefined
184
+
185
+ /** The memoized per-launch roll; computed once, stable for the whole run. */
186
+ export function rainbowRoll(): RainbowRoll {
187
+ if (rolled === undefined) rolled = rollRainbow(launchSeed())
188
+ return rolled
189
+ }
190
+
191
+ /**
192
+ * Replace the memoized roll: omit the seed for a fresh random one, or pass
193
+ * a uint32 to pin it. `/rainbow` uses this so a mid-session reroll actually
194
+ * recolors; {@link setTheme}(`'rainbow'`) must run afterwards so the
195
+ * palette accessor picks the new values up.
196
+ * @param seed - the seed to pin, or undefined for a new random roll.
197
+ * @returns the roll now in force.
198
+ */
199
+ export function rerollRainbow(seed?: number): RainbowRoll {
200
+ const next = seed ?? ((Date.now() ^ Math.floor(Math.random() * 0x100000000)) >>> 0)
201
+ rolled = rollRainbow(next)
202
+ return rolled
203
+ }
204
+
205
+ /** The current roll's seed as display copy (also the RAINBOW_SEED value). */
206
+ export function rainbowSeedLabel(): string {
207
+ return String(rainbowRoll().seed)
208
+ }
@@ -91,6 +91,31 @@ export function deepDivingSparkColor(tick: number, base: RgbTriple, highlight: R
91
91
  return blendRgb(highlight, base, deepDivingSparkIntensity(tick))
92
92
  }
93
93
 
94
+ /** One full prismatic flow lap — lively: violet → fuchsia → cyan → violet in 2.4s. */
95
+ export const FLOW_PERIOD_MS = 2_400
96
+
97
+ /**
98
+ * The prismatic flow color at one elapsed-time sample: a smoothstep walk
99
+ * around the anchor triangle, one full lap per {@link FLOW_PERIOD_MS}. Pure —
100
+ * the Ink layer owns the timer and passes its tick scaled by its own cadence
101
+ * (tick × tickMs). Callers gate on the animations preference and fall back to
102
+ * a static palette color when animation is off.
103
+ * @param elapsedMs - milliseconds since the flow started (any sign or size).
104
+ * @param anchors - the colors to walk, in order (theme.ts FLOW_ANCHORS).
105
+ * @returns the interpolated anchor color at this instant.
106
+ */
107
+ export function flowColor(elapsedMs: number, anchors: readonly RgbTriple[]): RgbTriple {
108
+ if (anchors.length === 0) throw new Error('flowColor needs at least one anchor')
109
+ if (anchors.length === 1) return anchors[0]
110
+ const lap = ((elapsedMs % FLOW_PERIOD_MS) + FLOW_PERIOD_MS) % FLOW_PERIOD_MS
111
+ const span = FLOW_PERIOD_MS / anchors.length
112
+ const at = lap / span
113
+ const index = Math.floor(at)
114
+ const phase = at - index
115
+ const eased = phase * phase * (3 - 2 * phase)
116
+ return blendRgb(anchors[(index + 1) % anchors.length], anchors[index % anchors.length], eased)
117
+ }
118
+
94
119
  /** Caret blink cadence: one blink step (on or off) per tick. */
95
120
  export const CARET_BLINK_TICK_MS = 530
96
121
 
@@ -332,6 +357,79 @@ export function envelope(elapsed: number, total: number, fadeIn: number, fadeOut
332
357
  return Math.min(Math.max(Math.min(rise, fall), 0), 1)
333
358
  }
334
359
 
360
+ /**
361
+ * The /rainbow celebration on the three-row composer band: a FIXED
362
+ * seven-color spectrum (not the rolled palette) that slides across every
363
+ * row as one ribbon. Triggered when switching to rainbow or rerolling the
364
+ * seed; independent of RAINBOW_SEED so the burst always reads as a prism.
365
+ */
366
+ export const RAINBOW_BURST_TICK_MS = 33
367
+ export const RAINBOW_BURST_DURATION_MS = 1_800
368
+ export const RAINBOW_BURST_HUES: readonly RgbTriple[] = [
369
+ [255, 59, 48],
370
+ [255, 149, 0],
371
+ [255, 204, 0],
372
+ [52, 199, 89],
373
+ [0, 199, 190],
374
+ [10, 132, 255],
375
+ [175, 82, 222],
376
+ ]
377
+
378
+ /**
379
+ * Smoothstep in 0..1, then wrap-lerp around the seven burst hues.
380
+ * Position 0 is red, wrapping back toward red at 1. Shared by the
381
+ * composer burst and the static rainbow whale header.
382
+ */
383
+ export function rainbowSpectrumHue(position: number): RgbTriple {
384
+ const hues = RAINBOW_BURST_HUES
385
+ const x = ((position % 1) + 1) % 1
386
+ const scaled = x * hues.length
387
+ const index = Math.floor(scaled)
388
+ const t = scaled - index
389
+ const eased = t * t * (3 - 2 * t)
390
+ const a = hues[index % hues.length]
391
+ const b = hues[(index + 1) % hues.length]
392
+ return [
393
+ Math.round(a[0] + (b[0] - a[0]) * eased),
394
+ Math.round(a[1] + (b[1] - a[1]) * eased),
395
+ Math.round(a[2] + (b[2] - a[2]) * eased),
396
+ ]
397
+ }
398
+
399
+ /**
400
+ * Background tint for one composer-band cell during the rainbow burst.
401
+ * Every row of a column shares the same hue (a solid ribbon); edge rows
402
+ * are slightly dimmer so the middle editor row reads as the crest. The
403
+ * spectrum slides ~1.2 widths over the burst, then fades to the band base.
404
+ * @param tick - frame index at {@link RAINBOW_BURST_TICK_MS}.
405
+ * @param column - band column (0..width-1).
406
+ * @param width - band width in columns.
407
+ * @param base - the theme's composerBand color to blend toward.
408
+ * @param row - band row (0..rows-1).
409
+ * @param rows - band height (composer is three rows: pad, editor, pad).
410
+ * @returns the blended RGB, or null after the burst (or at zero alpha).
411
+ */
412
+ export function rainbowBurstColumnBg(
413
+ tick: number,
414
+ column: number,
415
+ width: number,
416
+ base: RgbTriple,
417
+ row = 0,
418
+ rows = 1,
419
+ ): RgbTriple | null {
420
+ const elapsed = tick * RAINBOW_BURST_TICK_MS / 1000
421
+ const total = RAINBOW_BURST_DURATION_MS / 1000
422
+ const fade = envelope(elapsed, total, 0.18, 0.45)
423
+ if (fade <= 0) return null
424
+ const span = Math.max(1, width - 1)
425
+ const slide = elapsed / total * 1.2
426
+ const mixed = rainbowSpectrumHue(column / span + slide)
427
+ const edge = rows > 1 && (row === 0 || row === rows - 1)
428
+ const alpha = Math.min(0.72 * fade * (edge ? 0.78 : 1), 0.70)
429
+ if (alpha < 0.02) return null
430
+ return blendRgb(mixed, base, alpha)
431
+ }
432
+
335
433
  /** Per-band sampling context: everything geometry needs beyond the timeline. */
336
434
  interface BandContext {
337
435
  /** Aspect-weighted row offset from the band's center row (Pulse 2-D ring). */
@@ -522,19 +620,19 @@ export function deepseekWaveColumnBg(
522
620
  let bandIndex = 0
523
621
  for (const band of DEEPSEEK_WAVE_BANDS[style][tier]) {
524
622
  for (const [hue, strength] of bandSample(style, band, elapsed, column, width, { dy, u, undulating, pulseSpan, bandIndex, total })) {
525
- weights[hue] = style === 'aurora' ? weights[hue]! + strength : Math.max(weights[hue]!, strength)
623
+ weights[hue] = style === 'aurora' ? weights[hue] + strength : Math.max(weights[hue], strength)
526
624
  }
527
625
  bandIndex += 1
528
626
  }
529
- const weight = weights[0]! + weights[1]! + weights[2]!
627
+ const weight = weights[0] + weights[1] + weights[2]
530
628
  if (weight <= 0.01) return null
531
629
  let red = 0
532
630
  let green = 0
533
631
  let blue = 0
534
632
  for (let index = 0; index < 3; index += 1) {
535
- red += weights[index]! * hues[index]![0]
536
- green += weights[index]! * hues[index]![1]
537
- blue += weights[index]! * hues[index]![2]
633
+ red += weights[index] * hues[index][0]
634
+ green += weights[index] * hues[index][1]
635
+ blue += weights[index] * hues[index][2]
538
636
  }
539
637
  const mixed: RgbTriple = [
540
638
  Math.round(red / weight),
@@ -590,7 +688,7 @@ export function deepseekWaveWordVisible(tick: number, tier: DeepseekWaveTier, st
590
688
  * @returns the hue for that character.
591
689
  */
592
690
  export function deepseekWaveWordHue(index: number, hues: readonly [RgbTriple, RgbTriple, RgbTriple]): RgbTriple {
593
- return hues[index % hues.length]!
691
+ return hues[index % hues.length]
594
692
  }
595
693
 
596
694
  /**
@@ -58,7 +58,7 @@ export function splitGraphemes(text: string): readonly GraphemeSpan[] {
58
58
  /** Round one UTF-16 offset down to the grapheme boundary at or before it. */
59
59
  function floorBoundary(spans: readonly GraphemeSpan[], offset: number): number {
60
60
  for (let index = spans.length - 1; index >= 0; index -= 1) {
61
- const span = spans[index]!
61
+ const span = spans[index]
62
62
  if (span.end <= offset) return span.end
63
63
  if (span.start < offset) return span.start
64
64
  }
@@ -85,7 +85,7 @@ export function clampCursor(value: string, offset: number): number {
85
85
  export function deleteLastGrapheme(text: string): string {
86
86
  if (text === '') return ''
87
87
  const spans = splitGraphemes(text)
88
- return text.slice(0, spans[spans.length - 1]!.start)
88
+ return text.slice(0, spans[spans.length - 1].start)
89
89
  }
90
90
 
91
91
  /**
@@ -100,7 +100,7 @@ export function moveCursorBy(value: string, offset: number, delta: number): numb
100
100
  const current = boundaries.indexOf(clampCursor(value, offset))
101
101
  if (current === -1) return clampCursor(value, offset)
102
102
  const next = Math.max(0, Math.min(boundaries.length - 1, current + delta))
103
- return boundaries[next]!
103
+ return boundaries[next]
104
104
  }
105
105
 
106
106
  /**
@@ -221,9 +221,9 @@ export function editorRowParts(
221
221
  if (!caretEnabled || rowIndex !== caretRow) return { before: '', caret: '', after: row.text, hasCaret: false }
222
222
  const at = row.offsets.indexOf(cursor)
223
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]!) : ''
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
227
  return { before, caret, after, hasCaret: true }
228
228
  }
229
229
 
@@ -231,12 +231,12 @@ export function editorRowParts(
231
231
  export function caretSite(model: EditorModel, offset: number): CaretSite {
232
232
  const target = Math.max(0, Math.min(model.length, offset))
233
233
  for (let index = model.rows.length - 1; index >= 0; index -= 1) {
234
- const row = model.rows[index]!
234
+ const row = model.rows[index]
235
235
  const at = row.offsets.indexOf(target)
236
- if (at >= 0) return { row: index, column: row.columns[at]! }
236
+ if (at >= 0) return { row: index, column: row.columns[at] }
237
237
  }
238
238
  const last = model.rows[model.rows.length - 1]
239
- return { row: model.rows.length - 1, column: last === undefined ? 0 : last.columns[last.columns.length - 1]! }
239
+ return { row: model.rows.length - 1, column: last === undefined ? 0 : last.columns[last.columns.length - 1] }
240
240
  }
241
241
 
242
242
  /**
@@ -250,14 +250,14 @@ export function moveCursorVertically(model: EditorModel, offset: number, preferr
250
250
  if (delta === 0) return offset
251
251
  if (target < 0) return 0
252
252
  if (target >= model.rows.length) return model.length
253
- const row = model.rows[target]!
254
- const wanted = Math.max(0, Math.min(preferredColumn, row.columns[row.columns.length - 1]!))
253
+ const row = model.rows[target]
254
+ const wanted = Math.max(0, Math.min(preferredColumn, row.columns[row.columns.length - 1]))
255
255
  let best = 0
256
256
  for (let index = 1; index < row.columns.length; index += 1) {
257
- if (row.columns[index]! <= wanted) best = index
257
+ if (row.columns[index] <= wanted) best = index
258
258
  else break
259
259
  }
260
- return row.offsets[best]!
260
+ return row.offsets[best]
261
261
  }
262
262
 
263
263
  /** The start/end offsets of the logical line containing the cursor. */
@@ -309,13 +309,13 @@ export function moveWordLeft(value: string, offset: number): number {
309
309
  // left-hand fragment, instead of skipping the whole containing run.
310
310
  let index = runs.findLastIndex(run => run.start < cursor)
311
311
  if (index < 0) return 0
312
- if (runs[index]!.class === 'space') {
312
+ if (runs[index].class === 'space') {
313
313
  index -= 1
314
314
  if (index < 0) return 0
315
315
  }
316
316
  let target = index
317
- while (target > 0 && runs[target]!.class === 'punct' && runs[target - 1]!.class === 'punct') target -= 1
318
- return runs[target]!.start
317
+ while (target > 0 && runs[target].class === 'punct' && runs[target - 1].class === 'punct') target -= 1
318
+ return runs[target].start
319
319
  }
320
320
 
321
321
  /**
@@ -329,13 +329,13 @@ export function moveWordRight(value: string, offset: number): number {
329
329
  // right-hand fragment, instead of jumping straight to the following word.
330
330
  let index = runs.findIndex(run => run.end > cursor)
331
331
  if (index >= runs.length) return value.length
332
- if (runs[index]!.class === 'space') {
332
+ if (runs[index].class === 'space') {
333
333
  index += 1
334
334
  if (index >= runs.length) return value.length
335
335
  }
336
336
  let target = index
337
- while (target < runs.length - 1 && runs[target]!.class === 'punct' && runs[target + 1]!.class === 'punct') target += 1
338
- return runs[target]!.end
337
+ while (target < runs.length - 1 && runs[target].class === 'punct' && runs[target + 1].class === 'punct') target += 1
338
+ return runs[target].end
339
339
  }
340
340
 
341
341
  /** One edit outcome: the next draft value, cursor, and killed span (if any). */
@@ -362,7 +362,7 @@ function replaceRange(value: string, cursor: number, start: number, end: number,
362
362
  export function deleteBackward(value: string, cursor: number): EditResult {
363
363
  if (cursor === 0) return { value, cursor, killed: undefined }
364
364
  const spans = splitGraphemes(value.slice(0, cursor))
365
- const start = spans.length === 0 ? 0 : spans[spans.length - 1]!.start
365
+ const start = spans.length === 0 ? 0 : spans[spans.length - 1].start
366
366
  return replaceRange(value, cursor, start, cursor, false)
367
367
  }
368
368
 
@@ -487,12 +487,13 @@ export function composerMaxRows(terminalRows: number): number {
487
487
  }
488
488
 
489
489
  /**
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.
490
+ * History navigation starts with Up on an empty draft, or continues from an
491
+ * unchanged recalled entry whenever the caret sits on either text edge
492
+ * (start or end) - moving the caret into the interior returns the keys to
493
+ * ordinary editing until an edge is reached again.
493
494
  */
494
495
  export function shouldRecallNavigate(value: string, cursor: number, lastRecalled: string | null, direction: -1 | 1): boolean {
495
496
  if (value === '') return direction < 0
496
497
  if (lastRecalled !== value) return false
497
- return direction < 0 ? cursor === 0 : cursor === value.length
498
+ return cursor === 0 || cursor === value.length
498
499
  }