dsh-code 0.9.0 → 1.0.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 (59) hide show
  1. package/README.en.md +31 -8
  2. package/README.md +264 -241
  3. package/bin/deepseek.mjs +100 -6
  4. package/cordis.patch.yml +29 -1
  5. package/lib/index.mjs +2317 -708
  6. package/lib/startup.mjs +21 -11
  7. package/lib/{theme-BEi4i_aN.mjs → theme-DCT8Y2xf.mjs} +13 -9
  8. package/lib/types/app.d.ts +66 -14
  9. package/lib/types/attachments.d.ts +7 -0
  10. package/lib/types/editor.d.ts +6 -0
  11. package/lib/types/fork.d.ts +8 -0
  12. package/lib/types/git-workflow.d.ts +23 -0
  13. package/lib/types/index.d.ts +6 -0
  14. package/lib/types/kernel-panels.d.ts +39 -0
  15. package/lib/types/keyboard.d.ts +43 -0
  16. package/lib/types/mentions.d.ts +28 -38
  17. package/lib/types/presets.d.ts +1 -3
  18. package/lib/types/provider-settings.d.ts +16 -0
  19. package/lib/types/render/animations.d.ts +24 -41
  20. package/lib/types/render/editor.d.ts +137 -0
  21. package/lib/types/render/export.d.ts +1 -1
  22. package/lib/types/render/lines.d.ts +6 -2
  23. package/lib/types/render/markdown.d.ts +3 -1
  24. package/lib/types/render/projection.d.ts +29 -3
  25. package/lib/types/render/status.d.ts +5 -12
  26. package/lib/types/session-directory.d.ts +1 -3
  27. package/lib/types/startup.d.ts +14 -11
  28. package/lib/types/store.d.ts +11 -9
  29. package/lib/types/subagents.d.ts +3 -3
  30. package/lib/types/theme.d.ts +14 -1
  31. package/lib/types/version.d.ts +15 -2
  32. package/package.json +153 -117
  33. package/src/app.ts +4455 -3904
  34. package/src/attachments.ts +44 -0
  35. package/src/editor.ts +51 -0
  36. package/src/fork.ts +31 -0
  37. package/src/git-workflow.ts +87 -0
  38. package/src/index.ts +1510 -1374
  39. package/src/internals.ts +14 -1
  40. package/src/kernel-panels.ts +914 -798
  41. package/src/keyboard.ts +125 -0
  42. package/src/mentions.ts +72 -117
  43. package/src/presets.ts +1 -4
  44. package/src/provider-settings.ts +94 -0
  45. package/src/render/animations.ts +74 -60
  46. package/src/render/editor.ts +398 -0
  47. package/src/render/export.ts +79 -79
  48. package/src/render/lines.ts +342 -236
  49. package/src/render/markdown.ts +99 -26
  50. package/src/render/projection.ts +102 -19
  51. package/src/render/status.ts +713 -650
  52. package/src/render/text.ts +150 -150
  53. package/src/render/tool-detail.ts +3 -1
  54. package/src/session-directory.ts +3 -3
  55. package/src/startup.ts +136 -119
  56. package/src/store.ts +23 -11
  57. package/src/subagents.ts +13 -5
  58. package/src/theme.ts +214 -206
  59. package/src/version.ts +58 -1
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Terminal animation frame tables derived from the web design language:
3
3
  * the StateDot "ongoing" pixel chase (3×3 ring, 125ms flat-hold brightness
4
- * steps, 1s cycle) becomes the single-cell stepped pulse below and the
5
- * full-ring clockwise braille chase in {@link BUSY_CHASE_FRAMES}, and the
6
- * streaming caret blink is the Claude-Code convention.
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.
7
7
  *
8
8
  * The DeepSeek model-switch easter egg ports Codex's effort-ignition "Wave"
9
9
  * style (`codex-rs/tui/src/bottom_pane/effort_ignition_styles.rs`): switching
@@ -19,14 +19,6 @@
19
19
 
20
20
  import type { RgbTriple } from '../theme.ts'
21
21
 
