dsh-code 1.0.2 → 1.0.4

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 (53) hide show
  1. package/README.en.md +21 -13
  2. package/README.md +285 -271
  3. package/bin/deepseek.mjs +26 -3
  4. package/lib/index.mjs +2962 -1560
  5. package/lib/types/app.d.ts +13 -2
  6. package/lib/types/commands.d.ts +13 -0
  7. package/lib/types/editor-keys.d.ts +105 -0
  8. package/lib/types/git-workflow.d.ts +6 -2
  9. package/lib/types/index.d.ts +28 -0
  10. package/lib/types/input-split.d.ts +54 -0
  11. package/lib/types/kernel-panels.d.ts +3 -1
  12. package/lib/types/keyboard.d.ts +8 -0
  13. package/lib/types/model-capabilities.d.ts +82 -0
  14. package/lib/types/provider-settings.d.ts +84 -0
  15. package/lib/types/render/lines.d.ts +25 -0
  16. package/lib/types/render/markdown.d.ts +1 -1
  17. package/lib/types/render/projection.d.ts +22 -2
  18. package/lib/types/render/status.d.ts +22 -15
  19. package/lib/types/render/text.d.ts +15 -9
  20. package/lib/types/render/width.d.ts +29 -0
  21. package/lib/types/session-directory.d.ts +27 -0
  22. package/lib/types/settings-file.d.ts +33 -0
  23. package/lib/types/skills.d.ts +1 -1
  24. package/lib/types/store.d.ts +10 -0
  25. package/lib/types/subagents.d.ts +13 -3
  26. package/package.json +159 -159
  27. package/src/app.ts +4514 -3892
  28. package/src/approval.ts +8 -3
  29. package/src/authorization-panel.ts +2 -4
  30. package/src/commands.ts +27 -3
  31. package/src/editor-keys.ts +371 -0
  32. package/src/git-workflow.ts +10 -6
  33. package/src/index.ts +1752 -1523
  34. package/src/input-split.ts +191 -0
  35. package/src/internals.ts +26 -8
  36. package/src/kernel-panels.ts +26 -10
  37. package/src/keyboard.ts +123 -88
  38. package/src/mentions.ts +42 -9
  39. package/src/model-capabilities.ts +318 -0
  40. package/src/provider-settings.ts +220 -0
  41. package/src/questions.ts +20 -0
  42. package/src/render/lines.ts +415 -356
  43. package/src/render/markdown.ts +18 -19
  44. package/src/render/projection.ts +162 -52
  45. package/src/render/status.ts +76 -71
  46. package/src/render/text.ts +158 -150
  47. package/src/render/width.ts +189 -0
  48. package/src/session-directory.ts +56 -0
  49. package/src/settings-file.ts +56 -0
  50. package/src/skills.ts +19 -6
  51. package/src/store.ts +26 -7
  52. package/src/subagents.ts +39 -6
  53. package/src/theme-panel.ts +79 -72
