dsh-code 1.0.2 → 1.0.3

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.
@@ -9,6 +9,8 @@
9
9
  * @module @deepseek-ai/dsh-code/render/markdown
10
10
  */
11
11
 
12
+ import { stringWidth } from './width.ts'
13
+
12
14
  /** Style classes the renderer emits; the app maps them to colors/props. */
13
15
  export type MdStyle = 'plain' | 'bold' | 'italic' | 'boldItalic' | 'code' | 'accent' | 'accentBold' | 'dim' | 'strike'
14
16
 
@@ -30,14 +32,9 @@ function seg(text: string, style: MdStyle = 'plain'): MdSegment {
30
32
  return { text, style }
31
33
  }
32
34
 
33
- /** Visible width of a run in columns (CJK counts double). */
35
+ /** Visible width of a run in columns (grapheme-cluster and emoji aware). */
34
36
  export function visibleColumns(text: string): number {
35
- let columns = 0
36
- for (const char of text) {
37
- const code = char.codePointAt(0) ?? 0
38
- columns += code > 0x2e7f ? 2 : 1
39
- }
40
- return columns
37
+ return stringWidth(text)
41
38
  }
42
39
 
43
40
  interface WrapUnit {
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { boundContextSummary, type ContentBlock, type ImageBlock, type MessageId } from '@deepseek-ai/dsh-llm'
11
11
  import type { SessionEvent, TodoItem } from '@deepseek-ai/dsh-session'
12
+ import { graphemeWidth, splitGraphemes } from './width.ts'
12
13
  // Type-only imports merge the plugin-owned SessionEventMap variants
13
14
  // (agent/inbox/spliced, command/*, compaction/*, goal/change, llm/retry*,
14
15
  // plan/mode, permission/preset, sandbox/mode, session/title) into the union
@@ -336,13 +337,13 @@ function imagesOf(content: readonly ContentBlock[]): readonly ImageBlock['attach
336
337
  export function imageLabels(images: readonly ImageBlock['attachment'][] | undefined): string {
337
338
  if (images === undefined || images.length === 0) return ''
338
339
  return images.map((image, index) => {
339
- const rawName = image.name?.trim() || `image ${index + 1}`
340
- const name = rawName.length <= 80 ? rawName : `${rawName.slice(0, 79)}…`
341
- const original = image.originalDimensions
342
- const dimensions = original === undefined
343
- ? `${image.width}×${image.height}`
344
- : `${image.width}×${image.height} · original ${original.width}×${original.height}`
345
- return `[image: ${name} · ${dimensions} · ${image.bytes} B]`
340
+ const rawName = image.name?.trim() || `image ${index + 1}`
341
+ const name = rawName.length <= 80 ? rawName : `${rawName.slice(0, 79)}…`
342
+ const original = image.originalDimensions
343
+ const dimensions = original === undefined
344
+ ? `${image.width}×${image.height}`
345
+ : `${image.width}×${image.height} · original ${original.width}×${original.height}`
346
+ return `[image: ${name} · ${dimensions} · ${image.bytes} B]`
346
347
  }).join('\n')
347
348
  }
348
349
 
@@ -368,8 +369,8 @@ function reasoningOf(content: readonly ContentBlock[]): string {
368
369
  function estimateTokens(text: string): number {
369
370
  let wide = 0
370
371
  let narrow = 0
371
- for (const char of text) {
372
- if ((char.codePointAt(0) ?? 0) > 0x2e7f) wide += 1
372
+ for (const cluster of splitGraphemes(text)) {
373
+ if (graphemeWidth(cluster) > 1) wide += 1
373
374
  else narrow += 1
374
375
  }
375
376
  return wide + Math.ceil(narrow / 4)
@@ -1035,8 +1036,11 @@ function retireReplayEntry(acc: ReplayAccumulator, index: number): void {
1035
1036
  * to a sequential fold; only the `entries` container operations are mutable.
1036
1037
  *
1037
1038
  * @internal Test-instrumentation path; `projectEvents` is the public entry.
1039
+ * @returns whether the event changed the accumulated state — the live store
1040
+ * stays silent and keeps its snapshot identity for ignored events, exactly
1041
+ * like the copy-on-write reducer returning its input view unchanged.
1038
1042
  */
1039
- export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent): void {
1043
+ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent): boolean {
1040
1044
  switch (event.type) {
1041
1045
  case 'user/message': {
1042
1046
  const message = event.data
@@ -1066,7 +1070,7 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1066
1070
  prompt: acc.stats.contextSegments.prompt + estimateTokens(text),
1067
1071
  },
1068
1072
  }
1069
- return
1073
+ return true
1070
1074
  }
1071
1075
  const notice = message.source.kind === 'plugin' && message.source.form === 'notice'
1072
1076
  ? message.source.summary
@@ -1080,7 +1084,7 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1080
1084
  system: acc.stats.contextSegments.system + estimateTokens(summary),
1081
1085
  },
1082
1086
  }
1083
- return
1087
+ return true
1084
1088
  }
1085
1089
  case 'agent/inbox/spliced': {
1086
1090
  const { target, start, removedCount = 0, inserted } = event.data
@@ -1106,7 +1110,7 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1106
1110
  ids.push(message.id)
1107
1111
  acc.ops += 1
1108
1112
  }
1109
- return
1113
+ return true
1110
1114
  }
1111
1115
  case 'assistant/chunk': {
1112
1116
  const chunk = event.data.chunk
@@ -1125,13 +1129,13 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1125
1129
  }
1126
1130
  if (chunk.type === 'text-delta') {
1127
1131
  acc.streaming = appendStreamingTail(acc.streaming, chunk.text)
1128
- return
1132
+ return true
1129
1133
  }
1130
1134
  if (chunk.type === 'reasoning-delta') {
1131
1135
  acc.streamingReasoning = appendStreamingTail(acc.streamingReasoning, chunk.text)
1132
- return
1136
+ return true
1133
1137
  }
1134
- return
1138
+ return false
1135
1139
  }
