dsh-code 1.0.6 → 1.2.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.
Files changed (86) hide show
  1. package/README.en.md +123 -26
  2. package/README.md +124 -27
  3. package/bin/deepseek.mjs +283 -35
  4. package/cordis.patch.yml +97 -0
  5. package/lib/index.mjs +5008 -881
  6. package/lib/session-query.mjs +150 -0
  7. package/lib/startup.mjs +4 -4
  8. package/lib/{theme-DCT8Y2xf.mjs → theme-7u5Qo3dF.mjs} +657 -20
  9. package/lib/types/app.d.ts +106 -62
  10. package/lib/types/authorization-panel.d.ts +3 -3
  11. package/lib/types/git-workflow.d.ts +91 -2
  12. package/lib/types/i18n.d.ts +39 -0
  13. package/lib/types/index.d.ts +100 -1
  14. package/lib/types/input-split.d.ts +1 -1
  15. package/lib/types/kernel-panels.d.ts +107 -29
  16. package/lib/types/language-panel.d.ts +12 -0
  17. package/lib/types/locales/en.d.ts +450 -0
  18. package/lib/types/locales/zh.d.ts +9 -0
  19. package/lib/types/mentions.d.ts +7 -3
  20. package/lib/types/models.d.ts +14 -0
  21. package/lib/types/panel-accent.d.ts +28 -0
  22. package/lib/types/rainbow.d.ts +69 -0
  23. package/lib/types/render/animations.d.ts +42 -0
  24. package/lib/types/render/editor.d.ts +4 -3
  25. package/lib/types/render/ime-cursor.d.ts +60 -0
  26. package/lib/types/render/inspector.d.ts +26 -0
  27. package/lib/types/render/lines.d.ts +21 -1
  28. package/lib/types/render/markdown.d.ts +1 -1
  29. package/lib/types/render/projection.d.ts +130 -4
  30. package/lib/types/render/status.d.ts +9 -9
  31. package/lib/types/render/text.d.ts +6 -0
  32. package/lib/types/render/usage.d.ts +113 -0
  33. package/lib/types/session-directory.d.ts +17 -0
  34. package/lib/types/session-query.d.ts +92 -0
  35. package/lib/types/startup.d.ts +1 -1
  36. package/lib/types/terminal-title.d.ts +66 -0
  37. package/lib/types/theme-panel.d.ts +2 -2
  38. package/lib/types/theme.d.ts +271 -52
  39. package/lib/types/update-panel.d.ts +49 -0
  40. package/lib/types/update.d.ts +75 -0
  41. package/lib/types/version.d.ts +4 -3
  42. package/package.json +246 -90
  43. package/src/app.ts +1369 -509
  44. package/src/approval.ts +166 -166
  45. package/src/authorization-panel.ts +19 -16
  46. package/src/editor-keys.ts +371 -371
  47. package/src/git-workflow.ts +229 -3
  48. package/src/i18n.ts +68 -0
  49. package/src/index.ts +534 -80
  50. package/src/input-split.ts +3 -3
  51. package/src/internals.ts +5 -0
  52. package/src/kernel-panels.ts +554 -86
  53. package/src/keyboard.ts +5 -4
  54. package/src/language-panel.ts +53 -0
  55. package/src/locales/en.ts +489 -0
  56. package/src/locales/zh.ts +488 -0
  57. package/src/mentions.ts +8 -4
  58. package/src/models.ts +264 -212
  59. package/src/panel-accent.ts +41 -0
  60. package/src/presets.ts +1 -1
  61. package/src/provider-settings.ts +1 -1
  62. package/src/rainbow.ts +208 -0
  63. package/src/render/animations.ts +104 -6
  64. package/src/render/editor.ts +25 -24
  65. package/src/render/export.ts +116 -95
  66. package/src/render/ime-cursor.ts +147 -0
  67. package/src/render/inspector.ts +42 -0
  68. package/src/render/lines.ts +628 -415
  69. package/src/render/markdown.ts +15 -3
  70. package/src/render/projection.ts +572 -21
  71. package/src/render/status.ts +59 -39
  72. package/src/render/text.ts +14 -0
  73. package/src/render/tool-preview.ts +77 -77
  74. package/src/render/usage.ts +430 -0
  75. package/src/render/width.ts +2 -2
  76. package/src/session-directory.ts +8 -6
  77. package/src/session-query.ts +239 -0
  78. package/src/startup.ts +3 -3
  79. package/src/subagents.ts +229 -229
  80. package/src/terminal-title.ts +190 -0
  81. package/src/theme-panel.ts +17 -21
  82. package/src/theme.ts +281 -33
  83. package/src/update-panel.ts +256 -0
  84. package/src/update.ts +126 -0
  85. package/src/version.ts +58 -20
  86. 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
+ }
@@ -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) as boolean) return 2
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
@@ -3,6 +3,7 @@
3
3
  import { basename, dirname, resolve } from 'node:path'
4
4
  import { realpathSync } from 'node:fs'
5
5
  import { SESSION_FORMAT_VERSION, 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
@@ -26,6 +27,8 @@ export interface SessionQueryService {
26
27
  listSessions(signal?: AbortSignal): Promise<SessionRecord[]>
27
28
  readTitleSnapshots(ids: readonly string[], signal?: AbortSignal): Promise<TitleObservationResult[]>
28
29
  readSession(id: string, signal?: AbortSignal): Promise<SessionLogSnapshot>
30
+ /** Cross-session full-text search (SQLite FTS engine; openAt may gate it). */
31
+ searchSessions(request: { query: string; limit?: number }, exec?: { signal?: AbortSignal }): Promise<{ items: readonly { header: SessionHeader; live: boolean; persisted: boolean; bestMatch: { snippet: string; time: number } }[] }>
29
32
  }
30
33
 
31
34
  export type SessionScope = 'roots' | 'all'
@@ -99,7 +102,7 @@ export function matchSessionId(headers: readonly SessionHeader[], wanted: string
99
102
  if (matches.length > 1) {
100
103
  throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches): use more of the id`)
101
104
  }
102
- return matches[0]!
105
+ return matches[0]
103
106
  }
104
107
 
105
108
  /** The newest persisted ROOT session pinned to this cwd, or undefined. */
@@ -382,14 +385,13 @@ export function planSessionDeletion(records: readonly SessionRecord[], id: strin
382
385
  */
383
386
  export function formatRelativeTime(timestamp: number, now: number): string {
384
387
  const seconds = Math.round((now - timestamp) / 1000)
385
- if (seconds < 0) return 'now'
386
- if (seconds < 60) return 'now'
388
+ if (seconds < 60) return t('time.justNow')
387
389
  const minutes = Math.round(seconds / 60)
388
- if (minutes < 60) return `${minutes}m ago`
390
+ if (minutes < 60) return t('time.minutesAgo', { n: minutes })
389
391
  const hours = Math.round(minutes / 60)
390
- if (hours < 24) return `${hours}h ago`
392
+ if (hours < 24) return t('time.hoursAgo', { n: hours })
391
393
  const days = Math.round(hours / 24)
392
- if (days < 7) return `${days}d ago`
394
+ if (days < 7) return t('time.daysAgo', { n: days })
393
395
  const date = new Date(timestamp)
394
396
  const month = `${date.getMonth() + 1}`.padStart(2, '0')
395
397
  const day = `${date.getDate()}`.padStart(2, '0')