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/render/projection.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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: [...
|
|
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
|
}
|
package/src/render/status.ts
CHANGED
|
@@ -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
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
* group
|
|
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
|
|
143
|
-
* group: the bar shrinks
|
|
144
|
-
*
|
|
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
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
* the
|
|
154
|
-
*
|
|
155
|
-
*
|
|
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
|
|
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
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
463
|
-
//
|
|
464
|
-
//
|
|
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
|
|
573
|
-
//
|
|
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
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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) {
|
package/src/render/text.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/settings-file.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
|
91
|
-
// notification is the retry surface
|
|
92
|
-
|
|
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
|
})
|