@@ -1,356 +1,415 @@
1
- /** Width-safe styled physical rows for bounded terminal panels. */
2
-
3
- import { promptDisplayText, 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, truncateColumns } 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
- /** Prefix every wrapped physical row without exceeding the column budget. */
77
- function prefixedStyledLines(segments: readonly StyledSegment[], columns: number, prefix: string, prefixStyle: LineStyle = 'plain'): readonly StyledLine[] {
78
- const width = Math.max(1, Math.floor(columns))
79
- const prefixWidth = Math.min(width, visibleColumns(prefix))
80
- const bodyWidth = Math.max(1, width - prefixWidth)
81
- return styledLines(segments, bodyWidth).map(line => ({
82
- segments: [lineSegment(prefix, prefixStyle), ...line.segments],
83
- }))
84
- }
85
-
86
- /** Text convenience for a tool row whose continuation must keep its gutter. */
87
- function prefixedTextLines(text: string, columns: number, prefix: string, style: LineStyle = 'plain'): readonly StyledLine[] {
88
- return prefixedStyledLines([lineSegment(text, style)], columns, prefix, style)
89
- }
90
-
91
- /**
92
- * Wrap styled segments with a hanging indent: the first physical row carries
93
- * `firstPrefix` (often a marker plus gutter) and every wrapped continuation
94
- * carries the narrower `contPrefix`, so long tool summaries and prompts
95
- * align under their card instead of falling back to column zero. The first
96
- * row may hold one prefix-width more than the continuations.
97
- */
98
- function hangingStyledLines(
99
- segments: readonly StyledSegment[],
100
- columns: number,
101
- firstPrefix: string,
102
- firstStyle: LineStyle,
103
- contPrefix: string,
104
- contStyle: LineStyle = firstStyle,
105
- ): readonly StyledLine[] {
106
- const width = Math.max(2, Math.floor(columns))
107
- const firstPrefixText = truncateColumns(firstPrefix, Math.max(1, width - 1))
108
- const contPrefixText = truncateColumns(contPrefix, Math.max(1, width - 1))
109
- const firstBudget = Math.max(1, width - visibleColumns(firstPrefixText))
110
- const contBudget = Math.max(1, width - visibleColumns(contPrefixText))
111
- const lines: StyledLine[] = []
112
- let current: StyledSegment[] = []
113
- let used = 0
114
- let budget = firstBudget
115
- const flush = (): void => {
116
- lines.push({ segments: current })
117
- current = []
118
- used = 0
119
- budget = contBudget
120
- }
121
- for (const segment of segments) {
122
- const safe = displayText(segment.text).replaceAll('\t', ' ').replaceAll('\r', '')
123
- for (const char of safe) {
124
- if (char === '\n') {
125
- flush()
126
- continue
127
- }
128
- const cells = visibleColumns(char)
129
- if (used > 0 && used + cells > budget) flush()
130
- appendSegment(current, char, segment.style)
131
- used += cells
132
- }
133
- }
134
- if (current.length > 0 || lines.length === 0) flush()
135
- return lines.map((line, index) => ({
136
- segments: [
137
- lineSegment(index === 0 ? firstPrefixText : contPrefixText, index === 0 ? firstStyle : contStyle),
138
- ...line.segments,
139
- ],
140
- }))
141
- }
142
-
143
- /** Plain-text convenience over {@link hangingStyledLines}. */
144
- function hangingTextLines(
145
- text: string,
146
- columns: number,
147
- firstPrefix: string,
148
- firstStyle: LineStyle = 'plain',
149
- contPrefix = ' ',
150
- contStyle: LineStyle = firstStyle,
151
- ): readonly StyledLine[] {
152
- return hangingStyledLines([lineSegment(text, firstStyle)], columns, firstPrefix, firstStyle, contPrefix, contStyle)
153
- }
154
-
155
- /** Markdown rows re-hardened so a single long word cannot escape the budget. */
156
- export function markdownLines(text: string, columns: number): readonly StyledLine[] {
157
- const width = Math.max(1, Math.floor(columns))
158
- const parsed = renderMarkdown(displayText(text), Math.max(10, width))
159
- return parsed.flatMap(line => styledLines(
160
- line.segments.map(segment => lineSegment(segment.text, segment.style)),
161
- width,
162
- ))
163
- }
164
-
165
- /**
166
- * Codex-style reasoning rows: the marker occupies the reply gutter and every
167
- * wrapped or explicit continuation starts with the same two-column indent, so
168
- * reasoning content and assistant Markdown share one left edge.
169
- */
170
- export function reasoningLines(text: string, columns: number): readonly StyledLine[] {
171
- const width = Math.max(1, Math.floor(columns))
172
- if (width < 3) return textLines(text, width, 'dimItalic')
173
- const contentWidth = width - 2
174
- const content = displayText(text).replaceAll('\t', ' ').replaceAll('\r', '')
175
- .split('\n')
176
- .flatMap(line => styledLines([lineSegment(line, 'dimItalic')], contentWidth))
177
- return content.map((line, index) => ({
178
- segments: [
179
- lineSegment(index === 0 ? '✻ ' : ' ', 'dimItalic'),
180
- ...line.segments,
181
- ],
182
- }))
183
- }
184
-
185
- /** Expanded structured tool detail as scrollable, width-safe rows. */
186
- /**
187
- * Detail rows share the tool card's four-column hanging gutter: the summary
188
- * (⎿) and delegation prompt (└) continuations already sit at four columns, so
189
- * diff/read/web/raw rows align under them instead of floating two columns
190
- * shallower.
191
- */
192
- function toolDetailLines(detail: ToolDetail, columns: number): readonly StyledLine[] {
193
- switch (detail.kind) {
194
- case 'diff':
195
- return detail.diffs.flatMap(diff => [
196
- ...prefixedTextLines(`${diff.path}${diff.truncated ? ' (diff truncated)' : ''}`, columns, ' ── ', 'dim'),
197
- ...diff.lines.flatMap(line => prefixedTextLines(
198
- `${line.mark}${line.text}`,
199
- columns,
200
- ' ',
201
- line.mark === '+' ? 'success' : line.mark === '-' ? 'error' : 'dim',
202
- )),
203
- ])
204
- case 'read':
205
- return [
206
- ...prefixedTextLines(
207
- `${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)' : ''}`,
208
- columns,
209
- ' ── ',
210
- 'dim',
211
- ),
212
- ...detail.lines.flatMap(line => prefixedTextLines(`${String(line.number).padStart(5, ' ')} | ${line.text}`, columns, ' ', 'dim')),
213
- ]
214
- case 'web-search':
215
- return [
216
- ...detail.sources.flatMap(source => [
217
- ...prefixedStyledLines([
218
- lineSegment(source.title ?? source.url, 'brand'),
219
- lineSegment(` - ${source.url}`, 'dim'),
220
- ], columns, ' ? '),
221
- ...(source.snippet === '' ? [] : prefixedTextLines(source.snippet, columns, ' ', 'dim')),
222
- ]),
223
- ...prefixedTextLines(`${detail.sources.length} sources${detail.truncated ? ' (capped)' : ''}`, columns, ' ', 'dim'),
224
- ]
225
- case 'web-fetch':
226
- return prefixedTextLines(`${detail.url} · HTTP ${detail.statusCode}`, columns, ' ', 'dim')
227
- case 'raw':
228
- return [
229
- ...prefixedTextLines(detail.text, columns, ' ', 'dim'),
230
- ...prefixedTextLines(detail.truncated ? '… (output truncated)' : '(end of output)', columns, ' ', 'dim'),
231
- ]
232
- default: {
233
- const exhaustive: never = detail
234
- return exhaustive
235
- }
236
- }
237
- }
238
-
239
- /** Default compact tool-card window used while the Ctrl+R fold is closed. */
240
- const DEFAULT_TOOL_ROWS = 3
241
-
242
- /** Keep the invocation visible while making hidden tool output discoverable. */
243
- function compactToolLines(lines: readonly StyledLine[], columns: number): readonly StyledLine[] {
244
- if (lines.length <= DEFAULT_TOOL_ROWS) return lines
245
- return [
246
- ...lines.slice(0, DEFAULT_TOOL_ROWS - 1),
247
- ...textLines(' … output hidden · Ctrl+R', columns, 'dim').slice(0, 1),
248
- ]
249
- }
250
-
251
- /**
252
- * Convert one durable transcript entry to its complete scrollable row model.
253
- * The source entry stays intact; only the caller's visible slice is rendered.
254
- * Wrapped continuations keep a hanging indent aligned under each row's
255
- * content (Codex history-cell alignment) instead of resetting to column 0.
256
- */
257
- export function transcriptEntryLines(
258
- entry: TranscriptEntry,
259
- columns: number,
260
- showReasoning = true,
261
- reasoningToggleHint = true,
262
- showToolDetails = showReasoning,
263
- ): readonly StyledLine[] {
264
- const width = Math.max(1, Math.floor(columns))
265
- switch (entry.kind) {
266
- case 'user':
267
- return entry.notice
268
- ? hangingStyledLines([lineSegment(promptDisplayText(entry), 'dim')], width, '⤷ ', 'dim', ' ', 'dim')
269
- : hangingStyledLines([lineSegment(promptDisplayText(entry), 'plain')], width, '❯ ', 'brand', ' ', 'plain')
270
- case 'pending':
271
- // Codex PendingSteer: a queued prompt renders exactly like an ordinary
272
- // user row, so the durable user/message retires it without any flicker.
273
- return hangingStyledLines([lineSegment(promptDisplayText(entry), 'plain')], width, '❯ ', 'brand', ' ', 'plain')
274
- case 'assistant': {
275
- const reasoning = entry.reasoning === ''
276
- ? []
277
- : showReasoning
278
- ? reasoningLines(entry.reasoning, width)
279
- : textLines(`✻ Thinking (${entry.reasoning.length} chars${reasoningToggleHint ? ', Ctrl+R to expand' : ''})`, width, 'dim')
280
- // Every reply row carries the composer's two-column gutter, so reply
281
- // text aligns with the input cursor (Codex LIVE_PREFIX alignment); the
282
- // wrap budget shrinks by the same amount so no line double-wraps.
283
- const body = markdownLines(entry.text, Math.max(10, width - 2))
284
- .map(line => ({ segments: [{ text: ' ', style: 'plain' as const }, ...line.segments] }))
285
- // A cancelled stream's delivered prefix settles as this entry; one
286
- // bounded dim marker row distinguishes it from a completed reply.
287
- const interrupted = entry.interrupted === true ? textLines(' ⏹ interrupted', width, 'dim') : []
288
- return [...reasoning, ...body, ...interrupted]
289
- }
290
- case 'tool': {
291
- const mark = entry.state === 'running' ? '●' : entry.state === 'error' ? '⨯' : '⏺'
292
- const markStyle: LineStyle = entry.state === 'running' ? 'brand' : entry.state === 'error' ? 'error' : 'success'
293
- const summaryStyle: LineStyle = entry.state === 'error' ? 'error' : 'dim'
294
- const lines = [
295
- // The invocation row hangs wrapped previews under the call badge.
296
- ...hangingStyledLines([
297
- // Global call ordinal the same number an error line references.
298
- lineSegment(`[${entry.ordinal}] `, 'dim'),
299
- lineSegment(entry.name, 'brand'),
300
- lineSegment(entry.preview === '' ? '' : ` ${entry.preview}`, 'dim'),
301
- ], width, `${mark} `, markStyle, ' ', 'plain'),
302
- // A delegation card carries what the child was asked (Codex's
303
- // SpawnAgent prompt preview) while it runs, before any result.
304
- ...(entry.prompt === '' ? [] : hangingTextLines(entry.prompt, width, ' └ ', 'dim', ' ')),
305
- ...(entry.summary === '' ? [] : hangingTextLines(
306
- entry.state === 'error' ? `call ${entry.ordinal}: ${entry.summary}` : entry.summary,
307
- width, ' ⎿ ', summaryStyle, ' ', summaryStyle,
308
- )),
309
- ...(entry.detail === undefined ? [] : toolDetailLines(entry.detail, width)),
310
- ]
311
- return showToolDetails ? lines : compactToolLines(lines, width)
312
- }
313
- case 'command': {
314
- const mark = entry.state === 'running' ? '●' : entry.state === 'error' ? '' : ''
315
- const markStyle: LineStyle = entry.state === 'running' ? 'brand' : entry.state === 'error' ? 'error' : 'success'
316
- const summaryStyle: LineStyle = entry.state === 'error' ? 'error' : 'dim'
317
- return [
318
- ...hangingStyledLines([
319
- lineSegment(`/${entry.name}`, 'brand'),
320
- lineSegment(entry.args === '' ? '' : ` ${entry.args}`, 'dim'),
321
- ], width, `${mark} `, markStyle, ' ', 'plain'),
322
- ...(entry.summary === '' ? [] : hangingTextLines(entry.summary, width, ' ⎿ ', summaryStyle, ' ', summaryStyle)),
323
- ]
324
- }
325
- case 'turn-marker':
326
- return textLines(` ⏹ ${entry.text}`, width, 'dim')
327
- case 'compaction':
328
- return textLines(entry.ok
329
- ? ` ⧉ compacted ~${formatTokens(entry.tokens)} tokens`
330
- : ` ⧉ compaction failed: ${entry.error}`, width, 'dim')
331
- case 'retry':
332
- return textLines(
333
- ` ↻ retry ${entry.attempt}/${entry.max} · ${entry.code} · ${Math.round(entry.delayMs / 100) / 10}s`,
334
- width,
335
- entry.state === 'running' ? 'warn' : 'dim',
336
- )
337
- case 'files':
338
- return entry.paths.length === 0
339
- ? textLines(' no changed files', width, 'dim')
340
- : [
341
- ...textLines(` ⎄ ${entry.paths.length} changed file${entry.paths.length === 1 ? '' : 's'}`, width, 'dim'),
342
- ...entry.paths.flatMap(path => hangingTextLines(path, width, ' ', 'dim', ' ')),
343
- ]
344
- case 'error':
345
- return textLines(entry.text, width, 'error')
346
- default: {
347
- const exhaustive: never = entry
348
- return exhaustive
349
- }
350
- }
351
- }
352
-
353
- /** Settled-history variant carrying the Ctrl+R reasoning fold. */
354
- export function settledEntryLines(entry: TranscriptEntry, columns: number, showReasoning: boolean): readonly StyledLine[] {
355
- return transcriptEntryLines(entry, columns, showReasoning, false, showReasoning)
356
- }
1
+ /** Width-safe styled physical rows for bounded terminal panels. */
2
+
3
+ import { promptDisplayText, type TranscriptEntry } from './projection.ts'
4
+ import type { ToolDetail } from './tool-detail.ts'
5
+ import { renderMarkdown, visibleColumns, type MdStyle } from './markdown.ts'
6
+ import { graphemeWidth, splitGraphemes } from './width.ts'
7
+ import { formatTokens } from './status.ts'
8
+ import { displayText, truncateColumns } from './text.ts'
9
+
10
+ /** Presentation classes mapped to Ink colors by the app boundary. */
11
+ export type LineStyle = MdStyle | 'brand' | 'success' | 'error' | 'warn' | 'dimItalic'
12
+
13
+ /** One styled run within a physical terminal row. */
14
+ export interface StyledSegment {
15
+ text: string
16
+ style: LineStyle
17
+ }
18
+
19
+ /** One row guaranteed not to exceed the requested terminal width. */
20
+ export interface StyledLine {
21
+ segments: readonly StyledSegment[]
22
+ }
23
+
24
+ /** Construct one segment without leaking mutable objects into cached rows. */
25
+ export function lineSegment(text: string, style: LineStyle = 'plain'): StyledSegment {
26
+ return { text, style }
27
+ }
28
+
29
+ /** Append a character while merging adjacent runs with the same style. */
30
+ function appendSegment(target: StyledSegment[], text: string, style: LineStyle): void {
31
+ const previous = target[target.length - 1]
32
+ if (previous?.style === style) {
33
+ target[target.length - 1] = { text: previous.text + text, style }
34
+ return
35
+ }
36
+ target.push({ text, style })
37
+ }
38
+
39
+ /**
40
+ * Sanitize and hard-wrap styled content into exact physical rows.
41
+ * Tabs become two visible spaces because terminal tab stops are contextual
42
+ * and therefore cannot participate in a deterministic row budget.
43
+ */
44
+ export function styledLines(segments: readonly StyledSegment[], columns: number): readonly StyledLine[] {
45
+ const width = Math.max(1, Math.floor(columns))
46
+ const lines: StyledLine[] = []
47
+ let current: StyledSegment[] = []
48
+ let used = 0
49
+ const flush = (): void => {
50
+ lines.push({ segments: current })
51
+ current = []
52
+ used = 0
53
+ }
54
+
55
+ for (const segment of segments) {
56
+ const safe = displayText(segment.text).replaceAll('\t', ' ').replaceAll('\r', '')
57
+ // Grapheme clusters, never bare code points: a ZWJ family or a flag is
58
+ // one terminal cell run, and splitting it would both split the glyph
59
+ // across rows and double-count its width against the budget.
60
+ for (const cluster of splitGraphemes(safe)) {
61
+ if (cluster === '\n') {
62
+ flush()
63
+ continue
64
+ }
65
+ const cells = graphemeWidth(cluster)
66
+ if (used > 0 && used + cells > width) flush()
67
+ appendSegment(current, cluster, segment.style)
68
+ used += cells
69
+ }
70
+ }
71
+ if (current.length > 0 || lines.length === 0) flush()
72
+ return lines
73
+ }
74
+
75
+ /** Plain/dim text convenience over {@link styledLines}. */
76
+ export function textLines(text: string, columns: number, style: LineStyle = 'plain'): readonly StyledLine[] {
77
+ return styledLines([lineSegment(text, style)], columns)
78
+ }
79
+
80
+ /** Prefix every wrapped physical row without exceeding the column budget. */
81
+ function prefixedStyledLines(segments: readonly StyledSegment[], columns: number, prefix: string, prefixStyle: LineStyle = 'plain'): readonly StyledLine[] {
82
+ const width = Math.max(1, Math.floor(columns))
83
+ // Keep one column for the body even when the prefix alone would fill the
84
+ // row: a prefix allowed to claim the whole width pushed prefix+body one
85
+ // column past the budget on very narrow terminals.
86
+ const prefixWidth = Math.min(width - 1, visibleColumns(prefix))
87
+ const bodyWidth = Math.max(1, width - prefixWidth)
88
+ return styledLines(segments, bodyWidth).map(line => ({
89
+ segments: [lineSegment(prefix, prefixStyle), ...line.segments],
90
+ }))
91
+ }
92
+
93
+ /** Text convenience for a tool row whose continuation must keep its gutter. */
94
+ function prefixedTextLines(text: string, columns: number, prefix: string, style: LineStyle = 'plain'): readonly StyledLine[] {
95
+ return prefixedStyledLines([lineSegment(text, style)], columns, prefix, style)
96
+ }
97
+
98
+ /**
99
+ * Wrap styled segments with a hanging indent: the first physical row carries
100
+ * `firstPrefix` (often a marker plus gutter) and every wrapped continuation
101
+ * carries the narrower `contPrefix`, so long tool summaries and prompts
102
+ * align under their card instead of falling back to column zero. The first
103
+ * row may hold one prefix-width more than the continuations.
104
+ */
105
+ function hangingStyledLines(
106
+ segments: readonly StyledSegment[],
107
+ columns: number,
108
+ firstPrefix: string,
109
+ firstStyle: LineStyle,
110
+ contPrefix: string,
111
+ contStyle: LineStyle = firstStyle,
112
+ ): readonly StyledLine[] {
113
+ const width = Math.max(2, Math.floor(columns))
114
+ const firstPrefixText = truncateColumns(firstPrefix, Math.max(1, width - 1))
115
+ const contPrefixText = truncateColumns(contPrefix, Math.max(1, width - 1))
116
+ const firstBudget = Math.max(1, width - visibleColumns(firstPrefixText))
117
+ const contBudget = Math.max(1, width - visibleColumns(contPrefixText))
118
+ const lines: StyledLine[] = []
119
+ let current: StyledSegment[] = []
120
+ let used = 0
121
+ let budget = firstBudget
122
+ const flush = (): void => {
123
+ lines.push({ segments: current })
124
+ current = []
125
+ used = 0
126
+ budget = contBudget
127
+ }
128
+ for (const segment of segments) {
129
+ const safe = displayText(segment.text).replaceAll('\t', ' ').replaceAll('\r', '')
130
+ for (const cluster of splitGraphemes(safe)) {
131
+ if (cluster === '\n') {
132
+ flush()
133
+ continue
134
+ }
135
+ const cells = graphemeWidth(cluster)
136
+ if (used > 0 && used + cells > budget) flush()
137
+ appendSegment(current, cluster, segment.style)
138
+ used += cells
139
+ }
140
+ }
141
+ if (current.length > 0 || lines.length === 0) flush()
142
+ return lines.map((line, index) => ({
143
+ segments: [
144
+ lineSegment(index === 0 ? firstPrefixText : contPrefixText, index === 0 ? firstStyle : contStyle),
145
+ ...line.segments,
146
+ ],
147
+ }))
148
+ }
149
+
150
+ /** Plain-text convenience over {@link hangingStyledLines}. */
151
+ function hangingTextLines(
152
+ text: string,
153
+ columns: number,
154
+ firstPrefix: string,
155
+ firstStyle: LineStyle = 'plain',
156
+ contPrefix = ' ',
157
+ contStyle: LineStyle = firstStyle,
158
+ ): readonly StyledLine[] {
159
+ return hangingStyledLines([lineSegment(text, firstStyle)], columns, firstPrefix, firstStyle, contPrefix, contStyle)
160
+ }
161
+
162
+ /** Markdown rows re-hardened so a single long word cannot escape the budget. */
163
+ export function markdownLines(text: string, columns: number): readonly StyledLine[] {
164
+ const width = Math.max(1, Math.floor(columns))
165
+ // The markdown pass formats at the real width — a 10-column floor on a
166
+ // narrower terminal silently pushed rows past the budget (styledLines
167
+ // re-hardens long words at `width` either way).
168
+ const parsed = renderMarkdown(displayText(text), width)
169
+ return parsed.flatMap(line => styledLines(
170
+ line.segments.map(segment => lineSegment(segment.text, segment.style)),
171
+ width,
172
+ ))
173
+ }
174
+
175
+ /**
176
+ * Codex-style reasoning rows: the marker occupies the reply gutter and every
177
+ * wrapped or explicit continuation starts with the same two-column indent, so
178
+ * reasoning content and assistant Markdown share one left edge.
179
+ */
180
+ export function reasoningLines(text: string, columns: number): readonly StyledLine[] {
181
+ const width = Math.max(1, Math.floor(columns))
182
+ if (width < 3) return textLines(text, width, 'dimItalic')
183
+ const contentWidth = width - 2
184
+ const content = displayText(text).replaceAll('\t', ' ').replaceAll('\r', '')
185
+ .split('\n')
186
+ .flatMap(line => styledLines([lineSegment(line, 'dimItalic')], contentWidth))
187
+ return content.map((line, index) => ({
188
+ segments: [
189
+ lineSegment(index === 0 ? '✻ ' : ' ', 'dimItalic'),
190
+ ...line.segments,
191
+ ],
192
+ }))
193
+ }
194
+
195
+ /** Expanded structured tool detail as scrollable, width-safe rows. */
196
+ /**
197
+ * Detail rows share the tool card's four-column hanging gutter: the summary
198
+ * (⎿) and delegation prompt (└) continuations already sit at four columns, so
199
+ * diff/read/web/raw rows align under them instead of floating two columns
200
+ * shallower.
201
+ */
202
+ function toolDetailLines(detail: ToolDetail, columns: number): readonly StyledLine[] {
203
+ switch (detail.kind) {
204
+ case 'diff':
205
+ return detail.diffs.flatMap(diff => [
206
+ ...prefixedTextLines(`${diff.path}${diff.truncated ? ' (diff truncated)' : ''}`, columns, ' ── ', 'dim'),
207
+ ...diff.lines.flatMap(line => prefixedTextLines(
208
+ `${line.mark}${line.text}`,
209
+ columns,
210
+ ' ',
211
+ line.mark === '+' ? 'success' : line.mark === '-' ? 'error' : 'dim',
212
+ )),
213
+ ])
214
+ case 'read':
215
+ return [
216
+ ...prefixedTextLines(
217
+ `${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)' : ''}`,
218
+ columns,
219
+ ' ── ',
220
+ 'dim',
221
+ ),
222
+ ...detail.lines.flatMap(line => prefixedTextLines(`${String(line.number).padStart(5, ' ')} | ${line.text}`, columns, ' ', 'dim')),
223
+ ]
224
+ case 'web-search':
225
+ return [
226
+ ...detail.sources.flatMap(source => [
227
+ ...prefixedStyledLines([
228
+ lineSegment(source.title ?? source.url, 'brand'),
229
+ lineSegment(` - ${source.url}`, 'dim'),
230
+ ], columns, ' ? '),
231
+ ...(source.snippet === '' ? [] : prefixedTextLines(source.snippet, columns, ' ', 'dim')),
232
+ ]),
233
+ ...prefixedTextLines(`${detail.sources.length} sources${detail.truncated ? ' (capped)' : ''}`, columns, ' ', 'dim'),
234
+ ]
235
+ case 'web-fetch':
236
+ return prefixedTextLines(`${detail.url} · HTTP ${detail.statusCode}`, columns, ' ', 'dim')
237
+ case 'raw':
238
+ return [
239
+ ...prefixedTextLines(detail.text, columns, ' ', 'dim'),
240
+ ...prefixedTextLines(detail.truncated ? '… (output truncated)' : '(end of output)', columns, ' ', 'dim'),
241
+ ]
242
+ default: {
243
+ const exhaustive: never = detail
244
+ return exhaustive
245
+ }
246
+ }
247
+ }
248
+
249
+ /** Default compact tool-card window used while the Ctrl+R fold is closed. */
250
+ const DEFAULT_TOOL_ROWS = 3
251
+
252
+ /** Keep the invocation visible while making hidden tool output discoverable. */
253
+ function compactToolLines(lines: readonly StyledLine[], columns: number): readonly StyledLine[] {
254
+ if (lines.length <= DEFAULT_TOOL_ROWS) return lines
255
+ return [
256
+ ...lines.slice(0, DEFAULT_TOOL_ROWS - 1),
257
+ ...textLines(' … output hidden · Ctrl/Alt+R', columns, 'dim').slice(0, 1),
258
+ ]
259
+ }
260
+
261
+ /**
262
+ * Convert one durable transcript entry to its complete scrollable row model.
263
+ * The source entry stays intact; only the caller's visible slice is rendered.
264
+ * Wrapped continuations keep a hanging indent aligned under each row's
265
+ * content (Codex history-cell alignment) instead of resetting to column 0.
266
+ */
267
+ export function transcriptEntryLines(
268
+ entry: TranscriptEntry,
269
+ columns: number,
270
+ showReasoning = true,
271
+ reasoningToggleHint = true,
272
+ showToolDetails = showReasoning,
273
+ ): readonly StyledLine[] {
274
+ const width = Math.max(1, Math.floor(columns))
275
+ switch (entry.kind) {
276
+ case 'user':
277
+ return entry.notice
278
+ ? hangingStyledLines([lineSegment(promptDisplayText(entry), 'dim')], width, '⤷ ', 'dim', ' ', 'dim')
279
+ : hangingStyledLines([lineSegment(promptDisplayText(entry), 'plain')], width, '❯ ', 'brand', ' ', 'plain')
280
+ case 'pending':
281
+ // Codex PendingSteer: a queued prompt renders exactly like an ordinary
282
+ // user row, so the durable user/message retires it without any flicker.
283
+ return hangingStyledLines([lineSegment(promptDisplayText(entry), 'plain')], width, '❯ ', 'brand', ' ', 'plain')
284
+ case 'assistant': {
285
+ const reasoning = entry.reasoning === ''
286
+ ? []
287
+ : showReasoning
288
+ ? reasoningLines(entry.reasoning, width)
289
+ : textLines(`✻ Thinking (${entry.reasoning.length} chars${reasoningToggleHint ? ', Ctrl/Alt+R to expand' : ''})`, width, 'dim')
290
+ // Every reply row carries the composer's two-column gutter, so reply
291
+ // text aligns with the input cursor (Codex LIVE_PREFIX alignment); the
292
+ // wrap budget shrinks by the same amount so no line double-wraps.
293
+ const body = markdownLines(entry.text, Math.max(1, width - 2))
294
+ .map(line => ({ segments: [{ text: ' ', style: 'plain' as const }, ...line.segments] }))
295
+ // A cancelled stream's delivered prefix settles as this entry; one
296
+ // bounded dim marker row distinguishes it from a completed reply.
297
+ const interrupted = entry.interrupted === true ? textLines(' ⏹ interrupted', width, 'dim') : []
298
+ return [...reasoning, ...body, ...interrupted]
299
+ }
300
+ case 'tool': {
301
+ const mark = entry.state === 'running' ? '' : entry.state === 'error' ? '⨯' : '⏺'
302
+ const markStyle: LineStyle = entry.state === 'running' ? 'brand' : entry.state === 'error' ? 'error' : 'success'
303
+ const summaryStyle: LineStyle = entry.state === 'error' ? 'error' : 'dim'
304
+ const lines = [
305
+ // The invocation row hangs wrapped previews under the call badge.
306
+ ...hangingStyledLines([
307
+ // Global call ordinal the same number an error line references.
308
+ lineSegment(`[${entry.ordinal}] `, 'dim'),
309
+ lineSegment(entry.name, 'brand'),
310
+ lineSegment(entry.preview === '' ? '' : ` ${entry.preview}`, 'dim'),
311
+ ], width, `${mark} `, markStyle, ' ', 'plain'),
312
+ // A delegation card carries what the child was asked (Codex's
313
+ // SpawnAgent prompt preview) while it runs, before any result.
314
+ ...(entry.prompt === '' ? [] : hangingTextLines(entry.prompt, width, '', 'dim', ' ')),
315
+ ...(entry.summary === '' ? [] : hangingTextLines(
316
+ entry.state === 'error' ? `call ${entry.ordinal}: ${entry.summary}` : entry.summary,
317
+ width, ' ⎿ ', summaryStyle, ' ', summaryStyle,
318
+ )),
319
+ ...(entry.detail === undefined ? [] : toolDetailLines(entry.detail, width)),
320
+ ]
321
+ return showToolDetails ? lines : compactToolLines(lines, width)
322
+ }
323
+ case 'command': {
324
+ const mark = entry.state === 'running' ? '●' : entry.state === 'error' ? '⨯' : '⏺'
325
+ const markStyle: LineStyle = entry.state === 'running' ? 'brand' : entry.state === 'error' ? 'error' : 'success'
326
+ const summaryStyle: LineStyle = entry.state === 'error' ? 'error' : 'dim'
327
+ return [
328
+ ...hangingStyledLines([
329
+ lineSegment(`/${entry.name}`, 'brand'),
330
+ lineSegment(entry.args === '' ? '' : ` ${entry.args}`, 'dim'),
331
+ ], width, `${mark} `, markStyle, ' ', 'plain'),
332
+ ...(entry.summary === '' ? [] : hangingTextLines(entry.summary, width, ' ⎿ ', summaryStyle, ' ', summaryStyle)),
333
+ ]
334
+ }
335
+ case 'turn-marker':
336
+ return textLines(` ⏹ ${entry.text}`, width, 'dim')
337
+ case 'compaction':
338
+ return textLines(entry.ok
339
+ ? ` compacted ~${formatTokens(entry.tokens)} tokens`
340
+ : ` ⧉ compaction failed: ${entry.error}`, width, 'dim')
341
+ case 'retry':
342
+ return textLines(
343
+ entry.mode === 'always'
344
+ ? ` ↻ retry ${entry.attempt} · ${entry.code} · ${Math.round(entry.delayMs / 100) / 10}s`
345
+ : ` ↻ retry ${entry.attempt}/${entry.max} · ${entry.code} · ${Math.round(entry.delayMs / 100) / 10}s`,
346
+ width,
347
+ entry.state === 'running' ? 'warn' : 'dim',
348
+ )
349
+ case 'files':
350
+ return entry.paths.length === 0
351
+ ? textLines(' ⎄ no changed files', width, 'dim')
352
+ : [
353
+ ...textLines(` ⎄ ${entry.paths.length} changed file${entry.paths.length === 1 ? '' : 's'}`, width, 'dim'),
354
+ ...entry.paths.flatMap(path => hangingTextLines(path, width, ' ', 'dim', ' ')),
355
+ ]
356
+ case 'error':
357
+ return textLines(entry.text, width, 'error')
358
+ default: {
359
+ const exhaustive: never = entry
360
+ return exhaustive
361
+ }
362
+ }
363
+ }
364
+
365
+ /** Settled-history variant carrying the Ctrl+R reasoning fold. */
366
+ export function settledEntryLines(entry: TranscriptEntry, columns: number, showReasoning: boolean): readonly StyledLine[] {
367
+ return transcriptEntryLines(entry, columns, showReasoning, false, showReasoning)
368
+ }
369
+
370
+ /** The flexible rows of the live region; chrome (composer/notice/status) is never reduced. */
371
+ export interface LiveAllocation {
372
+ /** Settled tail rows currently rendered in the live tree. */
373
+ readonly live: number
374
+ /** Rows reserved for the streaming reasoning tail or its marker. */
375
+ readonly reasoning: number
376
+ /** Rows reserved for the streaming answer tail. */
377
+ readonly answer: number
378
+ }
379
+
380
+ /** A clamped allocation plus the invariant-trip warning that triggered it. */
381
+ export interface LiveAllocationAudit {
382
+ readonly allocation: LiveAllocation
383
+ readonly warning?: string
384
+ }
385
+
386
+ /**
387
+ * Clamp the live-region allocation so the flexible dynamic rows never exceed
388
+ * the post-chrome budget. By construction the caller derives these rows from
389
+ * the same budget; this is the runtime tripwire for a future edit that breaks
390
+ * that derivation. Reduction order: answer first (the freshest content is the
391
+ * live tail), then reasoning, then settled live rows; nothing goes negative.
392
+ * @param allocation - the intended row allocation.
393
+ * @param dynamicRows - the post-chrome row budget.
394
+ * @returns the clamped allocation and a warning string when clamping fired.
395
+ */
396
+ export function clampLiveAllocation(allocation: LiveAllocation, dynamicRows: number): LiveAllocationAudit {
397
+ const live = Math.max(0, Math.floor(allocation.live))
398
+ const reasoning = Math.max(0, Math.floor(allocation.reasoning))
399
+ const answer = Math.max(0, Math.floor(allocation.answer))
400
+ const budget = Math.max(0, Math.floor(dynamicRows))
401
+ let excess = live + reasoning + answer - budget
402
+ if (excess <= 0) return { allocation: { live, reasoning, answer } }
403
+ const take = (from: number): number => {
404
+ const cut = Math.min(from, excess)
405
+ excess -= cut
406
+ return from - cut
407
+ }
408
+ const clampedAnswer = take(answer)
409
+ const clampedReasoning = excess > 0 ? take(reasoning) : reasoning
410
+ const clampedLive = excess > 0 ? take(live) : live
411
+ return {
412
+ allocation: { live: clampedLive, reasoning: clampedReasoning, answer: clampedAnswer },
413
+ warning: `live rows ${live} + ${reasoning} + ${answer} exceed the dynamic budget ${budget}; clamped to ${clampedLive}/${clampedReasoning}/${clampedAnswer}`,
414
+ }
415
+ }