@brimveyn/aimux 1.22.10 → 1.22.12
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/package.json +2 -2
- package/src/index.tsx +15 -0
- package/src/integrations/claude-hooks-install.ts +3 -7
- package/src/integrations/claude-theme-sync.ts +5 -9
- package/src/platform/assistant-home.ts +25 -0
- package/src/platform/daemon-control.ts +10 -1
- package/src/services/ai-usage/adapters/claude.ts +2 -8
- package/src/services/ai-usage/adapters/codex.ts +1 -7
- package/src/services/usage-history/rollup.ts +278 -0
- package/src/services/usage-history/stats.ts +193 -0
- package/src/services/usage-history/store.ts +174 -0
- package/src/state/reducers/modal-state.ts +4 -0
- package/src/ui/components/modals/app/ai-usage-modal.tsx +351 -24
- package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +2 -7
- package/src/ui/format-number.ts +12 -0
- package/src/ui/root.tsx +1 -1
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { AIUsageTool } from '@brimveyn/aimux-config'
|
|
2
|
-
import type {
|
|
2
|
+
import type { RGBA } from '@opentui/core'
|
|
3
|
+
|
|
4
|
+
import { useTerminalDimensions } from '@opentui/react'
|
|
5
|
+
import { type ReactNode, useEffect, useMemo, useState } from 'react'
|
|
3
6
|
|
|
4
7
|
import type {
|
|
5
8
|
UsagePaceStage,
|
|
@@ -7,8 +10,24 @@ import type {
|
|
|
7
10
|
UsageWindow,
|
|
8
11
|
} from '../../../../services/ai-usage/types'
|
|
9
12
|
|
|
13
|
+
import {
|
|
14
|
+
buildHeatmap,
|
|
15
|
+
coveredWeeks,
|
|
16
|
+
type HeatmapCell,
|
|
17
|
+
mixColor,
|
|
18
|
+
monthRuler,
|
|
19
|
+
promptCounts,
|
|
20
|
+
summarizeDays,
|
|
21
|
+
} from '../../../../services/usage-history/stats'
|
|
22
|
+
import {
|
|
23
|
+
readUsageHistory,
|
|
24
|
+
type UsageDays,
|
|
25
|
+
type UsageHistoryFile,
|
|
26
|
+
} from '../../../../services/usage-history/store'
|
|
10
27
|
import { useAIUsageStore } from '../../../../state/ai-usage-store'
|
|
28
|
+
import { formatCompact } from '../../../format-number'
|
|
11
29
|
import { useTheme } from '../../../theme'
|
|
30
|
+
import { truncate } from '../../../truncate'
|
|
12
31
|
import { uiTokens } from '../../../ui-tokens'
|
|
13
32
|
import { ModalShell } from '../shared/modal-shell'
|
|
14
33
|
|
|
@@ -55,34 +74,63 @@ function paceStageIsBehind(stage: UsagePaceStage): boolean {
|
|
|
55
74
|
return stage === 'behind' || stage === 'farBehind' || stage === 'slightlyBehind'
|
|
56
75
|
}
|
|
57
76
|
|
|
58
|
-
|
|
77
|
+
const TOOLS: AIUsageTool[] = ['claude', 'codex']
|
|
78
|
+
const PAGE_LABELS = ['Live', 'History'] as const
|
|
79
|
+
|
|
80
|
+
/** A year of weeks plus the gutter needs ~58 columns, so History takes the terminal where Live stays a popover. */
|
|
81
|
+
const HISTORY_MAX_WIDTH = 104
|
|
82
|
+
|
|
83
|
+
export function AIUsageModal({ page }: { page: number }) {
|
|
84
|
+
const dimensions = useTerminalDimensions()
|
|
85
|
+
const isHistory = page === 1
|
|
86
|
+
|
|
87
|
+
const width = isHistory
|
|
88
|
+
? Math.max(uiTokens.modalWidth.md, Math.min(HISTORY_MAX_WIDTH, dimensions.width - 8))
|
|
89
|
+
: uiTokens.modalWidth.md
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<ModalShell title="AI usage" keybindsModeId="modal.ai-usage" width={width} listGap={1}>
|
|
93
|
+
<PageTabs active={page} />
|
|
94
|
+
{isHistory ? <HistoryPage width={width} /> : <LivePage />}
|
|
95
|
+
</ModalShell>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function PageTabs({ active }: { active: number }) {
|
|
100
|
+
const t = useTheme()
|
|
101
|
+
return (
|
|
102
|
+
<box flexDirection="row" gap={2}>
|
|
103
|
+
{PAGE_LABELS.map((label, index) => (
|
|
104
|
+
<text key={label} fg={index === active ? t.text : t.textMuted} selectable={false}>
|
|
105
|
+
{label}
|
|
106
|
+
</text>
|
|
107
|
+
))}
|
|
108
|
+
</box>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function LivePage() {
|
|
59
113
|
const t = useTheme()
|
|
60
114
|
const snapshots = useAIUsageStore((s) => s.snapshots)
|
|
61
115
|
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
116
|
+
const sections = TOOLS.map((tool) => ({ snap: snapshots[tool], tool })).filter(
|
|
117
|
+
(s): s is { snap: UsageSnapshot; tool: AIUsageTool } => s.snap !== undefined
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if (sections.length === 0) {
|
|
121
|
+
return (
|
|
122
|
+
<text fg={t.textMuted} selectable={false}>
|
|
123
|
+
no data yet — collecting…
|
|
124
|
+
</text>
|
|
125
|
+
)
|
|
126
|
+
}
|
|
66
127
|
|
|
67
128
|
return (
|
|
68
|
-
<
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
>
|
|
74
|
-
{sections.length === 0 ? (
|
|
75
|
-
<text fg={t.textMuted} selectable={false}>
|
|
76
|
-
no data yet — collecting…
|
|
77
|
-
</text>
|
|
78
|
-
) : (
|
|
79
|
-
<box flexDirection="column" gap={1}>
|
|
80
|
-
{sections.map(({ snap, tool }) => (
|
|
81
|
-
<ToolSection key={tool} snap={snap} tool={tool} />
|
|
82
|
-
))}
|
|
83
|
-
</box>
|
|
84
|
-
)}
|
|
85
|
-
</ModalShell>
|
|
129
|
+
<box flexDirection="column" gap={1}>
|
|
130
|
+
{sections.map(({ snap, tool }) => (
|
|
131
|
+
<ToolSection key={tool} snap={snap} tool={tool} />
|
|
132
|
+
))}
|
|
133
|
+
</box>
|
|
86
134
|
)
|
|
87
135
|
}
|
|
88
136
|
|
|
@@ -191,3 +239,282 @@ function PaceLine({ pace }: { pace: NonNullable<UsageWindow['pace']> }) {
|
|
|
191
239
|
</text>
|
|
192
240
|
)
|
|
193
241
|
}
|
|
242
|
+
|
|
243
|
+
/** One block per day, shaded by colour. Varying the glyph (`░▒▓█`) instead produced an indistinct mass. */
|
|
244
|
+
const CELL = '\u{2588}'
|
|
245
|
+
const ROW_LABELS = ['Mon', '', 'Wed', '', 'Fri', '', ''] as const
|
|
246
|
+
const ROW_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] as const
|
|
247
|
+
const GUTTER = 5
|
|
248
|
+
const COLUMN_GAP = 3
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Width on the box, never on the text: an empty <text> is not zero columns wide,
|
|
252
|
+
* so `Mon` and a padded blank did not start at the same column and the grid came
|
|
253
|
+
* out as a brick wall. Padding inside one string is fine; across siblings it is not.
|
|
254
|
+
*/
|
|
255
|
+
function Gutter({ label }: { label: string }) {
|
|
256
|
+
const t = useTheme()
|
|
257
|
+
return (
|
|
258
|
+
<box width={GUTTER} flexShrink={0}>
|
|
259
|
+
<text fg={t.textMuted} selectable={false}>
|
|
260
|
+
{label}
|
|
261
|
+
</text>
|
|
262
|
+
</box>
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function SectionHeader({ label, note }: { label: string; note?: string }) {
|
|
267
|
+
const t = useTheme()
|
|
268
|
+
return (
|
|
269
|
+
<box flexDirection="row" justifyContent="space-between">
|
|
270
|
+
<text fg={t.textMuted} selectable={false}>
|
|
271
|
+
{label}
|
|
272
|
+
</text>
|
|
273
|
+
{note != null && note !== '' ? (
|
|
274
|
+
<text fg={t.textMuted} selectable={false}>
|
|
275
|
+
{note}
|
|
276
|
+
</text>
|
|
277
|
+
) : null}
|
|
278
|
+
</box>
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Width of the `input` / `output` / `cache` label column. */
|
|
283
|
+
const LABEL_WIDTH = 11
|
|
284
|
+
|
|
285
|
+
function LabelledRow({ label, value }: { label: string; value: string }) {
|
|
286
|
+
const t = useTheme()
|
|
287
|
+
return (
|
|
288
|
+
<box flexDirection="row">
|
|
289
|
+
<box width={LABEL_WIDTH} flexShrink={0}>
|
|
290
|
+
<text fg={t.textMuted} selectable={false}>
|
|
291
|
+
{label}
|
|
292
|
+
</text>
|
|
293
|
+
</box>
|
|
294
|
+
<text fg={t.text} selectable={false}>
|
|
295
|
+
{value}
|
|
296
|
+
</text>
|
|
297
|
+
</box>
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function HeatRow({
|
|
302
|
+
cells,
|
|
303
|
+
cellWidth,
|
|
304
|
+
label,
|
|
305
|
+
ramp,
|
|
306
|
+
}: {
|
|
307
|
+
cells: HeatmapCell[]
|
|
308
|
+
cellWidth: number
|
|
309
|
+
label: string
|
|
310
|
+
ramp: (string | RGBA)[]
|
|
311
|
+
}) {
|
|
312
|
+
const t = useTheme()
|
|
313
|
+
|
|
314
|
+
// One <text> per colour run, not per cell: a full year is 371 cells.
|
|
315
|
+
const runs: { color: RGBA | string; start: number; text: string }[] = []
|
|
316
|
+
for (const [index, cell] of cells.entries()) {
|
|
317
|
+
const color = ramp[cell.level] ?? t.textMuted
|
|
318
|
+
const glyph = (cell.day === '' ? ' ' : CELL).repeat(cellWidth)
|
|
319
|
+
const last = runs.at(-1)
|
|
320
|
+
if (last !== undefined && last.color === color) last.text += glyph
|
|
321
|
+
else runs.push({ color, start: index, text: glyph })
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return (
|
|
325
|
+
<box flexDirection="row">
|
|
326
|
+
<Gutter label={label} />
|
|
327
|
+
{runs.map((run) => (
|
|
328
|
+
<text key={run.start} fg={run.color} selectable={false}>
|
|
329
|
+
{run.text}
|
|
330
|
+
</text>
|
|
331
|
+
))}
|
|
332
|
+
</box>
|
|
333
|
+
)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function Legend({ cellWidth, ramp }: { cellWidth: number; ramp: (string | RGBA)[] }) {
|
|
337
|
+
const t = useTheme()
|
|
338
|
+
return (
|
|
339
|
+
<box flexDirection="row">
|
|
340
|
+
<Gutter label="" />
|
|
341
|
+
<text fg={t.textMuted} selectable={false}>
|
|
342
|
+
less
|
|
343
|
+
</text>
|
|
344
|
+
<box width={1} flexShrink={0} />
|
|
345
|
+
{ramp.map((color, index) => (
|
|
346
|
+
<text key={color + String(index)} fg={color} selectable={false}>
|
|
347
|
+
{CELL.repeat(cellWidth)}
|
|
348
|
+
</text>
|
|
349
|
+
))}
|
|
350
|
+
<box width={1} flexShrink={0} />
|
|
351
|
+
<text fg={t.textMuted} selectable={false}>
|
|
352
|
+
more
|
|
353
|
+
</text>
|
|
354
|
+
</box>
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Right-hand ` 2.8B 45%` block, fixed so values line up down the column. */
|
|
359
|
+
const VALUE_WIDTH = 12
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* One ranked table. Columns are boxes, not padded strings; two side by side line
|
|
363
|
+
* up because they share a width. Sizing each to its own longest name gave the
|
|
364
|
+
* tables unrelated geometries and stranded `main` 45 columns from its number.
|
|
365
|
+
*/
|
|
366
|
+
function TopColumn({
|
|
367
|
+
entries,
|
|
368
|
+
title,
|
|
369
|
+
total,
|
|
370
|
+
width,
|
|
371
|
+
}: {
|
|
372
|
+
entries: [string, number][]
|
|
373
|
+
title: string
|
|
374
|
+
total: number
|
|
375
|
+
width: number
|
|
376
|
+
}) {
|
|
377
|
+
const t = useTheme()
|
|
378
|
+
const nameWidth = Math.max(4, width - VALUE_WIDTH)
|
|
379
|
+
|
|
380
|
+
return (
|
|
381
|
+
<box flexDirection="column" width={width} flexShrink={0}>
|
|
382
|
+
<text fg={t.textMuted} selectable={false}>
|
|
383
|
+
{title}
|
|
384
|
+
</text>
|
|
385
|
+
{entries.map(([name, value]) => (
|
|
386
|
+
<box key={name} flexDirection="row">
|
|
387
|
+
<box width={nameWidth} flexShrink={0}>
|
|
388
|
+
<text fg={t.text} selectable={false}>
|
|
389
|
+
{truncate(name, nameWidth)}
|
|
390
|
+
</text>
|
|
391
|
+
</box>
|
|
392
|
+
{/* Padding inside one node, so nothing here is a trailing space. */}
|
|
393
|
+
<text fg={t.text} selectable={false}>
|
|
394
|
+
{formatCompact(value).padStart(7) +
|
|
395
|
+
(total > 0 ? `${Math.round((value / total) * 100)}%` : '').padStart(5)}
|
|
396
|
+
</text>
|
|
397
|
+
</box>
|
|
398
|
+
))}
|
|
399
|
+
</box>
|
|
400
|
+
)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function HistoryPage({ width }: { width: number }) {
|
|
404
|
+
const t = useTheme()
|
|
405
|
+
const [history, setHistory] = useState<UsageHistoryFile | null>(null)
|
|
406
|
+
|
|
407
|
+
useEffect(() => {
|
|
408
|
+
// Synchronous, and ~100 KB even after a full year of rollups.
|
|
409
|
+
setHistory(readUsageHistory())
|
|
410
|
+
}, [])
|
|
411
|
+
|
|
412
|
+
// borderSubtle rather than a background token: backgrounds resolve to alpha 0
|
|
413
|
+
// in transparent mode. Above the early returns — hooks cannot be conditional.
|
|
414
|
+
const ramp = useMemo(
|
|
415
|
+
() => [
|
|
416
|
+
t.borderSubtle,
|
|
417
|
+
mixColor(t.borderSubtle, t.success, 0.35),
|
|
418
|
+
mixColor(t.borderSubtle, t.success, 0.6),
|
|
419
|
+
mixColor(t.borderSubtle, t.success, 0.82),
|
|
420
|
+
t.success,
|
|
421
|
+
],
|
|
422
|
+
[t.borderSubtle, t.success]
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
if (history === null) {
|
|
426
|
+
return (
|
|
427
|
+
<text fg={t.textMuted} selectable={false}>
|
|
428
|
+
reading history…
|
|
429
|
+
</text>
|
|
430
|
+
)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const claude: UsageDays = history.tools.claude ?? {}
|
|
434
|
+
const codex: UsageDays = history.tools.codex ?? {}
|
|
435
|
+
|
|
436
|
+
if (Object.keys(claude).length === 0 && Object.keys(codex).length === 0) {
|
|
437
|
+
return (
|
|
438
|
+
<text fg={t.textMuted} selectable={false}>
|
|
439
|
+
no history yet — the first rollup runs in the background; reopen in a minute
|
|
440
|
+
</text>
|
|
441
|
+
)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const inner = width - 4
|
|
445
|
+
const today = new Date()
|
|
446
|
+
// Bounded by what is recorded: empty months predating the first rollup read as broken.
|
|
447
|
+
const weeks = coveredWeeks(claude, today, Math.max(4, Math.min(53, inner - GUTTER)))
|
|
448
|
+
// Terminal cells are ~2x taller than wide, so a two-column day is roughly square.
|
|
449
|
+
const cellWidth = weeks * 2 + GUTTER <= inner ? 2 : 1
|
|
450
|
+
const grid = buildHeatmap(promptCounts(claude), weeks, today)
|
|
451
|
+
const summary = summarizeDays(claude)
|
|
452
|
+
const codexSummary = summarizeDays(codex)
|
|
453
|
+
|
|
454
|
+
const average =
|
|
455
|
+
summary.promptDays === 0 ? 0 : Math.round(summary.totalPrompts / summary.promptDays)
|
|
456
|
+
const { tokens } = summary
|
|
457
|
+
// `2 * columnWidth + COLUMN_GAP <= inner` by construction.
|
|
458
|
+
const columnWidth = Math.max(16, Math.floor((inner - COLUMN_GAP) / 2))
|
|
459
|
+
|
|
460
|
+
return (
|
|
461
|
+
<box flexDirection="column" gap={1}>
|
|
462
|
+
<box flexDirection="column">
|
|
463
|
+
<SectionHeader
|
|
464
|
+
label="ACTIVITY"
|
|
465
|
+
note={`${summary.totalPrompts.toLocaleString('en-US').replaceAll(',', ' ')} prompts over ${summary.promptDays} days · ${average} avg · ${summary.peakPrompts} peak`}
|
|
466
|
+
/>
|
|
467
|
+
<box flexDirection="row">
|
|
468
|
+
<Gutter label="" />
|
|
469
|
+
<text fg={t.textMuted} selectable={false}>
|
|
470
|
+
{monthRuler(grid, weeks, cellWidth)}
|
|
471
|
+
</text>
|
|
472
|
+
</box>
|
|
473
|
+
{grid.map((cells, index) => (
|
|
474
|
+
<HeatRow
|
|
475
|
+
key={ROW_KEYS[index]}
|
|
476
|
+
cells={cells}
|
|
477
|
+
cellWidth={cellWidth}
|
|
478
|
+
label={ROW_LABELS[index] ?? ''}
|
|
479
|
+
ramp={ramp}
|
|
480
|
+
/>
|
|
481
|
+
))}
|
|
482
|
+
<Legend cellWidth={cellWidth} ramp={ramp} />
|
|
483
|
+
</box>
|
|
484
|
+
|
|
485
|
+
<box flexDirection="column">
|
|
486
|
+
<SectionHeader
|
|
487
|
+
label="TOKENS"
|
|
488
|
+
note={`${summary.tokenDays} days retained · ${formatCompact(tokens.total)} total`}
|
|
489
|
+
/>
|
|
490
|
+
<LabelledRow label="input" value={formatCompact(tokens.input)} />
|
|
491
|
+
<LabelledRow label="output" value={formatCompact(tokens.output)} />
|
|
492
|
+
<LabelledRow
|
|
493
|
+
label="cache"
|
|
494
|
+
value={`${formatCompact(tokens.cacheRead)} read · ${formatCompact(tokens.cacheWrite)} written`}
|
|
495
|
+
/>
|
|
496
|
+
{codexSummary.tokens.total > 0 ? (
|
|
497
|
+
<LabelledRow
|
|
498
|
+
label="codex"
|
|
499
|
+
value={`${formatCompact(codexSummary.tokens.total)} over ${codexSummary.tokenDays} days`}
|
|
500
|
+
/>
|
|
501
|
+
) : null}
|
|
502
|
+
</box>
|
|
503
|
+
|
|
504
|
+
<box flexDirection="row" gap={COLUMN_GAP}>
|
|
505
|
+
<TopColumn
|
|
506
|
+
entries={summary.models}
|
|
507
|
+
title="MODELS"
|
|
508
|
+
total={summary.modelTotal}
|
|
509
|
+
width={columnWidth}
|
|
510
|
+
/>
|
|
511
|
+
<TopColumn
|
|
512
|
+
entries={summary.branches}
|
|
513
|
+
title="BRANCHES"
|
|
514
|
+
total={summary.branchTotal}
|
|
515
|
+
width={columnWidth}
|
|
516
|
+
/>
|
|
517
|
+
</box>
|
|
518
|
+
</box>
|
|
519
|
+
)
|
|
520
|
+
}
|
|
@@ -4,6 +4,7 @@ import { useCallback } from 'react'
|
|
|
4
4
|
|
|
5
5
|
import { useAIUsageStore } from '../../../../state/ai-usage-store'
|
|
6
6
|
import { dispatchGlobal } from '../../../../state/dispatch-ref'
|
|
7
|
+
import { formatCompact } from '../../../format-number'
|
|
7
8
|
import { useTheme } from '../../../theme'
|
|
8
9
|
|
|
9
10
|
/** nf-cod-claude / nf-cod-openai. Needs a nerd font, like the status bar separators. */
|
|
@@ -12,12 +13,6 @@ const ICON: Record<AIUsageTool, string> = {
|
|
|
12
13
|
codex: '\u{ec81}',
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
function formatTokens(total: number): string {
|
|
16
|
-
if (total >= 1_000_000) return `${(total / 1_000_000).toFixed(1)}M`
|
|
17
|
-
if (total >= 1_000) return `${(total / 1_000).toFixed(1)}k`
|
|
18
|
-
return String(total)
|
|
19
|
-
}
|
|
20
|
-
|
|
21
16
|
export function AIUsageIndicator() {
|
|
22
17
|
const t = useTheme()
|
|
23
18
|
const enabled = useAIUsageStore((s) => s.enabled)
|
|
@@ -65,7 +60,7 @@ export function AIUsageIndicator() {
|
|
|
65
60
|
}
|
|
66
61
|
|
|
67
62
|
const value =
|
|
68
|
-
snap.percent !== null ? `${Math.round(snap.percent)}%` :
|
|
63
|
+
snap.percent !== null ? `${Math.round(snap.percent)}%` : formatCompact(snap.tokens.total)
|
|
69
64
|
|
|
70
65
|
return (
|
|
71
66
|
<box key={tool} flexDirection="row">
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token counts, short enough for a status bar and a stats table alike.
|
|
3
|
+
*
|
|
4
|
+
* One function rather than one per surface: the indicator and the usage modal
|
|
5
|
+
* had drifted to `1.2k` and `1.2K` for the same number.
|
|
6
|
+
*/
|
|
7
|
+
export function formatCompact(value: number): string {
|
|
8
|
+
if (value >= 1e9) return `${(value / 1e9).toFixed(1)}B`
|
|
9
|
+
if (value >= 1e6) return `${(value / 1e6).toFixed(1)}M`
|
|
10
|
+
if (value >= 1e3) return `${(value / 1e3).toFixed(1)}k`
|
|
11
|
+
return String(value)
|
|
12
|
+
}
|