22
- /** Single-cell stepped pulse: flat holds mirroring the web's 125ms keyframes. */
23
- export const PULSE_FRAMES = ['█', '█', '▆', '▃', '▁', '▃', '▆', '█'] as const
24
-
25
- /** Pulse frame for a monotonic tick. */
26
- export function pulseFrame(tick: number): string {
27
- return PULSE_FRAMES[tick % PULSE_FRAMES.length] ?? PULSE_FRAMES[0]
28
- }
29
-
30
22
  /**
31
23
  * The web StateDot "ongoing" chase in terminal form: three cells of the 3×3
32
24
  * ring trail clockwise around the eight outer positions, one braille glyph
@@ -59,11 +51,15 @@ export function caretVisible(tick: number): boolean {
59
51
  export const DEEPSEEK_WAVE_TICK_MS = 33
60
52
 
61
53
  /**
62
- * The two DeepSeek wave tiers. The concept maps Codex's reasoning tiers to
54
+ * The DeepSeek wave tiers. The concept maps Codex's reasoning tiers to
63
55
  * model ids: `flash` runs the Max parameters, `deepseek` (pro models) runs
64
56
  * the Ultra parameters (dual band + tail sparkles on the Wave style).
57
+ * `unknown` is the "Into the Unknown" variant: it reuses the deepseek tier's
58
+ * exact parameters (dual band, durations, sparkles) for NON-DeepSeek models
59
+ * running a reasoning effort above high — the wordmark renders differently
60
+ * but the motion is identical.
65
61
  */
66
- export type DeepseekWaveTier = 'flash' | 'deepseek'
62
+ export type DeepseekWaveTier = 'flash' | 'deepseek' | 'unknown'
67
63
 
68
64
  /**
69
65
  * The three ignition styles — Codex `IgnitionStyle`: a traveling crest
@@ -72,18 +68,18 @@ export type DeepseekWaveTier = 'flash' | 'deepseek'
72
68
  */
73
69
  export type DeepseekWaveStyle = 'wave' | 'aurora' | 'pulse'
74
70
 
75
- /** All styles in canonical order, for random selection and tests. */
76
- export const DEEPSEEK_WAVE_STYLES: readonly DeepseekWaveStyle[] = ['wave', 'aurora', 'pulse']
71
+ /** All styles in canonical order, for random selection. */
72
+ const DEEPSEEK_WAVE_STYLES: readonly DeepseekWaveStyle[] = ['wave', 'aurora', 'pulse']
77
73
 
78
74
  /** Wave half-width in columns — Codex WAVE_HALF_WIDTH (9). */
79
75
  export const WAVE_HALF_WIDTH = 9
80
76
 
81
77
  /** Pulse ring half-width in columns — Codex PULSE_HALF_WIDTH (4.5). */
82
- export const PULSE_HALF_WIDTH = 4.5
78
+ const PULSE_HALF_WIDTH = 4.5
83
79
 
84
80
  /** Sparkle start and frame cadence — Codex SPARK_START / SPARK_FRAME. */
85
- export const SPARK_START_MS = 900
86
- export const SPARK_FRAME_MS = 100
81
+ const SPARK_START_MS = 900
82
+ const SPARK_FRAME_MS = 100
87
83
 
88
84
  /** Sparkle glyphs in frame order — Codex SPARK_GLYPHS (`· ✦ ✧`). */
89
85
  export const SPARK_GLYPHS = ['·', '✦', '✧'] as const
@@ -100,30 +96,36 @@ export const DEEPSEEK_WAVE_BANDS: Readonly<Record<DeepseekWaveStyle, Readonly<Re
100
96
  flash: [[0.10, 0.75, 1.0]],
101
97
  // Wave-Ultra: two offset bands for a richer crest.
102
98
  deepseek: [[0.10, 0.70, 1.0], [0.35, 0.55, 1.0]],
99
+ // Into the Unknown reuses the Ultra parameters verbatim.
100
+ unknown: [[0.10, 0.70, 1.0], [0.35, 0.55, 1.0]],
103
101
  },
104
102
  aurora: {
105
103
  // Aurora-Max: two drifting bands (hues 0 and 1).
106
104
  flash: [[0.35, 0.15, 0.0], [-0.50, 0.60, 1.0]],
107
105
  // Aurora-Ultra: a third band adds hue 2.
108
106
  deepseek: [[0.35, 0.15, 0.0], [-0.50, 0.60, 1.0], [0.75, 0.35, 2.0]],
107
+ unknown: [[0.35, 0.15, 0.0], [-0.50, 0.60, 1.0], [0.75, 0.35, 2.0]],
109
108
  },
110
109
  pulse: {
111
110
  // Pulse-Max: one expanding ring.
112
111
  flash: [[0.10, 0.60, 1.0]],
113
112
  // Pulse-Ultra: two rings (inner weaker, outer stronger).
114
113
  deepseek: [[0.10, 0.55, 0.8], [0.45, 0.55, 1.1]],
114
+ unknown: [[0.10, 0.55, 0.8], [0.45, 0.55, 1.1]],
115
115
  },
