dsh-code 0.9.1 → 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 +29 -13
  2. package/README.md +264 -248
  3. package/bin/deepseek.mjs +100 -6
  4. package/cordis.patch.yml +29 -1
  5. package/lib/index.mjs +2223 -687
  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 +10 -39
  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 -141
  33. package/src/app.ts +4455 -3917
  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 +25 -55
  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
@@ -40,34 +40,98 @@ export function visibleColumns(text: string): number {
40
40
  return columns
41
41
  }
42
42
 
43
- /** Walk a segment list, breaking it into lines that fit `width` columns. */
44
- function wrapSegments(segments: readonly MdSegment[], width: number): readonly MdSegment[][] {
45
- const lines: MdSegment[][] = []
46
- let current: MdSegment[] = []
47
- let used = 0
43
+ interface WrapUnit {
44
+ text: string
45
+ style: MdStyle
46
+ }
47
+
48
+ /** Punctuation that should not become the first visible glyph of a row. */
49
+ function isClosingPunctuation(text: string): boolean {
50
+ return /^[,.;:!?%、。,.!?:;%)》」』】〕〉》”’]/u.test(text)
51
+ }
52
+
53
+ /**
54
+ * Split prose into soft-wrap units: ASCII words stay intact, while wide CJK
55
+ * glyphs become individual break opportunities. This keeps ordinary prose
56
+ * readable without allowing a Chinese paragraph to become one overlong word.
57
+ */
58
+ function wrapUnits(segments: readonly MdSegment[]): readonly WrapUnit[] {
59
+ const units: WrapUnit[] = []
48
60
  for (const segment of segments) {
49
- // Break the segment at spaces into words so long runs wrap mid-text.
50
- const words = segment.text.split(/( )/u)
51
- for (const word of words) {
52
- if (word === '') continue
53
- const columns = visibleColumns(word)
54
- if (used + columns > width && used > 0) {
55
- lines.push(current)
56
- current = []
57
- used = 0
61
+ let word = ''
62
+ const flushWord = (): void => {
63
+ if (word !== '') units.push({ text: word, style: segment.style })
64
+ word = ''
65
+ }
66
+ for (const char of segment.text) {
67
+ if (char === ' ') {
68
+ flushWord()
69
+ units.push({ text: char, style: segment.style })
70
+ } else if (visibleColumns(char) > 1) {
71
+ flushWord()
72
+ units.push({ text: char, style: segment.style })
73
+ } else {
74
+ word += char
58
75
  }
59
- // A single word wider than the line still goes on its own line.
60
- current.push({ text: word, style: segment.style })
76
+ }
77
+ flushWord()
78
+ }
79
+ return units
80
+ }
81
+
82
+ /** Walk styled units, keeping logical rows within the physical column budget. */
83
+ function wrapSegments(segments: readonly MdSegment[], width: number): readonly (readonly MdSegment[])[] {
84
+ const limit = Math.max(1, Math.floor(width))
85
+ const lines: (readonly MdSegment[])[] = []
86
+ let current: WrapUnit[] = []
87
+ let used = 0
88
+ const flush = (): void => {
89
+ while (current.at(-1)?.text === ' ') {
90
+ used -= visibleColumns(current.pop()!.text)
91
+ }
92
+ if (current.length > 0) lines.push(merge(current))
93
+ current = []
94
+ used = 0
95
+ }
96
+ const appendAtom = (unit: WrapUnit): void => {
97
+ const columns = visibleColumns(unit.text)
98
+ if (used > 0 && used + columns > limit) flush()
99
+ current.push(unit)
100
+ used += columns
101
+ }
102
+ const append = (unit: WrapUnit): void => {
103
+ const columns = visibleColumns(unit.text)
104
+ if (used === 0 && columns > limit) {
105
+ for (const char of unit.text) appendAtom({ text: char, style: unit.style })
106
+ return
107
+ }
108
+ if (used + columns <= limit || current.length === 0) {
109
+ current.push(unit)
61
110
  used += columns
111
+ return
112
+ }
113
+ // Keep full-width punctuation attached to the preceding CJK glyph. If
114
+ // the row is full, move that glyph with the punctuation instead of
115
+ // producing a visually orphaned line beginning with ':' or '。'.
116
+ const previous = current.at(-1)
117
+ if (isClosingPunctuation(unit.text) && previous !== undefined && visibleColumns(previous.text) > 1) {
118
+ current.pop()
119
+ used -= visibleColumns(previous.text)
120
+ flush()
121
+ appendAtom(previous)
122
+ appendAtom(unit)
123
+ return
124
+ }
125
+ flush()
126
+ if (columns > limit) {
127
+ for (const char of unit.text) appendAtom({ text: char, style: unit.style })
128
+ } else {
129
+ appendAtom(unit)
62
130
  }
63
131
  }
64
- if (current.length > 0) lines.push(current)
65
- // Drop the trailing space a wrapped line picked up before the break.
66
- return lines.map(line => {
67
- const last = line[line.length - 1]
68
- if (last !== undefined && last.text === ' ' && line.length > 1) return line.slice(0, -1)
69
- return line
70
- })
132
+ for (const unit of wrapUnits(segments)) append(unit)
133
+ flush()
134
+ return lines
71
135
  }
72
136
 
73
137
  /** Join adjacent same-style runs so the app renders fewer elements. */
@@ -448,10 +512,18 @@ function renderTableRecords(table: ParsedTable, width: number): readonly MdLine[
448
512
  }
449
513
 
450
514
  /** Render markdown text into styled lines of at most `width` columns. */
451
- export function renderMarkdown(text: string, width: number): readonly MdLine[] {
515
+ export function renderMarkdown(
516
+ text: string,
517
+ width: number,
518
+ options: { physicalWrap?: boolean } = {},
519
+ ): readonly MdLine[] {
452
520
  const lines: MdLine[] = []
453
521
  let separatorPending = false
454
522
  const push = (segments: readonly MdSegment[]): void => {
523
+ if (options.physicalWrap === false) {
524
+ lines.push({ segments: merge(segments) })
525
+ return
526
+ }
455
527
  for (const wrapped of wrapSegments(segments, Math.max(10, width))) {
456
528
  lines.push({ segments: merge(wrapped) })
457
529
  }
@@ -520,12 +592,13 @@ export function renderMarkdown(text: string, width: number): readonly MdLine[] {
520
592
  }
521
593
  const ordered = ORDERED.exec(line)
522
594
  if (ordered !== null) {
523
- push([seg(` ${ordered[1] ?? ''}. `, 'accent'), ...parseInline(ordered[2] ?? '').map(run => seg(run.text, run.style))])
595
+ const prefix = options.physicalWrap === false ? `${ordered[1] ?? ''}. ` : ` ${ordered[1] ?? ''}. `
596
+ push([seg(prefix, 'accent'), ...parseInline(ordered[2] ?? '').map(run => seg(run.text, run.style))])
524
597
  continue
525
598
  }
526
599
  const unordered = UNORDERED.exec(line)
527
600
  if (unordered !== null) {
528
- push([seg(' • ', 'accent'), ...parseInline(unordered[1] ?? '').map(run => seg(run.text, run.style))])
601
+ push([seg(options.physicalWrap === false ? '• ' : ' • ', 'accent'), ...parseInline(unordered[1] ?? '').map(run => seg(run.text, run.style))])
529
602
  continue
530
603
  }
531
604
 
@@ -7,7 +7,7 @@
7
7
  * @module @deepseek-ai/dsh-tui/render/projection
8
8
  */
9
9
 
10
- import { boundContextSummary, type ContentBlock, type MessageId } from '@deepseek-ai/dsh-llm'
10
+ import { boundContextSummary, type ContentBlock, type ImageBlock, type MessageId } from '@deepseek-ai/dsh-llm'
11
11
  import type { SessionEvent, TodoItem } from '@deepseek-ai/dsh-session'
12
12
  // Type-only imports merge the plugin-owned SessionEventMap variants
13
13
  // (agent/inbox/spliced, command/*, compaction/*, goal/change, llm/retry*,
@@ -52,6 +52,8 @@ export interface UserEntry {
52
52
  /** True for collapsed injected context (plugin/continuation notices), which
53
53
  * the renderer marks with a dim ↳ instead of the user ❯ prompt. */
54
54
  notice: boolean
55
+ /** Durable image references carried by this prompt. */
56
+ images?: readonly ImageBlock['attachment'][]
55
57
  }
56
58
 
57
59
  /** One user message waiting in the agent inbox (the web's queued-message row). */
@@ -63,15 +65,20 @@ export interface PendingEntry {
63
65
  target: 'next-turn' | 'next-step'
64
66
  /** Full message text — Codex PendingSteer renders queued prompts exactly like user rows. */
65
67
  text: string
68
+ /** Durable image references queued with this prompt. */
69
+ images?: readonly ImageBlock['attachment'][]
66
70
  }
67
71
 
68
- /** One assembled assistant reply. */
72
+ /** One authoritative assembled assistant reply. */
69
73
  export interface AssistantEntry {
70
74
  kind: 'assistant'
71
75
  /** Joined text blocks of the assistant message. */
72
76
  text: string
73
- /** Joined reasoning blocks of the same message, empty when the model thought out loud. */
77
+ /** Joined reasoning blocks from the same assembled message. */
74
78
  reasoning: string
79
+ /** True when a cancelled stream's delivered prefix was finalized as this
80
+ * entry (rc.8 `assistant/message.interrupted`) — rendered with a marker. */
81
+ interrupted?: true
75
82
  }
76
83
 
77
84
  /** One model-requested tool invocation and its settled state. */
@@ -79,6 +86,13 @@ export interface ToolEntry {
79
86
  kind: 'tool'
80
87
  /** Correlation id shared with the matching `tool/result`. */
81
88
  callId: string
89
+ /**
90
+ * Global tool-call ordinal across the whole transcript (1, 2, 3…, never
91
+ * reset between turns). The tool-card badge and every error line that
92
+ * references the failed call share this number, so "call N" in an error
93
+ * always names the exact card the badge shows.
94
+ */
95
+ ordinal: number
82
96
  /** Tool name as the model addressed it. */
83
97
  name: string
84
98
  /** Raw arguments JSON string exactly as the model produced it. */
@@ -253,6 +267,12 @@ export interface TranscriptView {
253
267
  streamingReasoning: string
254
268
  /** Latest whole-list todo snapshot from `todo/write`, empty when none. */
255
269
  todos: readonly TodoItem[]
270
+ /**
271
+ * Global tool-call ordinal counter: the number the NEXT `tool/call` lands
272
+ * with (1-based). Never reset, so the counter and the badges/error lines
273
+ * stay consistent across turns and resumed sessions.
274
+ */
275
+ toolCallOrdinal: number
256
276
  /** True while a durable turn is open (`turn/start` … `turn/end`). */
257
277
  busy: boolean
258
278
  /** `turn/start` time of the open turn (0 while idle) — the web TurnStatus clock anchor. */
@@ -307,6 +327,27 @@ function textOf(content: readonly ContentBlock[]): string {
307
327
  return content.filter(block => block.type === 'text').map(block => block.text).join('')
308
328
  }
309
329
 
330
+ /** Durable image references in their model-visible order. */
331
+ function imagesOf(content: readonly ContentBlock[]): readonly ImageBlock['attachment'][] {
332
+ return content.filter((block): block is ImageBlock => block.type === 'image').map(block => block.attachment)
333
+ }
334
+
335
+ /** Human-readable bounded image labels for transcript, inspector, and export surfaces. */
336
+ export function imageLabels(images: readonly ImageBlock['attachment'][] | undefined): string {
337
+ if (images === undefined || images.length === 0) return ''
338
+ return images.map((image, index) => {
339
+ const rawName = image.name?.trim() || `image ${index + 1}`
340
+ const name = rawName.length <= 80 ? rawName : `${rawName.slice(0, 79)}…`
341
+ return `[image: ${name} · ${image.width}×${image.height} · ${image.bytes} B]`
342
+ }).join('\n')
343
+ }
344
+
345
+ /** Prompt text with its durable image labels, without exposing local paths or bytes. */
346
+ export function promptDisplayText(entry: Pick<UserEntry | PendingEntry, 'text' | 'images'>): string {
347
+ const labels = imageLabels(entry.images)
348
+ return entry.text === '' ? labels : labels === '' ? entry.text : `${entry.text}\n${labels}`
349
+ }
350
+
310
351
  /** Join the reasoning blocks of a content list; non-reasoning blocks contribute nothing. */
311
352
  function reasoningOf(content: readonly ContentBlock[]): string {
312
353
  return content.filter(block => block.type === 'reasoning').map(block => block.text).join('')
@@ -337,6 +378,7 @@ export function createTranscriptView(): TranscriptView {
337
378
  streaming: '',
338
379
  streamingReasoning: '',
339
380
  todos: [],
381
+ toolCallOrdinal: 0,
340
382
  busy: false,
341
383
  busySince: 0,
342
384
  model: '',
@@ -381,11 +423,12 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
381
423
  // to a bounded notice row, exactly like collapsed transcript context
382
424
  // elsewhere in the product; only direct human prompts render in full.
383
425
  const text = textOf(message.content)
426
+ const images = imagesOf(message.content)
384
427
  if (message.source.kind === 'user') {
385
428
  return {
386
429
  ...view,
387
430
  pending,
388
- entries: [...entries, { kind: 'user', text, notice: false }],
431
+ entries: [...entries, { kind: 'user', text, notice: false, ...(images.length === 0 ? {} : { images }) }],
389
432
  stats: {
390
433
  ...view.stats,
391
434
  contextSegments: {
@@ -436,6 +479,7 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
436
479
  messageId: message.id,
437
480
  target,
438
481
  text: pendingText(message.content),
482
+ ...imagesOf(message.content).length === 0 ? {} : { images: imagesOf(message.content) },
439
483
  }]
440
484
  }
441
485
  return { ...view, entries, pending: { ...view.pending, [target]: nextIds } }
@@ -459,10 +503,18 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
459
503
  }
460
504
  }
461
505
  if (chunk.type === 'text-delta') {
462
- return { ...view, streaming: appendStreamingTail(view.streaming, chunk.text), stats }
506
+ return {
507
+ ...view,
508
+ streaming: appendStreamingTail(view.streaming, chunk.text),
509
+ stats,
510
+ }
463
511
  }
464
512
  if (chunk.type === 'reasoning-delta') {
465
- return { ...view, streamingReasoning: appendStreamingTail(view.streamingReasoning, chunk.text), stats }
513
+ return {
514
+ ...view,
515
+ streamingReasoning: appendStreamingTail(view.streamingReasoning, chunk.text),
516
+ stats,
517
+ }
466
518
  }
467
519
  return view
468
520
  }
@@ -484,11 +536,7 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
484
536
  ...view,
485
537
  streaming: '',
486
538
  streamingReasoning: '',
487
- entries: [...view.entries, {
488
- kind: 'assistant',
489
- text,
490
- reasoning,
491
- }],
539
+ entries: [...view.entries, { kind: 'assistant', text, reasoning, interrupted: event.data.interrupted === true ? true : undefined }],
492
540
  stats: {
493
541
  ...view.stats,
494
542
  llmMs: view.stats.llmMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
@@ -519,11 +567,16 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
519
567
  const turnTools = view.anchors.turnTools.get(data.turn) ?? new Set<string>()
520
568
  turnTools.add(data.callId)
521
569
  view.anchors.turnTools.set(data.turn, turnTools)
570
+ const ordinal = view.toolCallOrdinal + 1
522
571
  return {
523
572
  ...view,
524
- entries: [...view.entries, {
525
- kind: 'tool',
573
+ toolCallOrdinal: ordinal,
574
+ entries: [
575
+ ...view.entries,
576
+ {
577
+ kind: 'tool',
526
578
  callId: data.callId,
579
+ ordinal,
527
580
  name: data.name,
528
581
  arguments: data.arguments,
529
582
  preview: toolArgumentsPreview(data.arguments, data.name),
@@ -607,7 +660,12 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
607
660
  }
608
661
  view.anchors.turnSteps.set(event.data.turn, key)
609
662
  view.anchors.stepStart.set(key, event.time)
610
- return { ...view, stats: { ...view.stats, steps: view.stats.steps + 1 } }
663
+ return {
664
+ ...view,
665
+ streaming: '',
666
+ streamingReasoning: '',
667
+ stats: { ...view.stats, steps: view.stats.steps + 1 },
668
+ }
611
669
  }
612
670
  case 'turn/end': {
613
671
  const reason = event.data.reason
@@ -651,13 +709,24 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
651
709
  for (const callId of turnToolSet) view.anchors.toolStart.delete(callId)
652
710
  view.anchors.turnTools.delete(event.data.turn)
653
711
  }
654
- if (appended.length === 0) return { ...view, busy: false, busySince: 0 }
655
- return { ...view, busy: false, busySince: 0, entries: [...view.entries, ...appended] }
712
+ if (appended.length === 0) {
713
+ return { ...view, busy: false, busySince: 0, streaming: '', streamingReasoning: '' }
714
+ }
715
+ return {
716
+ ...view,
717
+ busy: false,
718
+ busySince: 0,
719
+ streaming: '',
720
+ streamingReasoning: '',
721
+ entries: [...view.entries, ...appended],
722
+ }
656
723
  }
657
724
  case 'llm/retry': {
658
725
  const data = event.data
659
726
  return {
660
727
  ...view,
728
+ streaming: '',
729
+ streamingReasoning: '',
661
730
  entries: [...view.entries, {
662
731
  kind: 'retry',
663
732
  retryId: data.retryId,
@@ -842,6 +911,8 @@ export interface ReplayAccumulator {
842
911
  streaming: string
843
912
  streamingReasoning: string
844
913
  todos: readonly TodoItem[]
914
+ /** Global tool-call ordinal counter (see `TranscriptView.toolCallOrdinal`). */
915
+ toolCallOrdinal: number
845
916
  busy: boolean
846
917
  busySince: number
847
918
  model: string
@@ -877,6 +948,7 @@ export function createReplayAccumulator(): ReplayAccumulator {
877
948
  streaming: '',
878
949
  streamingReasoning: '',
879
950
  todos: [],
951
+ toolCallOrdinal: 0,
880
952
  busy: false,
881
953
  busySince: 0,
882
954
  model: '',
@@ -980,8 +1052,9 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
980
1052
  }
981
1053
  }
982
1054
  const text = textOf(message.content)
1055
+ const images = imagesOf(message.content)
983
1056
  if (message.source.kind === 'user') {
984
- appendReplayEntry(acc, { kind: 'user', text, notice: false })
1057
+ appendReplayEntry(acc, { kind: 'user', text, notice: false, ...(images.length === 0 ? {} : { images }) })
985
1058
  acc.stats = {
986
1059
  ...acc.stats,
987
1060
  contextSegments: {
@@ -1023,7 +1096,8 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1023
1096
  }
1024
1097
  }
1025
1098
  for (const message of inserted) {
1026
- appendReplayEntry(acc, { kind: 'pending', messageId: message.id, target, text: pendingText(message.content) })
1099
+ const images = imagesOf(message.content)
1100
+ appendReplayEntry(acc, { kind: 'pending', messageId: message.id, target, text: pendingText(message.content), ...(images.length === 0 ? {} : { images }) })
1027
1101
  indexList(acc.pendingIndex, message.id).push(acc.entries.length - 1)
1028
1102
  ids.push(message.id)
1029
1103
  acc.ops += 1
@@ -1068,7 +1142,7 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1068
1142
  const reasoning = reasoningOf(event.data.message.content)
1069
1143
  acc.streaming = ''
1070
1144
  acc.streamingReasoning = ''
1071
- appendReplayEntry(acc, { kind: 'assistant', text, reasoning })
1145
+ appendReplayEntry(acc, { kind: 'assistant', text, reasoning, interrupted: event.data.interrupted === true ? true : undefined })
1072
1146
  acc.stats = {
1073
1147
  ...acc.stats,
1074
1148
  llmMs: acc.stats.llmMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
@@ -1095,9 +1169,11 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1095
1169
  const turnTools = acc.turnTools.get(data.turn) ?? new Set<string>()
1096
1170
  turnTools.add(data.callId)
1097
1171
  acc.turnTools.set(data.turn, turnTools)
1172
+ acc.toolCallOrdinal += 1
1098
1173
  appendReplayEntry(acc, {
1099
1174
  kind: 'tool',
1100
1175
  callId: data.callId,
1176
+ ordinal: acc.toolCallOrdinal,
1101
1177
  name: data.name,
1102
1178
  arguments: data.arguments,
1103
1179
  preview: toolArgumentsPreview(data.arguments, data.name),
@@ -1173,12 +1249,16 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1173
1249
  }
1174
1250
  acc.turnSteps.set(event.data.turn, key)
1175
1251
  acc.stepStart.set(key, event.time)
1252
+ acc.streaming = ''
1253
+ acc.streamingReasoning = ''
1176
1254
  acc.stats = { ...acc.stats, steps: acc.stats.steps + 1 }
1177
1255
  return
1178
1256
  }
1179
1257
  case 'turn/end': {
1180
1258
  const reason = event.data.reason
1181
1259
  const appended: TranscriptEntry[] = []
1260
+ acc.streamingReasoning = ''
1261
+ acc.streaming = ''
1182
1262
  if (reason.kind === 'error') {
1183
1263
  const recovery = reason.error.code === 'MISSING_CREDENTIAL'
1184
1264
  ? ' · open /model to add an API key'
@@ -1217,6 +1297,8 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1217
1297
  }
1218
1298
  case 'llm/retry': {
1219
1299
  const data = event.data
1300
+ acc.streaming = ''
1301
+ acc.streamingReasoning = ''
1220
1302
  appendReplayEntry(acc, {
1221
1303
  kind: 'retry',
1222
1304
  retryId: data.retryId,
@@ -1354,6 +1436,7 @@ export function finishReplay(acc: ReplayAccumulator): TranscriptView {
1354
1436
  streaming: acc.streaming,
1355
1437
  streamingReasoning: acc.streamingReasoning,
1356
1438
  todos: acc.todos,
1439
+ toolCallOrdinal: acc.toolCallOrdinal,
1357
1440
  busy: acc.busy,
1358
1441
  busySince: acc.busySince,
1359
1442
  model: acc.model,