praxis-agent 0.34.0 → 0.35.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.md +4 -0
  2. package/dist/application/session-service.d.ts +0 -1
  3. package/dist/application/session-service.js +1619 -1617
  4. package/dist/application/subagent-service.js +27 -16
  5. package/dist/application/turn-lifecycle.d.ts +38 -0
  6. package/dist/application/turn-lifecycle.js +36 -0
  7. package/dist/cli/interactive.js +17 -7
  8. package/dist/cli/protocol.js +5 -2
  9. package/dist/cli/tui/claude-style.d.ts +5 -48
  10. package/dist/cli/tui/claude-style.js +19 -73
  11. package/dist/cli/tui/transcript-presentation.d.ts +89 -0
  12. package/dist/cli/tui/transcript-presentation.js +169 -0
  13. package/dist/cli/tui/transcript-viewport.d.ts +5 -34
  14. package/dist/cli/tui/transcript-viewport.js +639 -158
  15. package/dist/cli/tui/tui-view-model.d.ts +6 -2
  16. package/dist/cli/tui/tui-view-model.js +23 -6
  17. package/dist/compatibility/claude/context.d.ts +2 -2
  18. package/dist/compatibility/claude/context.js +2 -14
  19. package/dist/compatibility/claude/data-plane-adapter.d.ts +9 -0
  20. package/dist/compatibility/claude/data-plane-adapter.js +44 -0
  21. package/dist/compatibility/claude/paths.js +2 -2
  22. package/dist/compatibility/claude/shared-resources.d.ts +7 -38
  23. package/dist/compatibility/claude/transcript-codec.d.ts +21 -0
  24. package/dist/compatibility/claude/transcript-codec.js +512 -0
  25. package/dist/core/context.d.ts +25 -7
  26. package/dist/core/context.js +46 -0
  27. package/dist/core/prompt-composer.d.ts +8 -16
  28. package/dist/core/prompt-composer.js +65 -25
  29. package/dist/core/resources.d.ts +39 -0
  30. package/dist/core/resources.js +2 -0
  31. package/dist/core/runtime.d.ts +1 -1
  32. package/dist/core/session.d.ts +2 -0
  33. package/dist/core/session.js +5 -0
  34. package/dist/core/transcript-codec.d.ts +70 -0
  35. package/dist/core/transcript-codec.js +60 -0
  36. package/dist/core/transcript-event.d.ts +33 -0
  37. package/dist/core/transcript-event.js +198 -0
  38. package/dist/persistence/data-plane-adapter.d.ts +32 -0
  39. package/dist/persistence/data-plane-adapter.js +2 -0
  40. package/dist/persistence/data-plane.d.ts +4 -20
  41. package/dist/persistence/data-plane.js +10 -47
  42. package/dist/persistence/native-data-plane-adapter.d.ts +9 -0
  43. package/dist/persistence/native-data-plane-adapter.js +39 -0
  44. package/dist/persistence/native-resources.d.ts +4 -4
  45. package/dist/persistence/native-transcript-codec.d.ts +13 -0
  46. package/dist/persistence/native-transcript-codec.js +102 -0
  47. package/dist/tools/claude-user-message.d.ts +0 -1
  48. package/dist/tools/claude-user-message.js +0 -1
  49. package/package.json +6 -2
@@ -1,184 +1,665 @@
1
- /**
2
- * Rows reserved outside the shrinkable fullscreen transcript region: the top
3
- * identity chrome, the composer separator/prompt/footer, and the status line,
4
- * plus a small headroom so the active streaming tail stays visible below the
5
- * projected history suffix. Fullscreen computes the transcript budget as
6
- * `rows - FULLSCREEN_TRANSCRIPT_RESERVED_ROWS`.
7
- */
1
+ import { stripVTControlCharacters } from 'node:util';
8
2
  export const FULLSCREEN_TRANSCRIPT_RESERVED_ROWS = 12;
