dsh-code 0.7.0 → 0.8.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 (44) hide show
  1. package/README.en.md +20 -6
  2. package/README.md +20 -6
  3. package/lib/index.mjs +2685 -622
  4. package/lib/types/app.d.ts +77 -1
  5. package/lib/types/history.d.ts +15 -4
  6. package/lib/types/index.d.ts +48 -0
  7. package/lib/types/kernel-panels.d.ts +7 -0
  8. package/lib/types/permissions.d.ts +37 -0
  9. package/lib/types/presets.d.ts +2 -0
  10. package/lib/types/provider-settings.d.ts +144 -0
  11. package/lib/types/questions.d.ts +2 -0
  12. package/lib/types/render/animations.d.ts +8 -6
  13. package/lib/types/render/lines.d.ts +6 -0
  14. package/lib/types/render/markdown.d.ts +3 -3
  15. package/lib/types/render/projection.d.ts +95 -3
  16. package/lib/types/render/status.d.ts +26 -36
  17. package/lib/types/render/text.d.ts +14 -7
  18. package/lib/types/render/tool-detail.d.ts +3 -1
  19. package/lib/types/render/tool-preview.d.ts +4 -1
  20. package/lib/types/session-directory.d.ts +15 -0
  21. package/lib/types/store.d.ts +13 -2
  22. package/lib/types/version.d.ts +5 -0
  23. package/package.json +1 -1
  24. package/src/app.ts +847 -150
  25. package/src/approval.ts +11 -2
  26. package/src/history.ts +20 -5
  27. package/src/index.ts +402 -159
  28. package/src/kernel-panels.ts +45 -8
  29. package/src/permissions.ts +85 -0
  30. package/src/presets.ts +12 -0
  31. package/src/provider-settings.ts +520 -0
  32. package/src/questions.ts +15 -5
  33. package/src/render/animations.ts +32 -18
  34. package/src/render/lines.ts +21 -6
  35. package/src/render/markdown.ts +302 -4
  36. package/src/render/projection.ts +665 -10
  37. package/src/render/status.ts +68 -162
  38. package/src/render/text.ts +28 -9
  39. package/src/render/tool-detail.ts +81 -40
  40. package/src/render/tool-preview.ts +18 -2
  41. package/src/session-directory.ts +44 -5
  42. package/src/skills.ts +8 -4
  43. package/src/store.ts +26 -8
  44. package/src/version.ts +16 -0
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import { visibleColumns } from './markdown.ts'
14
- import type { ContextSegments, TranscriptStats } from './projection.ts'
14
+ import type { TranscriptStats } from './projection.ts'
15
15
  import { singleLineText, truncateColumns } from './text.ts'
16
16
 
17
17
  /**
@@ -79,14 +79,9 @@ export type StatusTone =
79
79
  | 'success'
80
80
  | 'warn'
81
81
  | 'error'
82
- // Context-bar segment tones, one DeepSeek blue shade per content type
83
- // (dark light: system/prompt/assistant/thinking/tools). Only the
84
- // context bar emits them; the footer maps each to an existing theme color.
85
- | 'ctxSystem'
86
- | 'ctxPrompt'
87
- | 'ctxAssistant'
88
- | 'ctxThinking'
89
- | 'ctxTools'
82
+ // Context-bar fill: one DeepSeek blue for the whole occupied run (the free
83
+ // track uses the dim 'label' tone). Only the context bar emits it.
84
+ | 'ctxFill'
90
85
 
91
86
  /** One colored run inside the status bar. */