1136
1140
  case 'assistant/message': {
1137
1141
  const key = `${event.data.turn}:${event.data.step}`
@@ -1165,7 +1169,7 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1165
1169
  assistant: acc.stats.contextSegments.assistant + estimateTokens(text),
1166
1170
  },
1167
1171
  }
1168
- return
1172
+ return true
1169
1173
  }
1170
1174
  case 'tool/call': {
1171
1175
  const data = event.data
@@ -1195,7 +1199,7 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1195
1199
  + (typeof data.arguments === 'string' ? estimateTokens(data.arguments) : 0),
1196
1200
  },
1197
1201
  }
1198
- return
1202
+ return true
1199
1203
  }
1200
1204
  case 'tool/result': {
1201
1205
  const block = event.data.message.content[0]
@@ -1231,18 +1235,18 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1231
1235
  tools: acc.stats.contextSegments.tools + estimateTokens(rawText),
1232
1236
  },
1233
1237
  }
1234
- return
1238
+ return true
1235
1239
  }
1236
1240
  case 'todo/write':
1237
1241
  acc.todos = event.data.todos
1238
- return
1242
+ return true
1239
1243
  case 'turn/start': {
1240
1244
  const wasBusy = acc.busy
1241
1245
  acc.busy = true
1242
1246
  acc.busySince = wasBusy ? acc.busySince : event.time
1243
1247
  acc.todos = []
1244
1248
  acc.stats = { ...acc.stats, turns: acc.stats.turns + 1 }
1245
- return
1249
+ return true
1246
1250
  }
1247
1251
  case 'step/start': {
1248
1252
  const key = `${event.data.turn}:${event.data.step}`
@@ -1256,7 +1260,7 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1256
1260
  acc.streaming = ''
1257
1261
  acc.streamingReasoning = ''
1258
1262
  acc.stats = { ...acc.stats, steps: acc.stats.steps + 1 }
1259
- return
1263
+ return true
1260
1264
  }
1261
1265
  case 'turn/end': {
1262
1266
  const reason = event.data.reason
@@ -1297,7 +1301,7 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1297
1301
  acc.busy = false
1298
1302
  acc.busySince = 0
1299
1303
  for (const entry of appended) appendReplayEntry(acc, entry)
1300
- return
1304
+ return true
1301
1305
  }
