dsh-code 0.7.0 → 0.9.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 (49) hide show
  1. package/README.en.md +30 -7
  2. package/README.md +30 -7
  3. package/lib/index.mjs +3791 -853
  4. package/lib/types/app.d.ts +90 -1
  5. package/lib/types/approval.d.ts +3 -1
  6. package/lib/types/history.d.ts +15 -4
  7. package/lib/types/index.d.ts +48 -0
  8. package/lib/types/kernel-panels.d.ts +65 -8
  9. package/lib/types/models.d.ts +15 -1
  10. package/lib/types/permissions.d.ts +37 -0
  11. package/lib/types/presets.d.ts +2 -0
  12. package/lib/types/provider-settings.d.ts +144 -0
  13. package/lib/types/questions.d.ts +2 -0
  14. package/lib/types/render/animations.d.ts +8 -6
  15. package/lib/types/render/lines.d.ts +6 -0
  16. package/lib/types/render/markdown.d.ts +3 -3
  17. package/lib/types/render/projection.d.ts +97 -3
  18. package/lib/types/render/status.d.ts +26 -36
  19. package/lib/types/render/text.d.ts +14 -7
  20. package/lib/types/render/tool-detail.d.ts +3 -1
  21. package/lib/types/render/tool-preview.d.ts +14 -1
  22. package/lib/types/session-directory.d.ts +61 -2
  23. package/lib/types/store.d.ts +13 -2
  24. package/lib/types/subagents.d.ts +60 -0
  25. package/lib/types/version.d.ts +5 -0
  26. package/package.json +1 -1
  27. package/src/app.ts +1200 -219
  28. package/src/approval.ts +161 -126
  29. package/src/history.ts +20 -5
  30. package/src/index.ts +577 -167
  31. package/src/kernel-panels.ts +354 -37
  32. package/src/models.ts +26 -0
  33. package/src/permissions.ts +85 -0
  34. package/src/presets.ts +12 -0
  35. package/src/provider-settings.ts +520 -0
  36. package/src/questions.ts +15 -5
  37. package/src/render/animations.ts +32 -18
  38. package/src/render/lines.ts +236 -218
  39. package/src/render/markdown.ts +302 -4
  40. package/src/render/projection.ts +670 -11
  41. package/src/render/status.ts +68 -162
  42. package/src/render/text.ts +28 -9
  43. package/src/render/tool-detail.ts +81 -40
  44. package/src/render/tool-preview.ts +77 -34
  45. package/src/session-directory.ts +171 -10
  46. package/src/skills.ts +8 -4
  47. package/src/store.ts +26 -8
  48. package/src/subagents.ts +165 -0
  49. package/src/version.ts +16 -0
@@ -115,19 +115,33 @@ export const DEEPSEEK_WAVE_BANDS: Readonly<Record<DeepseekWaveStyle, Readonly<Re
115
115
  },
116
116
  }
117
117
 
118
+ /** Extra display time applied to every Codex ignition style. */
119
+ export const DEEPSEEK_WAVE_DURATION_EXTENSION_MS = 200
120
+
121
+ /** Original Codex duration used as the animation's sampling timeline. */
122
+ function deepseekWaveBaseDuration(tier: DeepseekWaveTier, style: DeepseekWaveStyle): number {
123
+ switch (style) {
124
+ case 'aurora': return tier === 'flash' ? 1300 : 1600
125
+ case 'pulse': return tier === 'flash' ? 900 : 1250
126
+ case 'wave': return tier === 'flash' ? 1000 : 1300
127
+ }
128
+ }
129
+
118
130
  /**
119
- * Total animation duration Codex `IgnitionStyle::total_duration`: three
120
- * styles × two tiers.
131
+ * Total visible duration: the Codex ignition duration plus 200ms so its motion
132
+ * remains readable in a busy terminal.
121
133
  * @param tier - the active wave tier.
122
134
  * @param style - the active ignition style.
123
135
  * @returns the duration in milliseconds.
124
136
  */