92
87
  export interface StatusSpan {
@@ -114,11 +109,9 @@ export interface StatusRow {
114
109
  }
115
110
 
116
111
  /**
117
- * The footer layout: two stacked physical rows. Row 1 is the identity/state
118
- * row (busy dot, model, cwd, branch, plan, turns, tokens, title; goal,
119
- * sandbox, and permission badges). Row 2 is the run-meters row (mode, the
120
- * context progress bar, cache, and duration figures) and degrades to empty
121
- * before any row-1 content is touched.
112
+ * The footer layout: two stacked physical rows. Row 1 keeps the primary
113
+ * controls in model, cwd, mode, branch, context, permission order. Row 2
114
+ * carries every secondary session/run figure and degrades independently.
122
115
  */
123
116
  export interface StatusLayout {
124
117
  row1: StatusRow
@@ -145,94 +138,21 @@ export const CONTEXT_WARN_PERCENT = 90
145
138
  * the warning stays visible even at 100%+ occupancy. */
146
139
  const CONTEXT_MIN_FREE = 5
147
140
 
148
- /** One content-type segment of the context bar (pure data; colors live in app.ts). */
149
- export interface ContextSegmentSpec {
150
- key: keyof ContextSegments
151
- /** Tone the footer maps to a DeepSeek blue shade. */
152
- tone: StatusTone
153
- /** Labels longest → shortest; the first one fitting the segment width wins. */
154
- labels: readonly string[]
155
- }
156
-
157
- /** The five content types in conversation order, dark → light blue. */
158
- export const CONTEXT_SEGMENTS: readonly ContextSegmentSpec[] = [
159
- { key: 'system', tone: 'ctxSystem', labels: ['system', 'sys', 's'] },
160
- { key: 'prompt', tone: 'ctxPrompt', labels: ['prompt', 'pr', 'p'] },
161
- { key: 'assistant', tone: 'ctxAssistant', labels: ['assistant', 'ast', 'a'] },
162
- { key: 'thinking', tone: 'ctxThinking', labels: ['think', 'th', 't'] },
163
- { key: 'tools', tone: 'ctxTools', labels: ['tools', 'tl', 'x'] },
164
- ] as const
165
-
166
- /** First label whose visible width fits the segment; empty only when none do. */
167
- function chooseContextLabel(labels: readonly string[], width: number): string {
168
- for (const label of labels) {
169
- if (visibleColumns(label) <= width) return label
170
- }
171
- return ''
172
- }
173
-
174
- /** Center a label inside its segment width; the padding spaces carry the tone. */
175
- function centerInSegment(text: string, width: number): string {
176
- const textWidth = visibleColumns(text)
177
- const left = Math.floor((width - textWidth) / 2)
178
- return ' '.repeat(Math.max(0, left)) + text + ' '.repeat(Math.max(0, width - textWidth - left))
179
- }
180
-
181
- /**
182
- * Largest-remainder allocation: distribute `columns` across `values`
183
- * proportionally, handing each leftover column to the largest remainder.
184
- */
185
- function allocateProportionally(values: readonly number[], columns: number): number[] {
186
- if (columns <= 0) return values.map(() => 0)
187
- const total = values.reduce((sum, value) => sum + value, 0)
188
- if (total <= 0) return values.map(() => 0)
189
- const raw = values.map(value => value / total * columns)
190
- const allocated = raw.map(Math.floor)
191
- let remaining = columns - allocated.reduce((sum, value) => sum + value, 0)
192
- const remainders = raw
193
- .map((value, index) => ({ index, remainder: value - Math.floor(value) }))
194
- .sort((left, right) => right.remainder - left.remainder)
195
- for (const slot of remainders) {
196
- if (remaining <= 0) break
197
- allocated[slot.index] = (allocated[slot.index] ?? 0) + 1
198
- remaining -= 1
199
- }
200
- return allocated
201
- }
202
-
203
- /** Bar column widths: every non-zero segment keeps at least one column before
204
- * the remaining columns share by token proportion. */
205
- function allocateBarColumns(values: readonly number[], width: number): number[] {
206
- const visible = values
207
- .map((value, index) => (value > 0 ? index : -1))
208
- .filter(index => index >= 0)
209
- if (visible.length === 0 || visible.length >= width) {
210
- return allocateProportionally(values, width)
211
- }
212
- const minimum = values.map(() => 0)
213
- for (const index of visible) minimum[index] = 1
214
- const remaining = allocateProportionally(values, width - visible.length)
215
- return minimum.map((min, index) => min + (remaining[index] ?? 0))
216
- }
217
-
218
141
  /**
219
- * Render context occupancy as a segmented bar: one DeepSeek-blue run per
220
- * content type (system/prompt/assistant/thinking/tools), column widths
221
- * proportional to their estimated token share, each with a centered label
222
- * that shortens to fit (system→sys→s). The remaining free tail is a dim
223
- * track whose right edge carries the usage readout (`12.3K/1.0M 25%`,
224
- * shrinking to the bare percent as the tail narrows). The readout flips to
225
- * amber once occupancy reaches the warning threshold; the segment blues stay
226
- * untouched so the composition remains readable at full context. The used
227
- * total comes from the reported `lastPromptTokens`, never from the estimates.
228
- * @param segments - estimated used tokens per content type.
142
+ * Render context occupancy as ONE stepless bar: a solid DeepSeek-blue fill
143
+ * run, a dim dotted free track, and the usage readout riding the track's
144
+ * right edge (`12.3K/1.0M 25%`, shrinking to the bare percent as the track
145
+ * narrows). No per-content-type segmentation. Column split is deterministic:
146
+ * the free share is `Math.round(free/window*width)` clamped to at least
147
+ * CONTEXT_MIN_FREE columns and at most the full width; the fill takes every
148
+ * remaining column, so a given occupancy always renders the identical bar.
149
+ * The readout flips to amber once occupancy reaches the warning threshold.
229
150
  * @param usedTokens - reported used tokens (drives the readout and percent).
230
151
  * @param contextWindow - route capacity.
231
152
  * @param width - total bar interior columns.
232
153
  * @returns tone-split spans for the footer to paint.
233
154
  */
234
155
  export function contextBar(
235
- segments: ContextSegments,
236
156
  usedTokens: number,
237
157
  contextWindow: number,
238
158
  width: number,
@@ -255,26 +175,10 @@ export function contextBar(
255
175
  ? percentText
256
176
  : ''
257
177
 
258
- const values = CONTEXT_SEGMENTS.map(segment => segments[segment.key])
259
- const estimated = values.reduce((sum, value) => sum + value, 0)
260
- // No estimate yet (a resumed session before any text-bearing event): treat
261
- // the whole reported used context as one prompt segment instead of an
262
- // empty bar.
263
- const allocation = allocateBarColumns(
264
- estimated > 0 ? values : [0, used, 0, 0, 0],
265
- usedColumns,
266
- )
267
178
  const spans: StatusSpan[] = []
268
- for (let index = 0; index < CONTEXT_SEGMENTS.length; index += 1) {
269
- const columns = allocation[index] ?? 0
270
- if (columns <= 0) continue
271
- spans.push({
272
- text: centerInSegment(chooseContextLabel(CONTEXT_SEGMENTS[index].labels, columns), columns),
273
- tone: CONTEXT_SEGMENTS[index].tone,
274
- })
275
- }
179
+ if (usedColumns > 0) spans.push({ text: '█'.repeat(usedColumns), tone: 'ctxFill' })
276
180
  const pad = freeColumns - visibleColumns(readout)
277
- if (pad > 0) spans.push({ text: ''.repeat(pad), tone: 'label' })
181
+ if (pad > 0) spans.push({ text: ''.repeat(pad), tone: 'label' })
278
182
  if (readout !== '') spans.push({ text: readout, tone: readoutTone })
279
183
  return spans
280
184
  }
@@ -315,18 +219,18 @@ export interface StatusItemInfo {
315
219
  export const STATUS_ITEMS: readonly StatusItemInfo[] = [
316
220
  { id: 'model', label: 'model', description: 'provider/model serving this session', side: 'left' },
317
221
  { id: 'cwd', label: 'cwd', description: 'working-directory basename', side: 'left' },
222
+ { id: 'mode', label: 'mode', description: 'agent preset composing the session', side: 'left' },
318
223
  { id: 'branch', label: 'branch', description: 'git branch inside a repository', side: 'left' },
224
+ { id: 'context', label: 'context', description: 'context-window occupancy meter', side: 'left' },
225
+ { id: 'permission', label: 'permission', description: 'permission preset badge with cycle hint', side: 'right' },
319
226
  { id: 'plan', label: 'plan', description: 'plan-mode state mark', side: 'left' },
320
- { id: 'mode', label: 'mode', description: 'agent preset composing the session', side: 'left' },
321
227
  { id: 'turns', label: 'turns', description: 'turn and step counters', side: 'left' },
322
228
  { id: 'durations', label: 'durations', description: 'llm/ttft/decode/tool wall time', side: 'left' },
323
229
  { id: 'cache', label: 'cache', description: 'cache-hit share of billed input', side: 'left' },
324
- { id: 'context', label: 'context', description: 'context-window occupancy meter', side: 'left' },
325
230
  { id: 'tokens', label: 'tokens', description: 'cumulative input/output tokens', side: 'left' },
326
231
  { id: 'title', label: 'title', description: 'session title or short id', side: 'left' },
327
- { id: 'goal', label: 'goal', description: 'live goal phase and round progress', side: 'right' },
328
- { id: 'sandbox', label: 'sandbox', description: 'divergent sandbox-mode override', side: 'right' },
329
- { id: 'permission', label: 'permission', description: 'permission preset badge with cycle hint', side: 'right' },
232
+ { id: 'goal', label: 'goal', description: 'live goal phase and round progress', side: 'left' },
233
+ { id: 'sandbox', label: 'sandbox', description: 'divergent sandbox-mode override', side: 'left' },
330
234
  ]
331
235
 
332
236
  /**
@@ -360,28 +264,33 @@ export function parseStatuslineItems(value: unknown): readonly StatusItemId[] {
360
264
  const LEFT_RIGHT_GAP = 2
361
265
  /** Column held back so Ink/yoga measurement drift can never force a wrap. */
362
266
  const WIDTH_SAFETY = 1
267
+ /**
268
+ * Extra left padding on the secondary row so its content aligns with the
269
+ * model name's left edge on the primary row (padding 2 + busy dot 2). The
270
+ * layout subtracts it from row 2's budget so the indent can never wrap it.
271
+ */
272
+ export const STATUS_ROW2_INDENT = 2
363
273
  /** Column budget for the session title before it ellipsizes. */
364
274
  const TITLE_BUDGET = 48
365
275
 
366
276
  /**
367
- * Row 1 drop ranks (lowest drops first): the session title, then the token
368
- * figures, then turn/step counts, then the goal and divergent-sandbox badges,
369
- * with the permission badge last. The identity cluster never drops — it
370
- * ellipsizes instead.
277
+ * Primary-row drop ranks: context drops before permission; the identity
278
+ * cluster never drops and ellipsizes only after the right badge is gone.
279
+ * Secondary-row groups reuse the remaining ranks independently.
371
280
  */
372
281
  const RANK_TITLE = 10
373
282
  const RANK_TOKENS = 50
374
283
  const RANK_COUNTS = 90
284
+ const RANK_CONTEXT = 90
375
285
  const RANK_SANDBOX = 92
376
286
  const RANK_GOAL = 95
377
287
  const RANK_BADGE = 100
378
288
  const RANK_IDENTITY = Number.POSITIVE_INFINITY
379
289
 
380
- /** Row 2 drop ranks: duration figures go first, then cache, then the context bar, and mode survives longest. */
290
+ /** Row 2 drop ranks: title and durations go first; state and counts survive longest. */
381
291
  const RANK2_DURATIONS = 40
382
292
  const RANK2_CACHE = 50
383
- const RANK2_CONTEXT = 60
384
- const RANK2_MODE = 70
293
+ const RANK2_PLAN = 70
385
294
 
386
295
  /** Identity facts the runner resolves once at mount; empty strings drop out. */
387
296
  export interface StatusFacts {
@@ -403,7 +312,7 @@ export interface StatusFacts {
403
312
  goal: { phase: string; rounds: number; max: number } | undefined
404
313
  /** Whether plan mode is active (folded from 'plan/mode'). */
405
314
  plan: boolean
406
- /** Active permission preset (folded from 'permission/preset'), empty when unknown. */
315
+ /** Active or pending permission preset; empty only when the service is unavailable. */
407
316
  permission: string
408
317
  }
409
318
 
@@ -479,9 +388,13 @@ function buildCandidates(
479
388
  }
480
389
  const cwd = safe(facts.cwd)
481
390
  if (cwd !== '' && enabled.has('cwd')) push({ text: cwd, tone: 'path' })
391
+ const mode = safe(facts.mode ?? '')
392
+ if (mode !== '' && enabled.has('mode')) {
393
+ push({ text: '/mode ', tone: 'label' })
394
+ identity.push({ text: mode, tone: 'accent' })
395
+ }
482
396
  const branch = safe(facts.branch)
483
397
  if (branch !== '' && enabled.has('branch')) push({ text: '⑂ ' + branch, tone: 'branch' })
484
- if (facts.plan && enabled.has('plan')) push({ text: '⧉ plan', tone: 'accent' })
485
398
 
486
399
  const left: { group: StatusGroup; rank: number; id: string }[] = [
487
400
  { group: { spans: identity }, rank: RANK_IDENTITY, id: 'identity' },
@@ -489,14 +402,8 @@ function buildCandidates(
489
402
  const right: { span: StatusSpan; rank: number; id: string }[] = []
490
403
  const row2: { group: StatusGroup; rank: number; id: string }[] = []
491
404
 
492
- // Row 2 anchor: the agent preset composing the session.
493
- const mode = safe(facts.mode ?? '')
494
- if (mode !== '' && enabled.has('mode')) {
495
- row2.push({
496
- group: { spans: [{ text: 'mode ', tone: 'label' }, { text: mode, tone: 'accent' }] },
497
- rank: RANK2_MODE,
498
- id: 'mode',
499
- })
405
+ if (facts.plan && enabled.has('plan')) {
406
+ row2.push({ group: { spans: [{ text: '⧉ plan', tone: 'accent' }] }, rank: RANK2_PLAN, id: 'plan' })
500
407
  }
501
408
 
502
409
  if (stats.turns > 0 || stats.steps > 0) {
@@ -509,7 +416,7 @@ function buildCandidates(
509
416
  }
510
417
  pair('turns', String(stats.turns))
511
418
  pair('steps', String(stats.steps))
512
- left.push({ group: { spans: counts }, rank: RANK_COUNTS, id: 'turns' })
419
+ row2.push({ group: { spans: counts }, rank: RANK_COUNTS, id: 'turns' })
513
420
  }
514
421
  if (enabled.has('durations')) {
515
422
  // Model round-trip, first-token latency, decode rate, and tool wall
@@ -549,14 +456,14 @@ function buildCandidates(
549
456
  // is the most recent reported prompt size against the advertised route
550
457
  // capacity (the same figures the old bracket bar showed).
551
458
  if (stats.contextWindow > 0 && stats.lastPromptTokens > 0 && enabled.has('context')) {
552
- row2.push({
459
+ left.push({
553
460
  group: {
554
461
  spans: [
555
462
  { text: 'context ', tone: 'label' },
556
- ...contextBar(stats.contextSegments, stats.lastPromptTokens, stats.contextWindow, CONTEXT_BAR_WIDTH),
463
+ ...contextBar(stats.lastPromptTokens, stats.contextWindow, CONTEXT_BAR_WIDTH),
557
464
  ],
558
465
  },
559
- rank: RANK2_CONTEXT,
466
+ rank: RANK_CONTEXT,
560
467
  id: 'context',
561
468
  })
562
469
  }
@@ -568,7 +475,7 @@ function buildCandidates(
568
475
  }
569
476
  pair('in', formatTokens(stats.usage.inputTokens))
570
477
  pair('out', formatTokens(stats.usage.outputTokens))
571
- left.push({ group: { spans: tokens }, rank: RANK_TOKENS, id: 'tokens' })
478
+ row2.push({ group: { spans: tokens }, rank: RANK_TOKENS, id: 'tokens' })
572
479
  }
573
480
 
574
481
  // The session title replaces the bare short id whenever one has landed
@@ -577,28 +484,28 @@ function buildCandidates(
577
484
  const rawLabel = facts.title !== undefined && facts.title !== '' ? facts.title : facts.sessionId
578
485
  const label = truncateColumns(safe(rawLabel), TITLE_BUDGET)
579
486
  if (label !== '' && enabled.has('title')) {
580
- left.push({ group: { spans: [{ text: label, tone: 'meta' }] }, rank: RANK_TITLE, id: 'title' })
487
+ row2.push({ group: { spans: [{ text: label, tone: 'meta' }] }, rank: RANK_TITLE, id: 'title' })
581
488
  }
582
489
 
583
- // Goal badge on the right (Codex goal indicator): round progress while
584
- // active, the phase otherwise.
490
+ // Secondary state rides row 2; permission alone remains right-pinned on row 1.
585
491
  if (facts.goal !== undefined && enabled.has('goal')) {
586
- right.push({
587
- span: {
588
- text: facts.goal.phase === 'active'
589
- ? '◎ round ' + facts.goal.rounds + '/' + facts.goal.max
590
- : '◎ ' + safe(facts.goal.phase),
591
- tone: 'accent',
492
+ row2.push({
493
+ group: {
494
+ spans: [{
495
+ text: facts.goal.phase === 'active'
496
+ ? '◎ round ' + facts.goal.rounds + '/' + facts.goal.max
497
+ : '' + safe(facts.goal.phase),
498
+ tone: 'accent',
499
+ }],
592
500
  },
593
501
  rank: RANK_GOAL,
594
502
  id: 'goal',
595
503
  })
596
504
  }
597
- // The sandbox override stays implicit when it merely echoes the preset
598
- // the badge exists to surface a divergence, not to duplicate the label.
505
+ // The sandbox override stays implicit when it merely echoes the preset.
599
506
  const sandbox = safe(facts.sandbox ?? '')
600
507
  if (sandbox !== '' && sandbox.toLowerCase() !== facts.permission.toLowerCase() && enabled.has('sandbox')) {
601
- right.push({ span: { text: 'sandbox ' + sandbox, tone: 'warn' }, rank: RANK_SANDBOX, id: 'sandbox' })
508
+ row2.push({ group: { spans: [{ text: 'sandbox ' + sandbox, tone: 'warn' }] }, rank: RANK_SANDBOX, id: 'sandbox' })
602
509
  }
603
510
  const permission = safe(facts.permission)
604
511
  let badge = -1
@@ -610,12 +517,10 @@ function buildCandidates(
610
517
  }
611
518
 
612
519
  /**
613
- * Compose the two-row footer layout under a column budget. Row 1 (identity
614
- * and state badges) degrades in a fixed order cycle hint, then title, token
615
- * figures, turn/step counts, goal, divergent sandbox, permission badge and
616
- * only then ellipsizes the identity cluster, so the row never wraps. Row 2
617
- * (mode, context bar, cache, duration figures) fits its own budget and
618
- * degrades to empty before any row-1 content is touched.
520
+ * Compose the two-row footer layout under a column budget. Row 1 keeps model,
521
+ * cwd, mode, branch, context, then the right-pinned permission badge and cycle
522
+ * hint. It drops hint, context, and permission before ellipsizing identity.
523
+ * Row 2 fits all secondary figures and state within its own budget.
619
524
  * @param facts - identity facts resolved by the runner.
620
525
  * @param stats - session figures folded from the durable log.
621
526
  * @param columns - usable columns for each row (before their left padding).
@@ -711,13 +616,14 @@ export function layoutStatusBar(
711
616
  }
712
617
  }
713
618
 
714
- // Row 2 fits its own budget; the lowest-rank group drops first until the
715
- // row fits or nothing is left. An empty row2 is a valid state — the footer
716
- // degrades back to a single status row.
619
+ // Row 2 fits its own budget minus the model-name indent; the lowest-rank
620
+ // group drops first until the row fits or nothing is left. An empty row2 is
621
+ // a valid state — the footer degrades back to a single status row.
717
622
  const row2Kept = [...orderedRow2]
623
+ const row2Budget = Math.max(1, budget - STATUS_ROW2_INDENT)
718
624
  const row2Width = (): number =>
719
625
  joinWidth(row2Kept.map(entry => spansWidth(entry.group.spans)), groupSeparator)
720
- while (row2Width() > budget && row2Kept.length > 0) {
626
+ while (row2Width() > row2Budget && row2Kept.length > 0) {
721
627
  let dropIndex = 0
722
628
  let dropRank = Number.POSITIVE_INFINITY
723
629
  for (let index = 0; index < row2Kept.length; index += 1) {
@@ -3,8 +3,11 @@
3
3
  * tool payloads, skill descriptions). Control characters — including ANSI
4
4
  * CSI/OSC escape sequences — would otherwise pass through Ink into the
5
5
  * terminal, letting output rewrite the screen or inject prompts. Newlines
6
- * and tabs survive; everything else in C0/C1 plus DEL becomes a visible
7
- * `\xNN` escape.
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).
8
11
  *
9
12
  * @module @deepseek-ai/dsh-code/render/text
10
13
  */
@@ -13,14 +16,27 @@
13
16
  const CONTROL_ESCAPE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu
14
17
 
15
18
  /**
16
- * Escape control characters so externally sourced text cannot drive the
17
- * terminal.
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).
18
33
  * @param text - raw text from a session event, tool payload, or catalog.
19
- * @returns text with every control character (except `\n`, `\t`) rendered
20
- * as a literal `\xNN` escape.
34
+ * @returns display-safe text with every injectable character made visible.
21
35
  */
22
36
  export function displayText(text: string): string {
23
- return text.replace(CONTROL_ESCAPE, char => `\\x${char.charCodeAt(0).toString(16).padStart(2, '0')}`)
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')}`)
24
40
  }
25
41
 
26
42
  /** Collapse external text to one terminal-safe logical row. */
@@ -84,7 +100,10 @@ function previousCharacter(text: string, end: number): { char: string; start: nu
84
100
  * Keep only the newest display-safe text that fits a terminal rectangle.
85
101
  * The scan walks backward and stops as soon as the suffix is full, so a long
86
102
  * reasoning stream does not rescan its entire accumulated prefix per chunk.
87
- * Explicit newlines and terminal wrapping both consume rows.
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.
88
107
  * @param text - raw externally sourced text.
89
108
  * @param columns - available terminal columns.
90
109
  * @param rows - available terminal rows.
@@ -109,7 +128,7 @@ export function displayTail(text: string, columns: number, rows: number): Displa
109
128
  continue
110
129
  }
111
130
 
112
- const safe = displayText(previous.char)
131
+ const safe = previous.char === '\t' ? ' ' : displayText(previous.char)
113
132
  const width = cellWidth(safe)
114
133
  if (used > 0 && used + width > columnLimit) {
115
134
  if (row >= rowLimit) break
@@ -17,6 +17,9 @@ const MAX_READ_LINES = 120
17
17
  const MAX_SOURCES = 10
18
18
  const MAX_RAW_CHARS = 6000
19
19
  const MAX_LINE_COLUMNS = 240
20
+ /** Hard caps on adversarial `tool/result.meta` before any row is built. */
21
+ const MAX_DIFFS = 8
22
+ const MAX_DIFF_TEXT_CHARS = 24_000
20
23
 
21
24
  /** One rendered diff row: removed, added, or shared context. */
22
25
  export interface DiffLine {
@@ -77,15 +80,32 @@ function toLines(text: string): string[] {
77
80
  * Render one change as removed-then-added rows, hunked by common prefix and
78
81
  * suffix. A null before-image (file create) renders as pure additions. The
79
82
  * budget caps emitted rows and reports the cut, so a whole-file overwrite
80
- * never floods the transcript.
83
+ * never floods the transcript. Inputs are hard-capped before line splitting
84
+ * and the row list is built incrementally up to the budget — a crafted or
85
+ * replayed giant diff cannot force a full intermediate rows array.
81
86
  * @param oldText - prior content, or null for a create.
82
87
  * @param newText - content after the change.
83
88
  * @param budget - maximum rows to emit.
84
89
  * @returns the bounded rows and whether they were cut.
85
90
  */
86
91
  export function diffRows(oldText: string | null, newText: string, budget: number): { lines: readonly DiffLine[]; truncated: boolean } {
87
- const oldLines = oldText === null ? [] : toLines(oldText)
88
- const newLines = toLines(newText)
92
+ const oldRaw = oldText ?? ''
93
+ const newRaw = newText
94
+ let inputTruncated = false
95
+ let oldSource = oldRaw
96
+ let newSource = newRaw
97
+ // Bound the working arrays before `toLines` allocates them: keep a
98
+ // combined-characters share of each side proportional to its input size.
99
+ const combined = oldRaw.length + newRaw.length
100
+ if (combined > MAX_DIFF_TEXT_CHARS) {
101
+ inputTruncated = true
102
+ const oldShare = Math.min(oldRaw.length, Math.floor(MAX_DIFF_TEXT_CHARS * oldRaw.length / combined))
103
+ const newShare = Math.min(newRaw.length, MAX_DIFF_TEXT_CHARS - oldShare)
104
+ oldSource = oldRaw.slice(0, oldShare)
105
+ newSource = newRaw.slice(0, newShare)
106
+ }
107
+ const oldLines = oldText === null ? [] : toLines(oldSource)
108
+ const newLines = toLines(newSource)
89
109
  let prefix = 0
90
110
  while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) prefix += 1
91
111
  let suffix = 0
@@ -93,14 +113,20 @@ export function diffRows(oldText: string | null, newText: string, budget: number
93
113
  suffix < oldLines.length - prefix && suffix < newLines.length - prefix
94
114
  && oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]
95
115
  ) suffix += 1
96
- const removed = oldLines.slice(prefix, oldLines.length - suffix)
97
- const added = newLines.slice(prefix, newLines.length - suffix)
98
- const rows: DiffLine[] = [
99
- ...removed.map((text): DiffLine => ({ mark: '-', text: clipLine(text) })),
100
- ...added.map((text): DiffLine => ({ mark: '+', text: clipLine(text) })),
101
- ]
102
- if (rows.length <= budget) return { lines: rows, truncated: false }
103
- return { lines: rows.slice(0, budget), truncated: true }
116
+ const removedCount = oldLines.length - prefix - suffix
117
+ const addedCount = newLines.length - prefix - suffix
118
+ const truncated = inputTruncated || removedCount + addedCount > budget
119
+ // Build at most `budget` rows directly; never materialize the full hunk.
120
+ const rows: DiffLine[] = []
121
+ const removedLimit = Math.min(removedCount, Math.max(0, budget))
122
+ for (let index = 0; index < removedLimit; index += 1) {
123
+ rows.push({ mark: '-', text: clipLine(oldLines[prefix + index] ?? '') })
124
+ }
125
+ const addedLimit = Math.min(addedCount, Math.max(0, budget - removedLimit))
126
+ for (let index = 0; index < addedLimit; index += 1) {
127
+ rows.push({ mark: '+', text: clipLine(newLines[prefix + index] ?? '') })
128
+ }
129
+ return { lines: rows, truncated }
104
130
  }
105
131
 
106
132
  /** Whether `value` is a valid upstream FileDiff (defensive narrowing). */
@@ -140,45 +166,60 @@ export function toolResultDetail(meta: unknown, rawText: string): ToolDetail | u
140
166
  const record = meta as Record<string, unknown>
141
167
 
142
168
  const diffs = record['diffs']
143
- if (Array.isArray(diffs) && diffs.length > 0 && diffs.every(isFileDiff)) {
144
- const budget = Math.max(8, Math.floor(MAX_DIFF_LINES / diffs.length))
145
- return {
146
- kind: 'diff',
147
- diffs: diffs.map(diff => ({
148
- path: diff.path,
149
- ...diffRows(diff.oldText, diff.newText, budget),
150
- })),
169
+ // Validate and process only the capped prefix: an adversarial meta with
170
+ // thousands of diffs never runs `.every` over the full array.
171
+ if (Array.isArray(diffs) && diffs.length > 0) {
172
+ const capped = diffs.slice(0, MAX_DIFFS)
173
+ if (capped.every(isFileDiff)) {
174
+ const budget = Math.max(8, Math.floor(MAX_DIFF_LINES / capped.length))
175
+ // Diffs dropped beyond the cap must not vanish silently: the last
176
+ // kept diff reports the cut exactly like a hunk cut does.
177
+ const dropped = diffs.length > capped.length
178
+ const rendered = capped.map((diff, index) => {
179
+ const rows = diffRows(diff.oldText, diff.newText, budget)
180
+ return dropped && index === capped.length - 1
181
+ ? { path: diff.path, ...rows, truncated: true }
182
+ : { path: diff.path, ...rows }
183
+ })
184
+ return { kind: 'diff', diffs: rendered }
151
185
  }
152
186
  }
153
187
 
154
188
  const { path, offset, lines, totalLines } = record
155
189
  if (typeof path === 'string' && Number.isInteger(offset) && (offset as number) >= 1
156
190
  && Number.isInteger(totalLines) && (totalLines as number) >= 0
157
- && Array.isArray(lines) && lines.every(isReadLine)) {
158
- const window = lines as { number: number; text: string }[]
159
- const truncated = window.length > MAX_READ_LINES
160
- return {
161
- kind: 'read',
162
- path,
163
- offset: offset as number,
164
- lines: (truncated ? window.slice(0, MAX_READ_LINES) : window)
165
- .map(line => ({ number: line.number, text: clipLine(line.text) })),
166
- totalLines: totalLines as number,
167
- truncated,
191
+ && Array.isArray(lines)) {
192
+ // Validate the bounded window only: lines beyond the display cap are
193
+ // dropped anyway, so a giant persisted window cannot force a full-array
194
+ // validation pass before the slice.
195
+ const window = lines.slice(0, MAX_READ_LINES)
196
+ if (window.every(isReadLine)) {
197
+ const truncated = lines.length > MAX_READ_LINES
198
+ return {
199
+ kind: 'read',
200
+ path,
201
+ offset: offset as number,
202
+ lines: window.map(line => ({ number: line.number, text: clipLine(line.text) })),
203
+ totalLines: totalLines as number,
204
+ truncated,
205
+ }
168
206
  }
169
207
  }
170
208
 
171
209
  const sources = record['sources']
172
- if (Array.isArray(sources) && sources.every(isWebSource)) {
173
- const truncated = sources.length > MAX_SOURCES
174
- return {
175
- kind: 'web-search',
176
- sources: (truncated ? sources.slice(0, MAX_SOURCES) : sources).map(source => ({
177
- url: source.url,
178
- title: typeof source.title === 'string' ? source.title : undefined,
179
- snippet: typeof source.snippet === 'string' ? clipLine(source.snippet) : '',
180
- })),
181
- truncated,
210
+ if (Array.isArray(sources)) {
211
+ const capped = sources.slice(0, MAX_SOURCES)
212
+ if (capped.every(isWebSource)) {
213
+ const truncated = sources.length > MAX_SOURCES
214
+ return {
215
+ kind: 'web-search',
216
+ sources: capped.map(source => ({
217
+ url: source.url,
218
+ title: typeof source.title === 'string' ? source.title : undefined,
219
+ snippet: typeof source.snippet === 'string' ? clipLine(source.snippet) : '',
220
+ })),
221
+ truncated,
222
+ }
182
223
  }
183
224
  }
184
225