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
@@ -8,7 +8,8 @@
8
8
  */
9
9
 
10
10
  import { boundContextSummary, type ContentBlock, type ImageBlock, type MessageId } from '@deepseek-ai/dsh-llm'
11
- import type { SessionEvent, TodoItem } from '@deepseek-ai/dsh-session'
11
+ import type { SessionEvent } from '@deepseek-ai/dsh-session'
12
+ import type { TodoItem } from '@deepseek-ai/dsh-tool-todo'
12
13
  import { graphemeWidth, splitGraphemes } from './width.ts'
13
14
  // Type-only imports merge the plugin-owned SessionEventMap variants
14
15
  // (agent/inbox/spliced, command/*, compaction/*, goal/change, llm/retry*,
@@ -159,6 +160,8 @@ export interface RetryEntry {
159
160
  kind: 'retry'
160
161
  /** Correlation id shared with the matching `llm/retry-started`. */
161
162
  retryId: string
163
+ /** Retry policy mode from the event: `always` has no attempt cap. */
164
+ mode: 'normal' | 'always'
162
165
  /** Attempt ordinal and its cap. */
163
166
  attempt: number
164
167
  max: number
@@ -166,7 +169,11 @@ export interface RetryEntry {
166
169
  code: string
167
170
  /** Backoff wait before the next attempt, in ms. */
168
171
  delayMs: number
169
- /** `running` while the backoff waits, `done` once the attempt started. */
172
+ /**
173
+ * `running` while the backoff waits, `done` once the attempt started — or
174
+ * when the turn ended first (the turn-end sweep finalizes orphans so they
175
+ * never pin the settled boundary).
176
+ */
170
177
  state: 'running' | 'done'
171
178
  }
172
179
 
@@ -328,6 +335,25 @@ function textOf(content: readonly ContentBlock[]): string {
328
335
  return content.filter(block => block.type === 'text').map(block => block.text).join('')
329
336
  }
330
337
 
338
+ /**
339
+ * Snapshot-isolate one anchors block (Maps and their nested Sets): a view
340
+ * already handed to the renderer must never observe a later fold through a
341
+ * shared container. The collections are small and turn-bounded, so cloning
342
+ * per event is cheap next to the entries copy the reducer already makes.
343
+ */
344
+ function cloneViewAnchors(anchors: TranscriptView['anchors']): TranscriptView['anchors'] {
345
+ return {
346
+ stepStart: new Map(anchors.stepStart),
347
+ toolStart: new Map(anchors.toolStart),
348
+ firstChunkAt: new Map(anchors.firstChunkAt),
349
+ compactionTokens: new Map(anchors.compactionTokens),
350
+ lastPruneTokens: anchors.lastPruneTokens,
351
+ turnFiles: new Map([...anchors.turnFiles].map(([turn, files]) => [turn, new Set(files)])),
352
+ turnSteps: new Map(anchors.turnSteps),
353
+ turnTools: new Map([...anchors.turnTools].map(([turn, tools]) => [turn, new Set(tools)])),
354
+ }
355
+ }
356
+
331
357
  /** Durable image references in their model-visible order. */
332
358
  function imagesOf(content: readonly ContentBlock[]): readonly ImageBlock['attachment'][] {
333
359
  return content.filter((block): block is ImageBlock => block.type === 'image').map(block => block.attachment)
@@ -410,6 +436,10 @@ function pendingText(content: readonly ContentBlock[]): string {
410
436
  * @returns the view after the event; the input view is never mutated.
411
437
  */
412
438
  export function projectEvent(view: TranscriptView, event: SessionEvent): TranscriptView {
439
+ // Fold against a private anchors block so the documented contract holds —
440
+ // "the input view is never mutated" — even for the in-place anchor sweeps
441
+ // below; without this, every handed-out view shared live Maps.
442
+ view = { ...view, anchors: cloneViewAnchors(view.anchors) }
413
443
  switch (event.type) {
414
444
  case 'user/message': {
415
445
  // A queued row retires when its durable user message lands (the agent
@@ -467,10 +497,16 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
467
497
  const { target, start, removedCount = 0, inserted } = event.data
468
498
  const ids = view.pending[target]
469
499
  const removed = ids.slice(start, start + removedCount)
500
+ // In-place upstream semantics: the kernel's authoritative fold is
501
+ // `inbox.splice(start, removedCount, ...inserted)` — inserted ids land
502
+ // AT the splice position (prepend/replace shapes), never at the tail.
503
+ // A tail append diverged the id order, so later coordinate-based events
504
+ // (next-turn head claims, positioned remove/replace) tombstoned the
505
+ // wrong pending row.
470
506
  const nextIds = [
471
507
  ...ids.slice(0, start),
472
- ...ids.slice(start + removedCount),
473
508
  ...inserted.map(message => message.id),
509
+ ...ids.slice(start + removedCount),
474
510
  ]
475
511
  let entries = view.entries
476
512
  if (removed.length > 0) {
@@ -714,8 +750,26 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
714
750
  for (const callId of turnToolSet) view.anchors.toolStart.delete(callId)
715
751
  view.anchors.turnTools.delete(event.data.turn)
716
752
  }
753
+ // Orphaned retry/command rows can never be resolved after the turn
754
+ // ends: an aborted retry backoff returns upstream without its
755
+ // `llm/retry-started`, and crash repair synthesizes only tool/step/
756
+ // turn closers. Left `running` they pin the settled boundary forever,
757
+ // so the turn end finalizes them exactly like the anchor sweep above.
758
+ let orphans = false
759
+ const swept = view.entries.map((entry) => {
760
+ if (entry.kind === 'retry' && entry.state === 'running') {
761
+ orphans = true
762
+ return { ...entry, state: 'done' as const }
763
+ }
764
+ if (entry.kind === 'command' && entry.state === 'running') {
765
+ orphans = true
766
+ return { ...entry, state: 'error' as const, summary: 'interrupted before the turn ended' }
767
+ }
768
+ return entry
769
+ })
770
+ const entries = orphans ? swept : view.entries
717
771
  if (appended.length === 0) {
718
- return { ...view, busy: false, busySince: 0, streaming: '', streamingReasoning: '' }
772
+ return { ...view, busy: false, busySince: 0, streaming: '', streamingReasoning: '', entries }
719
773
  }
720
774
  return {
721
775
  ...view,
@@ -723,7 +777,7 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
723
777
  busySince: 0,
724
778
  streaming: '',
725
779
  streamingReasoning: '',
726
- entries: [...view.entries, ...appended],
780
+ entries: [...entries, ...appended],
727
781
  }
728
782
  }
729
783
  case 'llm/retry': {
@@ -735,6 +789,7 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
735
789
  entries: [...view.entries, {
736
790
  kind: 'retry',
737
791
  retryId: data.retryId,
792
+ mode: data.mode,
738
793
  attempt: data.retry,
739
794
  max: 'maxRetries' in data ? data.maxRetries : data.retry,
740
795
  code: data.failure.code,
@@ -995,6 +1050,31 @@ function indexList(map: Map<string, number[]>, id: string): number[] {
995
1050
  return list
996
1051
  }
997
1052
 
1053
+ /**
1054
+ * Finalize replay rows the ended turn left `running`, mirroring the reducer's
1055
+ * turn-end orphan sweep: an orphaned retry settles `done`, an orphaned command
1056
+ * settles `error` with an interruption note. Only the id-indexed rows are
1057
+ * visited, so the sweep stays O(retries+commands of the log), never a scan.
1058
+ */
1059
+ function finalizeReplayOrphans(acc: ReplayAccumulator): void {
1060
+ for (const list of acc.retryIndex.values()) {
1061
+ for (const index of list) {
1062
+ const entry = acc.entries[index]
1063
+ if (entry !== undefined && entry.kind === 'retry' && entry.state === 'running') {
1064
+ acc.entries[index] = { ...entry, state: 'done' }
1065
+ }
1066
+ }
1067
+ }
1068
+ for (const list of acc.commandIndex.values()) {
1069
+ for (const index of list) {
1070
+ const entry = acc.entries[index]
1071
+ if (entry !== undefined && entry.kind === 'command' && entry.state === 'running') {
1072
+ acc.entries[index] = { ...entry, state: 'error', summary: 'interrupted before the turn ended' }
1073
+ }
1074
+ }
1075
+ }
1076
+ }
1077
+
998
1078
  /**
999
1079
  * Apply an id-keyed update to every row that registered the id, mirroring the
1000
1080
  * copy-on-write reducer's full-array map semantics (all matching rows update,
@@ -1103,11 +1183,15 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1103
1183
  }
1104
1184
  }
1105
1185
  }
1186
+ // Mirror the reducer's in-place order: inserted ids join at the splice
1187
+ // position (upstream `splice(start, removedCount, ...inserted)`), never
1188
+ // at the tail — the id list must stay coordinate-compatible with every
1189
+ // later inbox event.
1190
+ ids.splice(start, 0, ...inserted.map(message => message.id))
1106
1191
  for (const message of inserted) {
1107
1192
  const images = imagesOf(message.content)
1108
1193
  appendReplayEntry(acc, { kind: 'pending', messageId: message.id, target, text: pendingText(message.content), ...(images.length === 0 ? {} : { images }) })
1109
1194
  indexList(acc.pendingIndex, message.id).push(acc.entries.length - 1)
1110
- ids.push(message.id)
1111
1195
  acc.ops += 1
1112
1196
  }
1113
1197
  return true
@@ -1298,6 +1382,7 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1298
1382
  for (const callId of turnToolSet) acc.toolStart.delete(callId)
1299
1383
  acc.turnTools.delete(event.data.turn)
1300
1384
  }
1385
+ finalizeReplayOrphans(acc)
1301
1386
  acc.busy = false
1302
1387
  acc.busySince = 0
1303
1388
  for (const entry of appended) appendReplayEntry(acc, entry)
@@ -1310,6 +1395,7 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1310
1395
  appendReplayEntry(acc, {
1311
1396
  kind: 'retry',
1312
1397
  retryId: data.retryId,
1398
+ mode: data.mode,
1313
1399
  attempt: data.retry,
1314
1400
  max: 'maxRetries' in data ? data.maxRetries : data.retry,
1315
1401
  code: data.failure.code,
@@ -1474,15 +1560,17 @@ function materializeReplayView(acc: ReplayAccumulator, copy: boolean): Transcrip
1474
1560
  goal: acc.goal,
1475
1561
  pending: { 'next-turn': [...acc.pendingTurn], 'next-step': [...acc.pendingStep] },
1476
1562
  stats: acc.stats,
1563
+ // Handed-out views get their own anchors snapshot: the accumulator keeps
1564
+ // folding its live containers, and no consumer may observe that.
1477
1565
  anchors: {
1478
- stepStart: acc.stepStart,
1479
- toolStart: acc.toolStart,
1480
- firstChunkAt: acc.firstChunkAt,
1481
- compactionTokens: acc.compactionTokens,
1566
+ stepStart: new Map(acc.stepStart),
1567
+ toolStart: new Map(acc.toolStart),
1568
+ firstChunkAt: new Map(acc.firstChunkAt),
1569
+ compactionTokens: new Map(acc.compactionTokens),
1482
1570
  lastPruneTokens: acc.lastPruneTokens,
1483
- turnFiles: acc.turnFiles,
1484
- turnSteps: acc.turnSteps,
1485
- turnTools: acc.turnTools,
1571
+ turnFiles: new Map([...acc.turnFiles].map(([turn, files]) => [turn, new Set(files)])),
1572
+ turnSteps: new Map(acc.turnSteps),
1573
+ turnTools: new Map([...acc.turnTools].map(([turn, tools]) => [turn, new Set(tools)])),
1486
1574
  },
1487
1575
  }
1488
1576
  }
@@ -126,37 +126,31 @@ export const STATUS_ITEM_SEPARATOR = ' · '
126
126
  export const STATUS_CYCLE_HINT = ' (shift+tab to cycle)'
127
127
 
128
128
  /**
129
- * Interior columns of the segmented context bar (content-type segments plus
130
- * the free tail whose right edge carries the usage readout). The layout
131
- * starts every bar at this width so the drop ladder can pre-measure the
132
- * group, then shrinks the bar inside a tighter budget before dropping it
133
- * (see CONTEXT_MIN_WIDTH) rather than asking the layout for more room.
129
+ * Interior columns of the context bar. The layout starts every bar at this
130
+ * width so the drop ladder can pre-measure the group, then degrades the
131
+ * readout and shrinks the bar inside a tighter budget before dropping the
132
+ * group (see CONTEXT_MIN_WIDTH) rather than asking the layout for more room.
134
133
  */
135
134
  export const CONTEXT_BAR_WIDTH = 24
136
135
  /** Occupancy at which the usage readout flips from brand blue to amber. */
137
136
  const CONTEXT_WARN_PERCENT = 90
138
- /** Free-tail floor in columns: wide enough for the bare percent readout, so
139
- * the warning stays visible even at 100%+ occupancy. */
140
- const CONTEXT_MIN_FREE = 5
141
137
  /**
142
- * Narrowest bar width the drop ladder tries before giving up on the context
143
- * group: the bar shrinks inside its own budget first (a few columns still
144
- * show the bare percent readout) and only then drops as a whole.
138
+ * Narrowest bar width the drop ladder keeps before dropping the whole
139
+ * context group: the bar shrinks to this floor first (the absolute readout
140
+ * survives), and only past it does the readout degrade and the group go.
145
141
  */
146
142
  const CONTEXT_MIN_WIDTH = 5
147
143
 
148
144
  /**
149
- * Render context occupancy as ONE stepless bar: a solid DeepSeek-blue fill
150
- * run, a dim dotted free track, and the usage readout riding the track's
151
- * right edge (`12.3K/1.0M 25%`, shrinking to the bare percent as the track
152
- * narrows). No per-content-type segmentation. Column split is deterministic:
153
- * the free share is `Math.round(free/window*width)` clamped to at least
154
- * CONTEXT_MIN_FREE columns and at most the full width; the fill takes every
155
- * remaining column, so a given occupancy always renders the identical bar.
156
- * The readout flips to amber once occupancy reaches the warning threshold.
157
- * @param usedTokens - reported used tokens (drives the readout and percent).
145
+ * Render context occupancy as ONE stepless proportional bar: a solid
146
+ * DeepSeek-blue fill run tracking the occupancy and a dim dotted free
147
+ * track for the rest. Nothing else lives inside the bar the usage
148
+ * readout rides outside it (see contextGroupSpans) so the geometry
149
+ * always reads as the true remaining share. A given occupancy always
150
+ * renders the identical bar.
151
+ * @param usedTokens - reported used tokens.
158
152
  * @param contextWindow - route capacity.
159
- * @param width - total bar interior columns.
153
+ * @param width - total bar columns.
160
154
  * @returns tone-split spans for the footer to paint.
161
155
  */
162
156
  export function contextBar(
@@ -166,27 +160,42 @@ export function contextBar(
166
160
  ): readonly StatusSpan[] {
167
161
  if (width <= 0 || contextWindow <= 0) return []
168
162
  const used = Math.max(0, usedTokens)
169
- const percent = Math.round(used / contextWindow * 100)
170
- const warning = percent >= CONTEXT_WARN_PERCENT
171
- const readoutTone: StatusTone = warning ? 'warn' : 'value'
172
-
173
- const freeShare = Math.round(Math.max(0, contextWindow - used) / contextWindow * width)
174
- const freeColumns = Math.min(width, Math.max(freeShare, CONTEXT_MIN_FREE))
175
- const usedColumns = Math.max(0, width - freeColumns)
163
+ const fill = Math.min(width, Math.max(0, Math.round(used / contextWindow * width)))
164
+ const spans: StatusSpan[] = []
165
+ if (fill > 0) spans.push({ text: ''.repeat(fill), tone: 'ctxFill' })
166
+ const free = width - fill
167
+ if (free > 0) spans.push({ text: '░'.repeat(free), tone: 'label' })
168
+ return spans
169
+ }
176
170
 
177
- const total = `${formatTokens(used)}/${formatTokens(contextWindow)}`
178
- const percentText = `${percent}%`
179
- const readout = freeColumns >= visibleColumns(`${total} ${percentText}`)
180
- ? `${total} ${percentText}`
181
- : freeColumns >= visibleColumns(percentText)
182
- ? percentText
183
- : ''
171
+ /** How much usage detail the context group's readout carries. */
172
+ export type ContextReadoutMode = 'full' | 'percent' | 'none'
184
173
 
185
- const spans: StatusSpan[] = []
186
- if (usedColumns > 0) spans.push({ text: '█'.repeat(usedColumns), tone: 'ctxFill' })
187
- const pad = freeColumns - visibleColumns(readout)
188
- if (pad > 0) spans.push({ text: '░'.repeat(pad), tone: 'label' })
189
- if (readout !== '') spans.push({ text: readout, tone: readoutTone })
174
+ /**
175
+ * Compose the context group: the proportional bar plus the usage readout
176
+ * OUTSIDE the bar, so the dotted track keeps its proportional meaning no
177
+ * matter how wide the readout is. `full` reads `12.3K/1.0M 25%`; `percent`
178
+ * drops the absolute pair; `none` is the bare bar. The readout turns amber
179
+ * once occupancy reaches the warning threshold.
180
+ */
181
+ export function contextGroupSpans(
182
+ usedTokens: number,
183
+ contextWindow: number,
184
+ barWidth: number,
185
+ readout: ContextReadoutMode,
186
+ ): readonly StatusSpan[] {
187
+ const spans: StatusSpan[] = [{ text: 'context ', tone: 'label' }]
188
+ spans.push(...contextBar(usedTokens, contextWindow, barWidth))
189
+ if (readout === 'none' || barWidth <= 0 || contextWindow <= 0) return spans
190
+ const used = Math.max(0, usedTokens)
191
+ const percent = Math.round(used / contextWindow * 100)
192
+ const text = readout === 'full'
193
+ ? `${formatTokens(used)}/${formatTokens(contextWindow)} ${percent}%`
194
+ : `${percent}%`
195
+ spans.push(
196
+ { text: ' ', tone: 'label' },
197
+ { text, tone: percent >= CONTEXT_WARN_PERCENT ? 'warn' : 'value' },
198
+ )
190
199
  return spans
191
200
  }
192
201
 
@@ -459,17 +468,13 @@ function buildCandidates(
459
468
  id: 'cache',
460
469
  })
461
470
  }
462
- // Context occupancy as a segmented bar: per-content-type runs colored by
463
- // their own blue shade with a right-aligned usage readout. The used total
464
- // is the most recent reported prompt size against the advertised route
465
- // capacity (the same figures the old bracket bar showed).
471
+ // Context occupancy as a purely proportional bar with the usage readout
472
+ // riding outside it: the used total is the most recent reported prompt
473
+ // size against the advertised route capacity.
466
474
  if (stats.contextWindow > 0 && stats.lastPromptTokens > 0 && enabled.has('context')) {
467
475
  left.push({
468
476
  group: {
469
- spans: [
470
- { text: 'context ', tone: 'label' },
471
- ...contextBar(stats.lastPromptTokens, stats.contextWindow, contextWidth),
472
- ],
477
+ spans: contextGroupSpans(stats.lastPromptTokens, stats.contextWindow, contextWidth, 'full'),
473
478
  },
474
479
  rank: RANK_CONTEXT,
475
480
  id: 'context',
@@ -569,19 +574,18 @@ export function layoutStatusBar(
569
574
  const leftKept = [...orderedLeft]
570
575
  const rightKept = [...orderedRight]
571
576
 
572
- // Context shrink state: the bar starts at its full budget and is rebuilt at
573
- // ever narrower widths before the drop ladder is allowed to discard it.
577
+ // Context degradation state: the readout drops its absolute pair first,
578
+ // then the bar shrinks inside its own budget, and only then is the whole
579
+ // group removed — the proportional meter outlives the auxiliary numbers.
574
580
  // Rebuilding replaces the group's spans in place so width() re-measures it.
581
+ let contextReadout: ContextReadoutMode = 'full'
575
582
  let contextWidth = maxContextWidth
576
583
  const rebuildContext = (): void => {
577
584
  const index = leftKept.findIndex(entry => entry.id === 'context')
578
585
  if (index < 0) return
579
586
  leftKept[index] = {
580
587
  group: {
581
- spans: [
582
- { text: 'context ', tone: 'label' },
583
- ...contextBar(stats.lastPromptTokens, stats.contextWindow, contextWidth),
584
- ],
588
+ spans: contextGroupSpans(stats.lastPromptTokens, stats.contextWindow, contextWidth, contextReadout),
585
589
  },
586
590
  rank: RANK_CONTEXT,
587
591
  id: 'context',
@@ -599,23 +603,24 @@ export function layoutStatusBar(
599
603
  }
600
604
 
601
605
  while (width() > budget) {
602
- // Context is the lowest-priority visual group. Shrink or remove it before
603
- // sacrificing the permission badge or its Shift+Tab affordance.
604
- // Shrink the context bar instead of dropping it: reserve everything else
605
- // and hand the deficit to the bar, clamped to CONTEXT_MIN_WIDTH. The
606
- // fixed label ('context ') and the bar's own readout keep shrinking to
607
- // the bare percent, so a tight terminal keeps context visible longer.
608
- if (leftKept.some(entry => entry.id === 'context') && contextWidth > CONTEXT_MIN_WIDTH) {
609
- const overflow = width() - budget
610
- contextWidth = Math.max(CONTEXT_MIN_WIDTH, contextWidth - overflow)
611
- rebuildContext()
612
- continue
613
- }
614
- const contextIndex = leftKept.findIndex(entry => entry.id === 'context')
615
- if (contextIndex >= 0) {
616
- // Once the meter reaches its minimum useful width, remove the whole
617
- // group before touching the permission badge or its keyboard hint.
618
- leftKept.splice(contextIndex, 1)
606
+ // Context is the lowest-priority visual group: the bar shrinks inside
607
+ // its own budget first (the absolute readout survives), then the
608
+ // readout degrades to the bare percent, and only then does the whole
609
+ // group go before the permission badge or its Shift+Tab affordance
610
+ // is touched.
611
+ if (leftKept.some(entry => entry.id === 'context')) {
612
+ if (contextWidth > CONTEXT_MIN_WIDTH) {
613
+ const overflow = width() - budget
614
+ contextWidth = Math.max(CONTEXT_MIN_WIDTH, contextWidth - overflow)
615
+ rebuildContext()
616
+ continue
617
+ }
618
+ if (contextReadout === 'full') {
619
+ contextReadout = 'percent'
620
+ rebuildContext()
621
+ continue
622
+ }
623
+ leftKept.splice(leftKept.findIndex(entry => entry.id === 'context'), 1)
619
624
  continue
620
625
  }
621
626
  if (hint && rightKept.length > 0 && leftKept.length > 0) {
@@ -145,8 +145,14 @@ export function displayTail(text: string, columns: number, rows: number): Displa
145
145
  used += width
146
146
  lastCluster = cluster
147
147
  }
148
- if (current !== '' || (wrapped.length > 0 && text.endsWith('\n'))) flush()
149
-
148
+ // A trailing newline means one deliberate empty caret row, but that row
149
+ // must never evict real content or fake a truncation marker: flush the
150
+ // content first, decide truncation on content alone, then append the blank
151
+ // row only when the whole tail still fits the budget.
152
+ const trailingBlank = current === '' && wrapped.length > 0 && text.endsWith('\n')
153
+ if (current !== '') flush()
150
154
  const truncated = wrapped.length > rowLimit
151
- return { text: (truncated ? wrapped.slice(-rowLimit) : wrapped).join('\n'), truncated }
155
+ const kept = truncated ? wrapped.slice(-rowLimit) : wrapped
156
+ const keptRows = trailingBlank && kept.length < rowLimit ? [...kept, ''] : kept
157
+ return { text: keptRows.join('\n'), truncated }
152
158
  }
@@ -16,9 +16,46 @@
16
16
  * @module @deepseek-ai/dsh-code/settings-file
17
17
  */
18
18
 
19
+ import { randomUUID } from 'node:crypto'
19
20
  import { mkdir, rename, writeFile } from 'node:fs/promises'
20
21
  import { dirname } from 'node:path'
21
22
 
23
+ /**
24
+ * Run one file operation with a bounded retry: one initial try plus at
25
+ * most `retries` more. Creating or replacing a file can fail transiently
26
+ * with EPERM/EACCES while an antivirus scanner or search indexer holds
27
+ * it — the standard graceful-fs remedy, not a workaround for a
28
+ * persistent permission problem. A save that still fails leaves its
29
+ * uniquely named temp file behind, so repeated crashed saves accumulate
30
+ * distinct leftovers rather than corrupting a shared one.
31
+ */
32
+ async function withTransientRetry(operation: () => Promise<void>, retries = 5): Promise<void> {
33
+ for (let attempt = 0; ; attempt += 1) {
34
+ try {
35
+ await operation()
36
+ return
37
+ } catch (error: unknown) {
38
+ const code = (error as NodeJS.ErrnoException).code
39
+ if (attempt >= retries || (code !== 'EPERM' && code !== 'EACCES')) throw error
40
+ await new Promise(resolve => setTimeout(resolve, 30 * (attempt + 1)))
41
+ }
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Write one file atomically: create the parent directory, write to a
47
+ * uniquely named temp file, and rename it into place. A crash midway
48
+ * can never leave a half-written document behind. Unique temp names
49
+ * keep concurrent writers (two terminals, two chains in one process)
50
+ * from sharing one temp path.
51
+ */
52
+ export async function writeFileAtomically(path: string, text: string): Promise<void> {
53
+ await mkdir(dirname(path), { recursive: true })
54
+ const temp = `${path}.${process.pid}.${randomUUID()}.tmp`
55
+ await withTransientRetry(() => writeFile(temp, text, 'utf8'))
56
+ await withTransientRetry(() => rename(temp, path))
57
+ }
58
+
22
59
  /** The serialized persistence surface; flush() is handed to the quit sequence. */
23
60
  export interface UserSettingsPersistence {
24
61
  /**
@@ -39,12 +76,7 @@ export function createUserSettingsPersistence(): UserSettingsPersistence {
39
76
  let chain: Promise<void> = Promise.resolve()
40
77
  return {
41
78
  save(path: string, text: string): Promise<void> {
42
- const write = chain.then(async () => {
43
- await mkdir(dirname(path), { recursive: true })
44
- const temp = `${path}.tmp`
45
- await writeFile(temp, text, 'utf8')
46
- await rename(temp, path)
47
- })
79
+ const write = chain.then(() => writeFileAtomically(path, text))
48
80
  // A failed write must not break the chain for later saves.
49
81
  chain = write.catch(() => {})
50
82
  return write
package/src/skills.ts CHANGED
@@ -61,35 +61,48 @@ function toRows(skills: readonly SkillSummary[]): readonly SkillRow[] {
61
61
  * @param ctx - context carrying the `skills` service (optional).
62
62
  * @returns the view the completion menu subscribes to.
63
63
  */
64
- export function watchSkills(ctx: Context): SkillsWatch {
64
+ export function watchSkills(ctx: Context, fallbackCwd?: string): SkillsWatch {
65
65
  const skills = ctx.get('skills')
66
66
  let agent: Agent | undefined
67
67
  let rows: readonly SkillRow[] = []
68
68
  let error: string | undefined
69
+ // The agent whose workspace the current rows were last successfully read
70
+ // from: a failure for an agent that never loaded must clear the rows, not
71
+ // keep another workspace's catalog answerable in this session.
72
+ let loadedFor: Agent | undefined
69
73
  const listeners = new Set<() => void>()
70
74
 
71
75
  const reload = (): void => {
72
76
  const target = agent
73
77
  if (skills === undefined || target === undefined) return
74
78
  Promise.resolve().then(() => skills.list({
75
- cwd: target.session.header.cwd,
79
+ cwd: target.session.header.cwd ?? fallbackCwd,
76
80
  scope: target,
77
81
  })).then((summaries: readonly SkillSummary[]) => {
78
82
  // A retarget landed while this catalog was loading: the rows belong to
79
83
  // another agent's workspace and must never overwrite the current view.
80
84
  if (agent !== target) return
81
85
  const next = toRows(summaries)
82
- const unchanged = next.length === rows.length && next.every((row, index) => row.name === rows[index]?.name)
86
+ // Description and invocation-flag edits must surface too: a name-only
87
+ // comparison silently dropped those change notifications.
88
+ const unchanged = next.length === rows.length && next.every((row, index) =>
89
+ row.name === rows[index]?.name
90
+ && row.description === rows[index]?.description
91
+ && row.modelInvocable === rows[index]?.modelInvocable)
83
92
  rows = next
93
+ loadedFor = target
84
94
  const recovered = error !== undefined
85
95
  error = undefined
86
96
  if (unchanged && !recovered) return
87
97
  for (const listener of listeners) listener()
88
98
  }).catch((cause: unknown) => {
89
99
  if (agent !== target) return
90
- // Discovery failure keeps the last good rows; the next skills/change
91
- // notification is the retry surface (mirrors the web directory).
92
- rows = [...rows]
100
+ // Discovery failure keeps the last good rows for the SAME agent (the
101
+ // next skills/change notification is the retry surface, mirroring the
102
+ // web directory); an agent that never loaded starts from empty rows
103
+ // stale rows from a previous workspace must not keep completing here.
104
+ if (loadedFor !== target) rows = []
105
+ else rows = [...rows]
93
106
  error = cause instanceof Error ? cause.message : String(cause)
94
107
  for (const listener of listeners) listener()
95
108
  })