9
- /**
10
- * Conservative width-aware row estimate for a block of text. Blank lines still
11
- * occupy a row, and every logical line wraps across `Math.ceil(length / width)`
12
- * rows so the estimate leans high and never drops content that would actually
13
- * fit.
14
- */
15
- function wrappedLineCount(text, width) {
16
- if (text === '')
17
- return 1;
18
- const usable = Math.max(1, width);
3
+ export const TRANSCRIPT_TRUNCATION_MARKER = '…';
4
+ const graphemeSegmenter = new Intl.Segmenter(undefined, {
5
+ granularity: 'grapheme',
6
+ });
7
+ const zeroWidthClusterPattern = /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Format}|\p{Nonspacing_Mark}|\p{Enclosing_Mark}|\p{Surrogate})+$/v;
8
+ const leadingNonPrintingPattern = /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Nonspacing_Mark}\p{Enclosing_Mark}\p{Surrogate}]+/v;
9
+ const rgiEmojiPattern = /^\p{RGI_Emoji}$/v;
10
+ const extendedPictographicPattern = /\p{Extended_Pictographic}/gu;
11
+ const spacingMarkPattern = /\p{Spacing_Mark}/v;
12
+ const unqualifiedKeycapPattern = /^[\d#*]\u20e3$/u;
13
+ function graphemes(text) {
14
+ return Array.from(graphemeSegmenter.segment(text), ({ segment }) => segment);
15
+ }
16
+ function isPrintableAscii(text) {
17
+ for (let index = 0; index < text.length; index += 1) {
18
+ const code = text.charCodeAt(index);
19
+ if (code < 0x20 || code > 0x7e)
20
+ return false;
21
+ }
22
+ return true;
23
+ }
24
+ function isWideCodePoint(codePoint) {
25
+ return (codePoint >= 0x1100 &&
26
+ (codePoint <= 0x115f ||
27
+ codePoint === 0x2329 ||
28
+ codePoint === 0x232a ||
29
+ (codePoint >= 0x2e80 && codePoint <= 0x303e) ||
30
+ (codePoint >= 0x3040 && codePoint <= 0xa4cf) ||
31
+ (codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
32
+ (codePoint >= 0xf900 && codePoint <= 0xfaff) ||
33
+ (codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
34
+ (codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
35
+ (codePoint >= 0xff00 && codePoint <= 0xff60) ||
36
+ (codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
37
+ (codePoint >= 0x1b000 && codePoint <= 0x1b001) ||
38
+ (codePoint >= 0x1f200 && codePoint <= 0x1f251) ||
39
+ (codePoint >= 0x20000 && codePoint <= 0x3fffd)));
40
+ }
41
+ function isDoubleWidthEmoji(cluster) {
42
+ if (rgiEmojiPattern.test(cluster) || unqualifiedKeycapPattern.test(cluster))
43
+ return true;
44
+ if (!cluster.includes('\u200d') || cluster.length > 50)
45
+ return false;
46
+ return (cluster.match(extendedPictographicPattern)?.length ?? 0) >= 2;
47
+ }
48
+ function graphemeWidth(cluster) {
49
+ if (!cluster || zeroWidthClusterPattern.test(cluster))
50
+ return 0;
51
+ if (isDoubleWidthEmoji(cluster))
52
+ return 2;
53
+ const visible = cluster.replace(leadingNonPrintingPattern, '');
54
+ const first = visible.codePointAt(0);
55
+ if (first === undefined)
56
+ return 0;
57
+ let width = isWideCodePoint(first) ? 2 : 1;
58
+ let firstVisible = true;
59
+ for (const character of visible) {
60
+ if (firstVisible) {
61
+ firstVisible = false;
62
+ continue;
63
+ }
64
+ const codePoint = character.codePointAt(0);
65
+ if (codePoint !== undefined &&
66
+ (spacingMarkPattern.test(character) ||
67
+ (codePoint >= 0xff00 && codePoint <= 0xffef)))
68
+ width += isWideCodePoint(codePoint) ? 2 : 1;
69
+ }
70
+ return width;
71
+ }
72
+ function terminalStringWidth(text) {
73
+ if (isPrintableAscii(text))
74
+ return text.length;
75
+ const first = text.codePointAt(0);
76
+ if (first !== undefined && text.length === (first > 0xffff ? 2 : 1)) {
77
+ if (first <= 0x1f || (first >= 0x7f && first <= 0x9f))
78
+ return 0;
79
+ if (isWideCodePoint(first))
80
+ return 2;
81
+ return first <= 0xffff ? 1 : isDoubleWidthEmoji(text) ? 2 : 1;
82
+ }
83
+ return graphemes(text).reduce((width, cluster) => width + graphemeWidth(cluster), 0);
84
+ }
85
+ function expandTabs(line) {
86
+ if (!line.includes('\t'))
87
+ return line;
88
+ let column = 0;
89
+ let expanded = '';
90
+ for (const cluster of graphemes(line)) {
91
+ if (cluster === '\t') {
92
+ const spaces = 8 - (column % 8);
93
+ expanded += ' '.repeat(spaces);
94
+ column += spaces;
95
+ continue;
96
+ }
97
+ expanded += cluster;
98
+ column += graphemeWidth(cluster);
99
+ }
100
+ return expanded;
101
+ }
102
+ function hardWrappedRows(word, width, startColumn) {
103
+ if (word.length === 0)
104
+ return { rows: 0, column: startColumn };
105
+ if (isPrintableAscii(word)) {
106
+ const occupied = startColumn + word.length;
107
+ return {
108
+ rows: Math.max(0, Math.ceil(occupied / width) - 1),
109
+ column: occupied % width || width,
110
+ };
111
+ }
112
+ const clusters = graphemes(word);
19
113
  let rows = 0;
20
- for (const line of text.split('\n')) {
21
- rows += Math.max(1, Math.ceil(line.length / usable));
114
+ let column = startColumn;
115
+ for (const [index, cluster] of clusters.entries()) {
116
+ const clusterWidth = graphemeWidth(cluster);
117
+ if (column + clusterWidth > width) {
118
+ rows += 1;
119
+ column = clusterWidth;
120
+ }
121
+ else {
122
+ column += clusterWidth;
123
+ }
124
+ if (column === width && index < clusters.length - 1) {
125
+ rows += 1;
126
+ column = 0;
127
+ }
128
+ }
129
+ return { rows, column };
130
+ }
131
+ function wrappedLineRows(line, width) {
132
+ const expanded = expandTabs(line);
133
+ if (isPrintableAscii(expanded) && expanded.length <= width)
134
+ return 1;
135
+ const words = expanded.split(' ');
136
+ let rows = 1;
137
+ let column = 0;
138
+ for (let index = 0; index < words.length; index += 1) {
139
+ const word = words[index] ?? '';
140
+ const wordWidth = isPrintableAscii(word)
141
+ ? word.length
142
+ : terminalStringWidth(word);
143
+ if (index > 0) {
144
+ if (column >= width) {
145
+ rows += 1;
146
+ column = 0;
147
+ }
148
+ column += 1;
149
+ }
150
+ if (wordWidth > width) {
151
+ const current = hardWrappedRows(word, width, column);
152
+ const fresh = hardWrappedRows(word, width, 0);
153
+ if (column > 0 && current.rows > fresh.rows) {
154
+ rows += 1;
155
+ column = 0;
156
+ rows += fresh.rows;
157
+ column = fresh.column;
158
+ }
159
+ else {
160
+ rows += current.rows;
161
+ column = current.column;
162
+ }
163
+ continue;
164
+ }
165
+ if (column + wordWidth > width && column > 0 && word.length > 0) {
166
+ rows += 1;
167
+ column = 0;
168
+ }
169
+ column += wordWidth;
22
170
  }
23
171
  return rows;
24
172
  }
173
+ function wrappedLineCount(text, width) {
174
+ const usable = Math.max(1, width);
175
+ if (isPrintableAscii(text))
176
+ return text.length <= usable ? 1 : wrappedLineRows(text, usable);
177
+ const visible = text.includes('\u001b') || text.includes('\u009b')
178
+ ? stripVTControlCharacters(text)
179
+ : text;
180
+ return visible
181
+ .normalize()
182
+ .replace(/\r\n?/gu, '\n')
183
+ .split('\n')
184
+ .reduce((rows, line) => rows + wrappedLineRows(line, usable), 0);
185
+ }
186
+ function markdownContentWidth(width) {
187
+ return Math.max(1, width - 4);
188
+ }
25
189
  function bounded(text, max) {
26
190
  return text.length > max ? text.slice(0, max) : text;
27
191
  }
28
- /**
29
- * Compact marker prepended to the projected tail of an oversized newest item so
30
- * the truncation is visually explicit while the newest lines remain visible.
31
- */
32
- export const TRANSCRIPT_TRUNCATION_MARKER = '…';
33
- /**
34
- * Projects a text block to the longest trailing suffix (newest content) that
35
- * fits `budget` terminal rows, prefixed with the truncation marker. The marker
36
- * merges into the first kept line, so the result is exactly
37
- * `${TRANSCRIPT_TRUNCATION_MARKER}${suffix}`. A text that already fits is
38
- * returned unchanged.
39
- */
40
- function projectTextTail(text, budget, width) {
41
- if (wrappedLineCount(text, width) <= budget)
192
+ function contentLines(text) {
193
+ const lines = text.split('\n');
194
+ if (lines.at(-1) === '')
195
+ lines.pop();
196
+ return lines;
197
+ }
198
+ function compactTokens(tokens) {
199
+ if (tokens >= 1_000_000)
200
+ return `${(tokens / 1_000_000).toFixed(1).replace(/\.0$/u, '')}m`;
201
+ if (tokens >= 1_000)
202
+ return `${(tokens / 1_000).toFixed(1).replace(/\.0$/u, '')}k`;
203
+ return String(tokens);
204
+ }
205
+ function percent(tokens, total) {
206
+ return `${Math.round((tokens / Math.max(1, total)) * 100 * 10) / 10}%`;
207
+ }
208
+ function contextUsageLineCount(item, width) {
209
+ const totalTokens = Math.max(1, item.contextWindowTokens);
210
+ const compactBuffer = Math.round(totalTokens * 0.165);
211
+ const usable = Math.max(1, totalTokens - compactBuffer);
212
+ const rightWidth = Math.max(1, width - 16);
213
+ const rightColumnRows = [
214
+ `${item.model ?? 'provider default'} · ${compactTokens(item.usedTokens)}/${compactTokens(totalTokens)} tokens (${percent(item.usedTokens, totalTokens)})`,
215
+ ' ',
216
+ 'Estimated usage by category',
217
+ `⛁ Messages and other context: ${compactTokens(item.usedTokens)} tokens (${percent(item.usedTokens, totalTokens)})`,
218
+ `⛶ Free space: ${compactTokens(Math.max(0, usable - item.usedTokens))} (${percent(Math.max(0, usable - item.usedTokens), totalTokens)})`,
219
+ `⛝ Autocompact buffer: ${compactTokens(compactBuffer)} tokens (${percent(compactBuffer, totalTokens)})`,
220
+ ].reduce((rows, line) => rows + wrappedLineCount(line, rightWidth), 0);
221
+ const gridRows = Math.max(width < 48 ? 10 : 5, rightColumnRows);
222
+ const memoryRows = item.memoryFiles.length
223
+ ? item.memoryFiles.reduce((rows, file, index) => rows +
224
+ wrappedLineCount(`${index === item.memoryFiles.length - 1 ? '└' : '├'} ${file.path}: ${file.tokens} tokens`, Math.max(1, width - 2)), 0)
225
+ : wrappedLineCount('└ No memory files', Math.max(1, width - 2));
226
+ const skillRows = item.skills.length
227
+ ? item.skills.reduce((rows, skill, index) => rows +
228
+ wrappedLineCount(`${index === item.skills.length - 1 ? '└' : '├'} ${skill.name}: ~${skill.tokens} tokens`, Math.max(1, width - 2)), 0)
229
+ : wrappedLineCount('└ No skills loaded', Math.max(1, width - 2));
230
+ return 8 + gridRows + memoryRows + skillRows;
231
+ }
232
+ function combinedShellOutput(stdout, stderr) {
233
+ return [stdout, stderr]
234
+ .filter(Boolean)
235
+ .join(stdout && stderr && !stdout.endsWith('\n') ? '\n' : '');
236
+ }
237
+ function orphanShellOutput(stdout, stderr) {
238
+ return [stdout, stderr].filter(Boolean).join('\n');
239
+ }
240
+ function inputString(entry, key) {
241
+ const value = entry.item.call.input[key];
242
+ return typeof value === 'string' ? value : '';
243
+ }
244
+ function toolHeading(entry) {
245
+ if (entry.item.call.name === 'Edit')
246
+ return `Update(${inputString(entry, 'file_path')})`;
247
+ if (entry.item.call.name === 'Read')
248
+ return `Read(${inputString(entry, 'file_path')})`;
249
+ if (entry.item.call.name === 'Bash')
250
+ return `Bash(${inputString(entry, 'command')})`;
251
+ return entry.item.call.name;
252
+ }
253
+ function visibleOutputLineCount(text, width, mode) {
254
+ const lines = contentLines(text);
255
+ const visible = mode === 'normal' ? lines.slice(0, 3) : lines;
256
+ const outputWidth = Math.max(1, width - 2);
257
+ const rows = visible.reduce((total, line, index) => total +
258
+ wrappedLineCount(`${index === 0 ? '⎿ ' : ' '}${line || ' '}`, outputWidth), 0);
259
+ return (rows +
260
+ (mode === 'normal' && lines.length > visible.length
261
+ ? wrappedLineCount(` … +${lines.length - visible.length} lines (ctrl+o to expand)`, outputWidth)
262
+ : 0));
263
+ }
264
+ function screenReaderContextLineCount(item, width) {
265
+ const totalTokens = Math.max(1, item.contextWindowTokens);
266
+ const compactBuffer = Math.round(totalTokens * 0.165);
267
+ return [
268
+ 'Context Usage',
269
+ `${item.model ?? 'provider default'} · ${item.usedTokens.toLocaleString()}/${totalTokens.toLocaleString()} tokens (${percent(item.usedTokens, totalTokens)})`,
270
+ `Autocompact buffer: ${compactBuffer.toLocaleString()} tokens`,
271
+ `Skills: ${item.skills.map(({ name }) => name).join(', ') || 'none'}`,
272
+ ].reduce((rows, line) => rows + wrappedLineCount(line, width), 0);
273
+ }
274
+ function textTail(text, budget, width) {
275
+ const usableBudget = Math.max(1, budget);
276
+ const usableWidth = Math.max(1, width);
277
+ if (wrappedLineCount(text, usableWidth) <= usableBudget)
42
278
  return text;
43
279
  const lines = text.split('\n');
44
- let start = lines.length;
45
- // Wrapped rows of every kept line after the current front line (marker-free).
280
+ const kept = [];
46
281
  let rowsAfter = 0;
47
- for (let index = lines.length - 1; index >= 0; index -= 1) {
48
- const line = lines[index];
49
- if (line === undefined)
50
- break;
51
- // This line becomes the front of the tail and carries the marker.
52
- if (rowsAfter +
53
- wrappedLineCount(`${TRANSCRIPT_TRUNCATION_MARKER}${line}`, width) >
54
- budget) {
55
- break;
282
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
283
+ const line = lines[i] ?? '';
284
+ const markedRows = wrappedLineCount(`${TRANSCRIPT_TRUNCATION_MARKER}${line}`, usableWidth);
285
+ if (rowsAfter + markedRows <= usableBudget) {
286
+ kept.unshift(line);
287
+ rowsAfter += wrappedLineCount(line, usableWidth);
288
+ continue;
289
+ }
290
+ const remainingRows = usableBudget - rowsAfter;
291
+ if (remainingRows > 0) {
292
+ const clusters = graphemes(line);
293
+ let low = 0;
294
+ let high = clusters.length;
295
+ let suffix = '';
296
+ while (low <= high) {
297
+ const middle = Math.floor((low + high) / 2);
298
+ const candidate = clusters.slice(clusters.length - middle).join('');
299
+ if (wrappedLineCount(`${TRANSCRIPT_TRUNCATION_MARKER}${candidate}`, usableWidth) <= remainingRows) {
300
+ suffix = candidate;
301
+ low = middle + 1;
302
+ }
303
+ else {
304
+ high = middle - 1;
305
+ }
306
+ }
307
+ while (suffix &&
308
+ wrappedLineCount(`${TRANSCRIPT_TRUNCATION_MARKER}${suffix}`, usableWidth) > remainingRows) {
309
+ const suffixClusters = graphemes(suffix);
310
+ suffix = suffixClusters.slice(0, -1).join('');
311
+ }
312
+ kept.unshift(suffix);
313
+ }
314
+ break;
315
+ }
316
+ return `${TRANSCRIPT_TRUNCATION_MARKER}${kept.join('\n')}`;
317
+ }
318
+ export function estimateTranscriptEntryLines(entry, width, mode) {
319
+ if (entry.kind === 'read-summary')
320
+ return wrappedLineCount(` Read ${entry.count} file${entry.count === 1 ? '' : 's'} (ctrl+o to expand)`, width);
321
+ if (entry.kind === 'item') {
322
+ const item = entry.item;
323
+ if (item.kind === 'user')
324
+ return (1 +
325
+ (mode === 'screen-reader' ? 1 : 0) +
326
+ wrappedLineCount(`${mode === 'screen-reader' ? 'You: ' : '❯ '}${item.text}`, width));
327
+ if (item.kind === 'assistant')
328
+ return (1 +
329
+ (mode === 'screen-reader' ? 1 : 0) +
330
+ wrappedLineCount(item.text, mode === 'screen-reader'
331
+ ? Math.max(1, width - 7)
332
+ : markdownContentWidth(width)));
333
+ if (item.kind === 'thinking') {
334
+ const summary = item.text.replace(/\s+/gu, ' ').trim();
335
+ if (mode === 'normal')
336
+ return (1 +
337
+ wrappedLineCount(`✻ Thought for a moment${summary ? ` · ${summary.slice(0, 160)}` : ''}`, width) +
338
+ (summary.length > 160
339
+ ? wrappedLineCount(' ctrl+o to expand thinking', width)
340
+ : 0));
341
+ const heading = mode === 'screen-reader'
342
+ ? 'Thinking:Thought for a moment'
343
+ : `✻ Thought for a moment${summary ? ` · ${summary.slice(0, 160)}` : ''}`;
344
+ return (1 +
345
+ wrappedLineCount(heading, width) +
346
+ wrappedLineCount(item.text, mode === 'screen-reader' ? width : markdownContentWidth(width)));
347
+ }
348
+ if (item.kind === 'compact') {
349
+ if (mode === 'screen-reader')
350
+ return 2 + wrappedLineCount(item.summary, markdownContentWidth(width));
351
+ return (1 +
352
+ wrappedLineCount('✻ Conversation compacted (ctrl+o for history)', width) +
353
+ (mode === 'audit'
354
+ ? wrappedLineCount(item.summary, markdownContentWidth(width))
355
+ : 0));
56
356
  }
57
- start = index;
58
- rowsAfter += wrappedLineCount(line, width);
59
- }
60
- return `${TRANSCRIPT_TRUNCATION_MARKER}${lines.slice(start).join('\n')}`;
61
- }
62
- /**
63
- * Returns a cloned oversized text-bearing item whose text/summary is a bounded
64
- * trailing suffix that fits `budget` rows, preserving the newest content and
65
- * the item kind without mutating the input. Non-text display items (context,
66
- * tool, tool-result, shell, shell-result) are returned unchanged.
67
- */
68
- function projectOversizedItem(item, budget, width) {
69
- switch (item.kind) {
70
- case 'user':
71
- case 'assistant':
72
- case 'notice':
73
- case 'warning':
74
- case 'local-result':
75
- return { ...item, text: projectTextTail(item.text, budget, width) };
76
- case 'thinking':
357
+ if (item.kind === 'context')
358
+ return mode === 'screen-reader'
359
+ ? screenReaderContextLineCount(item, width)
360
+ : contextUsageLineCount(item, width);
361
+ if (item.kind === 'local-result')
362
+ return wrappedLineCount(`⎿ ${item.text}`, Math.max(1, width - 2));
363
+ if (item.kind === 'notice')
364
+ return wrappedLineCount(`· ${item.text}`, width);
365
+ if (item.kind === 'warning')
366
+ return wrappedLineCount(`⚠ ${item.text}`, width);
367
+ return wrappedLineCount(item.text, width);
368
+ }
369
+ if (entry.kind === 'orphan-tool-result')
370
+ return (1 +
371
+ wrappedLineCount(bounded(entry.item.text, 500), Math.max(1, width - 2)));
372
+ if (entry.kind === 'orphan-shell-result')
373
+ return wrappedLineCount(`⎿ ${orphanShellOutput(entry.item.stdout, entry.item.stderr)}`, width);
374
+ if (entry.kind === 'tool') {
375
+ if (mode === 'normal' &&
376
+ entry.item.call.name === 'Read' &&
377
+ entry.result &&
378
+ !entry.result.isError)
379
+ return 1;
380
+ let rows = 1 + wrappedLineCount(`⏺ ${toolHeading(entry)}`, width);
381
+ if (!['Bash', 'Read', 'Edit'].includes(entry.item.call.name) &&
382
+ entry.item.detail)
383
+ rows += wrappedLineCount(` ${entry.item.detail}`, width);
384
+ if (!entry.result)
385
+ return rows;
386
+ if (entry.result.isError)
387
+ return (rows +
388
+ wrappedLineCount(`⎿ Error: ${bounded(entry.result.text, 500)}`, Math.max(1, width - 2)));
389
+ if (entry.item.call.name === 'Edit') {
390
+ const newLines = contentLines(inputString(entry, 'new_string'));
391
+ const oldLines = contentLines(inputString(entry, 'old_string'));
392
+ rows += wrappedLineCount(`⎿ Added ${newLines.length} line${newLines.length === 1 ? '' : 's'}, removed ${oldLines.length} line${oldLines.length === 1 ? '' : 's'}`, Math.max(1, width - 2));
393
+ if (mode !== 'normal') {
394
+ rows += oldLines.reduce((total, line, index) => total +
395
+ wrappedLineCount(` ${index + 1} -${line}`, Math.max(1, width - 2)), 0);
396
+ rows += newLines.reduce((total, line, index) => total +
397
+ wrappedLineCount(` ${index + 1} +${line}`, Math.max(1, width - 2)), 0);
398
+ }
399
+ return rows;
400
+ }
401
+ return rows + visibleOutputLineCount(entry.result.text, width, mode);
402
+ }
403
+ let rows = 1 +
404
+ wrappedLineCount(`${mode === 'screen-reader' ? 'Shell command: ' : '! '}${entry.item.command}`, width);
405
+ if (!entry.result)
406
+ return rows;
407
+ const output = combinedShellOutput(entry.result.stdout, entry.result.stderr);
408
+ const lines = contentLines(output);
409
+ if (lines.length === 0)
410
+ return rows + wrappedLineCount('⎿ ', Math.max(1, width - 2));
411
+ rows += visibleOutputLineCount(output, width, mode);
412
+ return rows;
413
+ }
414
+ export function transcriptPresentationLineCount(entries, width, mode) {
415
+ return entries.reduce((n, entry) => n + estimateTranscriptEntryLines(entry, width, mode), 0);
416
+ }
417
+ function fitEntryText(entry, value, replace, budget, viewportWidth, contentWidth, mode) {
418
+ if (estimateTranscriptEntryLines(entry, viewportWidth, mode) <= budget ||
419
+ !value)
420
+ return entry;
421
+ const withoutValue = replace(entry, '');
422
+ const remaining = budget - estimateTranscriptEntryLines(withoutValue, viewportWidth, mode);
423
+ if (remaining <= 0)
424
+ return withoutValue;
425
+ const candidate = textTail(value, remaining, contentWidth);
426
+ const projected = replace(entry, candidate);
427
+ if (estimateTranscriptEntryLines(projected, viewportWidth, mode) <= budget)
428
+ return projected;
429
+ const clusters = graphemes(candidate);
430
+ let low = 0;
431
+ let high = clusters.length;
432
+ let suffix = '';
433
+ while (low <= high) {
434
+ const middle = Math.floor((low + high) / 2);
435
+ const next = clusters.slice(clusters.length - middle).join('');
436
+ const nextEntry = replace(entry, next);
437
+ if (estimateTranscriptEntryLines(nextEntry, viewportWidth, mode) <= budget) {
438
+ suffix = next;
439
+ low = middle + 1;
440
+ }
441
+ else {
442
+ high = middle - 1;
443
+ }
444
+ }
445
+ return suffix ? replace(entry, suffix) : withoutValue;
446
+ }
447
+ function replaceToolInput(entry, key, value) {
448
+ return {
449
+ ...entry,
450
+ item: {
451
+ ...entry.item,
452
+ call: {
453
+ ...entry.item.call,
454
+ input: { ...entry.item.call.input, [key]: value },
455
+ },
456
+ },
457
+ };
458
+ }
459
+ function projectOversizedEntry(entry, budget, width, mode) {
460
+ if (entry.kind === 'item') {
461
+ const item = entry.item;
462
+ if (item.kind === 'thinking')
77
463
  return {
78
- ...item,
79
- text: projectTextTail(item.text, Math.max(1, budget - 1), width),
464
+ ...entry,
465
+ item: {
466
+ ...item,
467
+ text: textTail(item.text, Math.max(1, budget - 2), markdownContentWidth(width)),
468
+ },
80
469
  };
81
- case 'compact':
470
+ if (item.kind === 'compact')
82
471
  return {
83
- ...item,
84
- summary: projectTextTail(item.summary, Math.max(1, budget - 1), width),
472
+ ...entry,
473
+ item: {
474
+ ...item,
475
+ summary: textTail(item.summary, Math.max(1, budget - 2), markdownContentWidth(width)),
476
+ },
85
477
  };
86
- default:
87
- return item;
88
- }
89
- }
90
- /**
91
- * Deterministic estimate of the terminal rows one transcript item occupies in
92
- * the TUI presentation. It is intentionally conservative (leans toward
93
- * overestimation) so the suffix projector keeps the newest content visible
94
- * rather than dropping items that would actually render.
95
- */
96
- export function estimateTranscriptLines(item, width) {
97
- switch (item.kind) {
98
- case 'user':
99
- case 'assistant':
100
- case 'notice':
101
- case 'warning':
102
- case 'local-result':
103
- return wrappedLineCount(item.text, width);
104
- case 'thinking':
105
- return 1 + wrappedLineCount(item.text, width);
106
- case 'compact':
107
- return 1 + wrappedLineCount(item.summary, width);
108
- case 'context':
109
- return 12 + item.skills.length + item.memoryFiles.length;
110
- case 'tool':
111
- return 1 + (item.detail ? 1 : 0);
112
- case 'tool-result':
113
- return 1 + wrappedLineCount(bounded(item.text, 500), width);
114
- case 'shell':
115
- return 2 + wrappedLineCount(item.command, width);
116
- case 'shell-result':
117
- return wrappedLineCount(bounded(`${item.stdout}\n${item.stderr}`, 500), width);
118
- }
119
- }
120
- export function transcriptLineCount(items, width) {
121
- return items.reduce((total, item) => total + estimateTranscriptLines(item, width), 0);
122
- }
123
- /**
124
- * Pure, deterministic suffix projector for the fullscreen transcript region.
125
- *
126
- * Given transcript items, a terminal row budget, and the usable width, it
127
- * returns the longest suffix of items whose conservative line estimate fits
128
- * the budget. The newest user/assistant content is always retained; if even
129
- * the newest item exceeds the budget, that item is projected as a bounded tail
130
- * clone rather than rendering an empty transcript (or clipping the tail below
131
- * the viewport). Ordering and item object identity are preserved for items
132
- * that fit and the input is never mutated.
133
- */
134
- export function projectTranscriptTail(items, budget, width) {
135
- if (items.length === 0)
478
+ if ('text' in item)
479
+ return {
480
+ ...entry,
481
+ item: {
482
+ ...item,
483
+ text: textTail(item.text, Math.max(1, budget -
484
+ (item.kind === 'user'
485
+ ? 1
486
+ : item.kind === 'assistant'
487
+ ? mode === 'screen-reader'
488
+ ? 2
489
+ : 1
490
+ : 0)), item.kind === 'assistant'
491
+ ? mode === 'screen-reader'
492
+ ? Math.max(1, width - 7)
493
+ : markdownContentWidth(width)
494
+ : item.kind === 'user'
495
+ ? Math.max(1, width - (mode === 'screen-reader' ? 5 : 2))
496
+ : Math.max(1, width - (item.kind === 'local-result' ? 4 : 2))),
497
+ },
498
+ };
499
+ }
500
+ if (entry.kind === 'tool') {
501
+ let projected = entry;
502
+ if (!['Bash', 'Read', 'Edit'].includes(projected.item.call.name)) {
503
+ projected = fitEntryText(projected, projected.item.detail, (source, detail) => ({
504
+ ...source,
505
+ item: { ...source.item, detail },
506
+ }), budget, width, Math.max(1, width - 1), mode);
507
+ }
508
+ const inputKeys = projected.item.call.name === 'Edit'
509
+ ? ['file_path', 'old_string', 'new_string']
510
+ : projected.item.call.name === 'Read'
511
+ ? ['file_path']
512
+ : projected.item.call.name === 'Bash'
513
+ ? ['command']
514
+ : [];
515
+ for (const key of inputKeys) {
516
+ const value = inputString(projected, key);
517
+ projected = fitEntryText(projected, value, (source, projectedValue) => replaceToolInput(source, key, projectedValue), budget, width, Math.max(1, width - (key === 'file_path' ? 12 : 8)), mode);
518
+ }
519
+ const result = projected.result;
520
+ if (result) {
521
+ projected = fitEntryText(projected, result.text, (source, text) => ({
522
+ ...source,
523
+ result: { ...result, text },
524
+ }), budget, width, Math.max(1, width - 5), mode);
525
+ }
526
+ return projected;
527
+ }
528
+ if (entry.kind === 'shell') {
529
+ let projected = fitEntryText(entry, entry.item.command, (source, command) => ({
530
+ ...source,
531
+ item: { ...source.item, command },
532
+ }), budget, width, Math.max(1, width - 4), mode);
533
+ const result = projected.result;
534
+ if (result) {
535
+ const combined = combinedShellOutput(result.stdout, result.stderr);
536
+ projected = fitEntryText(projected, combined, (source, stdout) => ({
537
+ ...source,
538
+ result: { ...result, stdout, stderr: '' },
539
+ }), budget, width, Math.max(1, width - 5), mode);
540
+ }
541
+ return projected;
542
+ }
543
+ if (entry.kind === 'orphan-tool-result')
544
+ return {
545
+ ...entry,
546
+ item: {
547
+ ...entry.item,
548
+ text: textTail(entry.item.text, Math.max(1, budget - 1), Math.max(1, width - 2)),
549
+ },
550
+ };
551
+ if (entry.kind === 'orphan-shell-result') {
552
+ const combined = orphanShellOutput(entry.item.stdout, entry.item.stderr);
553
+ return {
554
+ ...entry,
555
+ item: {
556
+ ...entry.item,
557
+ stdout: textTail(combined, budget, Math.max(1, width - 2)),
558
+ stderr: '',
559
+ },
560
+ };
561
+ }
562
+ return entry;
563
+ }
564
+ export function projectTranscriptPresentationTail(entries, budget, width, mode) {
565
+ if (!entries.length)
136
566
  return [];
137
- const usableBudget = Math.max(1, budget);
567
+ const limit = Math.max(1, budget);
138
568
  let rows = 0;
139
- let start = items.length;
140
- for (let index = items.length - 1; index >= 0; index -= 1) {
141
- const item = items[index];
142
- if (item === undefined)
143
- break;
144
- const estimate = estimateTranscriptLines(item, width);
145
- if (rows + estimate > usableBudget) {
146
- // Stop at the first item that no longer fits. When no item has been
147
- // added yet, even the newest item exceeds the budget: returning it whole
148
- // would let Ink clip the oversized tail below the viewport, so project a
149
- // bounded suffix clone that keeps the newest content visible instead.
150
- return start === items.length
151
- ? [projectOversizedItem(item, usableBudget, width)]
152
- : items.slice(start);
153
- }
154
- rows += estimate;
155
- start = index;
156
- }
157
- return items.slice(start);
158
- }
159
- /** Projects a fixed-size transcript window, measured upward from the newest row. */
160
- export function projectTranscriptWindow(items, budget, width, scrollOffset) {
161
- if (scrollOffset <= 0)
162
- return projectTranscriptTail(items, budget, width);
163
- const endRows = Math.max(0, transcriptLineCount(items, width) - scrollOffset);
164
- const startRows = Math.max(0, endRows - budget);
569
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
570
+ const entry = entries[i];
571
+ if (!entry)
572
+ continue;
573
+ const count = estimateTranscriptEntryLines(entry, width, mode);
574
+ if (rows + count > limit) {
575
+ if (i !== entries.length - 1)
576
+ return entries.slice(i + 1);
577
+ const projected = projectOversizedEntry(entry, limit, width, mode);
578
+ return estimateTranscriptEntryLines(projected, width, mode) <= limit
579
+ ? [projected]
580
+ : [];
581
+ }
582
+ rows += count;
583
+ }
584
+ return entries;
585
+ }
586
+ function projectTranscriptPresentationStart(entries, budget, width, mode) {
587
+ const limit = Math.max(1, budget);
165
588
  let rows = 0;
166
- let start = 0;
167
589
  let end = 0;
168
- for (let index = 0; index < items.length; index += 1) {
169
- const item = items[index];
170
- if (!item)
171
- break;
172
- const nextRows = rows + estimateTranscriptLines(item, width);
173
- if (nextRows <= startRows) {
174
- start = index + 1;
590
+ for (const entry of entries) {
591
+ const count = estimateTranscriptEntryLines(entry, width, mode);
592
+ if (rows + count > limit) {
593
+ if (end > 0)
594
+ break;
595
+ const projected = projectOversizedEntry(entry, limit, width, mode);
596
+ return estimateTranscriptEntryLines(projected, width, mode) <= limit
597
+ ? [projected]
598
+ : [];
599
+ }
600
+ rows += count;
601
+ end += 1;
602
+ }
603
+ return entries.slice(0, end);
604
+ }
605
+ export function projectTranscriptPresentationWindow(entries, budget, width, scrollOffset, mode) {
606
+ if (scrollOffset <= 0)
607
+ return projectTranscriptPresentationTail(entries, budget, width, mode);
608
+ const limit = Math.max(1, budget);
609
+ const totalRows = transcriptPresentationLineCount(entries, width, mode);
610
+ const clampedOffset = Math.min(Math.max(0, scrollOffset), Math.max(0, totalRows - limit));
611
+ const startRows = Math.max(0, totalRows - clampedOffset - limit);
612
+ const endRows = totalRows - clampedOffset;
613
+ let rows = 0;
614
+ let start = entries.length;
615
+ let startBeforeRows = 0;
616
+ let startEntryRows = 0;
617
+ for (let i = 0; i < entries.length; i += 1) {
618
+ const entry = entries[i];
619
+ if (!entry)
620
+ continue;
621
+ const count = estimateTranscriptEntryLines(entry, width, mode);
622
+ const next = rows + count;
623
+ if (start === entries.length && next > startRows) {
624
+ start = i;
625
+ startBeforeRows = rows;
626
+ startEntryRows = count;
175
627
  }
176
- if (rows < endRows)
177
- end = index + 1;
178
- rows = nextRows;
628
+ rows = next;
629
+ }
630
+ if (start === entries.length)
631
+ return [];
632
+ let completeEnd = start - 1;
633
+ rows = 0;
634
+ for (let i = 0; i < entries.length; i += 1) {
635
+ const entry = entries[i];
636
+ if (!entry)
637
+ continue;
638
+ rows += estimateTranscriptEntryLines(entry, width, mode);
639
+ if (rows <= endRows)
640
+ completeEnd = i;
179
641
  if (rows >= endRows)
180
642
  break;
181
643
  }
182
- return items.slice(start, end);
644
+ const offsetWithinStart = startRows - startBeforeRows;
645
+ const remainingStartRows = startEntryRows - offsetWithinStart;
646
+ if (completeEnd < start) {
647
+ const entry = entries[start];
648
+ if (!entry)
649
+ return [];
650
+ return projectTranscriptPresentationStart([entry], limit, width, mode);
651
+ }
652
+ let candidates = entries.slice(start, completeEnd + 1);
653
+ if (offsetWithinStart > 0) {
654
+ const entry = candidates[0];
655
+ if (entry) {
656
+ const allowance = Math.min(limit, Math.max(1, remainingStartRows));
657
+ candidates = [
658
+ projectOversizedEntry(entry, allowance, width, mode),
659
+ ...candidates.slice(1),
660
+ ];
661
+ }
662
+ }
663
+ return projectTranscriptPresentationStart(candidates, limit, width, mode);
183
664
  }
184
665
  //# sourceMappingURL=transcript-viewport.js.map