dsh-code 0.9.1 → 1.0.1
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 +278 -249
- package/README.md +131 -102
- package/bin/deepseek.mjs +100 -6
- package/cordis.patch.yml +36 -1
- package/lib/index.mjs +3055 -819
- package/lib/startup.mjs +21 -11
- package/lib/{theme-BEi4i_aN.mjs → theme-DCT8Y2xf.mjs} +13 -9
- package/lib/types/app.d.ts +84 -16
- package/lib/types/attachments.d.ts +20 -0
- package/lib/types/authorization-panel.d.ts +22 -0
- package/lib/types/authorization.d.ts +36 -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 +41 -0
- package/lib/types/mentions.d.ts +30 -38
- package/lib/types/models.d.ts +3 -1
- package/lib/types/permissions.d.ts +4 -14
- package/lib/types/presets.d.ts +5 -20
- package/lib/types/provider-settings.d.ts +16 -0
- package/lib/types/render/animations.d.ts +10 -39
- 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 +6 -13
- 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 +159 -141
- package/src/app.ts +1490 -663
- package/src/attachments.ts +128 -0
- package/src/authorization-panel.ts +285 -0
- package/src/authorization.ts +147 -0
- package/src/editor.ts +51 -0
- package/src/fork.ts +31 -0
- package/src/git-workflow.ts +87 -0
- package/src/index.ts +1523 -1374
- package/src/internals.ts +14 -1
- package/src/kernel-panels.ts +914 -798
- package/src/keyboard.ts +126 -0
- package/src/mentions.ts +78 -117
- package/src/models.ts +20 -14
- package/src/permissions.ts +5 -13
- package/src/presets.ts +6 -22
- package/src/provider-settings.ts +95 -1
- package/src/render/animations.ts +420 -450
- 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 +106 -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 +4 -4
- 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
|
@@ -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
|
+
}
|
package/src/render/export.ts
CHANGED
|
@@ -1,85 +1,85 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Markdown export of one transcript view: the /export command's pure
|
|
3
|
-
* formatter. Deterministic and side-effect free — the runner owns the file
|
|
4
|
-
* write, so tests drive the builder with folded views directly.
|
|
5
|
-
*
|
|
6
|
-
* @module @deepseek-ai/dsh-code/render/export
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { assertNever } from '@deepseek-ai/dsh-llm'
|
|
10
|
-
import type
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Render the transcript as a standalone markdown document.
|
|
14
|
-
* @param view - the folded transcript view to export.
|
|
15
|
-
* @param sessionId - the full session identity for the header.
|
|
16
|
-
* @returns the complete markdown text.
|
|
17
|
-
*/
|
|
18
|
-
export function buildExportMarkdown(view: TranscriptView, sessionId: string): string {
|
|
19
|
-
const out: string[] = [
|
|
20
|
-
view.title === ''
|
|
21
|
-
? `# dsh session ${sessionId}`
|
|
22
|
-
: `# ${view.title}`,
|
|
23
|
-
`> session ${sessionId}`,
|
|
24
|
-
'',
|
|
25
|
-
]
|
|
26
|
-
for (const entry of view.entries) {
|
|
27
|
-
switch (entry.kind) {
|
|
28
|
-
case 'user':
|
|
29
|
-
if (entry.notice) {
|
|
30
|
-
out.push(`> ⤷ context: ${entry.text}`, '')
|
|
31
|
-
} else {
|
|
32
|
-
out.push('## user', '', entry.text, '')
|
|
33
|
-
}
|
|
34
|
-
break
|
|
1
|
+
/**
|
|
2
|
+
* Markdown export of one transcript view: the /export command's pure
|
|
3
|
+
* formatter. Deterministic and side-effect free — the runner owns the file
|
|
4
|
+
* write, so tests drive the builder with folded views directly.
|
|
5
|
+
*
|
|
6
|
+
* @module @deepseek-ai/dsh-code/render/export
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { assertNever } from '@deepseek-ai/dsh-llm'
|
|
10
|
+
import { imageLabels, type TranscriptView } from './projection.ts'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Render the transcript as a standalone markdown document.
|
|
14
|
+
* @param view - the folded transcript view to export.
|
|
15
|
+
* @param sessionId - the full session identity for the header.
|
|
16
|
+
* @returns the complete markdown text.
|
|
17
|
+
*/
|
|
18
|
+
export function buildExportMarkdown(view: TranscriptView, sessionId: string): string {
|
|
19
|
+
const out: string[] = [
|
|
20
|
+
view.title === ''
|
|
21
|
+
? `# dsh session ${sessionId}`
|
|
22
|
+
: `# ${view.title}`,
|
|
23
|
+
`> session ${sessionId}`,
|
|
24
|
+
'',
|
|
25
|
+
]
|
|
26
|
+
for (const entry of view.entries) {
|
|
27
|
+
switch (entry.kind) {
|
|
28
|
+
case 'user':
|
|
29
|
+
if (entry.notice) {
|
|
30
|
+
out.push(`> ⤷ context: ${entry.text}`, '')
|
|
31
|
+
} else {
|
|
32
|
+
out.push('## user', '', entry.text, ...(imageLabels(entry.images) === '' ? [] : [imageLabels(entry.images)]), '')
|
|
33
|
+
}
|
|
34
|
+
break
|
|
35
35
|
case 'assistant':
|
|
36
36
|
if (entry.reasoning !== '') {
|
|
37
37
|
out.push('<details><summary>thinking</summary>', '', entry.reasoning, '', '</details>', '')
|
|
38
38
|
}
|
|
39
39
|
out.push('## assistant', '', entry.text, '')
|
|
40
40
|
break
|
|
41
|
-
case 'tool':
|
|
42
|
-
out.push(`### tool \`${entry.name}\``, '')
|
|
43
|
-
if (entry.preview !== '') out.push(`- args: ${entry.preview}`)
|
|
44
|
-
if (entry.summary !== '') out.push(`- ${entry.state === 'error' ? 'error' : 'result'}: ${entry.summary}`)
|
|
45
|
-
out.push('')
|
|
46
|
-
break
|
|
47
|
-
case 'command':
|
|
48
|
-
out.push(`### /${entry.name}${entry.args === '' ? '' : ` ${entry.args}`}`, '')
|
|
49
|
-
if (entry.summary !== '') out.push(`- ${entry.state === 'error' ? 'error' : 'result'}: ${entry.summary}`)
|
|
50
|
-
out.push('')
|
|
51
|
-
break
|
|
52
|
-
case 'error':
|
|
53
|
-
out.push(`> ⨯ ${entry.text}`, '')
|
|
54
|
-
break
|
|
55
|
-
case 'turn-marker':
|
|
56
|
-
out.push(`> ${entry.text}`, '')
|
|
57
|
-
break
|
|
58
|
-
case 'compaction':
|
|
59
|
-
out.push(entry.ok
|
|
60
|
-
? `> compacted ~${entry.tokens} tokens`
|
|
61
|
-
: `> compaction failed: ${entry.error}`, '')
|
|
62
|
-
break
|
|
63
|
-
case 'retry':
|
|
64
|
-
out.push(`> retry ${entry.attempt}/${entry.max} (${entry.code})`, '')
|
|
65
|
-
break
|
|
66
|
-
case 'files':
|
|
67
|
-
out.push(`> files changed: ${entry.paths.join(', ')}`, '')
|
|
68
|
-
break
|
|
69
|
-
case 'pending':
|
|
70
|
-
// Codex PendingSteer: queued prompts export like ordinary user rows.
|
|
71
|
-
out.push('## user', '', entry.text, '')
|
|
72
|
-
break
|
|
73
|
-
default:
|
|
74
|
-
assertNever(entry, 'transcript entry kind')
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
if (view.streaming !== '') out.push('## assistant (streaming)', '', view.streaming, '')
|
|
78
|
-
const { stats } = view
|
|
79
|
-
out.push('---', '')
|
|
80
|
-
out.push(`- model: ${view.model === '' ? '(none yet)' : view.model}`)
|
|
81
|
-
out.push(`- turns: ${stats.turns} · steps: ${stats.steps}`)
|
|
82
|
-
out.push(`- tokens: ↑${stats.usage.inputTokens} ↓${stats.usage.outputTokens} · cache read ${stats.usage.cacheReadTokens}`)
|
|
83
|
-
out.push(`- todos: ${view.todos.length}`)
|
|
84
|
-
return out.join('\n')
|
|
85
|
-
}
|
|
41
|
+
case 'tool':
|
|
42
|
+
out.push(`### tool \`${entry.name}\``, '')
|
|
43
|
+
if (entry.preview !== '') out.push(`- args: ${entry.preview}`)
|
|
44
|
+
if (entry.summary !== '') out.push(`- ${entry.state === 'error' ? 'error' : 'result'}: ${entry.summary}`)
|
|
45
|
+
out.push('')
|
|
46
|
+
break
|
|
47
|
+
case 'command':
|
|
48
|
+
out.push(`### /${entry.name}${entry.args === '' ? '' : ` ${entry.args}`}`, '')
|
|
49
|
+
if (entry.summary !== '') out.push(`- ${entry.state === 'error' ? 'error' : 'result'}: ${entry.summary}`)
|
|
50
|
+
out.push('')
|
|
51
|
+
break
|
|
52
|
+
case 'error':
|
|
53
|
+
out.push(`> ⨯ ${entry.text}`, '')
|
|
54
|
+
break
|
|
55
|
+
case 'turn-marker':
|
|
56
|
+
out.push(`> ${entry.text}`, '')
|
|
57
|
+
break
|
|
58
|
+
case 'compaction':
|
|
59
|
+
out.push(entry.ok
|
|
60
|
+
? `> compacted ~${entry.tokens} tokens`
|
|
61
|
+
: `> compaction failed: ${entry.error}`, '')
|
|
62
|
+
break
|
|
63
|
+
case 'retry':
|
|
64
|
+
out.push(`> retry ${entry.attempt}/${entry.max} (${entry.code})`, '')
|
|
65
|
+
break
|
|
66
|
+
case 'files':
|
|
67
|
+
out.push(`> files changed: ${entry.paths.join(', ')}`, '')
|
|
68
|
+
break
|
|
69
|
+
case 'pending':
|
|
70
|
+
// Codex PendingSteer: queued prompts export like ordinary user rows.
|
|
71
|
+
out.push('## user', '', entry.text, ...(imageLabels(entry.images) === '' ? [] : [imageLabels(entry.images)]), '')
|
|
72
|
+
break
|
|
73
|
+
default:
|
|
74
|
+
assertNever(entry, 'transcript entry kind')
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (view.streaming !== '') out.push('## assistant (streaming)', '', view.streaming, '')
|
|
78
|
+
const { stats } = view
|
|
79
|
+
out.push('---', '')
|
|
80
|
+
out.push(`- model: ${view.model === '' ? '(none yet)' : view.model}`)
|
|
81
|
+
out.push(`- turns: ${stats.turns} · steps: ${stats.steps}`)
|
|
82
|
+
out.push(`- tokens: ↑${stats.usage.inputTokens} ↓${stats.usage.outputTokens} · cache read ${stats.usage.cacheReadTokens}`)
|
|
83
|
+
out.push(`- todos: ${view.todos.length}`)
|
|
84
|
+
return out.join('\n')
|
|
85
|
+
}
|