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.
- package/README.en.md +31 -8
- package/README.md +264 -241
- package/bin/deepseek.mjs +100 -6
- package/cordis.patch.yml +29 -1
- package/lib/index.mjs +2317 -708
- package/lib/startup.mjs +21 -11
- package/lib/{theme-BEi4i_aN.mjs → theme-DCT8Y2xf.mjs} +13 -9
- package/lib/types/app.d.ts +66 -14
- package/lib/types/attachments.d.ts +7 -0
- package/lib/types/editor.d.ts +6 -0
- package/lib/types/fork.d.ts +8 -0
- package/lib/types/git-workflow.d.ts +23 -0
- package/lib/types/index.d.ts +6 -0
- package/lib/types/kernel-panels.d.ts +39 -0
- package/lib/types/keyboard.d.ts +43 -0
- package/lib/types/mentions.d.ts +28 -38
- package/lib/types/presets.d.ts +1 -3
- package/lib/types/provider-settings.d.ts +16 -0
- package/lib/types/render/animations.d.ts +24 -41
- package/lib/types/render/editor.d.ts +137 -0
- package/lib/types/render/export.d.ts +1 -1
- package/lib/types/render/lines.d.ts +6 -2
- package/lib/types/render/markdown.d.ts +3 -1
- package/lib/types/render/projection.d.ts +29 -3
- package/lib/types/render/status.d.ts +5 -12
- package/lib/types/session-directory.d.ts +1 -3
- package/lib/types/startup.d.ts +14 -11
- package/lib/types/store.d.ts +11 -9
- package/lib/types/subagents.d.ts +3 -3
- package/lib/types/theme.d.ts +14 -1
- package/lib/types/version.d.ts +15 -2
- package/package.json +153 -117
- package/src/app.ts +4455 -3904
- package/src/attachments.ts +44 -0
- package/src/editor.ts +51 -0
- package/src/fork.ts +31 -0
- package/src/git-workflow.ts +87 -0
- package/src/index.ts +1510 -1374
- package/src/internals.ts +14 -1
- package/src/kernel-panels.ts +914 -798
- package/src/keyboard.ts +125 -0
- package/src/mentions.ts +72 -117
- package/src/presets.ts +1 -4
- package/src/provider-settings.ts +94 -0
- package/src/render/animations.ts +74 -60
- package/src/render/editor.ts +398 -0
- package/src/render/export.ts +79 -79
- package/src/render/lines.ts +342 -236
- package/src/render/markdown.ts +99 -26
- package/src/render/projection.ts +102 -19
- package/src/render/status.ts +713 -650
- package/src/render/text.ts +150 -150
- package/src/render/tool-detail.ts +3 -1
- package/src/session-directory.ts +3 -3
- package/src/startup.ts +136 -119
- package/src/store.ts +23 -11
- package/src/subagents.ts +13 -5
- package/src/theme.ts +214 -206
- package/src/version.ts +58 -1
package/src/render/animations.ts
CHANGED
|
@@ -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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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
|
|
76
|
-
|
|
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
|
-
|
|
78
|
+
const PULSE_HALF_WIDTH = 4.5
|
|
83
79
|
|
|
84
80
|
/** Sparkle start and frame cadence — Codex SPARK_START / SPARK_FRAME. */
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
|
125
|
-
case 'pulse': return
|
|
126
|
-
case 'wave': return
|
|
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
|
-
*
|
|
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
|
+
}
|