116
116
  }
117
117
 
118
118
  /** Extra display time applied to every Codex ignition style. */
119
- export const DEEPSEEK_WAVE_DURATION_EXTENSION_MS = 200
119
+ const DEEPSEEK_WAVE_DURATION_EXTENSION_MS = 200
120
120
 
121
121
  /** Original Codex duration used as the animation's sampling timeline. */
122
122
  function deepseekWaveBaseDuration(tier: DeepseekWaveTier, style: DeepseekWaveStyle): number {
123
+ // The unknown tier reuses the deepseek (pro) durations exactly.
124
+ const pro = tier === 'deepseek' || tier === 'unknown'
123
125
  switch (style) {
124
- case 'aurora': return tier === 'flash' ? 1300 : 1600
125
- case 'pulse': return tier === 'flash' ? 900 : 1250
126
- case 'wave': return tier === 'flash' ? 1000 : 1300
126
+ case 'aurora': return pro ? 1600 : 1300
127
+ case 'pulse': return pro ? 1250 : 900
128
+ case 'wave': return pro ? 1300 : 1000
127
129
  }
128
130
  }
129
131
 
@@ -155,15 +157,6 @@ export function deepseekWaveStyleRandom(previous: DeepseekWaveStyle | undefined)
155
157
  return candidates[Math.floor(Math.random() * candidates.length)] ?? 'wave'
156
158
  }
157
159
 
158
- /**
159
- * Blank-cell background the wave tint blends toward — fixed approximations
160
- * of the terminal's default background, mirroring Codex's
161
- * `user_message_bg_rgb` (which derives a near-black / near-white bubble tint
162
- * from the terminal background). The Ink layer picks the active theme's one.
163
- */
164
- export const WAVE_BASE_DARK: RgbTriple = [16, 18, 24]
165
- export const WAVE_BASE_LIGHT: RgbTriple = [242, 244, 248]
166
-
167
160
  /**
168
161
  * Tier for a `provider/model` label: a model id containing `flash` runs the
169
162
  * single-band flash tier; everything else (pro/reasoner/chat) runs the
@@ -270,14 +263,23 @@ function blendRgb(fg: RgbTriple, bg: RgbTriple, alpha: number): RgbTriple {
270
263
  }
271
264
 
272
265
  /**
273
- * The background color for one composer-row column at a tick Codex
266
+ * Per-row phase share of the duration: the crest reaches the top row first
267
+ * and the bottom row last, sweeping down the band. 0.12 keeps the bottom
268
+ * row's lag inside the 200ms duration extension.
269
+ */
270
+ const DEEPSEEK_WAVE_ROW_PHASE = 0.12
271
+
272
+ /**
273
+ * The background color for one composer-band column at a tick — Codex
274
274
  * `paint_bands` + `Canvas::tint` for all three styles. Bands overlap with a
275
275
  * max for Wave/Pulse and a SUM for Aurora (Codex differs by style), the
276
276
  * weighted hues mix per column (Wave/Pulse always end on hue 0), the tint
277
277
  * blends the mixed hue toward the blank-cell base at the style's alpha cap,
278
278
  * and Aurora applies its own fade envelope. Returns `null` when the column
279
279
  * should stay transparent, so the row returns to no `backgroundColor` on
280
- * both ends.
280
+ * both ends. With `rows > 1` each row samples the same timeline shifted by a
281
+ * per-row phase offset, so the crest cascades down the band instead of
282
+ * painting every row identically.
281
283
  * @param tick - wave frame (0, 1, … at DEEPSEEK_WAVE_TICK_MS).
282
284
  * @param column - column index in the content row (0..width-1).
283
285
  * @param width - content-row width in columns.
@@ -285,6 +287,8 @@ function blendRgb(fg: RgbTriple, bg: RgbTriple, alpha: number): RgbTriple {
285
287
  * @param style - the ignition style.
286
288
  * @param hues - the tier's three hues.
287
289
  * @param base - the blank-cell base color the tint blends toward.
290
+ * @param row - row index in the band (0..rows-1; default 0 = old single-row).
291
+ * @param rows - band height in rows (default 1).
288
292
  * @returns the blended RGB background, or null for transparent.
289
293
  */