1302
1306
  case 'llm/retry': {
1303
1307
  const data = event.data
@@ -1313,23 +1317,23 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1313
1317
  state: 'running',
1314
1318
  })
1315
1319
  indexList(acc.retryIndex, data.retryId).push(acc.entries.length - 1)
1316
- return
1320
+ return true
1317
1321
  }
1318
1322
  case 'llm/retry-started': {
1319
1323
  const data = event.data
1320
1324
  updateReplayById<RetryEntry>(acc, acc.retryIndex, data.retryId, entry => entry.retryId === data.retryId, entry => ({ ...entry, state: 'done' as const }))
1321
- return
1325
+ return true
1322
1326
  }
1323
1327
  case 'sandbox/mode':
1324
1328
  acc.sandbox = event.data.mode
1325
- return
1329
+ return true
1326
1330
  case 'goal/change': {
1327
1331
  const data = event.data
1328
1332
  const clip = (text: string): string => (text.length > 60 ? `${text.slice(0, 59)}…` : text)
1329
1333
  if (data.operation === 'clear') {
1330
1334
  acc.goal = undefined
1331
1335
  appendReplayEntry(acc, { kind: 'turn-marker', text: '◎ goal cleared' })
1332
- return
1336
+ return true
1333
1337
  }
1334
1338
  const goal: GoalFold = {
1335
1339
  objective: data.goal.objective,
@@ -1351,31 +1355,31 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1351
1355
  : undefined
1352
1356
  acc.goal = goal
1353
1357
  if (line !== undefined) appendReplayEntry(acc, { kind: 'turn-marker', text: line })
1354
- return
1358
+ return true
1355
1359
  }
1356
1360
  case 'session/title':
1357
1361
  acc.title = event.data.title
1358
- return
1362
+ return true
1359
1363
  case 'compaction/summary':
1360
1364
  if (acc.compactionTokens.size >= MAX_COMPACTION_SUMMARY_RESIDUE) {
1361
1365
  const oldest = acc.compactionTokens.keys().next().value
1362
1366
  if (oldest !== undefined) acc.compactionTokens.delete(oldest)
1363
1367
  }
1364
1368
  acc.compactionTokens.set(event.data.compactionId, event.data.shadowedTokenCount)
1365
- return
1369
+ return true
1366
1370
  case 'compaction/prune':
1367
1371
  acc.lastPruneTokens = event.data.shadowedTokenCount
1368
- return
1372
+ return true
1369
1373
  case 'compaction/end': {
1370
1374
  const ok = event.data.error === undefined
1371
1375
  const tokens = acc.compactionTokens.get(event.data.compactionId) ?? acc.lastPruneTokens
1372
1376
  acc.compactionTokens.delete(event.data.compactionId)
1373
1377
  appendReplayEntry(acc, { kind: 'compaction', ok, tokens, error: event.data.error ?? '' })
1374
- return
1378
+ return true
1375
1379
  }
1376
1380
  case 'request/context':
1377
1381
  acc.stats = { ...acc.stats, contextWindow: event.data.contextWindow ?? acc.stats.contextWindow }
1378
- return
1382
+ return true
1379
1383
  case 'request/header': {
1380
1384
  const config = event.data.header.config
1381
1385
  acc.model = `${config.provider}/${config.model}`
@@ -1387,14 +1391,14 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1387
1391
  system: estimateTokens(event.data.header.system ?? ''),
1388
1392
  },
1389
1393
  }
1390
- return
1394
+ return true
1391
1395
  }
1392
1396
  case 'plan/mode':
1393
1397
  acc.plan = event.data.active
1394
- return
1398
+ return true
1395
1399
  case 'permission/preset':
1396
1400
  acc.permission = event.data.preset
1397
- return
1401
+ return true
1398
1402
  case 'command/run': {
1399
1403
  const data = event.data
1400
1404
  appendReplayEntry(acc, {
@@ -1406,7 +1410,7 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1406
1410
  summary: '',
1407
1411
  })
1408
1412
  indexList(acc.commandIndex, data.commandId).push(acc.entries.length - 1)
1409
- return
1413
+ return true
1410
1414
  }
