dsh-code 1.0.1 → 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.
- package/README.en.md +21 -13
- package/README.md +21 -13
- package/lib/index.mjs +1902 -1092
- package/lib/types/app.d.ts +4 -13
- package/lib/types/attachments.d.ts +1 -1
- package/lib/types/editor-keys.d.ts +105 -0
- package/lib/types/git-workflow.d.ts +6 -2
- package/lib/types/keyboard.d.ts +31 -0
- package/lib/types/mentions.d.ts +2 -0
- package/lib/types/model-capabilities.d.ts +82 -0
- package/lib/types/provider-settings.d.ts +7 -0
- package/lib/types/render/animations.d.ts +27 -11
- package/lib/types/render/editor.d.ts +32 -7
- package/lib/types/render/lines.d.ts +26 -1
- package/lib/types/render/markdown.d.ts +1 -1
- package/lib/types/render/projection.d.ts +15 -1
- package/lib/types/render/text.d.ts +15 -9
- package/lib/types/render/width.d.ts +29 -0
- package/lib/types/session-directory.d.ts +27 -0
- package/lib/types/settings-file.d.ts +33 -0
- package/lib/types/store.d.ts +10 -0
- package/lib/types/subagents.d.ts +13 -3
- package/package.json +159 -159
- package/src/app.ts +920 -764
- package/src/attachments.ts +7 -0
- package/src/editor-keys.ts +371 -0
- package/src/git-workflow.ts +10 -6
- package/src/index.ts +1637 -1523
- package/src/internals.ts +26 -9
- package/src/keyboard.ts +131 -7
- package/src/mentions.ts +6 -1
- package/src/model-capabilities.ts +318 -0
- package/src/provider-settings.ts +16 -0
- package/src/render/animations.ts +64 -17
- package/src/render/editor.ts +125 -25
- package/src/render/lines.ts +403 -342
- package/src/render/markdown.ts +4 -7
- package/src/render/projection.ts +63 -40
- package/src/render/text.ts +152 -150
- package/src/render/width.ts +189 -0
- package/src/session-directory.ts +56 -0
- package/src/settings-file.ts +56 -0
- package/src/store.ts +26 -7
- package/src/subagents.ts +39 -6
package/src/render/markdown.ts
CHANGED
|
@@ -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 (
|
|
35
|
+
/** Visible width of a run in columns (grapheme-cluster and emoji aware). */
|
|
34
36
|
export function visibleColumns(text: string): number {
|
|
35
|
-
|
|
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 {
|
package/src/render/projection.ts
CHANGED
|
@@ -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
|
|
372
|
-
if ((
|
|
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):
|
|
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 {
|
package/src/render/text.ts
CHANGED
|
@@ -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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
*
|
|
22
|
-
* (U+
|
|
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
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
used
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
|
|
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
|
+
}
|