dsh-code 1.0.7 → 1.3.0
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.en.md +70 -24
- package/README.md +71 -25
- package/bin/deepseek.mjs +202 -39
- package/cordis.patch.yml +13 -4
- package/lib/index.mjs +5002 -1061
- package/lib/session-query.mjs +3 -2
- package/lib/startup.mjs +4 -4
- package/lib/{theme-DCT8Y2xf.mjs → theme-B3orFUYz.mjs} +665 -20
- package/lib/types/app.d.ts +120 -63
- package/lib/types/attachments.d.ts +16 -7
- package/lib/types/authorization-panel.d.ts +3 -3
- package/lib/types/git-workflow.d.ts +91 -2
- package/lib/types/history.d.ts +10 -0
- package/lib/types/i18n.d.ts +39 -0
- package/lib/types/index.d.ts +74 -2
- package/lib/types/input-split.d.ts +1 -1
- package/lib/types/kernel-panels.d.ts +89 -32
- package/lib/types/language-panel.d.ts +12 -0
- package/lib/types/locales/en.d.ts +498 -0
- package/lib/types/locales/zh.d.ts +9 -0
- package/lib/types/mentions.d.ts +7 -3
- package/lib/types/models.d.ts +14 -0
- package/lib/types/panel-accent.d.ts +28 -0
- package/lib/types/rainbow.d.ts +69 -0
- package/lib/types/render/animations.d.ts +42 -0
- package/lib/types/render/inspector.d.ts +26 -0
- package/lib/types/render/lines.d.ts +21 -1
- package/lib/types/render/markdown.d.ts +1 -1
- package/lib/types/render/projection.d.ts +95 -4
- package/lib/types/render/status.d.ts +12 -9
- package/lib/types/render/text.d.ts +6 -0
- package/lib/types/render/usage.d.ts +113 -0
- package/lib/types/session-directory.d.ts +42 -1
- package/lib/types/session-switch.d.ts +8 -0
- package/lib/types/startup.d.ts +1 -1
- package/lib/types/terminal-title.d.ts +8 -0
- package/lib/types/theme-panel.d.ts +2 -2
- package/lib/types/theme.d.ts +271 -52
- package/lib/types/update-panel.d.ts +27 -6
- package/lib/types/update.d.ts +10 -1
- package/lib/types/version.d.ts +6 -3
- package/package.json +26 -7
- package/src/app.ts +1426 -627
- package/src/approval.ts +166 -166
- package/src/attachments.ts +65 -19
- package/src/authorization-panel.ts +24 -18
- package/src/editor-keys.ts +371 -371
- package/src/fork.ts +11 -7
- package/src/git-workflow.ts +229 -3
- package/src/history.ts +14 -0
- package/src/i18n.ts +68 -0
- package/src/index.ts +503 -128
- package/src/input-split.ts +27 -7
- package/src/kernel-panels.ts +528 -113
- package/src/keyboard.ts +5 -4
- package/src/language-panel.ts +53 -0
- package/src/locales/en.ts +538 -0
- package/src/locales/zh.ts +537 -0
- package/src/mentions.ts +8 -4
- package/src/models.ts +264 -212
- package/src/panel-accent.ts +41 -0
- package/src/presets.ts +1 -1
- package/src/provider-settings.ts +1 -1
- package/src/rainbow.ts +218 -0
- package/src/render/animations.ts +104 -6
- package/src/render/editor.ts +20 -20
- package/src/render/export.ts +116 -95
- package/src/render/inspector.ts +42 -0
- package/src/render/lines.ts +628 -415
- package/src/render/markdown.ts +15 -3
- package/src/render/projection.ts +429 -19
- package/src/render/status.ts +119 -62
- package/src/render/text.ts +15 -0
- package/src/render/tool-preview.ts +77 -77
- package/src/render/usage.ts +430 -0
- package/src/render/width.ts +2 -2
- package/src/session-directory.ts +90 -9
- package/src/session-query.ts +8 -4
- package/src/session-switch.ts +14 -0
- package/src/startup.ts +3 -3
- package/src/store.ts +19 -1
- package/src/subagents.ts +229 -229
- package/src/terminal-title.ts +22 -5
- package/src/theme-panel.ts +17 -21
- package/src/theme.ts +281 -33
- package/src/update-panel.ts +148 -31
- package/src/update.ts +19 -3
- package/src/version.ts +63 -20
- package/src/whale-glyph.ts +23 -23
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session usage for the /usage panel: the provider-reported token totals, the
|
|
3
|
+
* context pressure and composition, and the exact per-turn accounting, folded
|
|
4
|
+
* into the styled rows the panel draws.
|
|
5
|
+
*
|
|
6
|
+
* Every figure comes from the harness token meter. The four token buckets are
|
|
7
|
+
* disjoint, so the prompt side is never double counted; the composition block
|
|
8
|
+
* is a density estimate and is labelled as one.
|
|
9
|
+
*
|
|
10
|
+
* @module @deepseek-ai/dsh-tui/render/usage
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
14
|
+
import type { TokenUsageProjection, TurnTokenUsage } from '@deepseek-ai/dsh-token-meter/client'
|
|
15
|
+
import { t } from '../i18n.ts'
|
|
16
|
+
import { lineSegment, type LineStyle, type StyledLine, type StyledSegment } from './lines.ts'
|
|
17
|
+
import { visibleColumns } from './markdown.ts'
|
|
18
|
+
import { formatTokens } from './status.ts'
|
|
19
|
+
import { truncateColumns } from './text.ts'
|
|
20
|
+
|
|
21
|
+
/** One completed turn's exact provider-reported accounting. */
|
|
22
|
+
export interface UsageTurn {
|
|
23
|
+
/** Durable turn number (`turn/start`). */
|
|
24
|
+
readonly turn: number
|
|
25
|
+
readonly usage: TurnTokenUsage
|
|
26
|
+
/**
|
|
27
|
+
* The model that billed this turn, or '' when nothing in the log names one.
|
|
28
|
+
* The meter's own `routes` is preferred; it is absent whenever ONE attempt
|
|
29
|
+
* went unattributed, so the turn's own assistant messages answer instead.
|
|
30
|
+
*/
|
|
31
|
+
readonly model: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Everything the panel reads. The totals come from the mounted projection and
|
|
36
|
+
* the per-turn rows from the meter's own fold; a missing projection renders as
|
|
37
|
+
* explicitly unavailable rather than as zeros, because an unmounted deployment
|
|
38
|
+
* and a session with no traffic are different facts.
|
|
39
|
+
*/
|
|
40
|
+
export interface UsageView {
|
|
41
|
+
/** Provider-reported usage over the whole durable log. */
|
|
42
|
+
readonly totals?: TokenUsageProjection
|
|
43
|
+
/** Completed turns, oldest first. */
|
|
44
|
+
readonly turns: readonly UsageTurn[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Prompt-side tokens the provider billed: uncached input plus both cache buckets. */
|
|
48
|
+
export function billedInputTokens(totals: TokenUsageProjection): number {
|
|
49
|
+
return totals.uncachedInputTokens + totals.cacheReadTokens + totals.cacheWriteTokens
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Prompt plus completion tokens over the whole log. */
|
|
53
|
+
export function usageTotalTokens(totals: TokenUsageProjection): number {
|
|
54
|
+
return billedInputTokens(totals) + totals.outputTokens
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Cache-hit share of billed prompt-side input.
|
|
59
|
+
* @param totals - cumulative provider-reported buckets.
|
|
60
|
+
* @returns percent rounded to one decimal place, or null when nothing was billed.
|
|
61
|
+
*/
|
|
62
|
+
export function usageCacheHitPercent(totals: TokenUsageProjection): number | null {
|
|
63
|
+
const billed = billedInputTokens(totals)
|
|
64
|
+
return billed === 0 ? null : Math.round(totals.cacheReadTokens / billed * 1_000) / 10
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** One complete turn's durable events, in log order. */
|
|
68
|
+
export interface TurnSlice {
|
|
69
|
+
readonly turn: number
|
|
70
|
+
readonly events: readonly SessionEvent[]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Split a durable log into COMPLETE turns. A turn still running has no
|
|
75
|
+
* `turn/end` yet, and an unfinished attempt has no exact accounting, so the
|
|
76
|
+
* trailing slice is dropped rather than guessed at.
|
|
77
|
+
* @param events - the whole durable log, in log order.
|
|
78
|
+
* @returns one slice per `turn/start`…`turn/end` span, oldest first.
|
|
79
|
+
*/
|
|
80
|
+
export function completedTurns(events: readonly SessionEvent[]): readonly TurnSlice[] {
|
|
81
|
+
const slices: TurnSlice[] = []
|
|
82
|
+
let open: { turn: number; events: SessionEvent[] } | undefined
|
|
83
|
+
for (const event of events) {
|
|
84
|
+
if (event.type === 'turn/start') {
|
|
85
|
+
open = { turn: event.data.turn, events: [event] }
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
if (open === undefined) continue
|
|
89
|
+
open.events.push(event)
|
|
90
|
+
if (event.type === 'turn/end') {
|
|
91
|
+
slices.push(open)
|
|
92
|
+
open = undefined
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return slices
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Exact usage for every completed turn that can be proven.
|
|
100
|
+
* @param events - the whole durable log, in log order.
|
|
101
|
+
* @param derive - the meter's per-turn fold, injected so this module stays pure.
|
|
102
|
+
* @returns one row per provable turn, oldest first; turns whose attempts did
|
|
103
|
+
* not all report usage are omitted rather than estimated, and so is a turn
|
|
104
|
+
* that billed nothing at all — an empty row is noise in a usage table.
|
|
105
|
+
*/
|
|
106
|
+
export function turnUsages(
|
|
107
|
+
events: readonly SessionEvent[],
|
|
108
|
+
derive: (events: readonly SessionEvent[]) => TurnTokenUsage | undefined,
|
|
109
|
+
): readonly UsageTurn[] {
|
|
110
|
+
const rows: UsageTurn[] = []
|
|
111
|
+
for (const slice of completedTurns(events)) {
|
|
112
|
+
const usage = derive(slice.events)
|
|
113
|
+
if (usage === undefined || usage.totalTokens === 0) continue
|
|
114
|
+
rows.push({ turn: slice.turn, usage, model: turnModel(slice, usage) })
|
|
115
|
+
}
|
|
116
|
+
return rows
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Pad to a column budget so labels line up across terminal widths. */
|
|
120
|
+
function padColumns(text: string, columns: number): string {
|
|
121
|
+
return text + ' '.repeat(Math.max(0, columns - visibleColumns(text)))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** One `label` + `value` row of a summary block. */
|
|
125
|
+
function pairLine(label: string, value: string, labelColumns: number, style: LineStyle = 'plain'): StyledLine {
|
|
126
|
+
const gap = ' '.repeat(Math.max(1, labelColumns - visibleColumns(label)))
|
|
127
|
+
return { segments: [lineSegment(' ' + label + gap, 'dim'), lineSegment(value, style)] }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** A bucket some turn of a group did not report, leaving the group sum a floor. */
|
|
131
|
+
export type PartialBucket = 'cacheReadTokens' | 'cacheWriteTokens' | 'reasoningTokens'
|
|
132
|
+
|
|
133
|
+
/** One model's merged totals across every turn it billed. */
|
|
134
|
+
export interface ModelUsage {
|
|
135
|
+
/** Model names joined with ` + ` (a turn that switched models lists both). */
|
|
136
|
+
readonly model: string
|
|
137
|
+
/** Turns attributed to this model. */
|
|
138
|
+
readonly turns: number
|
|
139
|
+
readonly uncachedInputTokens: number
|
|
140
|
+
readonly outputTokens: number
|
|
141
|
+
readonly totalTokens: number
|
|
142
|
+
/** Summed over the turns that reported it; absent when none did. */
|
|
143
|
+
readonly cacheReadTokens?: number
|
|
144
|
+
/** Summed over the turns that reported it; absent when none did. */
|
|
145
|
+
readonly cacheWriteTokens?: number
|
|
146
|
+
/** Summed over the turns that reported it; absent when none did. */
|
|
147
|
+
readonly reasoningTokens?: number
|
|
148
|
+
/**
|
|
149
|
+
* Buckets some turn of the group left unreported. The corresponding sum (and
|
|
150
|
+
* the hit share derived from it) counts only what WAS reported, so the
|
|
151
|
+
* display marks it as a floor rather than hiding the group's known traffic.
|
|
152
|
+
*/
|
|
153
|
+
readonly partial: readonly PartialBucket[]
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Merge the per-turn rows by the model that billed them, biggest spender
|
|
158
|
+
* first. A turn lands in exactly one group — the group named by every model it
|
|
159
|
+
* used — so the sums stay additive and a mid-turn model switch never counts
|
|
160
|
+
* the same tokens twice. Each bucket is merged over the turns that reported
|
|
161
|
+
* it, and {@link ModelUsage.partial} records the ones that stayed silent.
|
|
162
|
+
* @param turns - provable per-turn rows, oldest first.
|
|
163
|
+
* @returns one row per model group.
|
|
164
|
+
*/
|
|
165
|
+
export function modelTotals(turns: readonly UsageTurn[]): readonly ModelUsage[] {
|
|
166
|
+
const groups = new Map<string, readonly UsageTurn[]>()
|
|
167
|
+
for (const row of turns) {
|
|
168
|
+
groups.set(row.model, [...(groups.get(row.model) ?? []), row])
|
|
169
|
+
}
|
|
170
|
+
const merged: ModelUsage[] = []
|
|
171
|
+
for (const [model, rows] of groups) {
|
|
172
|
+
const partial: PartialBucket[] = []
|
|
173
|
+
// A bucket is summed over the turns that reported it. Refusing the whole
|
|
174
|
+
// group because ONE turn stayed silent would contradict the per-turn table
|
|
175
|
+
// right below, where those same turns show real cache reads; the floor is
|
|
176
|
+
// marked instead of thrown away.
|
|
177
|
+
const sum = (key: PartialBucket): number | undefined => {
|
|
178
|
+
const reported = rows.flatMap(row => row.usage[key] === undefined ? [] : [row.usage[key]])
|
|
179
|
+
if (reported.length === 0) return undefined
|
|
180
|
+
if (reported.length < rows.length) partial.push(key)
|
|
181
|
+
return reported.reduce((total, value) => total + value, 0)
|
|
182
|
+
}
|
|
183
|
+
const cacheReadTokens = sum('cacheReadTokens')
|
|
184
|
+
const cacheWriteTokens = sum('cacheWriteTokens')
|
|
185
|
+
const reasoningTokens = sum('reasoningTokens')
|
|
186
|
+
merged.push({
|
|
187
|
+
model,
|
|
188
|
+
turns: rows.length,
|
|
189
|
+
uncachedInputTokens: rows.reduce((sum, row) => sum + row.usage.uncachedInputTokens, 0),
|
|
190
|
+
outputTokens: rows.reduce((sum, row) => sum + row.usage.outputTokens, 0),
|
|
191
|
+
totalTokens: rows.reduce((sum, row) => sum + row.usage.totalTokens, 0),
|
|
192
|
+
cacheReadTokens,
|
|
193
|
+
cacheWriteTokens,
|
|
194
|
+
reasoningTokens,
|
|
195
|
+
partial,
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
// Biggest spender first; turns nothing could attribute stay at the bottom,
|
|
199
|
+
// because that row is a gap in the record rather than a model.
|
|
200
|
+
return merged.sort((left, right) => (
|
|
201
|
+
left.model === '' ? 1 : right.model === '' ? -1 : right.totalTokens - left.totalTokens
|
|
202
|
+
))
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Cut one styled line to a column budget, keeping every style it fits. */
|
|
206
|
+
function boundLine(line: StyledLine, width: number): StyledLine {
|
|
207
|
+
const segments: StyledSegment[] = []
|
|
208
|
+
let left = width
|
|
209
|
+
for (const segment of line.segments) {
|
|
210
|
+
if (left <= 0) break
|
|
211
|
+
const columns = visibleColumns(segment.text)
|
|
212
|
+
if (columns <= left) {
|
|
213
|
+
segments.push(segment)
|
|
214
|
+
left -= columns
|
|
215
|
+
continue
|
|
216
|
+
}
|
|
217
|
+
segments.push(lineSegment(truncateColumns(segment.text, left), segment.style))
|
|
218
|
+
left = 0
|
|
219
|
+
}
|
|
220
|
+
return { segments }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** A block heading. */
|
|
224
|
+
function heading(text: string): StyledLine {
|
|
225
|
+
return { segments: [lineSegment(text, 'accentBold')] }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** A dim explanatory or empty-state row. */
|
|
229
|
+
function note(text: string): StyledLine {
|
|
230
|
+
return { segments: [lineSegment(' ' + text, 'dim')] }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The bucket figures both tables print (a turn, or a merged model group). */
|
|
234
|
+
interface BucketRow {
|
|
235
|
+
readonly totalTokens: number
|
|
236
|
+
readonly uncachedInputTokens: number
|
|
237
|
+
readonly outputTokens: number
|
|
238
|
+
readonly cacheReadTokens?: number
|
|
239
|
+
readonly cacheWriteTokens?: number
|
|
240
|
+
readonly reasoningTokens?: number
|
|
241
|
+
/** Present on a merged group: buckets only some of its turns reported. */
|
|
242
|
+
readonly partial?: readonly PartialBucket[]
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Cache-hit share of one row's billed prompt side, or undefined when the row
|
|
247
|
+
* has no cache read to report. An unreported cache-write bucket counts as
|
|
248
|
+
* zero: the adapters that bill cache writes always report the bucket, so its
|
|
249
|
+
* absence means the route never tracked one — the same reading the status
|
|
250
|
+
* bar's own fold uses.
|
|
251
|
+
*/
|
|
252
|
+
function hitShare(row: BucketRow): number | undefined {
|
|
253
|
+
if (row.cacheReadTokens === undefined) return undefined
|
|
254
|
+
const billed = row.uncachedInputTokens + row.cacheReadTokens + (row.cacheWriteTokens ?? 0)
|
|
255
|
+
if (billed === 0) return undefined
|
|
256
|
+
return Math.round(row.cacheReadTokens / billed * 1_000) / 10
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** The token columns, in display order (shared by both tables). */
|
|
260
|
+
/** True when the row's bucket is a floor rather than a complete figure. */
|
|
261
|
+
function isPartial(row: BucketRow, key: PartialBucket): boolean {
|
|
262
|
+
return row.partial?.includes(key) === true
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const BUCKET_COLUMNS: readonly { label: () => string; value: (row: BucketRow) => string; width: number }[] = [
|
|
266
|
+
{ label: () => t('panel.usage.colTotal'), value: row => formatTokens(row.totalTokens), width: 9 },
|
|
267
|
+
{ label: () => t('panel.usage.colUncached'), value: row => formatTokens(row.uncachedInputTokens), width: 9 },
|
|
268
|
+
{ label: () => t('panel.usage.colCacheRead'), value: row => optionalTokens(row.cacheReadTokens, isPartial(row, 'cacheReadTokens')), width: 9 },
|
|
269
|
+
{
|
|
270
|
+
label: () => t('panel.usage.colCacheHit'),
|
|
271
|
+
value: row => percent(
|
|
272
|
+
hitShare(row),
|
|
273
|
+
isPartial(row, 'cacheReadTokens') || isPartial(row, 'cacheWriteTokens'),
|
|
274
|
+
),
|
|
275
|
+
width: 8,
|
|
276
|
+
},
|
|
277
|
+
{ label: () => t('panel.usage.colCacheWrite'), value: row => optionalTokens(row.cacheWriteTokens, isPartial(row, 'cacheWriteTokens')), width: 9 },
|
|
278
|
+
{ label: () => t('panel.usage.colOut'), value: row => formatTokens(row.outputTokens), width: 9 },
|
|
279
|
+
{ label: () => t('panel.usage.colThink'), value: row => optionalTokens(row.reasoningTokens, isPartial(row, 'reasoningTokens')), width: 9 },
|
|
280
|
+
]
|
|
281
|
+
|
|
282
|
+
/** A percentage, or an em dash when the row cannot prove one; `+` for a floor. */
|
|
283
|
+
function percent(value: number | undefined, partial = false): string {
|
|
284
|
+
return value === undefined ? '—' : value + '%' + (partial ? '+' : '')
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* A bucket as a table cell: an em dash when nothing reported it, and a `+`
|
|
289
|
+
* when the figure counts only the turns of a group that did report it.
|
|
290
|
+
*/
|
|
291
|
+
function optionalTokens(value: number | undefined, partial = false): string {
|
|
292
|
+
if (value === undefined) return '—'
|
|
293
|
+
return formatTokens(value) + (partial ? '+' : '')
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** What an unattributed turn or group is called in the tables. */
|
|
297
|
+
function modelLabel(model: string): string {
|
|
298
|
+
return model === '' ? t('panel.usage.unknownModel') : model
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* The model a turn's assistant messages came from: the one that produced most
|
|
303
|
+
* of them, with a tie naming every model involved. This is the fallback for
|
|
304
|
+
* turns whose meter `routes` were withheld, and it reads only what the log
|
|
305
|
+
* recorded — nothing is inferred from the current selection.
|
|
306
|
+
*/
|
|
307
|
+
function dominantModel(events: readonly SessionEvent[]): string {
|
|
308
|
+
const counts = new Map<string, number>()
|
|
309
|
+
for (const event of events) {
|
|
310
|
+
if (event.type !== 'assistant/message') continue
|
|
311
|
+
const source = event.data.message.source
|
|
312
|
+
if (source.kind !== 'model' || source.model === '') continue
|
|
313
|
+
counts.set(source.model, (counts.get(source.model) ?? 0) + 1)
|
|
314
|
+
}
|
|
315
|
+
if (counts.size === 0) return ''
|
|
316
|
+
const most = Math.max(...counts.values())
|
|
317
|
+
return [...counts.entries()]
|
|
318
|
+
.filter(([, count]) => count === most)
|
|
319
|
+
.map(([model]) => model)
|
|
320
|
+
.sort()
|
|
321
|
+
.join(' + ')
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** The model attribution for one turn: the meter's routes, else its messages. */
|
|
325
|
+
export function turnModel(slice: TurnSlice, usage: TurnTokenUsage): string {
|
|
326
|
+
const routes = usage.routes ?? []
|
|
327
|
+
if (routes.length > 0) return routes.map(route => route.model).join(' + ')
|
|
328
|
+
return dominantModel(slice.events)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Render one table: a dim header plus one row per entry. The free-text cell
|
|
333
|
+
* (the model name) keeps a readable share of the width, so trailing numeric
|
|
334
|
+
* columns drop first on a narrow terminal instead of leaving an ellipsised
|
|
335
|
+
* model name — the name is what the reader came for.
|
|
336
|
+
* @param columns - the numeric columns, most important first.
|
|
337
|
+
* @param rows - one entry per line, already in display order.
|
|
338
|
+
* @param text - how to read the free-text cell of one entry.
|
|
339
|
+
* @param width - the column budget.
|
|
340
|
+
* @param textFirst - true puts the free-text cell before the numbers (the
|
|
341
|
+
* model-group table); false appends it (the per-turn table).
|
|
342
|
+
* @returns the header row plus one row per entry.
|
|
343
|
+
*/
|
|
344
|
+
function table<T>(
|
|
345
|
+
columns: readonly { label: () => string; value: (row: T) => string; width: number }[],
|
|
346
|
+
rows: readonly T[],
|
|
347
|
+
text: (row: T) => string,
|
|
348
|
+
width: number,
|
|
349
|
+
textFirst: boolean,
|
|
350
|
+
): StyledLine[] {
|
|
351
|
+
const budget = width - 2
|
|
352
|
+
const minText = 14
|
|
353
|
+
const kept: typeof columns[number][] = []
|
|
354
|
+
let used = 0
|
|
355
|
+
for (const column of columns) {
|
|
356
|
+
if (used + column.width > budget - minText - 1) break
|
|
357
|
+
kept.push(column)
|
|
358
|
+
used += column.width
|
|
359
|
+
}
|
|
360
|
+
// Capped so a wide terminal does not stretch one column across the panel.
|
|
361
|
+
const textWidth = Math.min(24, Math.max(6, budget - used - 1))
|
|
362
|
+
const numbers = (row: T): string => kept.map(column => padColumns(column.value(row), column.width)).join('')
|
|
363
|
+
const header = kept.map(column => padColumns(column.label(), column.width)).join('')
|
|
364
|
+
const headerText = textFirst
|
|
365
|
+
? padColumns(t('panel.usage.colModel'), textWidth) + ' ' + header
|
|
366
|
+
: header + ' ' + t('panel.usage.colModel')
|
|
367
|
+
const lines: StyledLine[] = [
|
|
368
|
+
{ segments: [lineSegment(truncateColumns(' ' + headerText, width), 'dim')] },
|
|
369
|
+
]
|
|
370
|
+
for (const row of rows) {
|
|
371
|
+
const label = truncateColumns(text(row), textWidth)
|
|
372
|
+
lines.push({
|
|
373
|
+
segments: [lineSegment(' ' + (textFirst ? padColumns(label, textWidth) + ' ' + numbers(row) : numbers(row) + ' ' + label), 'plain')],
|
|
374
|
+
})
|
|
375
|
+
}
|
|
376
|
+
return lines
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Render the panel body.
|
|
381
|
+
* @param view - the projection totals plus the derived per-turn rows.
|
|
382
|
+
* @param columns - usable content columns inside the panel border.
|
|
383
|
+
* @returns bounded, styled rows ready to draw.
|
|
384
|
+
*/
|
|
385
|
+
export function usageLines(view: UsageView, columns: number): readonly StyledLine[] {
|
|
386
|
+
const width = Math.max(8, Math.floor(columns))
|
|
387
|
+
const lines: StyledLine[] = []
|
|
388
|
+
|
|
389
|
+
lines.push(heading(t('panel.usage.totals')))
|
|
390
|
+
const totals = view.totals
|
|
391
|
+
if (totals === undefined) {
|
|
392
|
+
lines.push(note(t('panel.usage.unavailable')))
|
|
393
|
+
} else {
|
|
394
|
+
const label = 12
|
|
395
|
+
lines.push(pairLine(t('panel.usage.uncached'), formatTokens(totals.uncachedInputTokens), label))
|
|
396
|
+
lines.push(pairLine(t('panel.usage.cacheWrite'), formatTokens(totals.cacheWriteTokens), label))
|
|
397
|
+
lines.push(pairLine(t('panel.usage.cacheRead'), formatTokens(totals.cacheReadTokens), label))
|
|
398
|
+
lines.push(pairLine(t('panel.usage.output'), formatTokens(totals.outputTokens), label))
|
|
399
|
+
lines.push(pairLine(t('panel.usage.total'), formatTokens(usageTotalTokens(totals)), label, 'bold'))
|
|
400
|
+
const hit = usageCacheHitPercent(totals)
|
|
401
|
+
if (hit !== null) lines.push(pairLine(t('panel.usage.cacheHit'), hit + '%', label))
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
lines.push({ segments: [] })
|
|
405
|
+
const models = modelTotals(view.turns)
|
|
406
|
+
lines.push(heading(models.length === 0 ? t('panel.usage.byModel') : `${t('panel.usage.byModel')} · ${models.length}`))
|
|
407
|
+
if (models.length === 0) {
|
|
408
|
+
lines.push(note(t('panel.usage.noTurns')))
|
|
409
|
+
} else {
|
|
410
|
+
lines.push(...table([
|
|
411
|
+
{ label: () => t('panel.usage.colTurns'), value: (row: ModelUsage) => String(row.turns), width: 7 },
|
|
412
|
+
...BUCKET_COLUMNS.map(column => ({ ...column, value: (row: ModelUsage) => column.value(row) })),
|
|
413
|
+
], models, row => modelLabel(row.model), width, true))
|
|
414
|
+
if (models.some(row => row.partial.length > 0)) lines.push(note(t('panel.usage.partialNote')))
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
lines.push({ segments: [] })
|
|
418
|
+
lines.push(heading(`${t('panel.usage.turns')} · ${view.turns.length}`))
|
|
419
|
+
if (view.turns.length === 0) {
|
|
420
|
+
lines.push(note(t('panel.usage.noTurns')))
|
|
421
|
+
} else {
|
|
422
|
+
const withTurn = [
|
|
423
|
+
{ label: () => t('panel.usage.colTurn'), value: (row: { turn: number }) => `#${row.turn}`, width: 7 },
|
|
424
|
+
...BUCKET_COLUMNS.map(column => ({ ...column, value: (row: UsageTurn) => column.value(row.usage) })),
|
|
425
|
+
]
|
|
426
|
+
lines.push(...table(withTurn, [...view.turns].reverse(), row => modelLabel(row.model), width, false))
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return lines.map(line => boundLine(line, width))
|
|
430
|
+
}
|
package/src/render/width.ts
CHANGED
|
@@ -118,7 +118,7 @@ function isWideCodePoint(code: number): boolean {
|
|
|
118
118
|
let high = WIDE_RANGES.length - 1
|
|
119
119
|
while (low <= high) {
|
|
120
120
|
const mid = (low + high) >> 1
|
|
121
|
-
const [start, end] = WIDE_RANGES[mid]
|
|
121
|
+
const [start, end] = WIDE_RANGES[mid]
|
|
122
122
|
if (code < start) high = mid - 1
|
|
123
123
|
else if (code > end) low = mid + 1
|
|
124
124
|
else return true
|
|
@@ -147,7 +147,7 @@ export function splitGraphemes(text: string): string[] {
|
|
|
147
147
|
export function graphemeWidth(cluster: string): number {
|
|
148
148
|
// VS16 requests emoji presentation: ❤️ / ✳️ render two cells even when the
|
|
149
149
|
// base glyph is text-default (width 1 without the selector).
|
|
150
|
-
if (cluster.includes('\u{fe0f}', 0)
|
|
150
|
+
if (cluster.includes('\u{fe0f}', 0)) return 2
|
|
151
151
|
const first = cluster.codePointAt(0) ?? 0
|
|
152
152
|
if (ZERO_WIDTH.test(String.fromCodePoint(first))) return 0
|
|
153
153
|
return isWideCodePoint(first) ? 2 : 1
|
package/src/session-directory.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { basename, dirname, resolve } from 'node:path'
|
|
4
4
|
import { realpathSync } from 'node:fs'
|
|
5
|
-
import { SESSION_FORMAT_VERSION, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
|
|
5
|
+
import { SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
|
|
6
|
+
import { t } from './i18n.ts'
|
|
6
7
|
|
|
7
8
|
export interface SessionRecord {
|
|
8
9
|
readonly header: SessionHeader
|
|
@@ -10,6 +11,52 @@ export interface SessionRecord {
|
|
|
10
11
|
readonly persisted: boolean
|
|
11
12
|
}
|
|
12
13
|
|
|
14
|
+
/** Minimal write handle retained while a planned deletion touches artifacts. */
|
|
15
|
+
export interface SessionDeletionLease {
|
|
16
|
+
close(): Promise<void>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Public persistence operation used to acquire the backend's write lease. */
|
|
20
|
+
export interface SessionDeletionPersistence {
|
|
21
|
+
open(id: SessionId, access: 'write'): Promise<SessionDeletionLease>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Acquire every subtree member's cross-process write lease before deleting
|
|
26
|
+
* any artifact. A partial acquisition is rolled back, so callers either hold
|
|
27
|
+
* the whole deletion boundary or touch nothing.
|
|
28
|
+
*/
|
|
29
|
+
export async function acquireSessionDeletionLeases(
|
|
30
|
+
persistence: SessionDeletionPersistence,
|
|
31
|
+
ids: readonly string[],
|
|
32
|
+
): Promise<readonly SessionDeletionLease[]> {
|
|
33
|
+
const leases: SessionDeletionLease[] = []
|
|
34
|
+
try {
|
|
35
|
+
for (const id of ids) leases.push(await persistence.open(SessionId(id), 'write'))
|
|
36
|
+
return leases
|
|
37
|
+
} catch (error: unknown) {
|
|
38
|
+
try {
|
|
39
|
+
await releaseSessionDeletionLeases(leases)
|
|
40
|
+
} catch (releaseError: unknown) {
|
|
41
|
+
throw new AggregateError([error, releaseError], 'session deletion lease acquisition and rollback failed')
|
|
42
|
+
}
|
|
43
|
+
throw error
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Release deletion leases in reverse acquisition order. */
|
|
48
|
+
export async function releaseSessionDeletionLeases(leases: readonly SessionDeletionLease[]): Promise<void> {
|
|
49
|
+
const failures: unknown[] = []
|
|
50
|
+
for (const lease of [...leases].reverse()) {
|
|
51
|
+
try {
|
|
52
|
+
await lease.close()
|
|
53
|
+
} catch (error: unknown) {
|
|
54
|
+
failures.push(error)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (failures.length > 0) throw new AggregateError(failures, 'failed to release session deletion leases')
|
|
58
|
+
}
|
|
59
|
+
|
|
13
60
|
export interface TitleObservationResult {
|
|
14
61
|
readonly sessionId: string
|
|
15
62
|
readonly status: 'fulfilled' | 'rejected'
|
|
@@ -26,6 +73,8 @@ export interface SessionQueryService {
|
|
|
26
73
|
listSessions(signal?: AbortSignal): Promise<SessionRecord[]>
|
|
27
74
|
readTitleSnapshots(ids: readonly string[], signal?: AbortSignal): Promise<TitleObservationResult[]>
|
|
28
75
|
readSession(id: string, signal?: AbortSignal): Promise<SessionLogSnapshot>
|
|
76
|
+
/** Cross-session full-text search (SQLite FTS engine; openAt may gate it). */
|
|
77
|
+
searchSessions(request: { query: string; limit?: number }, exec?: { signal?: AbortSignal }): Promise<{ items: readonly { header: SessionHeader; live: boolean; persisted: boolean; bestMatch: { snippet: string; time: number } }[] }>
|
|
29
78
|
}
|
|
30
79
|
|
|
31
80
|
export type SessionScope = 'roots' | 'all'
|
|
@@ -99,7 +148,27 @@ export function matchSessionId(headers: readonly SessionHeader[], wanted: string
|
|
|
99
148
|
if (matches.length > 1) {
|
|
100
149
|
throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches): use more of the id`)
|
|
101
150
|
}
|
|
102
|
-
return matches[0]
|
|
151
|
+
return matches[0]
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Unique picker-row match by exact id, unique prefix, or unique suffix.
|
|
156
|
+
* The resume list shows `id.slice(-12)`, so `/delete` arguments are often
|
|
157
|
+
* that tail rather than a leading prefix.
|
|
158
|
+
*/
|
|
159
|
+
export function matchSessionRow(rows: readonly SessionRow[], wanted: string): SessionRow {
|
|
160
|
+
const needle = wanted.trim()
|
|
161
|
+
if (needle === '') throw new Error('no persisted session matches ""')
|
|
162
|
+
const exact = rows.filter(row => row.id === needle)
|
|
163
|
+
if (exact[0] !== undefined && exact.length === 1) return exact[0]
|
|
164
|
+
const prefixed = rows.filter(row => row.id.startsWith(needle))
|
|
165
|
+
if (prefixed[0] !== undefined && prefixed.length === 1) return prefixed[0]
|
|
166
|
+
const suffixed = rows.filter(row => row.id.endsWith(needle))
|
|
167
|
+
if (suffixed[0] !== undefined && suffixed.length === 1) return suffixed[0]
|
|
168
|
+
if (prefixed.length > 1 || suffixed.length > 1) {
|
|
169
|
+
throw new Error(`session prefix "${needle}" is ambiguous (${Math.max(prefixed.length, suffixed.length)} matches): use more of the id`)
|
|
170
|
+
}
|
|
171
|
+
throw new Error(`no persisted session matches "${needle}"`)
|
|
103
172
|
}
|
|
104
173
|
|
|
105
174
|
/** The newest persisted ROOT session pinned to this cwd, or undefined. */
|
|
@@ -148,12 +217,19 @@ export function projectSessionRows(
|
|
|
148
217
|
preset: record.header.agentPreset ?? 'standard',
|
|
149
218
|
}
|
|
150
219
|
})
|
|
151
|
-
.filter(row =>
|
|
220
|
+
.filter(row => sessionRowMatchesQuery(row, needle))
|
|
152
221
|
.sort((left, right) => options.sort === 'newest'
|
|
153
222
|
? right.updatedAt - left.updatedAt || right.createdAt - left.createdAt
|
|
154
223
|
: left.updatedAt - right.updatedAt || left.createdAt - right.createdAt)
|
|
155
224
|
}
|
|
156
225
|
|
|
226
|
+
/** True when the picker query hits id, path, preset, or the displayed title. */
|
|
227
|
+
export function sessionRowMatchesQuery(row: Pick<SessionRow, 'id' | 'cwd' | 'workspace' | 'preset' | 'title'>, query: string): boolean {
|
|
228
|
+
const needle = query.trim().toLowerCase()
|
|
229
|
+
if (needle === '') return true
|
|
230
|
+
return `${row.id} ${row.cwd} ${row.workspace} ${row.preset} ${row.title ?? ''}`.toLowerCase().includes(needle)
|
|
231
|
+
}
|
|
232
|
+
|
|
157
233
|
/** Merge page-local title observations without disturbing directory order. */
|
|
158
234
|
export function mergeSessionTitles(
|
|
159
235
|
rows: readonly SessionRow[],
|
|
@@ -297,7 +373,13 @@ export function jsonlSessionRoot(persistence: unknown): string | undefined {
|
|
|
297
373
|
*/
|
|
298
374
|
export function collectDeletionSubtree(records: readonly SessionRecord[], id: string): string[] {
|
|
299
375
|
const parentOf = new Map<string, string | undefined>()
|
|
300
|
-
for (const record of records)
|
|
376
|
+
for (const record of records) {
|
|
377
|
+
// Only delegated subagents ride their parent's deletion. A fork is an
|
|
378
|
+
// independent conversation that merely shares lineage: deleting its
|
|
379
|
+
// origin must never take the branch's own log with it.
|
|
380
|
+
if (!isSubagentSession(record.header)) continue
|
|
381
|
+
parentOf.set(record.header.id, record.header.parentSession)
|
|
382
|
+
}
|
|
301
383
|
const doomed = new Set<string>([id])
|
|
302
384
|
// Iterate to a fixed point: children may be listed before their parents.
|
|
303
385
|
for (let pass = 0; pass < 2; pass += 1) {
|
|
@@ -382,14 +464,13 @@ export function planSessionDeletion(records: readonly SessionRecord[], id: strin
|
|
|
382
464
|
*/
|
|
383
465
|
export function formatRelativeTime(timestamp: number, now: number): string {
|
|
384
466
|
const seconds = Math.round((now - timestamp) / 1000)
|
|
385
|
-
if (seconds <
|
|
386
|
-
if (seconds < 60) return 'now'
|
|
467
|
+
if (seconds < 60) return t('time.justNow')
|
|
387
468
|
const minutes = Math.round(seconds / 60)
|
|
388
|
-
if (minutes < 60) return
|
|
469
|
+
if (minutes < 60) return t('time.minutesAgo', { n: minutes })
|
|
389
470
|
const hours = Math.round(minutes / 60)
|
|
390
|
-
if (hours < 24) return
|
|
471
|
+
if (hours < 24) return t('time.hoursAgo', { n: hours })
|
|
391
472
|
const days = Math.round(hours / 24)
|
|
392
|
-
if (days < 7) return
|
|
473
|
+
if (days < 7) return t('time.daysAgo', { n: days })
|
|
393
474
|
const date = new Date(timestamp)
|
|
394
475
|
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
|
395
476
|
const day = `${date.getDate()}`.padStart(2, '0')
|
package/src/session-query.ts
CHANGED
|
@@ -107,9 +107,13 @@ function sameHeader(a: SessionHeader, b: SessionHeader): boolean {
|
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
function materializePersistenceSnapshots(snapshots: readonly SessionPersistenceSnapshot[]): Map<SessionId, ObservedPersistedSession> {
|
|
110
|
-
|
|
110
|
+
// `Array.isArray` narrows a declared array type to `any[]`, which would let
|
|
111
|
+
// every later access escape the type system. Step through `unknown` so the
|
|
112
|
+
// guard stays a runtime assertion and the element type stays declared.
|
|
113
|
+
const candidate: unknown = snapshots
|
|
114
|
+
if (!Array.isArray(candidate)) throw new Error('persistence snapshots must be an array')
|
|
111
115
|
const result = new Map<SessionId, ObservedPersistedSession>()
|
|
112
|
-
for (const snapshot of
|
|
116
|
+
for (const snapshot of candidate as readonly SessionPersistenceSnapshot[]) {
|
|
113
117
|
if (typeof snapshot.revision !== 'string') {
|
|
114
118
|
throw new Error('persistence snapshot revision must be a string')
|
|
115
119
|
}
|
|
@@ -210,7 +214,7 @@ export async function observeStableWithSkip(
|
|
|
210
214
|
live.set(session.id, observed)
|
|
211
215
|
continue
|
|
212
216
|
}
|
|
213
|
-
if (durable !== undefined) assertSessionHeadersCompatible(observed.header, durable.header
|
|
217
|
+
if (durable !== undefined) assertSessionHeadersCompatible(observed.header, durable.header)
|
|
214
218
|
live.set(session.id, observed)
|
|
215
219
|
}
|
|
216
220
|
const sameLive = initiallyLive.size === live.size && [...initiallyLive].every(id => live.has(id))
|
|
@@ -228,7 +232,7 @@ const EngineBase = SqliteSessionQueryEngine as unknown as abstract new (ctx: nev
|
|
|
228
232
|
/** The engine this bundle mounts in place of the base `session-query-sqlite` row. */
|
|
229
233
|
export class SkipTolerantSessionQueryEngine extends EngineBase {
|
|
230
234
|
async _observeStable(indexed: ReadonlyMap<SessionId, { revision: SessionPersistenceRevision }>, signal: AbortSignal | undefined): Promise<unknown> {
|
|
231
|
-
return await observeStableWithSkip(this
|
|
235
|
+
return await observeStableWithSkip(this, indexed, signal)
|
|
232
236
|
}
|
|
233
237
|
}
|
|
234
238
|
|
package/src/session-switch.ts
CHANGED
|
@@ -13,6 +13,7 @@ interface Request<T> {
|
|
|
13
13
|
export class SessionSwitchQueue<T> {
|
|
14
14
|
private pending: Request<T> | undefined
|
|
15
15
|
private pumping = false
|
|
16
|
+
private running = false
|
|
16
17
|
|
|
17
18
|
constructor(
|
|
18
19
|
private readonly execute: (value: T) => Promise<void>,
|
|
@@ -34,6 +35,16 @@ export class SessionSwitchQueue<T> {
|
|
|
34
35
|
return true
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Whether a queued change is being activated right now. Between the idle
|
|
40
|
+
* wait and the handoff the old session is still installed, so a submission
|
|
41
|
+
* made in that window would start a turn the handoff then discards; callers
|
|
42
|
+
* use this to refuse one instead of losing it.
|
|
43
|
+
*/
|
|
44
|
+
get activating(): boolean {
|
|
45
|
+
return this.running
|
|
46
|
+
}
|
|
47
|
+
|
|
37
48
|
private async pump(): Promise<void> {
|
|
38
49
|
this.pumping = true
|
|
39
50
|
try {
|
|
@@ -43,10 +54,13 @@ export class SessionSwitchQueue<T> {
|
|
|
43
54
|
// Another request replaced this one while the turn was converging.
|
|
44
55
|
if (this.pending !== observed) continue
|
|
45
56
|
this.pending = undefined
|
|
57
|
+
this.running = true
|
|
46
58
|
try {
|
|
47
59
|
await this.execute(observed.value)
|
|
48
60
|
} catch (error: unknown) {
|
|
49
61
|
this.failed(error)
|
|
62
|
+
} finally {
|
|
63
|
+
this.running = false
|
|
50
64
|
}
|
|
51
65
|
}
|
|
52
66
|
} finally {
|