290
294
  export function deepseekWaveColumnBg(
@@ -295,9 +299,12 @@ export function deepseekWaveColumnBg(
295
299
  style: DeepseekWaveStyle,
296
300
  hues: readonly [RgbTriple, RgbTriple, RgbTriple],
297
301
  base: RgbTriple,
302
+ row = 0,
303
+ rows = 1,
298
304
  ): RgbTriple | null {
299
305
  const total = deepseekWaveBaseDuration(tier, style) / 1000
300
306
  const elapsed = deepseekWaveSampleElapsedMs(tick, tier, style) / 1000
307
+ - (row - (rows - 1) / 2) * total * DEEPSEEK_WAVE_ROW_PHASE
301
308
  const fade = style === 'aurora' ? envelope(elapsed, total, 0.25, 0.40) : 1
302
309
  const weights = [0, 0, 0]
303
310
  for (const band of DEEPSEEK_WAVE_BANDS[style][tier]) {
@@ -338,33 +345,6 @@ export function deepseekWaveSpark(tick: number): string | null {
338
345
  return SPARK_GLYPHS[frame] ?? null
339
346
  }
340
347
 
341
- /**
342
- * The composer BORDER color at a tick: the frame breathes with the wave —
343
- * the palette's static dim blends toward the tier accent as the crest is
344
- * alive and back, so the frame glows up while the wave sweeps and settles to
345
- * dim on both ends (first==last frame==dim, no hard jump).
346
- * @param tick - wave frame at DEEPSEEK_WAVE_TICK_MS.
347
- * @param tier - the wave tier.
348
- * @param hues - the tier's three hues; the border blends toward hues[0].
349
- * @param dim - the palette's static dim RGB (the resting border color).
350
- * @returns the blended border RGB.
351
- */
352
- export function deepseekWaveBorderColor(
353
- tick: number,
354
- tier: DeepseekWaveTier,
355
- style: DeepseekWaveStyle,
356
- hues: readonly [RgbTriple, RgbTriple, RgbTriple],
357
- dim: RgbTriple,
358
- ): RgbTriple {
359
- const total = deepseekWaveBaseDuration(tier, style) / 1000
360
- const elapsed = deepseekWaveSampleElapsedMs(tick, tier, style) / 1000
361
- // The border stays visibly on the tier accent for the whole sweep (a
362
- // floor keeps it glowing, not just cresting mid-wave): it ramps in as the
363
- // first band launches and relaxes after the last crest passes.
364
- const glow = envelope(elapsed, total, total * 0.25, total * 0.4)
365
- return blendRgb(hues[0], dim, 0.25 + glow * 0.6)
366
- }
367
-
368
348
  /**
369
349
  * Whether the `deepseek` wordmark rides the wave at this tick: it fades in
370
350
  * shortly after the first crest launches and out before the wave settles,
@@ -404,3 +384,37 @@ export function isOfficialDeepSeekLabel(label: string): boolean {
404
384
  const model = slash < 0 ? '' : label.slice(slash + 1)
405
385
  return provider.toLowerCase().includes('deepseek') || model.toLowerCase().includes('deepseek')
406
386
  }
387
+
388
+ /**
389
+ * Known reasoning-effort ranks in ascending order. Effort ids are opaque
390
+ * adapter-owned strings, so the rank table covers the conventional names
391
+ * (off → low → medium → high → xhigh → max/ultra); an unrecognized id
392
+ * ranks as unknown (0), which never triggers the high-effort wave.
393
+ */
394
+ const EFFORT_RANK: Readonly<Record<string, number>> = {
395
+ off: 0,
396
+ none: 0,
397
+ low: 1,
398
+ medium: 2,
399
+ med: 2,
400
+ high: 3,
401
+ xhigh: 4,
402
+ 'x-high': 4,
403
+ 'very-high': 4,
404
+ max: 5,
405
+ maximum: 5,
406
+ ultra: 5,
407
+ }
408
+
409
+ /**
410
+ * True when an effective reasoning effort is STRICTLY above `high` — the
411
+ * trigger gate for the "Into the Unknown" wave on non-DeepSeek routes.
412
+ * Absent efforts and unrecognized ids never qualify.
413
+ * @param effort - the effective reasoning-effort id ('' or undefined when none).
414
+ * @returns whether the effort ranks above high.
415
+ */
416
+ export function effortAboveHigh(effort: string | undefined): boolean {
417
+ if (effort === undefined || effort === '') return false
418
+ const rank = EFFORT_RANK[effort.trim().toLowerCase()]
419
+ return rank !== undefined && rank > 3
420
+ }
@@ -0,0 +1,398 @@
1
+ /**
2
+ * Pure composer editor model with Codex textarea semantics: a grapheme
3
+ * cursor over a column-safe multiline layout, word/piece motion, single-entry
4
+ * kill + yank, and the shell-recall boundary gate that keeps Up/Down usable
5
+ * inside a multiline draft.
6
+ *
7
+ * The model is intentionally string-offset based (UTF-16 indices clamped to
8
+ * grapheme boundaries) so the React state stays two primitives
9
+ * (value, cursor) and every operation here stays pure and testable.
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).
14
+ *
15
+ * @module @deepseek-ai/dsh-code/render/editor
16
+ */
17
+
18
+ import { visibleColumns } from './markdown.ts'
19
+
20
+ /** One grapheme cluster with its source span and display width in cells. */
21
+ export interface GraphemeSpan {
22
+ text: string
23
+ start: number
24
+ end: number
25
+ width: number
26
+ }
27
+
28
+ const segmenter: Intl.Segmenter | undefined = typeof Intl !== 'undefined' && 'Segmenter' in Intl
29
+ ? new Intl.Segmenter('en', { granularity: 'grapheme' })
30
+ : undefined
31
+
32
+ /**
33
+ * Split text into grapheme clusters. Falls back to code points when
34
+ * Intl.Segmenter is unavailable; the fallback still keeps surrogate pairs
35
+ * (emoji) atomic so the cursor can never split one.
36
+ */
37
+ export function splitGraphemes(text: string): readonly GraphemeSpan[] {
38
+ if (text === '') return []
39
+ const spans: GraphemeSpan[] = []
40
+ if (segmenter !== undefined) {
41
+ for (const piece of segmenter.segment(text)) {
42
+ spans.push({
43
+ text: piece.segment,
44
+ start: piece.index,
45
+ end: piece.index + piece.segment.length,
46
+ width: visibleColumns(piece.segment),
47
+ })
48
+ }
49
+ return spans
50
+ }
51
+ let start = 0
52
+ for (const char of text) {
53
+ spans.push({ text: char, start, end: start + char.length, width: visibleColumns(char) })
54
+ start += char.length
55
+ }
56
+ return spans
57
+ }
58
+
59
+ /** Round one UTF-16 offset down to the grapheme boundary at or before it. */
60
+ function floorBoundary(spans: readonly GraphemeSpan[], offset: number): number {
61
+ for (let index = spans.length - 1; index >= 0; index -= 1) {
62
+ const span = spans[index]!
63
+ if (span.end <= offset) return span.end
64
+ if (span.start < offset) return span.start
65
+ }
66
+ return 0
67
+ }
68
+
69
+ /** Clamp a cursor offset to the nearest grapheme boundary (surrogates, ZWJ, marks stay whole). */
70
+ export function clampCursor(value: string, offset: number): number {
71
+ if (value === '') return 0
72
+ const target = Math.max(0, Math.min(value.length, Math.floor(offset)))
73
+ if (target === 0 || target === value.length) return target
74
+ const spans = splitGraphemes(value)
75
+ const down = floorBoundary(spans, target)
76
+ if (down === target) return target
77
+ const up = spans.find(span => span.start >= target)?.start ?? value.length
78
+ return up - target < target - down ? up : down
79
+ }
80
+
81
+ /**
82
+ * Delete the final grapheme cluster (append-only drafts without a cursor).
83
+ * Surrogate pairs and multi-codepoint emoji stay whole instead of leaving a
84
+ * lone trailing code unit behind.
85
+ */
86
+ export function deleteLastGrapheme(text: string): string {
87
+ if (text === '') return ''
88
+ const spans = splitGraphemes(text)
89
+ return text.slice(0, spans[spans.length - 1]!.start)
90
+ }
91
+
92
+ /**
93
+ * Step the cursor by whole graphemes (negative steps left). The cursor is
94
+ * assumed to sit on a boundary; any drift is clamped first.
95
+ */
96
+ export function moveCursorBy(value: string, offset: number, delta: number): number {
97
+ if (delta === 0 || value === '') return clampCursor(value, offset)
98
+ const spans = splitGraphemes(value)
99
+ const boundaries: number[] = [0]
100
+ for (const span of spans) boundaries.push(span.end)
101
+ const current = boundaries.indexOf(clampCursor(value, offset))
102
+ if (current === -1) return clampCursor(value, offset)
103
+ const next = Math.max(0, Math.min(boundaries.length - 1, current + delta))
104
+ return boundaries[next]!
105
+ }
106
+
107
+ /**
108
+ * Normalize text entering the draft: CRLF/CR become LF, tabs become two
109
+ * spaces (terminal tab stops are contextual and cannot join a deterministic
110
+ * row budget), and every other C0 control byte plus DEL is REMOVED — the
111
+ * draft is data, so a stray ESC (Windows Terminal file drops) disappears
112
+ * instead of rendering as literal backslash-x-1-b text. Newlines survive.
113
+ */
114
+ export function sanitizeDraftText(text: string): string {
115
+ return text
116
+ .replaceAll('\r\n', '\n')
117
+ .replaceAll('\r', '\n')
118
+ // The draft is DATA, not display: strip C0 control bytes (the stray ESC
119
+ // that rides Windows Terminal file drops) and DEL instead of escaping
120
+ // them into visible "\x1b" text. Newlines survive; tabs widen.
121
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, '')
122
+ .replaceAll('\t', ' ')
123
+ }
124
+
125
+ /** One physical editor row: wrapped text plus its boundary map. */
126
+ export interface EditorRowModel {
127
+ /** Display text of the row (never contains `\n`; sanitized upstream). */
128
+ readonly text: string
129
+ /** Source offset of the first grapheme on the row. */
130
+ readonly start: number
131
+ /** Source offset just past the last grapheme on the row (before its newline). */
132
+ readonly end: number
133
+ /** Boundary offsets on the row, start to end inclusive. */
134
+ readonly offsets: readonly number[]
135
+ /** Display column of each boundary; `columns[i]` pairs with `offsets[i]`. */
136
+ readonly columns: readonly number[]
137
+ /** Code-unit cut in `text` at each boundary; `cuts[i]` pairs with `offsets[i]`. */
138
+ readonly cuts: readonly number[]
139
+ }
140
+
141
+ /** The wrapped physical-row model of one draft. */
142
+ export interface EditorModel {
143
+ readonly rows: readonly EditorRowModel[]
144
+ readonly length: number
145
+ }
146
+
147
+ /**
148
+ * Hard-wrap the draft into column-safe physical rows. Wide graphemes never
149
+ * split across rows (a grapheme that does not fit flushes the row first) and
150
+ * explicit newlines end their row without occupying a cell.
151
+ */
152
+ export function editorModel(value: string, columns: number): EditorModel {
153
+ const width = Math.max(1, Math.floor(columns))
154
+ const spans = splitGraphemes(value)
155
+ const rows: EditorRowModel[] = []
156
+ let text = ''
157
+ let start = 0
158
+ let used = 0
159
+ const offsets: number[] = [0]
160
+ const rowColumns: number[] = [0]
161
+ const rowCuts: number[] = [0]
162
+ const flush = (end: number): void => {
163
+ rows.push({ text, start, end, offsets: [...offsets], columns: [...rowColumns], cuts: [...rowCuts] })
164
+ text = ''
165
+ used = 0
166
+ offsets.length = 0
167
+ rowColumns.length = 0
168
+ rowCuts.length = 0
169
+ }
170
+ for (const span of spans) {
171
+ if (span.text === '\n') {
172
+ flush(span.start)
173
+ start = span.end
174
+ offsets.push(span.end)
175
+ rowColumns.push(0)
176
+ rowCuts.push(0)
177
+ continue
178
+ }
179
+ if (used > 0 && used + span.width > width) {
180
+ flush(span.start)
181
+ start = span.start
182
+ offsets.push(span.start)
183
+ rowColumns.push(0)
184
+ rowCuts.push(0)
185
+ }
186
+ text += span.text
187
+ used += span.width
188
+ offsets.push(span.end)
189
+ rowColumns.push(used)
190
+ rowCuts.push(text.length)
191
+ }
192
+ if (text !== '' || rows.length === 0) flush(value.length)
193
+ else if (offsets.length > 0) {
194
+ // Trailing newline leaves one pending empty boundary row.
195
+ rows.push({ text: '', start, end: value.length, offsets: [...offsets], columns: [...rowColumns], cuts: [...rowCuts] })
196
+ }
197
+ return { rows, length: value.length }
198
+ }
199
+
200
+ /** Where a cursor offset renders: the physical row and its display column. */
201
+ export interface CaretSite {
202
+ row: number
203
+ column: number
204
+ }
205
+
206
+ /** Map a cursor offset to its caret site on the wrapped rows. */
207
+ export function caretSite(model: EditorModel, offset: number): CaretSite {
208
+ const target = Math.max(0, Math.min(model.length, offset))
209
+ for (let index = model.rows.length - 1; index >= 0; index -= 1) {
210
+ const row = model.rows[index]!
211
+ const at = row.offsets.indexOf(target)
212
+ if (at >= 0) return { row: index, column: row.columns[at]! }
213
+ }
214
+ const last = model.rows[model.rows.length - 1]
215
+ return { row: model.rows.length - 1, column: last === undefined ? 0 : last.columns[last.columns.length - 1]! }
216
+ }
217
+
218
+ /**
219
+ * Move the caret across physical rows keeping a preferred display column
220
+ * (Codex `preferred_col`): horizontal moves reset the preference, vertical
221
+ * moves reuse it, clamped to each row's width.
222
+ */
223
+ export function moveCursorVertically(model: EditorModel, offset: number, preferredColumn: number, delta: number): number {
224
+ const site = caretSite(model, offset)
225
+ const target = site.row + delta
226
+ if (target < 0 || target >= model.rows.length || delta === 0) return offset
227
+ const row = model.rows[target]!
228
+ const wanted = Math.max(0, Math.min(preferredColumn, row.columns[row.columns.length - 1]!))
229
+ let best = 0
230
+ for (let index = 1; index < row.columns.length; index += 1) {
231
+ if (row.columns[index]! <= wanted) best = index
232
+ else break
233
+ }
234
+ return row.offsets[best]!
235
+ }
236
+
237
+ /** The start/end offsets of the logical line containing the cursor. */
238
+ export function lineBounds(value: string, offset: number): { start: number; end: number } {
239
+ const target = Math.max(0, Math.min(value.length, offset))
240
+ const start = value.lastIndexOf('\n', Math.max(0, target - 1)) + 1
241
+ const end = value.indexOf('\n', target)
242
+ return { start, end: end === -1 ? value.length : end }
243
+ }
244
+
245
+ /** Codex WORD_SEPARATORS: punctuation runs are their own word pieces. */
246
+ const WORD_SEPARATORS = new Set('`~!@#$%^&*()-=+[{]}\\|;:\'",.<>/?')
247
+
248
+ type PieceClass = 'space' | 'punct' | 'word'
249
+
250
+ function classifyGrapheme(text: string): PieceClass {
251
+ if (/^\s$/u.test(text)) return 'space'
252
+ return WORD_SEPARATORS.has(text) ? 'punct' : 'word'
253
+ }
254
+
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
259
+ for (const span of splitGraphemes(value)) {
260
+ const klass = span.text === '\n' ? 'space' : classifyGrapheme(span.text)
261
+ if (current !== undefined && current.class === klass) {
262
+ current.end = span.end
263
+ continue
264
+ }
265
+ current = { start: span.start, end: span.end, class: klass }
266
+ runs.push(current)
267
+ }
268
+ return runs
269
+ }
270
+
271
+ /**
272
+ * Codex `beginning_of_previous_word`: skip whitespace left, then land on the
273
+ * START of the trailing non-space piece (extending over separator pieces).
274
+ */
275
+ export function moveWordLeft(value: string, offset: number): number {
276
+ const cursor = Math.max(0, Math.min(value.length, offset))
277
+ 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
281
+ if (index < 0) return 0
282
+ if (runs[index]!.class === 'space') {
283
+ index -= 1
284
+ if (index < 0) return 0
285
+ }
286
+ let target = index
287
+ while (target > 0 && runs[target]!.class === 'punct' && runs[target - 1]!.class === 'punct') target -= 1
288
+ return runs[target]!.start
289
+ }
290
+
291
+ /**
292
+ * Codex `end_of_next_word`: skip whitespace right, then land on the END of
293
+ * the leading non-space piece (extending over separator pieces).
294
+ */
295
+ export function moveWordRight(value: string, offset: number): number {
296
+ const cursor = Math.max(0, Math.min(value.length, offset))
297
+ const runs = pieceRuns(value)
298
+ let index = 0
299
+ while (index < runs.length && runs[index]!.start < cursor) index += 1
300
+ if (index >= runs.length) return value.length
301
+ if (runs[index]!.class === 'space') {
302
+ index += 1
303
+ if (index >= runs.length) return value.length
304
+ }
305
+ let target = index
306
+ while (target < runs.length - 1 && runs[target]!.class === 'punct' && runs[target + 1]!.class === 'punct') target += 1
307
+ return runs[target]!.end
308
+ }
309
+
310
+ /** One edit outcome: the next draft value, cursor, and killed span (if any). */
311
+ export interface EditResult {
312
+ value: string
313
+ cursor: number
314
+ /** Text removed into the kill buffer; undefined when nothing was killed. */
315
+ killed: string | undefined
316
+ }
317
+
318
+ function replaceRange(value: string, cursor: number, start: number, end: number, killed: boolean): EditResult {
319
+ const from = Math.min(start, end)
320
+ const to = Math.max(start, end)
321
+ if (from >= to) return { value, cursor, killed: undefined }
322
+ const text = value.slice(from, to)
323
+ return {
324
+ value: value.slice(0, from) + value.slice(to),
325
+ cursor: Math.max(0, cursor - Math.max(0, Math.min(cursor, to) - from)),
326
+ killed: killed ? text : undefined,
327
+ }
328
+ }
329
+
330
+ /** Delete the grapheme cluster before the cursor. */
331
+ export function deleteBackward(value: string, cursor: number): EditResult {
332
+ if (cursor === 0) return { value, cursor, killed: undefined }
333
+ const spans = splitGraphemes(value.slice(0, cursor))
334
+ const start = spans.length === 0 ? 0 : spans[spans.length - 1]!.start
335
+ return replaceRange(value, cursor, start, cursor, false)
336
+ }
337
+
338
+ /** Delete the grapheme cluster at the cursor. */
339
+ export function deleteForward(value: string, cursor: number): EditResult {
340
+ if (cursor >= value.length) return { value, cursor, killed: undefined }
341
+ const span = splitGraphemes(value).find(candidate => candidate.start >= cursor)
342
+ return replaceRange(value, cursor, cursor, span === undefined ? value.length : span.end, false)
343
+ }
344
+
345
+ /** Delete back to the start of the previous word (fills the kill buffer). */
346
+ export function deleteWordBackward(value: string, cursor: number): EditResult {
347
+ const start = moveWordLeft(value, cursor)
348
+ return replaceRange(value, cursor, start, cursor, true)
349
+ }
350
+
351
+ /** Delete forward to the end of the next word (fills the kill buffer). */
352
+ export function deleteWordForward(value: string, cursor: number): EditResult {
353
+ const end = moveWordRight(value, cursor)
354
+ return replaceRange(value, cursor, cursor, end, true)
355
+ }
356
+
357
+ /** Ctrl+U: kill from the line start to the cursor; at BOL, kill the newline. */
358
+ export function killToLineStart(value: string, cursor: number): EditResult {
359
+ const bounds = lineBounds(value, cursor)
360
+ if (cursor > bounds.start) return replaceRange(value, cursor, bounds.start, cursor, true)
361
+ if (bounds.start > 0) return replaceRange(value, cursor, bounds.start - 1, bounds.start, true)
362
+ return { value, cursor, killed: undefined }
363
+ }
364
+
365
+ /** Ctrl+K: kill from the cursor to the line end; at EOL, kill the newline. */
366
+ export function killToLineEnd(value: string, cursor: number): EditResult {
367
+ const bounds = lineBounds(value, cursor)
368
+ if (cursor < bounds.end) return replaceRange(value, cursor, cursor, bounds.end, true)
369
+ if (bounds.end < value.length) return replaceRange(value, cursor, bounds.end, bounds.end + 1, true)
370
+ return { value, cursor, killed: undefined }
371
+ }
372
+
373
+ /** Insert sanitized text at the cursor. */
374
+ export function insertText(value: string, cursor: number, text: string): EditResult {
375
+ const safe = sanitizeDraftText(text)
376
+ if (safe === '') return { value, cursor, killed: undefined }
377
+ return { value: value.slice(0, cursor) + safe + value.slice(cursor), cursor: cursor + safe.length, killed: undefined }
378
+ }
379
+
380
+ /**
381
+ * Composer editor row budget: the editor itself never grows past this many
382
+ * physical rows; deeper drafts scroll internally to keep the caret visible.
383
+ * Short terminals collapse toward one row so the live transcript keeps room.
384
+ */
385
+ export function composerMaxRows(terminalRows: number): number {
386
+ return Math.max(1, Math.min(6, Math.floor((Math.max(1, terminalRows) - 10) / 3)))
387
+ }
388
+
389
+ /**
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.
393
+ */
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
398
+ }