dsh-code 1.0.3 → 1.0.5
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 +293 -285
- package/bin/deepseek.mjs +245 -12
- package/cordis.patch.yml +12 -14
- package/lib/index.mjs +1939 -903
- package/lib/types/app.d.ts +11 -2
- package/lib/types/commands.d.ts +13 -0
- package/lib/types/git-workflow.d.ts +7 -2
- package/lib/types/history.d.ts +18 -11
- 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/presets.d.ts +4 -1
- package/lib/types/provider-settings.d.ts +77 -0
- package/lib/types/questions.d.ts +16 -12
- package/lib/types/render/projection.d.ts +9 -2
- package/lib/types/render/status.d.ts +22 -15
- package/lib/types/settings-file.d.ts +8 -0
- package/lib/types/skills.d.ts +1 -1
- package/package.json +49 -46
- 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/git-workflow.ts +29 -10
- package/src/history.ts +22 -13
- package/src/index.ts +203 -61
- 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/permissions.ts +1 -1
- package/src/presets.ts +19 -6
- package/src/provider-settings.ts +204 -0
- package/src/questions.ts +58 -55
- package/src/render/export.ts +7 -7
- package/src/render/lines.ts +24 -12
- package/src/render/markdown.ts +15 -13
- package/src/render/projection.ts +101 -13
- package/src/render/status.ts +76 -71
- package/src/render/text.ts +9 -3
- package/src/settings-file.ts +38 -6
- 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
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The terminal ask_user_question
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
2
|
+
* The terminal ask_user_question answerer: one `user-questions/request`
|
|
3
|
+
* waterfall listener that drives a FIFO queue — one question request on
|
|
4
|
+
* screen at a time, everything else waiting — then resolves the collected
|
|
5
|
+
* answers back into the waterfall. Mirrors the approval answerer's claim/
|
|
6
|
+
* defer split: only agents this TUI owns are answered, every other request
|
|
7
|
+
* falls through to the next answerer.
|
|
8
8
|
*
|
|
9
|
-
* Plan reviews (`exit_plan_mode`) arrive through the same
|
|
9
|
+
* Plan reviews (`exit_plan_mode`) arrive through the same waterfall with an
|
|
10
10
|
* `intent: { kind: 'plan-review' }` — the renderer highlights the approve
|
|
11
11
|
* option; the answer encoding is identical either way.
|
|
12
12
|
*
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import type { Context } from '@deepseek-ai/cordis'
|
|
17
|
+
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
17
18
|
import {
|
|
18
19
|
UserQuestionError,
|
|
19
20
|
type AskUserQuestionAnswer,
|
|
@@ -56,13 +57,15 @@ const ABORT_ERROR = new UserQuestionError(
|
|
|
56
57
|
)
|
|
57
58
|
|
|
58
59
|
/**
|
|
59
|
-
* Mount the
|
|
60
|
-
* @param ctx - context
|
|
61
|
-
* @
|
|
62
|
-
*
|
|
60
|
+
* Mount the `user-questions/request` answerer over a FIFO queue.
|
|
61
|
+
* @param ctx - plugin context whose event bus carries the waterfall.
|
|
62
|
+
* @param owns - agents this terminal answers for; every other request is
|
|
63
|
+
* deferred back into the waterfall (`next()`), so sibling answerers stay
|
|
64
|
+
* usable. Agent-less asks are claimed: this TUI is the only human surface
|
|
65
|
+
* in the process.
|
|
66
|
+
* @returns the store the renderer subscribes to.
|
|
63
67
|
*/
|
|
64
|
-
export function mountQuestionProvider(ctx: Context): QuestionStore {
|
|
65
|
-
const service = ctx.get('userQuestions')
|
|
68
|
+
export function mountQuestionProvider(ctx: Context, owns: (agent: Agent) => boolean): QuestionStore {
|
|
66
69
|
let snapshot: QuestionSnapshot = { pending: undefined }
|
|
67
70
|
let active: PendingQuestion | undefined
|
|
68
71
|
const queue: PendingQuestion[] = []
|
|
@@ -79,49 +82,49 @@ export function mountQuestionProvider(ctx: Context): QuestionStore {
|
|
|
79
82
|
set({ pending: next })
|
|
80
83
|
}
|
|
81
84
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}
|
|
85
|
+
ctx.on('user-questions/request', (
|
|
86
|
+
request: AskUserQuestionRequest,
|
|
87
|
+
next: () => Promise<AskUserQuestionAnswer>,
|
|
88
|
+
): Promise<AskUserQuestionAnswer> => {
|
|
89
|
+
if (request.agent !== undefined && !owns(request.agent)) return next()
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
// Abort settles through the same channel as an Esc cancel: the
|
|
92
|
+
// owning tool/step died, so the answer must not linger.
|
|
93
|
+
const onAbort = (): void => {
|
|
94
|
+
if (active === pending) {
|
|
95
|
+
active = undefined
|
|
96
|
+
set({ pending: undefined })
|
|
97
|
+
advance()
|
|
98
|
+
} else {
|
|
99
|
+
const at = queue.indexOf(pending)
|
|
100
|
+
if (at >= 0) queue.splice(at, 1)
|
|
101
|
+
}
|
|
102
|
+
reject(ABORT_ERROR)
|
|
103
|
+
}
|
|
104
|
+
// Detach on every settle so an answered/cancelled question never
|
|
105
|
+
// retains a listener on the owning tool call's signal.
|
|
106
|
+
const detachAbort = (): void => {
|
|
107
|
+
if (request.signal !== undefined) request.signal.removeEventListener('abort', onAbort)
|
|
108
|
+
}
|
|
109
|
+
const pending: PendingQuestion = {
|
|
110
|
+
request,
|
|
111
|
+
resolve,
|
|
112
|
+
reject,
|
|
113
|
+
detachAbort,
|
|
114
|
+
}
|
|
115
|
+
if (request.signal?.aborted === true) {
|
|
116
|
+
reject(ABORT_ERROR)
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
request.signal?.addEventListener('abort', onAbort, { once: true })
|
|
120
|
+
if (active === undefined) {
|
|
121
|
+
active = pending
|
|
122
|
+
set({ pending })
|
|
123
|
+
} else {
|
|
124
|
+
queue.push(pending)
|
|
125
|
+
}
|
|
123
126
|
})
|
|
124
|
-
}
|
|
127
|
+
})
|
|
125
128
|
|
|
126
129
|
return {
|
|
127
130
|
subscribe(listener: () => void): () => void {
|
package/src/render/export.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* @module @deepseek-ai/dsh-code/render/export
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { assertNever } from '@deepseek-ai/dsh-
|
|
9
|
+
import { assertNever } from '@deepseek-ai/dsh-util-values'
|
|
10
10
|
import { imageLabels, type TranscriptView } from './projection.ts'
|
|
11
11
|
|
|
12
12
|
/**
|
|
@@ -32,12 +32,12 @@ export function buildExportMarkdown(view: TranscriptView, sessionId: string): st
|
|
|
32
32
|
out.push('## user', '', entry.text, ...(imageLabels(entry.images) === '' ? [] : [imageLabels(entry.images)]), '')
|
|
33
33
|
}
|
|
34
34
|
break
|
|
35
|
-
case 'assistant':
|
|
36
|
-
if (entry.reasoning !== '') {
|
|
37
|
-
out.push('<details><summary>thinking</summary>', '', entry.reasoning, '', '</details>', '')
|
|
38
|
-
}
|
|
39
|
-
out.push('## assistant', '', entry.text, '')
|
|
40
|
-
break
|
|
35
|
+
case 'assistant':
|
|
36
|
+
if (entry.reasoning !== '') {
|
|
37
|
+
out.push('<details><summary>thinking</summary>', '', entry.reasoning, '', '</details>', '')
|
|
38
|
+
}
|
|
39
|
+
out.push('## assistant', '', entry.text, '')
|
|
40
|
+
break
|
|
41
41
|
case 'tool':
|
|
42
42
|
out.push(`### tool \`${entry.name}\``, '')
|
|
43
43
|
if (entry.preview !== '') out.push(`- args: ${entry.preview}`)
|
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
|
}
|