dsh-code 1.0.3 → 1.0.4
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.md +291 -285
- package/bin/deepseek.mjs +26 -3
- package/lib/index.mjs +2749 -1783
- package/lib/types/app.d.ts +11 -2
- package/lib/types/commands.d.ts +13 -0
- package/lib/types/index.d.ts +28 -0
- package/lib/types/input-split.d.ts +54 -0
- package/lib/types/kernel-panels.d.ts +3 -1
- package/lib/types/keyboard.d.ts +8 -0
- package/lib/types/provider-settings.d.ts +77 -0
- package/lib/types/render/projection.d.ts +7 -1
- package/lib/types/render/status.d.ts +22 -15
- package/lib/types/skills.d.ts +1 -1
- package/package.json +1 -1
- package/src/app.ts +5459 -4900
- package/src/approval.ts +8 -3
- package/src/authorization-panel.ts +2 -4
- package/src/commands.ts +27 -3
- package/src/index.ts +153 -38
- package/src/input-split.ts +191 -0
- package/src/internals.ts +26 -8
- package/src/kernel-panels.ts +26 -10
- package/src/keyboard.ts +123 -88
- package/src/mentions.ts +42 -9
- package/src/provider-settings.ts +204 -0
- package/src/questions.ts +20 -0
- package/src/render/lines.ts +24 -12
- package/src/render/markdown.ts +15 -13
- package/src/render/projection.ts +99 -12
- package/src/render/status.ts +76 -71
- package/src/render/text.ts +9 -3
- package/src/skills.ts +19 -6
- package/src/theme-panel.ts +79 -72
package/src/provider-settings.ts
CHANGED
|
@@ -34,6 +34,21 @@ interface LlmFace {
|
|
|
34
34
|
readonly settingsPath: readonly string[]
|
|
35
35
|
readonly declared?: boolean
|
|
36
36
|
}[]
|
|
37
|
+
/**
|
|
38
|
+
* Registered endpoint model discovery; absent on an older service. The
|
|
39
|
+
* request is a draft (provider route and/or baseURL, optional one-shot
|
|
40
|
+
* key); the reply is candidate metadata for adoption, never a write.
|
|
41
|
+
*/
|
|
42
|
+
discoverModels?(
|
|
43
|
+
settingsNs: string,
|
|
44
|
+
request: { readonly provider?: string; readonly baseURL?: string; readonly api?: string; readonly apiKey?: string },
|
|
45
|
+
signal?: AbortSignal,
|
|
46
|
+
): Promise<readonly {
|
|
47
|
+
readonly id: string
|
|
48
|
+
readonly name?: string
|
|
49
|
+
readonly contextWindow?: number
|
|
50
|
+
readonly maxTokens?: number
|
|
51
|
+
}[]>
|
|
37
52
|
}
|
|
38
53
|
|
|
39
54
|
/** One redacted settings descriptor (subset of `SettingsDescriptor`). */
|
|
@@ -71,11 +86,19 @@ interface ProviderEventsFace {
|
|
|
71
86
|
on(event: string, listener: (...args: unknown[]) => void): () => void
|
|
72
87
|
}
|
|
73
88
|
|
|
89
|
+
/** One resolved credential value; never rendered, never persisted by callers. */
|
|
90
|
+
interface CredentialValueFace {
|
|
91
|
+
readonly value: string
|
|
92
|
+
readonly source: string
|
|
93
|
+
}
|
|
94
|
+
|
|
74
95
|
/** The subset of the `credentials` service this module reads and writes. */
|
|
75
96
|
interface CredentialsFace {
|
|
76
97
|
describe(ref: string): Promise<CredentialFactsFace>
|
|
77
98
|
set(ref: string, value: string): Promise<void>
|
|
78
99
|
unset(ref: string): Promise<void>
|
|
100
|
+
/** Same-process value resolution; absent on an older service. */
|
|
101
|
+
resolve?(ref: string): Promise<CredentialValueFace | undefined>
|
|
79
102
|
}
|
|
80
103
|
|
|
81
104
|
/* ------------------------------------------------------------------ *
|
|
@@ -152,6 +175,7 @@ function configurationOf(profile: unknown): ProviderConfiguration {
|
|
|
152
175
|
})
|
|
153
176
|
return {
|
|
154
177
|
...(typeof record.baseURL === 'string' && record.baseURL.trim() !== '' ? { baseURL: record.baseURL } : {}),
|
|
178
|
+
...(typeof record.api === 'string' && record.api.trim() !== '' ? { api: record.api } : {}),
|
|
155
179
|
models,
|
|
156
180
|
}
|
|
157
181
|
}
|
|
@@ -211,9 +235,100 @@ export interface ProviderModelSettings {
|
|
|
211
235
|
/** The small, portable subset of a provider profile the terminal edits. */
|
|
212
236
|
export interface ProviderConfiguration {
|
|
213
237
|
readonly baseURL?: string
|
|
238
|
+
/**
|
|
239
|
+
* Wire protocol the stored profile names (e.g. `openai-responses`), when it
|
|
240
|
+
* names one. Load-only: the editor never writes it, but endpoint discovery
|
|
241
|
+
* passes it so the listing speaks the same protocol as real requests.
|
|
242
|
+
*/
|
|
243
|
+
readonly api?: string
|
|
214
244
|
readonly models: readonly ProviderModelSettings[]
|
|
215
245
|
}
|
|
216
246
|
|
|
247
|
+
/** One model an endpoint reported about itself (mirrors `LlmDiscoveredModel`). */
|
|
248
|
+
export interface DiscoveredModelView {
|
|
249
|
+
/** Model id the endpoint accepts. */
|
|
250
|
+
readonly id: string
|
|
251
|
+
/** Human-readable name when the endpoint supplies one. */
|
|
252
|
+
readonly name?: string
|
|
253
|
+
/** Context window when disclosed; adoption still owes it if absent. */
|
|
254
|
+
readonly contextWindow?: number
|
|
255
|
+
/** Output cap when disclosed. */
|
|
256
|
+
readonly maxTokens?: number
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** One model an endpoint reported about itself (mirrors LlmDiscoveredModel). */
|
|
260
|
+
export interface DiscoveredModelView {
|
|
261
|
+
/** Model id the endpoint accepts. */
|
|
262
|
+
readonly id: string
|
|
263
|
+
/** Human-readable name when the endpoint supplies one. */
|
|
264
|
+
readonly name?: string
|
|
265
|
+
/** Context window when disclosed; adoption still owes it if absent. */
|
|
266
|
+
readonly contextWindow?: number
|
|
267
|
+
/** Output cap when disclosed. */
|
|
268
|
+
readonly maxTokens?: number
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The seven canonical reasoning levels a reasoningEfforts key may name -
|
|
273
|
+
* pi-ai's THINKING_LEVELS. A pi-ai upgrade that adds or removes one fails
|
|
274
|
+
* upstream's own drift gate; this mirror exists so the terminal editor can
|
|
275
|
+
* validate drafts without importing the pi-ai package.
|
|
276
|
+
*/
|
|
277
|
+
export const REASONING_EFFORT_LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* One stored reasoningEfforts declaration: a display-level to wire-value map
|
|
281
|
+
* (null sends no reasoning parameter), an explicit false disabling the
|
|
282
|
+
* picker, or undefined leaving the entry to inherit.
|
|
283
|
+
*/
|
|
284
|
+
export type ReasoningEffortsValue = Record<string, string | null> | false | undefined
|
|
285
|
+
|
|
286
|
+
/** Whether a raw extras value is a declared efforts dict (non-empty, non-false). */
|
|
287
|
+
export function isDeclaredReasoningEfforts(value: unknown): value is Record<string, string | null> {
|
|
288
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) && Object.keys(value).length > 0
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Parse the setup page's compact efforts draft into a storable declaration.
|
|
293
|
+
* Grammar: empty = clear back to inherit; the single token "false" = disable
|
|
294
|
+
* the picker; otherwise space-separated level:wire pairs where level is one
|
|
295
|
+
* of REASONING_EFFORT_LEVELS and wire is any non-empty string or the literal
|
|
296
|
+
* "null" (send no parameter).
|
|
297
|
+
*/
|
|
298
|
+
export function parseReasoningEffortsDraft(draft: string):
|
|
299
|
+
| { readonly ok: true; readonly value: ReasoningEffortsValue }
|
|
300
|
+
| { readonly ok: false; readonly error: string } {
|
|
301
|
+
const text = draft.trim()
|
|
302
|
+
if (text === '') return { ok: true, value: undefined }
|
|
303
|
+
if (text === 'false') return { ok: true, value: false }
|
|
304
|
+
const value: Record<string, string | null> = {}
|
|
305
|
+
for (const token of text.split(/\s+/u)) {
|
|
306
|
+
const split = token.indexOf(':')
|
|
307
|
+
if (split <= 0 || split === token.length - 1) {
|
|
308
|
+
return { ok: false, error: 'each entry needs level:wire, got "' + token + '"' }
|
|
309
|
+
}
|
|
310
|
+
const level = token.slice(0, split)
|
|
311
|
+
const wire = token.slice(split + 1)
|
|
312
|
+
if (!(REASONING_EFFORT_LEVELS as readonly string[]).includes(level)) {
|
|
313
|
+
return { ok: false, error: '"' + level + '" is not a level; use one of ' + REASONING_EFFORT_LEVELS.join('/') }
|
|
314
|
+
}
|
|
315
|
+
if (level in value) {
|
|
316
|
+
return { ok: false, error: 'level "' + level + '" appears twice' }
|
|
317
|
+
}
|
|
318
|
+
value[level] = wire === 'null' ? null : wire
|
|
319
|
+
}
|
|
320
|
+
return { ok: true, value }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Serialize a stored declaration back to the compact draft form (stored key order preserved). */
|
|
324
|
+
export function serializeReasoningEfforts(value: unknown): string {
|
|
325
|
+
if (value === false) return 'false'
|
|
326
|
+
if (!isDeclaredReasoningEfforts(value)) return ''
|
|
327
|
+
return Object.entries(value)
|
|
328
|
+
.map(([level, wire]) => level + ':' + (wire === null ? 'null' : String(wire)))
|
|
329
|
+
.join(' ')
|
|
330
|
+
}
|
|
331
|
+
|
|
217
332
|
/**
|
|
218
333
|
* One provider row in the TUI provider-management panel: the configurable
|
|
219
334
|
* directory entry joined with its settings profile and credential facts.
|
|
@@ -551,6 +666,95 @@ export async function saveProviderConfiguration(
|
|
|
551
666
|
}
|
|
552
667
|
}
|
|
553
668
|
|
|
669
|
+
/**
|
|
670
|
+
* Interrogate a provider endpoint for the models it really serves, through
|
|
671
|
+
* the model-discovery capability the provider's settings namespace
|
|
672
|
+
* registered — the same pipe the official Web Models page uses. The request
|
|
673
|
+
* is a draft: a typed key forces direct endpoint interrogation (gateway
|
|
674
|
+
* truth), while an empty key lets the harness resolve the route's stored
|
|
675
|
+
* credential; with neither baseURL nor route the adapter answers from its
|
|
676
|
+
* own knowledge.
|
|
677
|
+
* @param ctx - context carrying the `llm` service (optional discovery).
|
|
678
|
+
* @param target - provider row whose settings namespace serves the draft.
|
|
679
|
+
* @param request - typed key and/or endpoint override for this one probe.
|
|
680
|
+
* @param signal - caller cancellation (panel navigation aborts the probe).
|
|
681
|
+
* @returns the advertised models in endpoint order, deduplicated.
|
|
682
|
+
*/
|
|
683
|
+
export async function discoverProviderModels(
|
|
684
|
+
ctx: Context,
|
|
685
|
+
target: ProviderTargetView,
|
|
686
|
+
request: { readonly apiKey?: string; readonly baseURL?: string },
|
|
687
|
+
signal?: AbortSignal,
|
|
688
|
+
): Promise<readonly DiscoveredModelView[]> {
|
|
689
|
+
if (target.settingsNs.length === 0) {
|
|
690
|
+
throw new ProviderSettingsError(`provider "${target.provider}" has no managed settings namespace; its models cannot be discovered here`)
|
|
691
|
+
}
|
|
692
|
+
const llm = ctx.get('llm') as LlmFace | undefined
|
|
693
|
+
if (llm?.discoverModels === undefined) {
|
|
694
|
+
throw new ProviderSettingsError('model discovery is unavailable in this profile; enter models by hand')
|
|
695
|
+
}
|
|
696
|
+
const typedKey = request.apiKey?.trim()
|
|
697
|
+
const baseURL = request.baseURL?.trim()
|
|
698
|
+
const hasUrl = baseURL !== undefined && baseURL !== ''
|
|
699
|
+
let oneShotKey = typedKey
|
|
700
|
+
// A filled endpoint means the user wants THAT endpoint's real list: sending
|
|
701
|
+
// the route id alongside would make the adapter short-circuit to its
|
|
702
|
+
// installed catalog (official providers) and ignore the URL entirely. The
|
|
703
|
+
// probe therefore goes out as a draft — with the typed key, or with the
|
|
704
|
+
// stored credential resolved once for this request (never displayed,
|
|
705
|
+
// never persisted; exactly what provider-mode resolution does internally).
|
|
706
|
+
if (hasUrl && (oneShotKey === undefined || oneShotKey === '')) {
|
|
707
|
+
const credentialsService = ctx.get('credentials') as CredentialsFace | undefined
|
|
708
|
+
const ref = target.credentialRef ?? target.suggestedRef
|
|
709
|
+
if (credentialsService?.resolve !== undefined && ref !== undefined) {
|
|
710
|
+
try {
|
|
711
|
+
// Call resolve AS A METHOD on the service: destructured off it the
|
|
712
|
+
// call loses `this` and throws on the provider's first field read
|
|
713
|
+
// (the same lesson loadProviderSettings documents for listProviders).
|
|
714
|
+
const resolved = await credentialsService.resolve(ref)
|
|
715
|
+
oneShotKey = resolved?.value
|
|
716
|
+
} catch {
|
|
717
|
+
// A failed resolution degrades to an unauthenticated probe; the
|
|
718
|
+
// endpoint's own 401 names the problem better than we can.
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
const draft = hasUrl
|
|
723
|
+
? {
|
|
724
|
+
...(oneShotKey !== undefined && oneShotKey !== '' ? { apiKey: oneShotKey } : {}),
|
|
725
|
+
baseURL: baseURL!,
|
|
726
|
+
...target.configuration.api === undefined ? {} : { api: target.configuration.api },
|
|
727
|
+
}
|
|
728
|
+
: {
|
|
729
|
+
// No endpoint override: the route's own knowledge answers (the official
|
|
730
|
+
// catalog for builtin providers — richer than any listing).
|
|
731
|
+
provider: target.provider,
|
|
732
|
+
}
|
|
733
|
+
try {
|
|
734
|
+
const discovered = signal === undefined
|
|
735
|
+
? await llm.discoverModels(target.settingsNs, draft)
|
|
736
|
+
: await llm.discoverModels(target.settingsNs, draft, signal)
|
|
737
|
+
// Defensive dedupe in endpoint order (the service dedupes too; an older
|
|
738
|
+
// one must not leak duplicate rows into the checkable list).
|
|
739
|
+
const seen = new Set<string>()
|
|
740
|
+
const rows: DiscoveredModelView[] = []
|
|
741
|
+
for (const model of discovered) {
|
|
742
|
+
if (typeof model.id !== 'string' || model.id.trim() === '' || seen.has(model.id)) continue
|
|
743
|
+
seen.add(model.id)
|
|
744
|
+
rows.push({
|
|
745
|
+
id: model.id,
|
|
746
|
+
...model.name === undefined ? {} : { name: model.name },
|
|
747
|
+
...model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow },
|
|
748
|
+
...model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens },
|
|
749
|
+
})
|
|
750
|
+
}
|
|
751
|
+
return rows
|
|
752
|
+
} catch (error) {
|
|
753
|
+
if (signal?.aborted === true) throw error
|
|
754
|
+
throw new ProviderSettingsError(singleLine(messageOf(error)))
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
554
758
|
/**
|
|
555
759
|
* Remove the currently named credential without touching the provider
|
|
556
760
|
* profile. Only the resolved profile's own reference is unset; a dormant or
|
package/src/questions.ts
CHANGED
|
@@ -80,6 +80,26 @@ export function mountQuestionProvider(ctx: Context): QuestionStore {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
if (service !== undefined) {
|
|
83
|
+
// Capability guard against contract drift: the pinned release exposes
|
|
84
|
+
// registerProvider, but an upstream alignment may replace it with the
|
|
85
|
+
// 'user-questions/request' waterfall. Degrade to the permanently-empty
|
|
86
|
+
// store (the no-service path) instead of failing startup with a
|
|
87
|
+
// TypeError on a missing method.
|
|
88
|
+
if (typeof (service as unknown as { registerProvider?: unknown }).registerProvider !== 'function') {
|
|
89
|
+
return {
|
|
90
|
+
subscribe(listener: () => void): () => void {
|
|
91
|
+
listeners.add(listener)
|
|
92
|
+
return () => {
|
|
93
|
+
listeners.delete(listener)
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
getSnapshot(): QuestionSnapshot {
|
|
97
|
+
return snapshot
|
|
98
|
+
},
|
|
99
|
+
submit(): void {},
|
|
100
|
+
cancel(): void {},
|
|
101
|
+
}
|
|
102
|
+
}
|
|
83
103
|
service.registerProvider({
|
|
84
104
|
ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
|
|
85
105
|
return new Promise((resolve, reject) => {
|
package/src/render/lines.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { promptDisplayText, type TranscriptEntry } from './projection.ts'
|
|
4
4
|
import type { ToolDetail } from './tool-detail.ts'
|
|
5
5
|
import { renderMarkdown, visibleColumns, type MdStyle } from './markdown.ts'
|
|
6
|
+
import { graphemeWidth, splitGraphemes } from './width.ts'
|
|
6
7
|
import { formatTokens } from './status.ts'
|
|
7
8
|
import { displayText, truncateColumns } from './text.ts'
|
|
8
9
|
|
|
@@ -53,14 +54,17 @@ export function styledLines(segments: readonly StyledSegment[], columns: number)
|
|
|
53
54
|
|
|
54
55
|
for (const segment of segments) {
|
|
55
56
|
const safe = displayText(segment.text).replaceAll('\t', ' ').replaceAll('\r', '')
|
|
56
|
-
|
|
57
|
-
|
|
57
|
+
// Grapheme clusters, never bare code points: a ZWJ family or a flag is
|
|
58
|
+
// one terminal cell run, and splitting it would both split the glyph
|
|
59
|
+
// across rows and double-count its width against the budget.
|
|
60
|
+
for (const cluster of splitGraphemes(safe)) {
|
|
61
|
+
if (cluster === '\n') {
|
|
58
62
|
flush()
|
|
59
63
|
continue
|
|
60
64
|
}
|
|
61
|
-
const cells =
|
|
65
|
+
const cells = graphemeWidth(cluster)
|
|
62
66
|
if (used > 0 && used + cells > width) flush()
|
|
63
|
-
appendSegment(current,
|
|
67
|
+
appendSegment(current, cluster, segment.style)
|
|
64
68
|
used += cells
|
|
65
69
|
}
|
|
66
70
|
}
|
|
@@ -76,7 +80,10 @@ export function textLines(text: string, columns: number, style: LineStyle = 'pla
|
|
|
76
80
|
/** Prefix every wrapped physical row without exceeding the column budget. */
|
|
77
81
|
function prefixedStyledLines(segments: readonly StyledSegment[], columns: number, prefix: string, prefixStyle: LineStyle = 'plain'): readonly StyledLine[] {
|
|
78
82
|
const width = Math.max(1, Math.floor(columns))
|
|
79
|
-
|
|
83
|
+
// Keep one column for the body even when the prefix alone would fill the
|
|
84
|
+
// row: a prefix allowed to claim the whole width pushed prefix+body one
|
|
85
|
+
// column past the budget on very narrow terminals.
|
|
86
|
+
const prefixWidth = Math.min(width - 1, visibleColumns(prefix))
|
|
80
87
|
const bodyWidth = Math.max(1, width - prefixWidth)
|
|
81
88
|
return styledLines(segments, bodyWidth).map(line => ({
|
|
82
89
|
segments: [lineSegment(prefix, prefixStyle), ...line.segments],
|
|
@@ -120,14 +127,14 @@ function hangingStyledLines(
|
|
|
120
127
|
}
|
|
121
128
|
for (const segment of segments) {
|
|
122
129
|
const safe = displayText(segment.text).replaceAll('\t', ' ').replaceAll('\r', '')
|
|
123
|
-
for (const
|
|
124
|
-
if (
|
|
130
|
+
for (const cluster of splitGraphemes(safe)) {
|
|
131
|
+
if (cluster === '\n') {
|
|
125
132
|
flush()
|
|
126
133
|
continue
|
|
127
134
|
}
|
|
128
|
-
const cells =
|
|
135
|
+
const cells = graphemeWidth(cluster)
|
|
129
136
|
if (used > 0 && used + cells > budget) flush()
|
|
130
|
-
appendSegment(current,
|
|
137
|
+
appendSegment(current, cluster, segment.style)
|
|
131
138
|
used += cells
|
|
132
139
|
}
|
|
133
140
|
}
|
|
@@ -155,7 +162,10 @@ function hangingTextLines(
|
|
|
155
162
|
/** Markdown rows re-hardened so a single long word cannot escape the budget. */
|
|
156
163
|
export function markdownLines(text: string, columns: number): readonly StyledLine[] {
|
|
157
164
|
const width = Math.max(1, Math.floor(columns))
|
|
158
|
-
|
|
165
|
+
// The markdown pass formats at the real width — a 10-column floor on a
|
|
166
|
+
// narrower terminal silently pushed rows past the budget (styledLines
|
|
167
|
+
// re-hardens long words at `width` either way).
|
|
168
|
+
const parsed = renderMarkdown(displayText(text), width)
|
|
159
169
|
return parsed.flatMap(line => styledLines(
|
|
160
170
|
line.segments.map(segment => lineSegment(segment.text, segment.style)),
|
|
161
171
|
width,
|
|
@@ -280,7 +290,7 @@ export function transcriptEntryLines(
|
|
|
280
290
|
// Every reply row carries the composer's two-column gutter, so reply
|
|
281
291
|
// text aligns with the input cursor (Codex LIVE_PREFIX alignment); the
|
|
282
292
|
// wrap budget shrinks by the same amount so no line double-wraps.
|
|
283
|
-
const body = markdownLines(entry.text, Math.max(
|
|
293
|
+
const body = markdownLines(entry.text, Math.max(1, width - 2))
|
|
284
294
|
.map(line => ({ segments: [{ text: ' ', style: 'plain' as const }, ...line.segments] }))
|
|
285
295
|
// A cancelled stream's delivered prefix settles as this entry; one
|
|
286
296
|
// bounded dim marker row distinguishes it from a completed reply.
|
|
@@ -330,7 +340,9 @@ export function transcriptEntryLines(
|
|
|
330
340
|
: ` ⧉ compaction failed: ${entry.error}`, width, 'dim')
|
|
331
341
|
case 'retry':
|
|
332
342
|
return textLines(
|
|
333
|
-
|
|
343
|
+
entry.mode === 'always'
|
|
344
|
+
? ` ↻ retry ${entry.attempt} · ${entry.code} · ${Math.round(entry.delayMs / 100) / 10}s`
|
|
345
|
+
: ` ↻ retry ${entry.attempt}/${entry.max} · ${entry.code} · ${Math.round(entry.delayMs / 100) / 10}s`,
|
|
334
346
|
width,
|
|
335
347
|
entry.state === 'running' ? 'warn' : 'dim',
|
|
336
348
|
)
|
package/src/render/markdown.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* @module @deepseek-ai/dsh-code/render/markdown
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { stringWidth } from './width.ts'
|
|
12
|
+
import { graphemeWidth, splitGraphemes, stringWidth } from './width.ts'
|
|
13
13
|
|
|
14
14
|
/** Style classes the renderer emits; the app maps them to colors/props. */
|
|
15
15
|
export type MdStyle = 'plain' | 'bold' | 'italic' | 'boldItalic' | 'code' | 'accent' | 'accentBold' | 'dim' | 'strike'
|
|
@@ -60,15 +60,17 @@ function wrapUnits(segments: readonly MdSegment[]): readonly WrapUnit[] {
|
|
|
60
60
|
if (word !== '') units.push({ text: word, style: segment.style })
|
|
61
61
|
word = ''
|
|
62
62
|
}
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
// Grapheme clusters keep ZWJ families, flags, and combining sequences
|
|
64
|
+
// whole; a cluster wider than one cell is its own break opportunity.
|
|
65
|
+
for (const cluster of splitGraphemes(segment.text)) {
|
|
66
|
+
if (cluster === ' ') {
|
|
65
67
|
flushWord()
|
|
66
|
-
units.push({ text:
|
|
67
|
-
} else if (
|
|
68
|
+
units.push({ text: cluster, style: segment.style })
|
|
69
|
+
} else if (graphemeWidth(cluster) > 1) {
|
|
68
70
|
flushWord()
|
|
69
|
-
units.push({ text:
|
|
71
|
+
units.push({ text: cluster, style: segment.style })
|
|
70
72
|
} else {
|
|
71
|
-
word +=
|
|
73
|
+
word += cluster
|
|
72
74
|
}
|
|
73
75
|
}
|
|
74
76
|
flushWord()
|
|
@@ -99,7 +101,7 @@ function wrapSegments(segments: readonly MdSegment[], width: number): readonly (
|
|
|
99
101
|
const append = (unit: WrapUnit): void => {
|
|
100
102
|
const columns = visibleColumns(unit.text)
|
|
101
103
|
if (used === 0 && columns > limit) {
|
|
102
|
-
for (const
|
|
104
|
+
for (const cluster of splitGraphemes(unit.text)) appendAtom({ text: cluster, style: unit.style })
|
|
103
105
|
return
|
|
104
106
|
}
|
|
105
107
|
if (used + columns <= limit || current.length === 0) {
|
|
@@ -121,7 +123,7 @@ function wrapSegments(segments: readonly MdSegment[], width: number): readonly (
|
|
|
121
123
|
}
|
|
122
124
|
flush()
|
|
123
125
|
if (columns > limit) {
|
|
124
|
-
for (const
|
|
126
|
+
for (const cluster of splitGraphemes(unit.text)) appendAtom({ text: cluster, style: unit.style })
|
|
125
127
|
} else {
|
|
126
128
|
appendAtom(unit)
|
|
127
129
|
}
|
|
@@ -336,12 +338,12 @@ function hardWrapSegments(segments: readonly MdSegment[], width: number): readon
|
|
|
336
338
|
used = 0
|
|
337
339
|
}
|
|
338
340
|
for (const segment of segments) {
|
|
339
|
-
for (const
|
|
340
|
-
const cells =
|
|
341
|
+
for (const cluster of splitGraphemes(segment.text)) {
|
|
342
|
+
const cells = graphemeWidth(cluster)
|
|
341
343
|
if (used > 0 && used + cells > width) flush()
|
|
342
344
|
const previous = current.at(-1)
|
|
343
|
-
if (previous?.style === segment.style) previous.text +=
|
|
344
|
-
else current.push({ text:
|
|
345
|
+
if (previous?.style === segment.style) previous.text += cluster
|
|
346
|
+
else current.push({ text: cluster, style: segment.style })
|
|
345
347
|
used += cells
|
|
346
348
|
}
|
|
347
349
|
}
|
package/src/render/projection.ts
CHANGED
|
@@ -159,6 +159,8 @@ export interface RetryEntry {
|
|
|
159
159
|
kind: 'retry'
|
|
160
160
|
/** Correlation id shared with the matching `llm/retry-started`. */
|
|
161
161
|
retryId: string
|
|
162
|
+
/** Retry policy mode from the event: `always` has no attempt cap. */
|
|
163
|
+
mode: 'normal' | 'always'
|
|
162
164
|
/** Attempt ordinal and its cap. */
|
|
163
165
|
attempt: number
|
|
164
166
|
max: number
|
|
@@ -166,7 +168,11 @@ export interface RetryEntry {
|
|
|
166
168
|
code: string
|
|
167
169
|
/** Backoff wait before the next attempt, in ms. */
|
|
168
170
|
delayMs: number
|
|
169
|
-
/**
|
|
171
|
+
/**
|
|
172
|
+
* `running` while the backoff waits, `done` once the attempt started — or
|
|
173
|
+
* when the turn ended first (the turn-end sweep finalizes orphans so they
|
|
174
|
+
* never pin the settled boundary).
|
|
175
|
+
*/
|
|
170
176
|
state: 'running' | 'done'
|
|
171
177
|
}
|
|
172
178
|
|
|
@@ -328,6 +334,25 @@ function textOf(content: readonly ContentBlock[]): string {
|
|
|
328
334
|
return content.filter(block => block.type === 'text').map(block => block.text).join('')
|
|
329
335
|
}
|
|
330
336
|
|
|
337
|
+
/**
|
|
338
|
+
* Snapshot-isolate one anchors block (Maps and their nested Sets): a view
|
|
339
|
+
* already handed to the renderer must never observe a later fold through a
|
|
340
|
+
* shared container. The collections are small and turn-bounded, so cloning
|
|
341
|
+
* per event is cheap next to the entries copy the reducer already makes.
|
|
342
|
+
*/
|
|
343
|
+
function cloneViewAnchors(anchors: TranscriptView['anchors']): TranscriptView['anchors'] {
|
|
344
|
+
return {
|
|
345
|
+
stepStart: new Map(anchors.stepStart),
|
|
346
|
+
toolStart: new Map(anchors.toolStart),
|
|
347
|
+
firstChunkAt: new Map(anchors.firstChunkAt),
|
|
348
|
+
compactionTokens: new Map(anchors.compactionTokens),
|
|
349
|
+
lastPruneTokens: anchors.lastPruneTokens,
|
|
350
|
+
turnFiles: new Map([...anchors.turnFiles].map(([turn, files]) => [turn, new Set(files)])),
|
|
351
|
+
turnSteps: new Map(anchors.turnSteps),
|
|
352
|
+
turnTools: new Map([...anchors.turnTools].map(([turn, tools]) => [turn, new Set(tools)])),
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
331
356
|
/** Durable image references in their model-visible order. */
|
|
332
357
|
function imagesOf(content: readonly ContentBlock[]): readonly ImageBlock['attachment'][] {
|
|
333
358
|
return content.filter((block): block is ImageBlock => block.type === 'image').map(block => block.attachment)
|
|
@@ -410,6 +435,10 @@ function pendingText(content: readonly ContentBlock[]): string {
|
|
|
410
435
|
* @returns the view after the event; the input view is never mutated.
|
|
411
436
|
*/
|
|
412
437
|
export function projectEvent(view: TranscriptView, event: SessionEvent): TranscriptView {
|
|
438
|
+
// Fold against a private anchors block so the documented contract holds —
|
|
439
|
+
// "the input view is never mutated" — even for the in-place anchor sweeps
|
|
440
|
+
// below; without this, every handed-out view shared live Maps.
|
|
441
|
+
view = { ...view, anchors: cloneViewAnchors(view.anchors) }
|
|
413
442
|
switch (event.type) {
|
|
414
443
|
case 'user/message': {
|
|
415
444
|
// A queued row retires when its durable user message lands (the agent
|
|
@@ -467,10 +496,16 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
|
|
|
467
496
|
const { target, start, removedCount = 0, inserted } = event.data
|
|
468
497
|
const ids = view.pending[target]
|
|
469
498
|
const removed = ids.slice(start, start + removedCount)
|
|
499
|
+
// In-place upstream semantics: the kernel's authoritative fold is
|
|
500
|
+
// `inbox.splice(start, removedCount, ...inserted)` — inserted ids land
|
|
501
|
+
// AT the splice position (prepend/replace shapes), never at the tail.
|
|
502
|
+
// A tail append diverged the id order, so later coordinate-based events
|
|
503
|
+
// (next-turn head claims, positioned remove/replace) tombstoned the
|
|
504
|
+
// wrong pending row.
|
|
470
505
|
const nextIds = [
|
|
471
506
|
...ids.slice(0, start),
|
|
472
|
-
...ids.slice(start + removedCount),
|
|
473
507
|
...inserted.map(message => message.id),
|
|
508
|
+
...ids.slice(start + removedCount),
|
|
474
509
|
]
|
|
475
510
|
let entries = view.entries
|
|
476
511
|
if (removed.length > 0) {
|
|
@@ -714,8 +749,26 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
|
|
|
714
749
|
for (const callId of turnToolSet) view.anchors.toolStart.delete(callId)
|
|
715
750
|
view.anchors.turnTools.delete(event.data.turn)
|
|
716
751
|
}
|
|
752
|
+
// Orphaned retry/command rows can never be resolved after the turn
|
|
753
|
+
// ends: an aborted retry backoff returns upstream without its
|
|
754
|
+
// `llm/retry-started`, and crash repair synthesizes only tool/step/
|
|
755
|
+
// turn closers. Left `running` they pin the settled boundary forever,
|
|
756
|
+
// so the turn end finalizes them exactly like the anchor sweep above.
|
|
757
|
+
let orphans = false
|
|
758
|
+
const swept = view.entries.map((entry) => {
|
|
759
|
+
if (entry.kind === 'retry' && entry.state === 'running') {
|
|
760
|
+
orphans = true
|
|
761
|
+
return { ...entry, state: 'done' as const }
|
|
762
|
+
}
|
|
763
|
+
if (entry.kind === 'command' && entry.state === 'running') {
|
|
764
|
+
orphans = true
|
|
765
|
+
return { ...entry, state: 'error' as const, summary: 'interrupted before the turn ended' }
|
|
766
|
+
}
|
|
767
|
+
return entry
|
|
768
|
+
})
|
|
769
|
+
const entries = orphans ? swept : view.entries
|
|
717
770
|
if (appended.length === 0) {
|
|
718
|
-
return { ...view, busy: false, busySince: 0, streaming: '', streamingReasoning: '' }
|
|
771
|
+
return { ...view, busy: false, busySince: 0, streaming: '', streamingReasoning: '', entries }
|
|
719
772
|
}
|
|
720
773
|
return {
|
|
721
774
|
...view,
|
|
@@ -723,7 +776,7 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
|
|
|
723
776
|
busySince: 0,
|
|
724
777
|
streaming: '',
|
|
725
778
|
streamingReasoning: '',
|
|
726
|
-
entries: [...
|
|
779
|
+
entries: [...entries, ...appended],
|
|
727
780
|
}
|
|
728
781
|
}
|
|
729
782
|
case 'llm/retry': {
|
|
@@ -735,6 +788,7 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
|
|
|
735
788
|
entries: [...view.entries, {
|
|
736
789
|
kind: 'retry',
|
|
737
790
|
retryId: data.retryId,
|
|
791
|
+
mode: data.mode,
|
|
738
792
|
attempt: data.retry,
|
|
739
793
|
max: 'maxRetries' in data ? data.maxRetries : data.retry,
|
|
740
794
|
code: data.failure.code,
|
|
@@ -995,6 +1049,31 @@ function indexList(map: Map<string, number[]>, id: string): number[] {
|
|
|
995
1049
|
return list
|
|
996
1050
|
}
|
|
997
1051
|
|
|
1052
|
+
/**
|
|
1053
|
+
* Finalize replay rows the ended turn left `running`, mirroring the reducer's
|
|
1054
|
+
* turn-end orphan sweep: an orphaned retry settles `done`, an orphaned command
|
|
1055
|
+
* settles `error` with an interruption note. Only the id-indexed rows are
|
|
1056
|
+
* visited, so the sweep stays O(retries+commands of the log), never a scan.
|
|
1057
|
+
*/
|
|
1058
|
+
function finalizeReplayOrphans(acc: ReplayAccumulator): void {
|
|
1059
|
+
for (const list of acc.retryIndex.values()) {
|
|
1060
|
+
for (const index of list) {
|
|
1061
|
+
const entry = acc.entries[index]
|
|
1062
|
+
if (entry !== undefined && entry.kind === 'retry' && entry.state === 'running') {
|
|
1063
|
+
acc.entries[index] = { ...entry, state: 'done' }
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
for (const list of acc.commandIndex.values()) {
|
|
1068
|
+
for (const index of list) {
|
|
1069
|
+
const entry = acc.entries[index]
|
|
1070
|
+
if (entry !== undefined && entry.kind === 'command' && entry.state === 'running') {
|
|
1071
|
+
acc.entries[index] = { ...entry, state: 'error', summary: 'interrupted before the turn ended' }
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
998
1077
|
/**
|
|
999
1078
|
* Apply an id-keyed update to every row that registered the id, mirroring the
|
|
1000
1079
|
* copy-on-write reducer's full-array map semantics (all matching rows update,
|
|
@@ -1103,11 +1182,15 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
|
|
|
1103
1182
|
}
|
|
1104
1183
|
}
|
|
1105
1184
|
}
|
|
1185
|
+
// Mirror the reducer's in-place order: inserted ids join at the splice
|
|
1186
|
+
// position (upstream `splice(start, removedCount, ...inserted)`), never
|
|
1187
|
+
// at the tail — the id list must stay coordinate-compatible with every
|
|
1188
|
+
// later inbox event.
|
|
1189
|
+
ids.splice(start, 0, ...inserted.map(message => message.id))
|
|
1106
1190
|
for (const message of inserted) {
|
|
1107
1191
|
const images = imagesOf(message.content)
|
|
1108
1192
|
appendReplayEntry(acc, { kind: 'pending', messageId: message.id, target, text: pendingText(message.content), ...(images.length === 0 ? {} : { images }) })
|
|
1109
1193
|
indexList(acc.pendingIndex, message.id).push(acc.entries.length - 1)
|
|
1110
|
-
ids.push(message.id)
|
|
1111
1194
|
acc.ops += 1
|
|
1112
1195
|
}
|
|
1113
1196
|
return true
|
|
@@ -1298,6 +1381,7 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
|
|
|
1298
1381
|
for (const callId of turnToolSet) acc.toolStart.delete(callId)
|
|
1299
1382
|
acc.turnTools.delete(event.data.turn)
|
|
1300
1383
|
}
|
|
1384
|
+
finalizeReplayOrphans(acc)
|
|
1301
1385
|
acc.busy = false
|
|
1302
1386
|
acc.busySince = 0
|
|
1303
1387
|
for (const entry of appended) appendReplayEntry(acc, entry)
|
|
@@ -1310,6 +1394,7 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
|
|
|
1310
1394
|
appendReplayEntry(acc, {
|
|
1311
1395
|
kind: 'retry',
|
|
1312
1396
|
retryId: data.retryId,
|
|
1397
|
+
mode: data.mode,
|
|
1313
1398
|
attempt: data.retry,
|
|
1314
1399
|
max: 'maxRetries' in data ? data.maxRetries : data.retry,
|
|
1315
1400
|
code: data.failure.code,
|
|
@@ -1474,15 +1559,17 @@ function materializeReplayView(acc: ReplayAccumulator, copy: boolean): Transcrip
|
|
|
1474
1559
|
goal: acc.goal,
|
|
1475
1560
|
pending: { 'next-turn': [...acc.pendingTurn], 'next-step': [...acc.pendingStep] },
|
|
1476
1561
|
stats: acc.stats,
|
|
1562
|
+
// Handed-out views get their own anchors snapshot: the accumulator keeps
|
|
1563
|
+
// folding its live containers, and no consumer may observe that.
|
|
1477
1564
|
anchors: {
|
|
1478
|
-
stepStart: acc.stepStart,
|
|
1479
|
-
toolStart: acc.toolStart,
|
|
1480
|
-
firstChunkAt: acc.firstChunkAt,
|
|
1481
|
-
compactionTokens: acc.compactionTokens,
|
|
1565
|
+
stepStart: new Map(acc.stepStart),
|
|
1566
|
+
toolStart: new Map(acc.toolStart),
|
|
1567
|
+
firstChunkAt: new Map(acc.firstChunkAt),
|
|
1568
|
+
compactionTokens: new Map(acc.compactionTokens),
|
|
1482
1569
|
lastPruneTokens: acc.lastPruneTokens,
|
|
1483
|
-
turnFiles: acc.turnFiles,
|
|
1484
|
-
turnSteps: acc.turnSteps,
|
|
1485
|
-
turnTools: acc.turnTools,
|
|
1570
|
+
turnFiles: new Map([...acc.turnFiles].map(([turn, files]) => [turn, new Set(files)])),
|
|
1571
|
+
turnSteps: new Map(acc.turnSteps),
|
|
1572
|
+
turnTools: new Map([...acc.turnTools].map(([turn, tools]) => [turn, new Set(tools)])),
|
|
1486
1573
|
},
|
|
1487
1574
|
}
|
|
1488
1575
|
}
|