125
137
  export function deepseekWaveDuration(tier: DeepseekWaveTier, style: DeepseekWaveStyle = 'wave'): number {
126
- switch (style) {
127
- case 'aurora': return tier === 'flash' ? 1300 : 1600
128
- case 'pulse': return tier === 'flash' ? 900 : 1250
129
- case 'wave': return tier === 'flash' ? 1000 : 1300
130
- }
138
+ return deepseekWaveBaseDuration(tier, style) + DEEPSEEK_WAVE_DURATION_EXTENSION_MS
139
+ }
140
+
141
+ /** Map the extended display timeline back onto the original Codex samples. */
142
+ function deepseekWaveSampleElapsedMs(tick: number, tier: DeepseekWaveTier, style: DeepseekWaveStyle): number {
143
+ const base = deepseekWaveBaseDuration(tier, style)
144
+ return tick * DEEPSEEK_WAVE_TICK_MS * base / deepseekWaveDuration(tier, style)
131
145
  }
132
146
 
133
147
  /**
@@ -282,8 +296,8 @@ export function deepseekWaveColumnBg(
282
296
  hues: readonly [RgbTriple, RgbTriple, RgbTriple],
283
297
  base: RgbTriple,
284
298
  ): RgbTriple | null {
285
- const total = deepseekWaveDuration(tier, style) / 1000
286
- const elapsed = (tick * DEEPSEEK_WAVE_TICK_MS) / 1000
299
+ const total = deepseekWaveBaseDuration(tier, style) / 1000
300
+ const elapsed = deepseekWaveSampleElapsedMs(tick, tier, style) / 1000
287
301
  const fade = style === 'aurora' ? envelope(elapsed, total, 0.25, 0.40) : 1
288
302
  const weights = [0, 0, 0]
289
303
  for (const band of DEEPSEEK_WAVE_BANDS[style][tier]) {
@@ -311,14 +325,14 @@ export function deepseekWaveColumnBg(
311
325
  }
312
326
 
313
327
  /**
314
- * The sparkle glyph for a tick — Codex `spark_frame`: from 900ms on, one
315
- * glyph every 100ms through ✧`, then silent. The deepseek (Ultra) tier
316
- * only; the Ink layer still must skip occupied cells.
328
+ * The sparkle glyph for a tick — Codex `spark_frame`, sampled on the same
329
+ * proportionally slowed DeepSeek Wave timeline as the composer background.
330
+ * The Ink layer still must skip occupied cells.
317
331
  * @param tick - wave frame at DEEPSEEK_WAVE_TICK_MS.
318
- * @returns the sparkle glyph, or null outside the 900..1200ms window.
332
+ * @returns the sparkle glyph, or null outside the stretched tail window.
319
333
  */
