dsh-code 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +30 -7
- package/README.md +30 -7
- package/lib/index.mjs +3791 -853
- package/lib/types/app.d.ts +90 -1
- package/lib/types/approval.d.ts +3 -1
- package/lib/types/history.d.ts +15 -4
- package/lib/types/index.d.ts +48 -0
- package/lib/types/kernel-panels.d.ts +65 -8
- package/lib/types/models.d.ts +15 -1
- package/lib/types/permissions.d.ts +37 -0
- package/lib/types/presets.d.ts +2 -0
- package/lib/types/provider-settings.d.ts +144 -0
- package/lib/types/questions.d.ts +2 -0
- package/lib/types/render/animations.d.ts +8 -6
- package/lib/types/render/lines.d.ts +6 -0
- package/lib/types/render/markdown.d.ts +3 -3
- package/lib/types/render/projection.d.ts +97 -3
- package/lib/types/render/status.d.ts +26 -36
- package/lib/types/render/text.d.ts +14 -7
- package/lib/types/render/tool-detail.d.ts +3 -1
- package/lib/types/render/tool-preview.d.ts +14 -1
- package/lib/types/session-directory.d.ts +61 -2
- package/lib/types/store.d.ts +13 -2
- package/lib/types/subagents.d.ts +60 -0
- package/lib/types/version.d.ts +5 -0
- package/package.json +1 -1
- package/src/app.ts +1200 -219
- package/src/approval.ts +161 -126
- package/src/history.ts +20 -5
- package/src/index.ts +577 -167
- package/src/kernel-panels.ts +354 -37
- package/src/models.ts +26 -0
- package/src/permissions.ts +85 -0
- package/src/presets.ts +12 -0
- package/src/provider-settings.ts +520 -0
- package/src/questions.ts +15 -5
- package/src/render/animations.ts +32 -18
- package/src/render/lines.ts +236 -218
- package/src/render/markdown.ts +302 -4
- package/src/render/projection.ts +670 -11
- package/src/render/status.ts +68 -162
- package/src/render/text.ts +28 -9
- package/src/render/tool-detail.ts +81 -40
- package/src/render/tool-preview.ts +77 -34
- package/src/session-directory.ts +171 -10
- package/src/skills.ts +8 -4
- package/src/store.ts +26 -8
- package/src/subagents.ts +165 -0
- package/src/version.ts +16 -0
package/src/render/status.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { visibleColumns } from './markdown.ts'
|
|
14
|
-
import type {
|
|
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
|
|
83
|
-
//
|
|
84
|
-
|
|
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
|
|
118
|
-
*
|
|
119
|
-
*
|
|
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
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
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
|
-
|
|
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: '
|
|
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: '
|
|
328
|
-
{ id: 'sandbox', label: 'sandbox', description: 'divergent sandbox-mode override', side: '
|
|
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
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
493
|
-
|
|
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
|
-
|
|
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
|
-
|
|
459
|
+
left.push({
|
|
553
460
|
group: {
|
|
554
461
|
spans: [
|
|
555
462
|
{ text: 'context ', tone: 'label' },
|
|
556
|
-
...contextBar(stats.
|
|
463
|
+
...contextBar(stats.lastPromptTokens, stats.contextWindow, CONTEXT_BAR_WIDTH),
|
|
557
464
|
],
|
|
558
465
|
},
|
|
559
|
-
rank:
|
|
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
|
-
|
|
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
|
-
|
|
487
|
+
row2.push({ group: { spans: [{ text: label, tone: 'meta' }] }, rank: RANK_TITLE, id: 'title' })
|
|
581
488
|
}
|
|
582
489
|
|
|
583
|
-
//
|
|
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
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
|
-
|
|
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
|
|
614
|
-
*
|
|
615
|
-
*
|
|
616
|
-
*
|
|
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
|
|
715
|
-
// row fits or nothing is left. An empty row2 is
|
|
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() >
|
|
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) {
|
package/src/render/text.ts
CHANGED
|
@@ -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
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
17
|
-
*
|
|
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
|
|
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
|
|
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
|
|
88
|
-
const
|
|
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
|
|
97
|
-
const
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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)
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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)
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|