dsh-code 1.0.7 → 1.3.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.
- package/README.en.md +70 -24
- package/README.md +71 -25
- package/bin/deepseek.mjs +202 -39
- package/cordis.patch.yml +13 -4
- package/lib/index.mjs +5002 -1061
- package/lib/session-query.mjs +3 -2
- package/lib/startup.mjs +4 -4
- package/lib/{theme-DCT8Y2xf.mjs → theme-B3orFUYz.mjs} +665 -20
- package/lib/types/app.d.ts +120 -63
- package/lib/types/attachments.d.ts +16 -7
- package/lib/types/authorization-panel.d.ts +3 -3
- package/lib/types/git-workflow.d.ts +91 -2
- package/lib/types/history.d.ts +10 -0
- package/lib/types/i18n.d.ts +39 -0
- package/lib/types/index.d.ts +74 -2
- package/lib/types/input-split.d.ts +1 -1
- package/lib/types/kernel-panels.d.ts +89 -32
- package/lib/types/language-panel.d.ts +12 -0
- package/lib/types/locales/en.d.ts +498 -0
- package/lib/types/locales/zh.d.ts +9 -0
- package/lib/types/mentions.d.ts +7 -3
- package/lib/types/models.d.ts +14 -0
- package/lib/types/panel-accent.d.ts +28 -0
- package/lib/types/rainbow.d.ts +69 -0
- package/lib/types/render/animations.d.ts +42 -0
- package/lib/types/render/inspector.d.ts +26 -0
- package/lib/types/render/lines.d.ts +21 -1
- package/lib/types/render/markdown.d.ts +1 -1
- package/lib/types/render/projection.d.ts +95 -4
- package/lib/types/render/status.d.ts +12 -9
- package/lib/types/render/text.d.ts +6 -0
- package/lib/types/render/usage.d.ts +113 -0
- package/lib/types/session-directory.d.ts +42 -1
- package/lib/types/session-switch.d.ts +8 -0
- package/lib/types/startup.d.ts +1 -1
- package/lib/types/terminal-title.d.ts +8 -0
- package/lib/types/theme-panel.d.ts +2 -2
- package/lib/types/theme.d.ts +271 -52
- package/lib/types/update-panel.d.ts +27 -6
- package/lib/types/update.d.ts +10 -1
- package/lib/types/version.d.ts +6 -3
- package/package.json +26 -7
- package/src/app.ts +1426 -627
- package/src/approval.ts +166 -166
- package/src/attachments.ts +65 -19
- package/src/authorization-panel.ts +24 -18
- package/src/editor-keys.ts +371 -371
- package/src/fork.ts +11 -7
- package/src/git-workflow.ts +229 -3
- package/src/history.ts +14 -0
- package/src/i18n.ts +68 -0
- package/src/index.ts +503 -128
- package/src/input-split.ts +27 -7
- package/src/kernel-panels.ts +528 -113
- package/src/keyboard.ts +5 -4
- package/src/language-panel.ts +53 -0
- package/src/locales/en.ts +538 -0
- package/src/locales/zh.ts +537 -0
- package/src/mentions.ts +8 -4
- package/src/models.ts +264 -212
- package/src/panel-accent.ts +41 -0
- package/src/presets.ts +1 -1
- package/src/provider-settings.ts +1 -1
- package/src/rainbow.ts +218 -0
- package/src/render/animations.ts +104 -6
- package/src/render/editor.ts +20 -20
- package/src/render/export.ts +116 -95
- package/src/render/inspector.ts +42 -0
- package/src/render/lines.ts +628 -415
- package/src/render/markdown.ts +15 -3
- package/src/render/projection.ts +429 -19
- package/src/render/status.ts +119 -62
- package/src/render/text.ts +15 -0
- package/src/render/tool-preview.ts +77 -77
- package/src/render/usage.ts +430 -0
- package/src/render/width.ts +2 -2
- package/src/session-directory.ts +90 -9
- package/src/session-query.ts +8 -4
- package/src/session-switch.ts +14 -0
- package/src/startup.ts +3 -3
- package/src/store.ts +19 -1
- package/src/subagents.ts +229 -229
- package/src/terminal-title.ts +22 -5
- package/src/theme-panel.ts +17 -21
- package/src/theme.ts +281 -33
- package/src/update-panel.ts +148 -31
- package/src/update.ts +19 -3
- package/src/version.ts +63 -20
- package/src/whale-glyph.ts +23 -23
package/src/render/lines.ts
CHANGED
|
@@ -1,415 +1,628 @@
|
|
|
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 './
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
*
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
readonly
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
1
|
+
/** Width-safe styled physical rows for bounded terminal panels. */
|
|
2
|
+
|
|
3
|
+
import { promptDisplayText, type TranscriptEntry, type ToolEntry } 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, displayText, truncateColumns } from './text.ts'
|
|
8
|
+
import { t } from '../i18n.ts'
|
|
9
|
+
|
|
10
|
+
/** Trailing marker naming how a settled prompt was delivered; '' for ordinary. */
|
|
11
|
+
function deliveryMarker(delivery: 'queued' | 'steered' | undefined): string {
|
|
12
|
+
if (delivery === 'queued') return t('entry.delivery.queued')
|
|
13
|
+
if (delivery === 'steered') return t('entry.delivery.steered')
|
|
14
|
+
return ''
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Presentation classes mapped to Ink colors by the app boundary. */
|
|
18
|
+
export type LineStyle = MdStyle | 'brand' | 'success' | 'error' | 'warn' | 'dimItalic' | 'diffAdd' | 'diffDel'
|
|
19
|
+
| 'promptRow' | 'promptQueuedRow' | 'promptSteeredRow'
|
|
20
|
+
|
|
21
|
+
/** One styled run within a physical terminal row. */
|
|
22
|
+
export interface StyledSegment {
|
|
23
|
+
text: string
|
|
24
|
+
style: LineStyle
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One row guaranteed not to exceed the requested terminal width. */
|
|
28
|
+
export interface StyledLine {
|
|
29
|
+
segments: readonly StyledSegment[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Construct one segment without leaking mutable objects into cached rows. */
|
|
33
|
+
export function lineSegment(text: string, style: LineStyle = 'plain'): StyledSegment {
|
|
34
|
+
return { text, style }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Classify one unified-diff row for coloring: additions and deletions carry
|
|
39
|
+
* the diff tint styles (background on rich terminals), hunk headers and file
|
|
40
|
+
* markers stay brand-blue, and everything else is dim context.
|
|
41
|
+
*/
|
|
42
|
+
export function diffLineStyle(line: string): LineStyle {
|
|
43
|
+
if (line.startsWith('+') && !line.startsWith('+++')) return 'diffAdd'
|
|
44
|
+
if (line.startsWith('-') && !line.startsWith('---')) return 'diffDel'
|
|
45
|
+
if (line.startsWith('@@') || line.startsWith('diff --git') || line.startsWith('index ')) return 'brand'
|
|
46
|
+
return 'dim'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Extend pure diff-tinted rows to the full width with same-styled padding so
|
|
51
|
+
* the tint reads as one unbroken bar (GitHub-style), including wrapped
|
|
52
|
+
* continuation rows; mixed or non-diff rows pass through untouched.
|
|
53
|
+
*/
|
|
54
|
+
export function fillDiffLineBars(lines: readonly StyledLine[], columns: number): readonly StyledLine[] {
|
|
55
|
+
const width = Math.max(1, Math.floor(columns))
|
|
56
|
+
let changed = false
|
|
57
|
+
const filled = lines.map(line => {
|
|
58
|
+
const style = line.segments[0]?.style
|
|
59
|
+
if (style !== 'diffAdd' && style !== 'diffDel') return line
|
|
60
|
+
if (line.segments.some(segment => segment.style !== style)) return line
|
|
61
|
+
const used = visibleColumns(line.segments.map(segment => segment.text).join(''))
|
|
62
|
+
const pad = width - used
|
|
63
|
+
if (pad <= 0) return line
|
|
64
|
+
changed = true
|
|
65
|
+
return { segments: [...line.segments, lineSegment(' '.repeat(pad), style)] }
|
|
66
|
+
})
|
|
67
|
+
// Identity-stable when nothing needed padding: callers cache row arrays.
|
|
68
|
+
return changed ? filled : lines
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Append a character while merging adjacent runs with the same style. */
|
|
72
|
+
function appendSegment(target: StyledSegment[], text: string, style: LineStyle): void {
|
|
73
|
+
const previous = target[target.length - 1]
|
|
74
|
+
if (previous?.style === style) {
|
|
75
|
+
target[target.length - 1] = { text: previous.text + text, style }
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
target.push({ text, style })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Sanitize and hard-wrap styled content into exact physical rows.
|
|
83
|
+
* Tabs become two visible spaces because terminal tab stops are contextual
|
|
84
|
+
* and therefore cannot participate in a deterministic row budget.
|
|
85
|
+
*/
|
|
86
|
+
export function styledLines(segments: readonly StyledSegment[], columns: number): readonly StyledLine[] {
|
|
87
|
+
const width = Math.max(1, Math.floor(columns))
|
|
88
|
+
const lines: StyledLine[] = []
|
|
89
|
+
let current: StyledSegment[] = []
|
|
90
|
+
let used = 0
|
|
91
|
+
const flush = (): void => {
|
|
92
|
+
lines.push({ segments: current })
|
|
93
|
+
current = []
|
|
94
|
+
used = 0
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (const segment of segments) {
|
|
98
|
+
const safe = displayText(segment.text).replaceAll('\t', ' ').replaceAll('\r', '')
|
|
99
|
+
// Grapheme clusters, never bare code points: a ZWJ family or a flag is
|
|
100
|
+
// one terminal cell run, and splitting it would both split the glyph
|
|
101
|
+
// across rows and double-count its width against the budget.
|
|
102
|
+
for (const cluster of splitGraphemes(safe)) {
|
|
103
|
+
if (cluster === '\n') {
|
|
104
|
+
flush()
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
const cells = graphemeWidth(cluster)
|
|
108
|
+
if (used > 0 && used + cells > width) flush()
|
|
109
|
+
appendSegment(current, cluster, segment.style)
|
|
110
|
+
used += cells
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (current.length > 0 || lines.length === 0) flush()
|
|
114
|
+
return lines
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Plain/dim text convenience over {@link styledLines}. */
|
|
118
|
+
export function textLines(text: string, columns: number, style: LineStyle = 'plain'): readonly StyledLine[] {
|
|
119
|
+
return styledLines([lineSegment(text, style)], columns)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Prefix every wrapped physical row without exceeding the column budget. */
|
|
123
|
+
function prefixedStyledLines(segments: readonly StyledSegment[], columns: number, prefix: string, prefixStyle: LineStyle = 'plain'): readonly StyledLine[] {
|
|
124
|
+
const width = Math.max(1, Math.floor(columns))
|
|
125
|
+
// Keep one column for the body even when the prefix alone would fill the
|
|
126
|
+
// row: a prefix allowed to claim the whole width pushed prefix+body one
|
|
127
|
+
// column past the budget on very narrow terminals.
|
|
128
|
+
const prefixWidth = Math.min(width - 1, visibleColumns(prefix))
|
|
129
|
+
const bodyWidth = Math.max(1, width - prefixWidth)
|
|
130
|
+
return styledLines(segments, bodyWidth).map(line => ({
|
|
131
|
+
segments: [lineSegment(prefix, prefixStyle), ...line.segments],
|
|
132
|
+
}))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Text convenience for a tool row whose continuation must keep its gutter. */
|
|
136
|
+
function prefixedTextLines(text: string, columns: number, prefix: string, style: LineStyle = 'plain'): readonly StyledLine[] {
|
|
137
|
+
return prefixedStyledLines([lineSegment(text, style)], columns, prefix, style)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Wrap styled segments with a hanging indent: the first physical row carries
|
|
142
|
+
* `firstPrefix` (often a marker plus gutter) and every wrapped continuation
|
|
143
|
+
* carries the narrower `contPrefix`, so long tool summaries and prompts
|
|
144
|
+
* align under their card instead of falling back to column zero. The first
|
|
145
|
+
* row may hold one prefix-width more than the continuations.
|
|
146
|
+
*/
|
|
147
|
+
function hangingStyledLines(
|
|
148
|
+
segments: readonly StyledSegment[],
|
|
149
|
+
columns: number,
|
|
150
|
+
firstPrefix: string,
|
|
151
|
+
firstStyle: LineStyle,
|
|
152
|
+
contPrefix: string,
|
|
153
|
+
contStyle: LineStyle = firstStyle,
|
|
154
|
+
): readonly StyledLine[] {
|
|
155
|
+
const width = Math.max(2, Math.floor(columns))
|
|
156
|
+
const firstPrefixText = truncateColumns(firstPrefix, Math.max(1, width - 1))
|
|
157
|
+
const contPrefixText = truncateColumns(contPrefix, Math.max(1, width - 1))
|
|
158
|
+
const firstBudget = Math.max(1, width - visibleColumns(firstPrefixText))
|
|
159
|
+
const contBudget = Math.max(1, width - visibleColumns(contPrefixText))
|
|
160
|
+
const lines: StyledLine[] = []
|
|
161
|
+
let current: StyledSegment[] = []
|
|
162
|
+
let used = 0
|
|
163
|
+
let budget = firstBudget
|
|
164
|
+
const flush = (): void => {
|
|
165
|
+
lines.push({ segments: current })
|
|
166
|
+
current = []
|
|
167
|
+
used = 0
|
|
168
|
+
budget = contBudget
|
|
169
|
+
}
|
|
170
|
+
for (const segment of segments) {
|
|
171
|
+
const safe = displayText(segment.text).replaceAll('\t', ' ').replaceAll('\r', '')
|
|
172
|
+
for (const cluster of splitGraphemes(safe)) {
|
|
173
|
+
if (cluster === '\n') {
|
|
174
|
+
flush()
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
const cells = graphemeWidth(cluster)
|
|
178
|
+
if (used > 0 && used + cells > budget) flush()
|
|
179
|
+
appendSegment(current, cluster, segment.style)
|
|
180
|
+
used += cells
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (current.length > 0 || lines.length === 0) flush()
|
|
184
|
+
return lines.map((line, index) => ({
|
|
185
|
+
segments: [
|
|
186
|
+
lineSegment(index === 0 ? firstPrefixText : contPrefixText, index === 0 ? firstStyle : contStyle),
|
|
187
|
+
...line.segments,
|
|
188
|
+
],
|
|
189
|
+
}))
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Plain-text convenience over {@link hangingStyledLines}. */
|
|
193
|
+
function hangingTextLines(
|
|
194
|
+
text: string,
|
|
195
|
+
columns: number,
|
|
196
|
+
firstPrefix: string,
|
|
197
|
+
firstStyle: LineStyle = 'plain',
|
|
198
|
+
contPrefix = ' ',
|
|
199
|
+
contStyle: LineStyle = firstStyle,
|
|
200
|
+
): readonly StyledLine[] {
|
|
201
|
+
return hangingStyledLines([lineSegment(text, firstStyle)], columns, firstPrefix, firstStyle, contPrefix, contStyle)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Markdown rows re-hardened so a single long word cannot escape the budget. */
|
|
205
|
+
export function markdownLines(text: string, columns: number): readonly StyledLine[] {
|
|
206
|
+
const width = Math.max(1, Math.floor(columns))
|
|
207
|
+
// The markdown pass formats at the real width — a 10-column floor on a
|
|
208
|
+
// narrower terminal silently pushed rows past the budget (styledLines
|
|
209
|
+
// re-hardens long words at `width` either way).
|
|
210
|
+
const parsed = renderMarkdown(displayText(text), width)
|
|
211
|
+
const rows = parsed.flatMap(line => styledLines(
|
|
212
|
+
line.segments.map(segment => lineSegment(segment.text, segment.style)),
|
|
213
|
+
width,
|
|
214
|
+
))
|
|
215
|
+
// ```diff fence rows fill their full width so pasted diffs read as bars.
|
|
216
|
+
return fillDiffLineBars(rows, width)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Codex-style reasoning rows: the marker occupies the reply gutter and every
|
|
221
|
+
* wrapped or explicit continuation starts with the same two-column indent, so
|
|
222
|
+
* reasoning content and assistant Markdown share one left edge.
|
|
223
|
+
*/
|
|
224
|
+
export function reasoningLines(text: string, columns: number): readonly StyledLine[] {
|
|
225
|
+
const width = Math.max(1, Math.floor(columns))
|
|
226
|
+
if (width < 3) return textLines(text, width, 'dimItalic')
|
|
227
|
+
const contentWidth = width - 2
|
|
228
|
+
const content = displayText(text).replaceAll('\t', ' ').replaceAll('\r', '')
|
|
229
|
+
.split('\n')
|
|
230
|
+
.flatMap(line => styledLines([lineSegment(line, 'dimItalic')], contentWidth))
|
|
231
|
+
return content.map((line, index) => ({
|
|
232
|
+
segments: [
|
|
233
|
+
lineSegment(index === 0 ? '✻ ' : ' ', 'dimItalic'),
|
|
234
|
+
...line.segments,
|
|
235
|
+
],
|
|
236
|
+
}))
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Expanded structured tool detail as scrollable, width-safe rows. */
|
|
240
|
+
/**
|
|
241
|
+
* Detail rows share the tool card's four-column hanging gutter: the summary
|
|
242
|
+
* (⎿) and delegation prompt (└) continuations already sit at four columns, so
|
|
243
|
+
* diff/read/web/raw rows align under them instead of floating two columns
|
|
244
|
+
* shallower.
|
|
245
|
+
*/
|
|
246
|
+
function toolDetailLines(detail: ToolDetail, columns: number): readonly StyledLine[] {
|
|
247
|
+
switch (detail.kind) {
|
|
248
|
+
case 'diff':
|
|
249
|
+
return detail.diffs.flatMap(diff => [
|
|
250
|
+
...prefixedTextLines(`${diff.path}${diff.truncated ? ' (diff truncated)' : ''}`, columns, ' ── ', 'dim'),
|
|
251
|
+
...fillDiffLineBars(diff.lines.flatMap(line => prefixedTextLines(
|
|
252
|
+
`${line.mark}${line.text}`,
|
|
253
|
+
columns,
|
|
254
|
+
' ',
|
|
255
|
+
line.mark === '+' ? 'diffAdd' : line.mark === '-' ? 'diffDel' : 'dim',
|
|
256
|
+
)), columns),
|
|
257
|
+
])
|
|
258
|
+
case 'read':
|
|
259
|
+
return [
|
|
260
|
+
...prefixedTextLines(
|
|
261
|
+
`${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)' : ''}`,
|
|
262
|
+
columns,
|
|
263
|
+
' ── ',
|
|
264
|
+
'dim',
|
|
265
|
+
),
|
|
266
|
+
...detail.lines.flatMap(line => prefixedTextLines(`${String(line.number).padStart(5, ' ')} | ${line.text}`, columns, ' ', 'dim')),
|
|
267
|
+
]
|
|
268
|
+
case 'web-search':
|
|
269
|
+
return [
|
|
270
|
+
...detail.sources.flatMap(source => [
|
|
271
|
+
...prefixedStyledLines([
|
|
272
|
+
lineSegment(source.title ?? source.url, 'brand'),
|
|
273
|
+
lineSegment(` - ${source.url}`, 'dim'),
|
|
274
|
+
], columns, ' ? '),
|
|
275
|
+
...(source.snippet === '' ? [] : prefixedTextLines(source.snippet, columns, ' ', 'dim')),
|
|
276
|
+
]),
|
|
277
|
+
...prefixedTextLines(`${detail.sources.length} sources${detail.truncated ? ' (capped)' : ''}`, columns, ' ', 'dim'),
|
|
278
|
+
]
|
|
279
|
+
case 'web-fetch':
|
|
280
|
+
return prefixedTextLines(`${detail.url} · HTTP ${detail.statusCode}`, columns, ' ', 'dim')
|
|
281
|
+
case 'raw':
|
|
282
|
+
return [
|
|
283
|
+
...prefixedTextLines(detail.text, columns, ' ', 'dim'),
|
|
284
|
+
...prefixedTextLines(detail.truncated ? '… (output truncated)' : '(end of output)', columns, ' ', 'dim'),
|
|
285
|
+
]
|
|
286
|
+
default: {
|
|
287
|
+
const exhaustive: never = detail
|
|
288
|
+
return exhaustive
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Style the lines of one user prompt for display: plain verbatim, except
|
|
295
|
+
* inside ```diff / ```patch fences where added and removed lines take the
|
|
296
|
+
* shared diff tints (the review prompt pastes its diff this way, and the
|
|
297
|
+
* user row does not go through the markdown renderer). The fence markers
|
|
298
|
+
* and file headers stay plain.
|
|
299
|
+
*/
|
|
300
|
+
export function userPromptSegments(text: string): readonly StyledSegment[] {
|
|
301
|
+
if (!text.includes('```')) return [lineSegment(text, 'plain')]
|
|
302
|
+
const segments: StyledSegment[] = []
|
|
303
|
+
const lines = text.split('\n')
|
|
304
|
+
let diffFence = false
|
|
305
|
+
let inFence = false
|
|
306
|
+
for (let at = 0; at < lines.length; at += 1) {
|
|
307
|
+
const line = lines[at]
|
|
308
|
+
const suffix = at === lines.length - 1 ? '' : '\n'
|
|
309
|
+
if (line.trimStart().startsWith('```')) {
|
|
310
|
+
const language = line.trim().slice(3).trim()
|
|
311
|
+
inFence = !inFence
|
|
312
|
+
diffFence = inFence && (language === 'diff' || language === 'patch')
|
|
313
|
+
segments.push(lineSegment(line + suffix, 'plain'))
|
|
314
|
+
continue
|
|
315
|
+
}
|
|
316
|
+
const style: LineStyle = !diffFence
|
|
317
|
+
? 'plain'
|
|
318
|
+
: line.startsWith('+') && !line.startsWith('+++')
|
|
319
|
+
? 'diffAdd'
|
|
320
|
+
: line.startsWith('-') && !line.startsWith('---')
|
|
321
|
+
? 'diffDel'
|
|
322
|
+
: 'plain'
|
|
323
|
+
segments.push(lineSegment(line + suffix, style))
|
|
324
|
+
}
|
|
325
|
+
return segments
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Full-row diff bars for mixed-gutter rows (user prompts): any row carrying
|
|
330
|
+
* a diff tint takes it over entirely — gutter included — and pads to the
|
|
331
|
+
* full width, so a pasted review diff reads as unbroken bars like the /diff
|
|
332
|
+
* panel. Rows without a diff tint pass through untouched.
|
|
333
|
+
*/
|
|
334
|
+
function paintDiffRowBars(lines: readonly StyledLine[], columns: number): readonly StyledLine[] {
|
|
335
|
+
const width = Math.max(1, Math.floor(columns))
|
|
336
|
+
let changed = false
|
|
337
|
+
const painted = lines.map(line => {
|
|
338
|
+
const tint = line.segments.find(segment => segment.style === 'diffAdd' || segment.style === 'diffDel')?.style
|
|
339
|
+
if (tint === undefined) return line
|
|
340
|
+
changed = true
|
|
341
|
+
const used = visibleColumns(line.segments.map(segment => segment.text).join(''))
|
|
342
|
+
const pad = Math.max(0, width - used)
|
|
343
|
+
return { segments: [...line.segments.map(segment => ({ text: segment.text, style: tint })), lineSegment(' '.repeat(pad), tint)] }
|
|
344
|
+
})
|
|
345
|
+
return changed ? painted : lines
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Default compact tool-card window used while the Ctrl+R fold is closed. */
|
|
349
|
+
const DEFAULT_TOOL_ROWS = 3
|
|
350
|
+
|
|
351
|
+
/** The bar style one prompt row wears, chosen by how it was delivered. */
|
|
352
|
+
function promptRowStyle(delivery: 'queued' | 'steered' | undefined): LineStyle {
|
|
353
|
+
if (delivery === 'queued') return 'promptQueuedRow'
|
|
354
|
+
if (delivery === 'steered') return 'promptSteeredRow'
|
|
355
|
+
return 'promptRow'
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Extend every row to the full width with the row's own style, so a prompt
|
|
360
|
+
* bar reads as one unbroken block instead of stopping at the last glyph.
|
|
361
|
+
* Rows already at width (or past it) pass through untouched.
|
|
362
|
+
*/
|
|
363
|
+
function fillRowBars(lines: readonly StyledLine[], columns: number, row: LineStyle): readonly StyledLine[] {
|
|
364
|
+
const width = Math.max(1, Math.floor(columns))
|
|
365
|
+
let changed = false
|
|
366
|
+
const filled = lines.map(line => {
|
|
367
|
+
const used = visibleColumns(line.segments.map(segment => segment.text).join(''))
|
|
368
|
+
const pad = width - used
|
|
369
|
+
if (pad <= 0) return line
|
|
370
|
+
changed = true
|
|
371
|
+
return { segments: [...line.segments, lineSegment(' '.repeat(pad), row)] }
|
|
372
|
+
})
|
|
373
|
+
return changed ? filled : lines
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Render one prompt as a full-width bar: the row style paints the glyph, the
|
|
378
|
+
* text, the delivery marker, and the padding, while a ```diff fence pasted
|
|
379
|
+
* into the prompt keeps its own green/red tint so the inner diff still reads
|
|
380
|
+
* as a diff.
|
|
381
|
+
*/
|
|
382
|
+
function paintPromptRow(
|
|
383
|
+
segments: readonly StyledSegment[],
|
|
384
|
+
columns: number,
|
|
385
|
+
row: LineStyle,
|
|
386
|
+
glyph: string,
|
|
387
|
+
): readonly StyledLine[] {
|
|
388
|
+
const width = Math.max(1, Math.floor(columns))
|
|
389
|
+
const recolored = segments.map(segment =>
|
|
390
|
+
segment.style === 'diffAdd' || segment.style === 'diffDel' ? segment : { ...segment, style: row })
|
|
391
|
+
const lines = hangingStyledLines(recolored, width, glyph, row, ' ', row)
|
|
392
|
+
// Diff bars pad themselves to the full width; only the remaining rows need
|
|
393
|
+
// the prompt bar's padding.
|
|
394
|
+
return fillRowBars(paintDiffRowBars(lines, width), width, row)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Format one sub-dispatch duration in seconds at tenth resolution. */
|
|
398
|
+
function subDispatchSeconds(durationMs: number): string {
|
|
399
|
+
if (durationMs < 100) return ''
|
|
400
|
+
return ` · ${Math.round(durationMs / 100) / 10}s`
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Nested PTC sub-dispatch rows under a `run_code` parent card: one bounded
|
|
405
|
+
* row per child call (running keeps the brand pulse mark, errors carry the
|
|
406
|
+
* bounded settle summary), plus a count row for evicted earlier dispatches.
|
|
407
|
+
*/
|
|
408
|
+
function subDispatchLines(entry: ToolEntry, columns: number): readonly StyledLine[] {
|
|
409
|
+
if (entry.subs.length === 0) return []
|
|
410
|
+
const width = Math.max(1, Math.floor(columns))
|
|
411
|
+
const rows = entry.subs.flatMap(sub => {
|
|
412
|
+
const mark = sub.state === 'running' ? '●' : sub.state === 'error' ? '⨯' : '⏺'
|
|
413
|
+
const style: LineStyle = sub.state === 'running' ? 'brand' : sub.state === 'error' ? 'error' : 'dim'
|
|
414
|
+
const label = sub.preview === '' ? sub.name : `${sub.name} ${sub.preview}`
|
|
415
|
+
if (sub.state === 'error' && sub.summary !== '') {
|
|
416
|
+
return textLines(` ┆ ${mark} ${label} · ${sub.summary}${subDispatchSeconds(sub.durationMs)}`, width, style)
|
|
417
|
+
}
|
|
418
|
+
return textLines(` ┆ ${mark} ${label}${subDispatchSeconds(sub.durationMs)}`, width, style)
|
|
419
|
+
})
|
|
420
|
+
if (entry.subsDropped === 0) return rows
|
|
421
|
+
return [...rows, ...textLines(` ┆ … ${entry.subsDropped} earlier dispatch${entry.subsDropped === 1 ? '' : 'es'}`, width, 'dim')]
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Keep the invocation visible while making hidden tool output discoverable. */
|
|
425
|
+
function compactToolLines(lines: readonly StyledLine[], columns: number): readonly StyledLine[] {
|
|
426
|
+
if (lines.length <= DEFAULT_TOOL_ROWS) return lines
|
|
427
|
+
return [
|
|
428
|
+
...lines.slice(0, DEFAULT_TOOL_ROWS - 1),
|
|
429
|
+
...textLines(' … output hidden · Ctrl/Alt+R', columns, 'dim').slice(0, 1),
|
|
430
|
+
]
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Convert one durable transcript entry to its complete scrollable row model.
|
|
435
|
+
* The source entry stays intact; only the caller's visible slice is rendered.
|
|
436
|
+
* Wrapped continuations keep a hanging indent aligned under each row's
|
|
437
|
+
* content (Codex history-cell alignment) instead of resetting to column 0.
|
|
438
|
+
*/
|
|
439
|
+
export function transcriptEntryLines(
|
|
440
|
+
entry: TranscriptEntry,
|
|
441
|
+
columns: number,
|
|
442
|
+
showReasoning = true,
|
|
443
|
+
reasoningToggleHint = true,
|
|
444
|
+
showToolDetails = showReasoning,
|
|
445
|
+
): readonly StyledLine[] {
|
|
446
|
+
const width = Math.max(1, Math.floor(columns))
|
|
447
|
+
switch (entry.kind) {
|
|
448
|
+
case 'user': {
|
|
449
|
+
if (entry.notice) {
|
|
450
|
+
return hangingStyledLines([lineSegment(promptDisplayText(entry), 'dim')], width, '⤷ ', 'dim', ' ', 'dim')
|
|
451
|
+
}
|
|
452
|
+
// A settled prompt is one full-width bar whose color says how it was
|
|
453
|
+
// delivered, and a steered one switches to the ↳ prompt as well.
|
|
454
|
+
const row = promptRowStyle(entry.delivery)
|
|
455
|
+
const marker = deliveryMarker(entry.delivery)
|
|
456
|
+
const segments = userPromptSegments(promptDisplayText(entry))
|
|
457
|
+
const tagged = marker === '' ? segments : [...segments, lineSegment(` ${marker}`, row)]
|
|
458
|
+
return paintPromptRow(tagged, width, row, entry.delivery === 'steered' ? '↳ ' : '❯ ')
|
|
459
|
+
}
|
|
460
|
+
case 'pending': {
|
|
461
|
+
// The queued/steered preview uses the same bar it will keep once the
|
|
462
|
+
// durable message retires it, so nothing shifts under the reader.
|
|
463
|
+
const row = promptRowStyle(entry.target === 'next-step' ? 'steered' : 'queued')
|
|
464
|
+
const lines = hangingStyledLines(
|
|
465
|
+
[{ ...lineSegment(promptDisplayText(entry), 'plain'), style: row }],
|
|
466
|
+
width,
|
|
467
|
+
entry.target === 'next-step' ? '↳ ' : '❯ ',
|
|
468
|
+
row,
|
|
469
|
+
' ',
|
|
470
|
+
row,
|
|
471
|
+
)
|
|
472
|
+
return fillRowBars(paintDiffRowBars(lines, width), width, row)
|
|
473
|
+
}
|
|
474
|
+
case 'assistant': {
|
|
475
|
+
const reasoning = entry.reasoning === ''
|
|
476
|
+
? []
|
|
477
|
+
: showReasoning
|
|
478
|
+
? reasoningLines(entry.reasoning, width)
|
|
479
|
+
: textLines(`✻ Thinking (${entry.reasoning.length} chars${reasoningToggleHint ? ', Ctrl/Alt+R to expand' : ''})`, width, 'dim')
|
|
480
|
+
// Every reply row carries the composer's two-column gutter, so reply
|
|
481
|
+
// text aligns with the input cursor (Codex LIVE_PREFIX alignment); the
|
|
482
|
+
// wrap budget shrinks by the same amount so no line double-wraps.
|
|
483
|
+
const body = markdownLines(entry.text, Math.max(1, width - 2))
|
|
484
|
+
.map(line => ({ segments: [{ text: ' ', style: 'plain' as const }, ...line.segments] }))
|
|
485
|
+
// A cancelled stream's delivered prefix settles as this entry; one
|
|
486
|
+
// bounded dim marker row distinguishes it from a completed reply.
|
|
487
|
+
const interrupted = entry.interrupted === true ? textLines(' ⏹ interrupted', width, 'dim') : []
|
|
488
|
+
return [...reasoning, ...body, ...interrupted]
|
|
489
|
+
}
|
|
490
|
+
case 'tool': {
|
|
491
|
+
const mark = entry.state === 'running' ? '●' : entry.state === 'error' ? '⨯' : '⏺'
|
|
492
|
+
const markStyle: LineStyle = entry.state === 'running' ? 'brand' : entry.state === 'error' ? 'error' : 'success'
|
|
493
|
+
const summaryStyle: LineStyle = entry.state === 'error' ? 'error' : 'dim'
|
|
494
|
+
const lines = [
|
|
495
|
+
// The invocation row hangs wrapped previews under the call badge.
|
|
496
|
+
...hangingStyledLines([
|
|
497
|
+
// Global call ordinal — the same number an error line references.
|
|
498
|
+
lineSegment(`[${entry.ordinal}] `, 'dim'),
|
|
499
|
+
lineSegment(entry.name, 'brand'),
|
|
500
|
+
lineSegment(entry.preview === '' ? '' : ` ${entry.preview}`, 'dim'),
|
|
501
|
+
], width, `${mark} `, markStyle, ' ', 'plain'),
|
|
502
|
+
// A delegation card carries what the child was asked (Codex's
|
|
503
|
+
// SpawnAgent prompt preview) while it runs, before any result.
|
|
504
|
+
...(entry.prompt === '' ? [] : hangingTextLines(entry.prompt, width, ' └ ', 'dim', ' ')),
|
|
505
|
+
// Nested PTC sub-dispatches (run_code): one bounded row per child
|
|
506
|
+
// call, live while the parent card itself is still running.
|
|
507
|
+
...subDispatchLines(entry, width),
|
|
508
|
+
...(entry.summary === '' ? [] : hangingTextLines(
|
|
509
|
+
entry.state === 'error' ? `call ${entry.ordinal}: ${entry.summary}` : entry.summary,
|
|
510
|
+
width, ' ⎿ ', summaryStyle, ' ', summaryStyle,
|
|
511
|
+
)),
|
|
512
|
+
...(entry.detail === undefined ? [] : toolDetailLines(entry.detail, width)),
|
|
513
|
+
]
|
|
514
|
+
return showToolDetails ? lines : compactToolLines(lines, width)
|
|
515
|
+
}
|
|
516
|
+
case 'command': {
|
|
517
|
+
const mark = entry.state === 'running' ? '●' : entry.state === 'error' ? '⨯' : '⏺'
|
|
518
|
+
const markStyle: LineStyle = entry.state === 'running' ? 'brand' : entry.state === 'error' ? 'error' : 'success'
|
|
519
|
+
const summaryStyle: LineStyle = entry.state === 'error' ? 'error' : 'dim'
|
|
520
|
+
return [
|
|
521
|
+
...hangingStyledLines([
|
|
522
|
+
lineSegment(`/${entry.name}`, 'brand'),
|
|
523
|
+
lineSegment(entry.args === '' ? '' : ` ${entry.args}`, 'dim'),
|
|
524
|
+
], width, `${mark} `, markStyle, ' ', 'plain'),
|
|
525
|
+
...(entry.summary === '' ? [] : hangingTextLines(entry.summary, width, ' ⎿ ', summaryStyle, ' ', summaryStyle)),
|
|
526
|
+
]
|
|
527
|
+
}
|
|
528
|
+
case 'workflow': {
|
|
529
|
+
const mark = entry.state === 'running' ? '●' : entry.state === 'error' ? '⨯' : entry.state === 'cancelled' ? '⏹' : '⏺'
|
|
530
|
+
const markStyle: LineStyle = entry.state === 'running' ? 'brand' : entry.state === 'error' ? 'error' : entry.state === 'cancelled' ? 'dim' : 'success'
|
|
531
|
+
const lines = [
|
|
532
|
+
...hangingStyledLines([
|
|
533
|
+
lineSegment('workflow ', 'brand'),
|
|
534
|
+
lineSegment(entry.name, 'dim'),
|
|
535
|
+
], width, `${mark} `, markStyle, ' ', 'plain'),
|
|
536
|
+
// One bounded row per member; the child session id cross-links the
|
|
537
|
+
// subagent feed's live rows for the same agent.
|
|
538
|
+
...entry.members.flatMap(member => {
|
|
539
|
+
const state = member.outcome === 'running' ? '●' : member.outcome === 'failed' ? '⨯' : member.outcome === 'cancelled' ? '⏹' : '⏺'
|
|
540
|
+
const style: LineStyle = member.outcome === 'running' ? 'brand' : member.outcome === 'failed' ? 'error' : 'dim'
|
|
541
|
+
const phase = member.phase === '' ? '' : ` [${member.phase}]`
|
|
542
|
+
return textLines(` ┆ ${state} ${member.label}${phase}`, width, style)
|
|
543
|
+
}),
|
|
544
|
+
...(entry.membersDropped === 0 ? [] : textLines(` ┆ … ${entry.membersDropped} earlier member${entry.membersDropped === 1 ? '' : 's'}`, width, 'dim')),
|
|
545
|
+
]
|
|
546
|
+
return showToolDetails ? lines : compactToolLines(lines, width)
|
|
547
|
+
}
|
|
548
|
+
case 'turn-marker':
|
|
549
|
+
return textLines(` ⏹ ${entry.text}`, width, 'dim')
|
|
550
|
+
case 'compaction':
|
|
551
|
+
return textLines(entry.ok
|
|
552
|
+
? ` ⧉ compacted ~${formatTokens(entry.tokens)} tokens`
|
|
553
|
+
: ` ⧉ compaction failed: ${entry.error}`, width, 'dim')
|
|
554
|
+
case 'retry':
|
|
555
|
+
return textLines(
|
|
556
|
+
entry.mode === 'always'
|
|
557
|
+
? ` ↻ retry ${entry.attempt} · ${entry.code} · ${Math.round(entry.delayMs / 100) / 10}s`
|
|
558
|
+
: ` ↻ retry ${entry.attempt}/${entry.max} · ${entry.code} · ${Math.round(entry.delayMs / 100) / 10}s`,
|
|
559
|
+
width,
|
|
560
|
+
entry.state === 'running' ? 'warn' : 'dim',
|
|
561
|
+
)
|
|
562
|
+
case 'files':
|
|
563
|
+
return entry.paths.length === 0
|
|
564
|
+
? textLines(' ⎄ no changed files', width, 'dim')
|
|
565
|
+
: [
|
|
566
|
+
...textLines(` ⎄ ${entry.paths.length} changed file${entry.paths.length === 1 ? '' : 's'}`, width, 'dim'),
|
|
567
|
+
...entry.paths.flatMap(path => hangingTextLines(path, width, ' ', 'dim', ' ')),
|
|
568
|
+
]
|
|
569
|
+
case 'error':
|
|
570
|
+
return textLines(entry.text, width, 'error')
|
|
571
|
+
default: {
|
|
572
|
+
const exhaustive: never = entry
|
|
573
|
+
return exhaustive
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Settled-history variant carrying the Ctrl+R reasoning fold. */
|
|
579
|
+
export function settledEntryLines(entry: TranscriptEntry, columns: number, showReasoning: boolean): readonly StyledLine[] {
|
|
580
|
+
return transcriptEntryLines(entry, columns, showReasoning, false, showReasoning)
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** The flexible rows of the live region; chrome (composer/notice/status) is never reduced. */
|
|
584
|
+
export interface LiveAllocation {
|
|
585
|
+
/** Settled tail rows currently rendered in the live tree. */
|
|
586
|
+
readonly live: number
|
|
587
|
+
/** Rows reserved for the streaming reasoning tail or its marker. */
|
|
588
|
+
readonly reasoning: number
|
|
589
|
+
/** Rows reserved for the streaming answer tail. */
|
|
590
|
+
readonly answer: number
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** A clamped allocation plus the invariant-trip warning that triggered it. */
|
|
594
|
+
export interface LiveAllocationAudit {
|
|
595
|
+
readonly allocation: LiveAllocation
|
|
596
|
+
readonly warning?: string
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Clamp the live-region allocation so the flexible dynamic rows never exceed
|
|
601
|
+
* the post-chrome budget. By construction the caller derives these rows from
|
|
602
|
+
* the same budget; this is the runtime tripwire for a future edit that breaks
|
|
603
|
+
* that derivation. Reduction order: answer first (the freshest content is the
|
|
604
|
+
* live tail), then reasoning, then settled live rows; nothing goes negative.
|
|
605
|
+
* @param allocation - the intended row allocation.
|
|
606
|
+
* @param dynamicRows - the post-chrome row budget.
|
|
607
|
+
* @returns the clamped allocation and a warning string when clamping fired.
|
|
608
|
+
*/
|
|
609
|
+
export function clampLiveAllocation(allocation: LiveAllocation, dynamicRows: number): LiveAllocationAudit {
|
|
610
|
+
const live = Math.max(0, Math.floor(allocation.live))
|
|
611
|
+
const reasoning = Math.max(0, Math.floor(allocation.reasoning))
|
|
612
|
+
const answer = Math.max(0, Math.floor(allocation.answer))
|
|
613
|
+
const budget = Math.max(0, Math.floor(dynamicRows))
|
|
614
|
+
let excess = live + reasoning + answer - budget
|
|
615
|
+
if (excess <= 0) return { allocation: { live, reasoning, answer } }
|
|
616
|
+
const take = (from: number): number => {
|
|
617
|
+
const cut = Math.min(from, excess)
|
|
618
|
+
excess -= cut
|
|
619
|
+
return from - cut
|
|
620
|
+
}
|
|
621
|
+
const clampedAnswer = take(answer)
|
|
622
|
+
const clampedReasoning = excess > 0 ? take(reasoning) : reasoning
|
|
623
|
+
const clampedLive = excess > 0 ? take(live) : live
|
|
624
|
+
return {
|
|
625
|
+
allocation: { live: clampedLive, reasoning: clampedReasoning, answer: clampedAnswer },
|
|
626
|
+
warning: `live rows ${live} + ${reasoning} + ${answer} exceed the dynamic budget ${budget}; clamped to ${clampedLive}/${clampedReasoning}/${clampedAnswer}`,
|
|
627
|
+
}
|
|
628
|
+
}
|