320
334
  export function deepseekWaveSpark(tick: number): string | null {
321
- const elapsed = tick * DEEPSEEK_WAVE_TICK_MS
335
+ const elapsed = deepseekWaveSampleElapsedMs(tick, 'deepseek', 'wave')
322
336
  if (elapsed < SPARK_START_MS) return null
323
337
  const frame = Math.floor((elapsed - SPARK_START_MS) / SPARK_FRAME_MS)
324
338
  return SPARK_GLYPHS[frame] ?? null
@@ -342,8 +356,8 @@ export function deepseekWaveBorderColor(
342
356
  hues: readonly [RgbTriple, RgbTriple, RgbTriple],
343
357
  dim: RgbTriple,
344
358
  ): RgbTriple {
345
- const total = deepseekWaveDuration(tier, style) / 1000
346
- const elapsed = (tick * DEEPSEEK_WAVE_TICK_MS) / 1000
359
+ const total = deepseekWaveBaseDuration(tier, style) / 1000
360
+ const elapsed = deepseekWaveSampleElapsedMs(tick, tier, style) / 1000
347
361
  // The border stays visibly on the tier accent for the whole sweep (a
348
362
  // floor keeps it glowing, not just cresting mid-wave): it ramps in as the
349
363
  // first band launches and relaxes after the last crest passes.
@@ -361,8 +375,8 @@ export function deepseekWaveBorderColor(
361
375
  * @returns true while the wordmark should be visible.
362
376
  */
363
377
  export function deepseekWaveWordVisible(tick: number, tier: DeepseekWaveTier, style: DeepseekWaveStyle = 'wave'): boolean {
364
- const total = deepseekWaveDuration(tier, style) / 1000
365
- const elapsed = (tick * DEEPSEEK_WAVE_TICK_MS) / 1000
378
+ const total = deepseekWaveBaseDuration(tier, style) / 1000
379
+ const elapsed = deepseekWaveSampleElapsedMs(tick, tier, style) / 1000
366
380
  return envelope(elapsed, total, total * 0.2, total * 0.35) > 0.25
367
381
  }
368
382
 
@@ -1,218 +1,236 @@
1
- /** Width-safe styled physical rows for bounded terminal panels. */
2
-
3
- import type { TranscriptEntry } from './projection.ts'
4
- import type { ToolDetail } from './tool-detail.ts'
5
- import { renderMarkdown, visibleColumns, type MdStyle } from './markdown.ts'
6
- import { formatTokens } from './status.ts'
7
- import { displayText } from './text.ts'
8
-
9
- /** Presentation classes mapped to Ink colors by the app boundary. */
10
- export type LineStyle = MdStyle | 'brand' | 'success' | 'error' | 'warn' | 'dimItalic'
11
-
12
- /** One styled run within a physical terminal row. */
13
- export interface StyledSegment {
14
- text: string
15
- style: LineStyle
16
- }
17
-
18
- /** One row guaranteed not to exceed the requested terminal width. */
19
- export interface StyledLine {
20
- segments: readonly StyledSegment[]
21
- }
22
-
23
- /** Construct one segment without leaking mutable objects into cached rows. */
24
- export function lineSegment(text: string, style: LineStyle = 'plain'): StyledSegment {
25
- return { text, style }
26
- }
27
-
28
- /** Append a character while merging adjacent runs with the same style. */
29
- function appendSegment(target: StyledSegment[], text: string, style: LineStyle): void {
30
- const previous = target[target.length - 1]
31
- if (previous?.style === style) {
32
- target[target.length - 1] = { text: previous.text + text, style }
33
- return
34
- }
35
- target.push({ text, style })
36
- }
37
-
38
- /**
39
- * Sanitize and hard-wrap styled content into exact physical rows.
40
- * Tabs become two visible spaces because terminal tab stops are contextual
41
- * and therefore cannot participate in a deterministic row budget.
42
- */
43
- export function styledLines(segments: readonly StyledSegment[], columns: number): readonly StyledLine[] {
44
- const width = Math.max(1, Math.floor(columns))
45
- const lines: StyledLine[] = []
46
- let current: StyledSegment[] = []
47
- let used = 0
48
- const flush = (): void => {
49
- lines.push({ segments: current })
50
- current = []
51
- used = 0
52
- }
53
-
54
- for (const segment of segments) {
55
- const safe = displayText(segment.text).replaceAll('\t', ' ').replaceAll('\r', '')
56
- for (const char of safe) {
57
- if (char === '\n') {
58
- flush()
59
- continue
60
- }
61
- const cells = visibleColumns(char)
62
- if (used > 0 && used + cells > width) flush()
63
- appendSegment(current, char, segment.style)
64
- used += cells
65
- }
66
- }
67
- if (current.length > 0 || lines.length === 0) flush()
68
- return lines
69
- }
70
-
71
- /** Plain/dim text convenience over {@link styledLines}. */
72
- export function textLines(text: string, columns: number, style: LineStyle = 'plain'): readonly StyledLine[] {
73
- return styledLines([lineSegment(text, style)], columns)
74
- }
75
-
76
- /** Markdown rows re-hardened so a single long word cannot escape the budget. */
77
- export function markdownLines(text: string, columns: number): readonly StyledLine[] {
78
- const width = Math.max(1, Math.floor(columns))
79
- const parsed = renderMarkdown(displayText(text), Math.max(10, width))
80
- return parsed.flatMap(line => styledLines(
81
- line.segments.map(segment => lineSegment(segment.text, segment.style)),
82
- width,
83
- ))
84
- }
85
-
86
- /** Expanded structured tool detail as scrollable, width-safe rows. */
87
- function toolDetailLines(detail: ToolDetail, columns: number): readonly StyledLine[] {
88
- switch (detail.kind) {
89
- case 'diff':
90
- return detail.diffs.flatMap(diff => [
91
- ...styledLines([
92
- lineSegment(' ── ', 'dim'),
93
- lineSegment(diff.path, 'dim'),
94
- lineSegment(diff.truncated ? ' (diff truncated)' : '', 'dim'),
95
- ], columns),
96
- ...diff.lines.flatMap(line => styledLines([
97
- lineSegment(` ${line.mark}${line.text}`, line.mark === '+' ? 'success' : line.mark === '-' ? 'error' : 'dim'),
98
- ], columns)),
99
- ])
100
- case 'read':
101
- return [
102
- ...textLines(
103
- ` ── ${detail.path} · lines ${detail.offset}-${detail.lines.length > 0 ? detail.lines[detail.lines.length - 1]!.number : detail.offset - 1} of ${detail.totalLines}${detail.truncated ? ' (window truncated)' : ''}`,
104
- columns,
105
- 'dim',
106
- ),
107
- ...detail.lines.flatMap(line => textLines(` ${String(line.number).padStart(5, ' ')} | ${line.text}`, columns, 'dim')),
108
- ]
109
- case 'web-search':
110
- return [
111
- ...detail.sources.flatMap(source => [
112
- ...styledLines([
113
- lineSegment(` ? ${source.title ?? source.url}`, 'brand'),
114
- lineSegment(` - ${source.url}`, 'dim'),
115
- ], columns),
116
- ...(source.snippet === '' ? [] : textLines(` ${source.snippet}`, columns, 'dim')),
117
- ]),
118
- ...textLines(` ${detail.sources.length} sources${detail.truncated ? ' (capped)' : ''}`, columns, 'dim'),
119
- ]
120
- case 'web-fetch':
121
- return textLines(` ${detail.url} · HTTP ${detail.statusCode}`, columns, 'dim')
122
- case 'raw':
123
- return [
124
- ...textLines(` ${detail.text}`, columns, 'dim'),
125
- ...textLines(detail.truncated ? ' … (output truncated)' : ' (end of output)', columns, 'dim'),
126
- ]
127
- default: {
128
- const exhaustive: never = detail
129
- return exhaustive
130
- }
131
- }
132
- }
133
-
134
- /**
135
- * Convert one durable transcript entry to its complete scrollable row model.
136
- * The source entry stays intact; only the caller's visible slice is rendered.
137
- */
138
- export function transcriptEntryLines(entry: TranscriptEntry, columns: number): readonly StyledLine[] {
139
- const width = Math.max(1, Math.floor(columns))
140
- switch (entry.kind) {
141
- case 'user':
142
- return styledLines([
143
- lineSegment(entry.notice ? '⤷ ' : '❯ ', entry.notice ? 'dim' : 'brand'),
144
- lineSegment(entry.text, entry.notice ? 'dim' : 'plain'),
145
- ], width)
146
- case 'pending':
147
- // Codex PendingSteer: a queued prompt renders exactly like an ordinary
148
- // user row, so the durable user/message retires it without any flicker.
149
- return styledLines([
150
- lineSegment('❯ ', 'brand'),
151
- lineSegment(entry.text, 'plain'),
152
- ], width)
153
- case 'assistant': {
154
- const reasoning = entry.reasoning === ''
155
- ? []
156
- : styledLines([
157
- lineSegment(' ✻ ', 'dimItalic'),
158
- lineSegment(entry.reasoning, 'dimItalic'),
159
- ], width)
160
- // Every reply row carries the composer's two-column gutter, so reply
161
- // text aligns with the input cursor (Codex LIVE_PREFIX alignment); the
162
- // wrap budget shrinks by the same amount so no line double-wraps.
163
- const body = markdownLines(entry.text, Math.max(10, width - 2))
164
- .map(line => ({ segments: [{ text: ' ', style: 'plain' as const }, ...line.segments] }))
165
- return [...reasoning, ...body]
166
- }
167
- case 'tool': {
168
- const mark = entry.state === 'running' ? '●' : entry.state === 'error' ? '⨯' : '⏺'
169
- const markStyle: LineStyle = entry.state === 'running' ? 'brand' : entry.state === 'error' ? 'error' : 'success'
170
- return [
171
- ...styledLines([
172
- lineSegment(`${mark} `, markStyle),
173
- lineSegment(entry.name, 'brand'),
174
- lineSegment(entry.preview === '' ? '' : ` ${entry.preview}`, 'dim'),
175
- ], width),
176
- ...(entry.summary === '' ? [] : textLines(` ⎿ ${entry.summary}`, width, entry.state === 'error' ? 'error' : 'dim')),
177
- ...(entry.detail === undefined ? [] : toolDetailLines(entry.detail, width)),
178
- ]
179
- }
180
- case 'command': {
181
- const mark = entry.state === 'running' ? '●' : entry.state === 'error' ? '⨯' : '⏺'
182
- const markStyle: LineStyle = entry.state === 'running' ? 'brand' : entry.state === 'error' ? 'error' : 'success'
183
- return [
184
- ...styledLines([
185
- lineSegment(`${mark} `, markStyle),
186
- lineSegment(`/${entry.name}`, 'brand'),
187
- lineSegment(entry.args === '' ? '' : ` ${entry.args}`, 'dim'),
188
- ], width),
189
- ...(entry.summary === '' ? [] : textLines(` ${entry.summary}`, width, entry.state === 'error' ? 'error' : 'dim')),
190
- ]
191
- }
192
- case 'turn-marker':
193
- return textLines(` ${entry.text}`, width, 'dim')
194
- case 'compaction':
195
- return textLines(entry.ok
196
- ? ` ⧉ compacted ~${formatTokens(entry.tokens)} tokens`
197
- : ` ⧉ compaction failed: ${entry.error}`, width, 'dim')
198
- case 'retry':
199
- return textLines(
200
- ` ↻ retry ${entry.attempt}/${entry.max} · ${entry.code} · ${Math.round(entry.delayMs / 100) / 10}s`,
201
- width,
202
- entry.state === 'running' ? 'warn' : 'dim',
203
- )
204
- case 'files':
205
- return entry.paths.length === 0
206
- ? textLines(' ⎄ no changed files', width, 'dim')
207
- : [
208
- ...textLines(` ⎄ ${entry.paths.length} changed file${entry.paths.length === 1 ? '' : 's'}`, width, 'dim'),
209
- ...entry.paths.flatMap(path => textLines(` ${path}`, width, 'dim')),
210
- ]
211
- case 'error':
212
- return textLines(entry.text, width, 'error')
213
- default: {
214
- const exhaustive: never = entry
215
- return exhaustive
216
- }
217
- }
218
- }
1
+ /** Width-safe styled physical rows for bounded terminal panels. */
2
+
3
+ import type { TranscriptEntry } from './projection.ts'
4
+ import type { ToolDetail } from './tool-detail.ts'
5
+ import { renderMarkdown, visibleColumns, type MdStyle } from './markdown.ts'
6
+ import { formatTokens } from './status.ts'
7
+ import { displayText } from './text.ts'
8
+
9
+ /** Presentation classes mapped to Ink colors by the app boundary. */
10
+ export type LineStyle = MdStyle | 'brand' | 'success' | 'error' | 'warn' | 'dimItalic'
11
+
12
+ /** One styled run within a physical terminal row. */
13
+ export interface StyledSegment {
14
+ text: string
15
+ style: LineStyle
16
+ }
17
+
18
+ /** One row guaranteed not to exceed the requested terminal width. */
19
+ export interface StyledLine {
20
+ segments: readonly StyledSegment[]
21
+ }
22
+
23
+ /** Construct one segment without leaking mutable objects into cached rows. */
24
+ export function lineSegment(text: string, style: LineStyle = 'plain'): StyledSegment {
25
+ return { text, style }
26
+ }
27
+
28
+ /** Append a character while merging adjacent runs with the same style. */
29
+ function appendSegment(target: StyledSegment[], text: string, style: LineStyle): void {
30
+ const previous = target[target.length - 1]
31
+ if (previous?.style === style) {
32
+ target[target.length - 1] = { text: previous.text + text, style }
33
+ return
34
+ }
35
+ target.push({ text, style })
36
+ }
37
+
38
+ /**
39
+ * Sanitize and hard-wrap styled content into exact physical rows.
40
+ * Tabs become two visible spaces because terminal tab stops are contextual
41
+ * and therefore cannot participate in a deterministic row budget.
42
+ */
43
+ export function styledLines(segments: readonly StyledSegment[], columns: number): readonly StyledLine[] {
44
+ const width = Math.max(1, Math.floor(columns))
45
+ const lines: StyledLine[] = []
46
+ let current: StyledSegment[] = []
47
+ let used = 0
48
+ const flush = (): void => {
49
+ lines.push({ segments: current })
50
+ current = []
51
+ used = 0
52
+ }
53
+
54
+ for (const segment of segments) {
55
+ const safe = displayText(segment.text).replaceAll('\t', ' ').replaceAll('\r', '')
56
+ for (const char of safe) {
57
+ if (char === '\n') {
58
+ flush()
59
+ continue
60
+ }
61
+ const cells = visibleColumns(char)
62
+ if (used > 0 && used + cells > width) flush()
63
+ appendSegment(current, char, segment.style)
64
+ used += cells
65
+ }
66
+ }
67
+ if (current.length > 0 || lines.length === 0) flush()
68
+ return lines
69
+ }
70
+
71
+ /** Plain/dim text convenience over {@link styledLines}. */
72
+ export function textLines(text: string, columns: number, style: LineStyle = 'plain'): readonly StyledLine[] {
73
+ return styledLines([lineSegment(text, style)], columns)
74
+ }
75
+
76
+ /** Markdown rows re-hardened so a single long word cannot escape the budget. */
77
+ export function markdownLines(text: string, columns: number): readonly StyledLine[] {
78
+ const width = Math.max(1, Math.floor(columns))
79
+ const parsed = renderMarkdown(displayText(text), Math.max(10, width))
80
+ return parsed.flatMap(line => styledLines(
81
+ line.segments.map(segment => lineSegment(segment.text, segment.style)),
82
+ width,
83
+ ))
84
+ }
85
+
86
+ /**
87
+ * Codex-style reasoning rows: the marker occupies the reply gutter and every
88
+ * wrapped or explicit continuation starts with the same two-column indent, so
89
+ * reasoning content and assistant Markdown share one left edge.
90
+ */
91
+ export function reasoningLines(text: string, columns: number): readonly StyledLine[] {
92
+ const width = Math.max(1, Math.floor(columns))
93
+ if (width < 3) return textLines(text, width, 'dimItalic')
94
+ const contentWidth = width - 2
95
+ const content = displayText(text).replaceAll('\t', ' ').replaceAll('\r', '')
96
+ .split('\n')
97
+ .flatMap(line => styledLines([lineSegment(line, 'dimItalic')], contentWidth))
98
+ return content.map((line, index) => ({
99
+ segments: [
100
+ lineSegment(index === 0 ? '' : ' ', 'dimItalic'),
101
+ ...line.segments,
102
+ ],
103
+ }))
104
+ }
105
+
106
+ /** Expanded structured tool detail as scrollable, width-safe rows. */
107
+ function toolDetailLines(detail: ToolDetail, columns: number): readonly StyledLine[] {
108
+ switch (detail.kind) {
109
+ case 'diff':
110
+ return detail.diffs.flatMap(diff => [
111
+ ...styledLines([
112
+ lineSegment(' ── ', 'dim'),
113
+ lineSegment(diff.path, 'dim'),
114
+ lineSegment(diff.truncated ? ' (diff truncated)' : '', 'dim'),
115
+ ], columns),
116
+ ...diff.lines.flatMap(line => styledLines([
117
+ lineSegment(` ${line.mark}${line.text}`, line.mark === '+' ? 'success' : line.mark === '-' ? 'error' : 'dim'),
118
+ ], columns)),
119
+ ])
120
+ case 'read':
121
+ return [
122
+ ...textLines(
123
+ ` ── ${detail.path} · lines ${detail.offset}-${detail.lines.length > 0 ? detail.lines[detail.lines.length - 1]!.number : detail.offset - 1} of ${detail.totalLines}${detail.truncated ? ' (window truncated)' : ''}`,
124
+ columns,
125
+ 'dim',
126
+ ),
127
+ ...detail.lines.flatMap(line => textLines(` ${String(line.number).padStart(5, ' ')} | ${line.text}`, columns, 'dim')),
128
+ ]
129
+ case 'web-search':
130
+ return [
131
+ ...detail.sources.flatMap(source => [
132
+ ...styledLines([
133
+ lineSegment(` ? ${source.title ?? source.url}`, 'brand'),
134
+ lineSegment(` - ${source.url}`, 'dim'),
135
+ ], columns),
136
+ ...(source.snippet === '' ? [] : textLines(` ${source.snippet}`, columns, 'dim')),
137
+ ]),
138
+ ...textLines(` ${detail.sources.length} sources${detail.truncated ? ' (capped)' : ''}`, columns, 'dim'),
139
+ ]
140
+ case 'web-fetch':
141
+ return textLines(` ${detail.url} · HTTP ${detail.statusCode}`, columns, 'dim')
142
+ case 'raw':
143
+ return [
144
+ ...textLines(` ${detail.text}`, columns, 'dim'),
145
+ ...textLines(detail.truncated ? ' … (output truncated)' : ' (end of output)', columns, 'dim'),
146
+ ]
147
+ default: {
148
+ const exhaustive: never = detail
149
+ return exhaustive
150
+ }
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Convert one durable transcript entry to its complete scrollable row model.
156
+ * The source entry stays intact; only the caller's visible slice is rendered.
157
+ */
158
+ export function transcriptEntryLines(entry: TranscriptEntry, columns: number): readonly StyledLine[] {
159
+ const width = Math.max(1, Math.floor(columns))
160
+ switch (entry.kind) {
161
+ case 'user':
162
+ return styledLines([
163
+ lineSegment(entry.notice ? '⤷ ' : '❯ ', entry.notice ? 'dim' : 'brand'),
164
+ lineSegment(entry.text, entry.notice ? 'dim' : 'plain'),
165
+ ], width)
166
+ case 'pending':
167
+ // Codex PendingSteer: a queued prompt renders exactly like an ordinary
168
+ // user row, so the durable user/message retires it without any flicker.
169
+ return styledLines([
170
+ lineSegment('❯ ', 'brand'),
171
+ lineSegment(entry.text, 'plain'),
172
+ ], width)
173
+ case 'assistant': {
174
+ const reasoning = entry.reasoning === '' ? [] : reasoningLines(entry.reasoning, width)
175
+ // Every reply row carries the composer's two-column gutter, so reply
176
+ // text aligns with the input cursor (Codex LIVE_PREFIX alignment); the
177
+ // wrap budget shrinks by the same amount so no line double-wraps.
178
+ const body = markdownLines(entry.text, Math.max(10, width - 2))
179
+ .map(line => ({ segments: [{ text: ' ', style: 'plain' as const }, ...line.segments] }))
180
+ return [...reasoning, ...body]
181
+ }
182
+ case 'tool': {
183
+ const mark = entry.state === 'running' ? '●' : entry.state === 'error' ? '⨯' : '⏺'
184
+ const markStyle: LineStyle = entry.state === 'running' ? 'brand' : entry.state === 'error' ? 'error' : 'success'
185
+ return [
186
+ ...styledLines([
187
+ lineSegment(`${mark} `, markStyle),
188
+ lineSegment(entry.name, 'brand'),
189
+ lineSegment(entry.preview === '' ? '' : ` ${entry.preview}`, 'dim'),
190
+ ], width),
191
+ // A delegation card carries what the child was asked (Codex's
192
+ // SpawnAgent prompt preview) while it runs, before any result.
193
+ ...(entry.prompt === '' ? [] : textLines(` ${entry.prompt}`, width, 'dim')),
194
+ ...(entry.summary === '' ? [] : textLines(` ⎿ ${entry.summary}`, width, entry.state === 'error' ? 'error' : 'dim')),
195
+ ...(entry.detail === undefined ? [] : toolDetailLines(entry.detail, width)),
196
+ ]
197
+ }
198
+ case 'command': {
199
+ const mark = entry.state === 'running' ? '●' : entry.state === 'error' ? '⨯' : '⏺'
200
+ const markStyle: LineStyle = entry.state === 'running' ? 'brand' : entry.state === 'error' ? 'error' : 'success'
201
+ return [
202
+ ...styledLines([
203
+ lineSegment(`${mark} `, markStyle),
204
+ lineSegment(`/${entry.name}`, 'brand'),
205
+ lineSegment(entry.args === '' ? '' : ` ${entry.args}`, 'dim'),
206
+ ], width),
207
+ ...(entry.summary === '' ? [] : textLines(` ⎿ ${entry.summary}`, width, entry.state === 'error' ? 'error' : 'dim')),
208
+ ]
209
+ }
210
+ case 'turn-marker':
211
+ return textLines(` ⏹ ${entry.text}`, width, 'dim')
212
+ case 'compaction':
213
+ return textLines(entry.ok
214
+ ? ` ⧉ compacted ~${formatTokens(entry.tokens)} tokens`
215
+ : ` ⧉ compaction failed: ${entry.error}`, width, 'dim')
216
+ case 'retry':
217
+ return textLines(
218
+ ` ↻ retry ${entry.attempt}/${entry.max} · ${entry.code} · ${Math.round(entry.delayMs / 100) / 10}s`,
219
+ width,
220
+ entry.state === 'running' ? 'warn' : 'dim',
221
+ )
222
+ case 'files':
223
+ return entry.paths.length === 0
224
+ ? textLines(' ⎄ no changed files', width, 'dim')
225
+ : [
226
+ ...textLines(` ⎄ ${entry.paths.length} changed file${entry.paths.length === 1 ? '' : 's'}`, width, 'dim'),
227
+ ...entry.paths.flatMap(path => textLines(` ${path}`, width, 'dim')),
228
+ ]
229
+ case 'error':
230
+ return textLines(entry.text, width, 'error')
231
+ default: {
232
+ const exhaustive: never = entry
233
+ return exhaustive
234
+ }
235
+ }
236
+ }