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.
Files changed (45) hide show
  1. package/README.md +293 -285
  2. package/bin/deepseek.mjs +245 -12
  3. package/cordis.patch.yml +12 -14
  4. package/lib/index.mjs +1939 -903
  5. package/lib/types/app.d.ts +11 -2
  6. package/lib/types/commands.d.ts +13 -0
  7. package/lib/types/git-workflow.d.ts +7 -2
  8. package/lib/types/history.d.ts +18 -11
  9. package/lib/types/index.d.ts +28 -0
  10. package/lib/types/input-split.d.ts +54 -0
  11. package/lib/types/kernel-panels.d.ts +3 -1
  12. package/lib/types/keyboard.d.ts +8 -0
  13. package/lib/types/presets.d.ts +4 -1
  14. package/lib/types/provider-settings.d.ts +77 -0
  15. package/lib/types/questions.d.ts +16 -12
  16. package/lib/types/render/projection.d.ts +9 -2
  17. package/lib/types/render/status.d.ts +22 -15
  18. package/lib/types/settings-file.d.ts +8 -0
  19. package/lib/types/skills.d.ts +1 -1
  20. package/package.json +49 -46
  21. package/src/app.ts +5459 -4900
  22. package/src/approval.ts +8 -3
  23. package/src/authorization-panel.ts +2 -4
  24. package/src/commands.ts +27 -3
  25. package/src/git-workflow.ts +29 -10
  26. package/src/history.ts +22 -13
  27. package/src/index.ts +203 -61
  28. package/src/input-split.ts +191 -0
  29. package/src/internals.ts +26 -8
  30. package/src/kernel-panels.ts +26 -10
  31. package/src/keyboard.ts +123 -88
  32. package/src/mentions.ts +42 -9
  33. package/src/permissions.ts +1 -1
  34. package/src/presets.ts +19 -6
  35. package/src/provider-settings.ts +204 -0
  36. package/src/questions.ts +58 -55
  37. package/src/render/export.ts +7 -7
  38. package/src/render/lines.ts +24 -12
  39. package/src/render/markdown.ts +15 -13
  40. package/src/render/projection.ts +101 -13
  41. package/src/render/status.ts +76 -71
  42. package/src/render/text.ts +9 -3
  43. package/src/settings-file.ts +38 -6
  44. package/src/skills.ts +19 -6
  45. package/src/theme-panel.ts +79 -72
@@ -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 provider: registers the single UI provider
3
- * on `ctx.userQuestions` and drives it with a FIFO queue — one question
4
- * request on screen at a time, everything else waiting — then resolves the
5
- * collected answers back into the tool's promise. The community TUI proved
6
- * this exact pipeline shape; here the dialog is an Ink bar instead of a
7
- * pi-tui inline modal.
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 service with an
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 single `ctx.userQuestions` UI provider over a FIFO queue.
60
- * @param ctx - context carrying the `userQuestions` service (dsh-base).
61
- * @returns the store the renderer subscribes to; a context without the
62
- * service yields a permanently empty store.
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
- if (service !== undefined) {
83
- service.registerProvider({
84
- ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
85
- return new Promise((resolve, reject) => {
86
- // Abort settles through the same channel as an Esc cancel: the
87
- // owning tool/step died, so the answer must not linger.
88
- const onAbort = (): void => {
89
- if (active === pending) {
90
- active = undefined
91
- set({ pending: undefined })
92
- advance()
93
- } else {
94
- const at = queue.indexOf(pending)
95
- if (at >= 0) queue.splice(at, 1)
96
- }
97
- reject(ABORT_ERROR)
98
- }
99
- // Detach on every settle so an answered/cancelled question never
100
- // retains a listener on the owning tool call's signal.
101
- const detachAbort = (): void => {
102
- if (request.signal !== undefined) request.signal.removeEventListener('abort', onAbort)
103
- }
104
- const pending: PendingQuestion = {
105
- request,
106
- resolve,
107
- reject,
108
- detachAbort,
109
- }
110
- if (request.signal?.aborted === true) {
111
- reject(ABORT_ERROR)
112
- return
113
- }
114
- request.signal?.addEventListener('abort', onAbort, { once: true })
115
- if (active === undefined) {
116
- active = pending
117
- set({ pending })
118
- } else {
119
- queue.push(pending)
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 {
@@ -6,7 +6,7 @@
6
6
  * @module @deepseek-ai/dsh-code/render/export
7
7
  */
8
8
 
9
- import { assertNever } from '@deepseek-ai/dsh-llm'
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}`)
@@ -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
- for (const char of safe) {
57
- if (char === '\n') {
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 = visibleColumns(char)
65
+ const cells = graphemeWidth(cluster)
62
66
  if (used > 0 && used + cells > width) flush()
63
- appendSegment(current, char, segment.style)
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
- const prefixWidth = Math.min(width, visibleColumns(prefix))
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 char of safe) {
124
- if (char === '\n') {
130
+ for (const cluster of splitGraphemes(safe)) {
131
+ if (cluster === '\n') {
125
132
  flush()
126
133
  continue
127
134
  }
128
- const cells = visibleColumns(char)
135
+ const cells = graphemeWidth(cluster)
129
136
  if (used > 0 && used + cells > budget) flush()
130
- appendSegment(current, char, segment.style)
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
- const parsed = renderMarkdown(displayText(text), Math.max(10, width))
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(10, width - 2))
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
- ` ↻ retry ${entry.attempt}/${entry.max} · ${entry.code} · ${Math.round(entry.delayMs / 100) / 10}s`,
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
  )
@@ -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
- for (const char of segment.text) {
64
- if (char === ' ') {
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: char, style: segment.style })
67
- } else if (visibleColumns(char) > 1) {
68
+ units.push({ text: cluster, style: segment.style })
69
+ } else if (graphemeWidth(cluster) > 1) {
68
70
  flushWord()
69
- units.push({ text: char, style: segment.style })
71
+ units.push({ text: cluster, style: segment.style })
70
72
  } else {
71
- word += char
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 char of unit.text) appendAtom({ text: char, style: unit.style })
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 char of unit.text) appendAtom({ text: char, style: unit.style })
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 char of segment.text) {
340
- const cells = visibleColumns(char)
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 += char
344
- else current.push({ text: char, style: segment.style })
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
  }