1411
1415
  case 'command/done': {
1412
1416
  const data = event.data
@@ -1416,10 +1420,10 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1416
1420
  summary: boundContextSummary(data.text ?? ''),
1417
1421
  })
1418
1422
  updateReplayById<CommandEntry>(acc, acc.commandIndex, data.commandId, entry => entry.commandId === data.commandId, update)
1419
- return
1423
+ return true
1420
1424
  }
1421
1425
  default:
1422
- return
1426
+ return false
1423
1427
  }
1424
1428
  }
1425
1429
 
@@ -1431,8 +1435,27 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1431
1435
  * @internal Test-instrumentation path; `projectEvents` is the public entry.
1432
1436
  */
1433
1437
  export function finishReplay(acc: ReplayAccumulator): TranscriptView {
1438
+ return materializeReplayView(acc, false)
1439
+ }
1440
+
1441
+ /**
1442
+ * Materialize the accumulated fold as a fresh immutable snapshot for the
1443
+ * live store. Unlike {@link finishReplay} — the one-shot replay entry, which
1444
+ * hands the accumulator's own arrays through because the accumulator is
1445
+ * discarded — every array a renderer can hold is copied here, so later
1446
+ * folds never mutate a snapshot already handed out. Same fields, same
1447
+ * tombstone compaction.
1448
+ *
1449
+ * @internal Live-store path; `projectEvents` is the public entry.
1450
+ */
1451
+ export function snapshotReplayView(acc: ReplayAccumulator): TranscriptView {
1452
+ return materializeReplayView(acc, true)
1453
+ }
1454
+
1455
+ /** Field-for-field materialization; `copy` selects snapshot array isolation. */
1456
+ function materializeReplayView(acc: ReplayAccumulator, copy: boolean): TranscriptView {
1434
1457
  const entries: readonly TranscriptEntry[] = acc.removedCount === 0
1435
- ? acc.entries as TranscriptEntry[]
1458
+ ? (copy ? [...acc.entries] : acc.entries) as TranscriptEntry[]
1436
1459
  : acc.entries.filter((entry): entry is TranscriptEntry => entry !== undefined)
1437
1460
  if (acc.removedCount > 0) acc.ops += acc.entries.length
1438
1461
  return {
@@ -1,150 +1,152 @@
1
- /**
2
- * Display-boundary sanitization for externally sourced text (model output,
3
- * tool payloads, skill descriptions). Control characters — including ANSI
4
- * CSI/OSC escape sequences — would otherwise pass through Ink into the
5
- * terminal, letting output rewrite the screen or inject prompts. Newlines
6
- * survive; everything else in C0/C1 plus DEL becomes a visible `\xNN`
7
- * escape, and bidi overrides / invisible format controls / Unicode line and
8
- * paragraph separators become a visible `\uXXXX` escape (terminal emulators
9
- * that render bidirectional text would otherwise reorder the displayed
10
- * glyphs and let a command read as something it is not).
11
- *
12
- * @module @deepseek-ai/dsh-code/render/text
13
- */
14
-
15
- /** C0 controls except tab (0x09) and newline (0x0a), plus DEL and C1. */
16
- const CONTROL_ESCAPE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu
17
-
18
- /**
19
- * Bidi overrides and isolates (U+202A-202E, U+2066-2069), the Arabic Letter
20
- * Mark (U+061C), directional and zero-width format characters (U+200B,
21
- * U+200E/200F, U+2060-2064, U+FEFF), and Unicode line/paragraph separators
22
- * (U+2028/2029). Terminal emulators with bidi support (Windows Terminal,
23
- * iTerm2, kitty, WezTerm) reorder or hide these, so they must never reach
24
- * the terminal raw.
25
- */
26
- const INVISIBLE_ESCAPE = /[\u061c\u200b\u200e\u200f\u2028\u2029\u202a-\u202e\u2060-\u2064\u2066-\u206f\ufeff]/gu
27
-
28
- /**
29
- * Escape control and deceptive characters so externally sourced text cannot
30
- * drive the terminal. C0/C1/DEL render as a literal `\xNN` escape; bidi,
31
- * invisible-format, and separator controls render as a literal `\uXXXX`
32
- * escape. Newlines and tabs survive (budgeted callers normalize tabs).
33
- * @param text - raw text from a session event, tool payload, or catalog.
34
- * @returns display-safe text with every injectable character made visible.
35
- */
36
- export function displayText(text: string): string {
37
- return text
38
- .replace(CONTROL_ESCAPE, char => `\\x${char.charCodeAt(0).toString(16).padStart(2, '0')}`)
39
- .replace(INVISIBLE_ESCAPE, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`)
40
- }
41
-
42
- /** Collapse external text to one terminal-safe logical row. */
43
- export function singleLineText(text: string): string {
44
- return displayText(text).replace(/\r?\n/gu, ' ').replace(/\t/gu, ' ')
45
- }
46
-
47
- /** Terminal-cell width matching the TUI's existing CJK-aware wrapping rule. */
48
- function cellWidth(text: string): number {
49
- let columns = 0
50
- for (const char of text) {
51
- columns += (char.codePointAt(0) ?? 0) > 0x2e7f ? 2 : 1
52
- }
53
- return columns
54
- }
55
-
56
- /**
57
- * Truncate one display-safe row without ever exceeding its physical-column
58
- * budget. The ellipsis is included inside the budget, matching Codex's popup
59
- * truncation contract; the previous app-local helper appended it after the
60
- * row was already full and could force an extra terminal wrap.
61
- */
62
- export function truncateColumns(text: string, columns: number): string {
63
- const limit = Math.max(0, Math.floor(columns))
64
- if (limit === 0) return ''
65
- if (cellWidth(text) <= limit) return text
66
-
67
- const contentLimit = limit - 1
68
- let used = 0
69
- let result = ''
70
- for (const char of text) {
71
- const width = cellWidth(char)
72
- if (used + width > contentLimit) break
73
- result += char
74
- used += width
75
- }
76
- return `${result}…`
77
- }
78
-
79
- /** A display-safe suffix bounded by terminal rows and columns. */
80
- export interface DisplayTail {
81
- /** Sanitized suffix suitable for direct terminal rendering. */
82
- text: string
83
- /** Whether content before the returned suffix was omitted. */
84
- truncated: boolean
85
- }
86
-
87
- /** Read one Unicode character immediately before `end`. */
88
- function previousCharacter(text: string, end: number): { char: string; start: number } {
89
- const last = text.charCodeAt(end - 1)
90
- if (last >= 0xdc00 && last <= 0xdfff && end >= 2) {
91
- const first = text.charCodeAt(end - 2)
92
- if (first >= 0xd800 && first <= 0xdbff) {
93
- return { char: text.slice(end - 2, end), start: end - 2 }
94
- }
95
- }
96
- return { char: text.slice(end - 1, end), start: end - 1 }
97
- }
98
-
99
- /**
100
- * Keep only the newest display-safe text that fits a terminal rectangle.
101
- * The scan walks backward and stops as soon as the suffix is full, so a long
102
- * reasoning stream does not rescan its entire accumulated prefix per chunk.
103
- * Explicit newlines and terminal wrapping both consume rows; tabs expand to
104
- * two spaces so terminal tab stops (which render at contextual column 8
105
- * boundaries, not at the budgeted cell count) cannot inflate the physical
106
- * row count of the live region.
107
- * @param text - raw externally sourced text.
108
- * @param columns - available terminal columns.
109
- * @param rows - available terminal rows.
110
- * @returns a sanitized bounded suffix and whether an earlier prefix was cut.
111
- */
112
- export function displayTail(text: string, columns: number, rows: number): DisplayTail {
113
- const columnLimit = Math.max(1, Math.floor(columns))
114
- const rowLimit = Math.max(1, Math.floor(rows))
115
- const reversed: string[] = []
116
- let row = 1
117
- let used = 0
118
- let end = text.length
119
-
120
- while (end > 0) {
121
- const previous = previousCharacter(text, end)
122
- if (previous.char === '\n') {
123
- if (row >= rowLimit) break
124
- reversed.push('\n')
125
- row += 1
126
- used = 0
127
- end = previous.start
128
- continue
129
- }
130
-
131
- const safe = previous.char === '\t' ? ' ' : displayText(previous.char)
132
- const width = cellWidth(safe)
133
- if (used > 0 && used + width > columnLimit) {
134
- if (row >= rowLimit) break
135
- // Materialize the soft wrap. Ink otherwise reflows at word boundaries
136
- // and can turn a cell-counted two-row suffix into three rendered rows.
137
- reversed.push('\n')
138
- row += 1
139
- used = 0
140
- }
141
- const extraRows = Math.floor(Math.max(0, width - 1) / columnLimit)
142
- if (row + extraRows > rowLimit) break
143
- row += extraRows
144
- reversed.push(safe)
145
- used = extraRows === 0 ? used + width : width - extraRows * columnLimit
146
- end = previous.start
147
- }
148
-
149
- return { text: reversed.reverse().join(''), truncated: end > 0 }
150
- }
1
+ /**
2
+ * Display-boundary sanitization for externally sourced text (model output,
3
+ * tool payloads, skill descriptions). Control characters — including ANSI
4
+ * CSI/OSC escape sequences — would otherwise pass through Ink into the
5
+ * terminal, letting output rewrite the screen or inject prompts. Newlines
6
+ * survive; everything else in C0/C1 plus DEL becomes a visible `\xNN`
7
+ * escape, and bidi overrides / invisible format controls / Unicode line and
8
+ * paragraph separators become a visible `\uXXXX` escape (terminal emulators
9
+ * that render bidirectional text would otherwise reorder the displayed
10
+ * glyphs and let a command read as something it is not).
11
+ *
12
+ * @module @deepseek-ai/dsh-code/render/text
13
+ */
14
+
15
+ import { graphemeWidth, splitGraphemes, stringWidth } from './width.ts'
16
+
17
+ /** C0 controls except tab (0x09) and newline (0x0a), plus DEL and C1. */
18
+ const CONTROL_ESCAPE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu
19
+
20
+ /**
21
+ * Bidi overrides and isolates (U+202A-202E, U+2066-2069), the Arabic Letter
22
+ * Mark (U+061C), directional and zero-width format characters (U+200B,
23
+ * U+200E/200F, U+2060-2064, U+FEFF), and Unicode line/paragraph separators
24
+ * (U+2028/2029). Terminal emulators with bidi support (Windows Terminal,
25
+ * iTerm2, kitty, WezTerm) reorder or hide these, so they must never reach
26
+ * the terminal raw.
27
+ */
28
+ const INVISIBLE_ESCAPE = /[\u061c\u200b\u200e\u200f\u2028\u2029\u202a-\u202e\u2060-\u2064\u2066-\u206f\ufeff]/gu
29
+
30
+ /**
31
+ * Escape control and deceptive characters so externally sourced text cannot
32
+ * drive the terminal. C0/C1/DEL render as a literal `\xNN` escape; bidi,
33
+ * invisible-format, and separator controls render as a literal `\uXXXX`
34
+ * escape. Newlines and tabs survive (budgeted callers normalize tabs).
35
+ * @param text - raw text from a session event, tool payload, or catalog.
36
+ * @returns display-safe text with every injectable character made visible.
37
+ */
38
+ export function displayText(text: string): string {
39
+ return text
40
+ .replace(CONTROL_ESCAPE, char => `\\x${char.charCodeAt(0).toString(16).padStart(2, '0')}`)
41
+ .replace(INVISIBLE_ESCAPE, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`)
42
+ }
43
+
44
+ /** Collapse external text to one terminal-safe logical row. */
45
+ export function singleLineText(text: string): string {
46
+ return displayText(text).replace(/\r?\n/gu, ' ↵ ').replace(/\t/gu, ' ')
47
+ }
48
+
49
+ /**
50
+ * Truncate one display-safe row without ever exceeding its physical-column
51
+ * budget. The ellipsis is included inside the budget, matching Codex's popup
52
+ * truncation contract; the cut walks grapheme clusters so emoji and
53
+ * combining sequences never split mid-cluster.
54
+ */
55
+ export function truncateColumns(text: string, columns: number): string {
56
+ const limit = Math.max(0, Math.floor(columns))
57
+ if (limit === 0) return ''
58
+ if (stringWidth(text) <= limit) return text
59
+
60
+ const contentLimit = limit - 1
61
+ let used = 0
62
+ let result = ''
63
+ for (const cluster of splitGraphemes(text)) {
64
+ const width = graphemeWidth(cluster)
65
+ if (used + width > contentLimit) break
66
+ result += cluster
67
+ used += width
68
+ }
69
+ return `${result}…`
70
+ }
71
+
72
+ /** A display-safe suffix bounded by terminal rows and columns. */
73
+ export interface DisplayTail {
74
+ /** Sanitized suffix suitable for direct terminal rendering. */
75
+ text: string
76
+ /** Whether content before the returned suffix was omitted. */
77
+ truncated: boolean
78
+ }
79
+
80
+ /** Punctuation that must never START a physical row (CJK kinsoku tail set). */
81
+ const ROW_START_FORBIDDEN = ',。、;:!?)】」』〉》…‥'
82
+
83
+ /** Punctuation that must never END a physical row (CJK kinsoku head set). */
84
+ const ROW_END_FORBIDDEN = '(【「『〈《'
85
+
86
+ /**
87
+ * Keep the newest display-safe text that fits a terminal rectangle, wrapping
88
+ * FORWARD from the start of the text and slicing the tail rows.
89
+ *
90
+ * Forward wrapping is what keeps a streaming tail calm: rows already produced
91
+ * never re-wrap as tokens append (a backward scan recomputes every wrap point
92
+ * per chunk and the whole visible block jumps), and the wrap rules match the
93
+ * settled text's renderer so the flush at turn end does not reflow the block
94
+ * a second time. CJK kinsoku applies at both edges: closing punctuation
95
+ * overhangs up to two cells onto the filled row instead of starting the next
96
+ * one (within the caret column the caller reserves), and opening punctuation
97
+ * moves down instead of dangling at a row end. Tabs expand to two spaces so
98
+ * terminal tab stops cannot inflate the physical row count; clusters carry
99
+ * emoji presentation and combining marks whole.
100
+ * @param text - raw externally sourced text.
101
+ * @param columns - available terminal columns.
102
+ * @param rows - available terminal rows.
103
+ * @returns a sanitized bounded suffix and whether an earlier prefix was cut.
104
+ */
105
+ export function displayTail(text: string, columns: number, rows: number): DisplayTail {
106
+ const columnLimit = Math.max(1, Math.floor(columns))
107
+ const rowLimit = Math.max(1, Math.floor(rows))
108
+ const wrapped: string[] = []
109
+ let current = ''
110
+ let used = 0
111
+ let lastCluster = ''
112
+ const flush = (): void => {
113
+ wrapped.push(current)
114
+ current = ''
115
+ used = 0
116
+ lastCluster = ''
117
+ }
118
+
119
+ for (const cluster of splitGraphemes(text)) {
120
+ if (cluster === '\n') {
121
+ flush()
122
+ continue
123
+ }
124
+ const safe = cluster === '\t' ? ' ' : displayText(cluster)
125
+ // safe can be a multi-character escape literal (\xNN / \uXXXX); only
126
+ // stringWidth budgets the whole visible escape, never its first byte.
127
+ const width = stringWidth(safe)
128
+ if (used > 0 && used + width > columnLimit) {
129
+ const overhang = width <= 2 && ROW_START_FORBIDDEN.includes(cluster)
130
+ if (!overhang) {
131
+ // Kinsoku head: an opening mark at the row edge moves down with the
132
+ // incoming cluster instead of dangling at the end of the filled row.
133
+ if (lastCluster !== '' && ROW_END_FORBIDDEN.includes(lastCluster)) {
134
+ const carried = lastCluster
135
+ current = current.slice(0, current.length - carried.length)
136
+ flush()
137
+ current = carried
138
+ used = stringWidth(carried)
139
+ } else {
140
+ flush()
141
+ }
142
+ }
143
+ }
144
+ current += safe
145
+ used += width
146
+ lastCluster = cluster
147
+ }
148
+ if (current !== '' || (wrapped.length > 0 && text.endsWith('\n'))) flush()
149
+
150
+ const truncated = wrapped.length > rowLimit
151
+ return { text: (truncated ? wrapped.slice(-rowLimit) : wrapped).join('\n'), truncated }
152
+ }