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.
Files changed (67) hide show
  1. package/README.en.md +278 -249
  2. package/README.md +131 -102
  3. package/bin/deepseek.mjs +100 -6
  4. package/cordis.patch.yml +36 -1
  5. package/lib/index.mjs +3055 -819
  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 +84 -16
  9. package/lib/types/attachments.d.ts +20 -0
  10. package/lib/types/authorization-panel.d.ts +22 -0
  11. package/lib/types/authorization.d.ts +36 -0
  12. package/lib/types/editor.d.ts +6 -0
  13. package/lib/types/fork.d.ts +8 -0
  14. package/lib/types/git-workflow.d.ts +23 -0
  15. package/lib/types/index.d.ts +6 -0
  16. package/lib/types/kernel-panels.d.ts +39 -0
  17. package/lib/types/keyboard.d.ts +41 -0
  18. package/lib/types/mentions.d.ts +30 -38
  19. package/lib/types/models.d.ts +3 -1
  20. package/lib/types/permissions.d.ts +4 -14
  21. package/lib/types/presets.d.ts +5 -20
  22. package/lib/types/provider-settings.d.ts +16 -0
  23. package/lib/types/render/animations.d.ts +10 -39
  24. package/lib/types/render/editor.d.ts +137 -0
  25. package/lib/types/render/export.d.ts +1 -1
  26. package/lib/types/render/lines.d.ts +6 -2
  27. package/lib/types/render/markdown.d.ts +3 -1
  28. package/lib/types/render/projection.d.ts +29 -3
  29. package/lib/types/render/status.d.ts +6 -13
  30. package/lib/types/session-directory.d.ts +1 -3
  31. package/lib/types/startup.d.ts +14 -11
  32. package/lib/types/store.d.ts +11 -9
  33. package/lib/types/subagents.d.ts +3 -3
  34. package/lib/types/theme.d.ts +14 -1
  35. package/lib/types/version.d.ts +15 -2
  36. package/package.json +159 -141
  37. package/src/app.ts +1490 -663
  38. package/src/attachments.ts +128 -0
  39. package/src/authorization-panel.ts +285 -0
  40. package/src/authorization.ts +147 -0
  41. package/src/editor.ts +51 -0
  42. package/src/fork.ts +31 -0
  43. package/src/git-workflow.ts +87 -0
  44. package/src/index.ts +1523 -1374
  45. package/src/internals.ts +14 -1
  46. package/src/kernel-panels.ts +914 -798
  47. package/src/keyboard.ts +126 -0
  48. package/src/mentions.ts +78 -117
  49. package/src/models.ts +20 -14
  50. package/src/permissions.ts +5 -13
  51. package/src/presets.ts +6 -22
  52. package/src/provider-settings.ts +95 -1
  53. package/src/render/animations.ts +420 -450
  54. package/src/render/editor.ts +398 -0
  55. package/src/render/export.ts +79 -79
  56. package/src/render/lines.ts +342 -236
  57. package/src/render/markdown.ts +99 -26
  58. package/src/render/projection.ts +106 -19
  59. package/src/render/status.ts +713 -650
  60. package/src/render/text.ts +150 -150
  61. package/src/render/tool-detail.ts +3 -1
  62. package/src/session-directory.ts +4 -4
  63. package/src/startup.ts +136 -119
  64. package/src/store.ts +23 -11
  65. package/src/subagents.ts +13 -5
  66. package/src/theme.ts +214 -206
  67. 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
+ }
@@ -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 { 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, '')
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
+ }