opencode-visual-cache 1.7.0-beta.3 → 1.7.0-beta.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/dist/_version.d.ts +1 -1
- package/dist/_version.js +1 -1
- package/dist/balance-providers.js +27 -1
- package/dist/index.js +4 -1
- package/dist/tui.js +31 -2
- package/dist/v2/index.js +1 -1
- package/dist/v2/status.d.ts +3 -1
- package/dist/v2/status.js +12 -2
- package/dist/v2.js +104 -73
- package/package.json +1 -1
- package/src/_version.ts +1 -1
- package/src/balance-providers.ts +25 -1
- package/src/core/color.ts +108 -108
- package/src/core/currency.ts +46 -46
- package/src/core/estimate.ts +36 -36
- package/src/core/format.ts +81 -81
- package/src/core/index.ts +5 -5
- package/src/core/types.ts +17 -17
- package/src/index.tsx +3 -0
- package/src/panel/panel-api.ts +104 -104
- package/src/v2/data.ts +112 -112
- package/src/v2/index.tsx +187 -187
- package/src/v2/sidebar.tsx +69 -69
- package/src/v2/status.tsx +108 -96
- package/src/v2/theme.ts +19 -19
- package/src/v2/types.ts +159 -159
- package/src/v2/v2-panel-api.ts +177 -177
- package/tui/index.js +1 -1
package/src/core/color.ts
CHANGED
|
@@ -1,108 +1,108 @@
|
|
|
1
|
-
/** Extract { r, g, b } (0–255) from a hex string or RGBA-like object. */
|
|
2
|
-
export function rgb(raw: unknown): { r: number; g: number; b: number } | null {
|
|
3
|
-
if (typeof raw === "string" && raw.startsWith("#")) {
|
|
4
|
-
const h = raw.slice(1)
|
|
5
|
-
return {
|
|
6
|
-
r: parseInt(h.slice(0, 2), 16),
|
|
7
|
-
g: parseInt(h.slice(2, 4), 16),
|
|
8
|
-
b: parseInt(h.slice(4, 6), 16),
|
|
9
|
-
}
|
|
10
|
-
}
|
|
11
|
-
if (raw && typeof raw === "object") {
|
|
12
|
-
const o = raw as Record<string, unknown>
|
|
13
|
-
if (typeof o.r === "number" && typeof o.g === "number" && typeof o.b === "number") {
|
|
14
|
-
// RGBA channels may be 0-1 floats; detect and upscale.
|
|
15
|
-
const scale = o.r > 1 || o.g > 1 || o.b > 1 ? 1 : 255
|
|
16
|
-
return {
|
|
17
|
-
r: Math.round(o.r * scale),
|
|
18
|
-
g: Math.round(o.g * scale),
|
|
19
|
-
b: Math.round(o.b * scale),
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
return null
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** HSL saturation of an RGB color (0–1). */
|
|
27
|
-
export function saturation(r: number, g: number, b: number): number {
|
|
28
|
-
const max = Math.max(r, g, b) / 255
|
|
29
|
-
const min = Math.min(r, g, b) / 255
|
|
30
|
-
const delta = max - min
|
|
31
|
-
if (delta === 0) return 0
|
|
32
|
-
const L = (max + min) / 2
|
|
33
|
-
return L <= 0.5 ? delta / (max + min) : delta / (2 - max - min)
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* If the colour's saturation exceeds `maxSat`, pull it toward grey
|
|
38
|
-
* until saturation drops to maxSat. Returns a hex string.
|
|
39
|
-
*/
|
|
40
|
-
export function desaturateTo(raw: unknown, maxSat: number, fallback: string): string {
|
|
41
|
-
const c = rgb(raw)
|
|
42
|
-
if (!c) return fallback
|
|
43
|
-
const sat = saturation(c.r, c.g, c.b)
|
|
44
|
-
if (sat <= maxSat) {
|
|
45
|
-
// already muted — return as hex
|
|
46
|
-
return "#" + [c.r, c.g, c.b].map((v) => v.toString(16).padStart(2, "0")).join("")
|
|
47
|
-
}
|
|
48
|
-
/**
|
|
49
|
-
* Binary search for the optimal grey-mix ratio α (0…1).
|
|
50
|
-
*
|
|
51
|
-
* 12 iterations → 1/2^12 ≈ 1/4096 resolution. The downstream RGB
|
|
52
|
-
* channels are only 0–255 (8 bit), so 8 iterations (1/256) would
|
|
53
|
-
* technically suffice; 12 is intentionally over-budget — the extra
|
|
54
|
-
* precision costs almost nothing and guarantees the saturation probe
|
|
55
|
-
* converges to within a fraction of an 8‑bit step, eliminating
|
|
56
|
-
* colour banding in edge cases.
|
|
57
|
-
*/
|
|
58
|
-
// Bt.601 luma (perceptual brightness used as the grey anchor)
|
|
59
|
-
const luma = c.r * 0.299 + c.g * 0.587 + c.b * 0.114
|
|
60
|
-
let lo = 0, hi = 1
|
|
61
|
-
for (let i = 0; i < 12; i++) {
|
|
62
|
-
const mid = (lo + hi) / 2
|
|
63
|
-
const nr = Math.round(c.r + (luma - c.r) * mid)
|
|
64
|
-
const ng = Math.round(c.g + (luma - c.g) * mid)
|
|
65
|
-
const nb = Math.round(c.b + (luma - c.b) * mid)
|
|
66
|
-
if (saturation(nr, ng, nb) > maxSat) lo = mid
|
|
67
|
-
else hi = mid
|
|
68
|
-
}
|
|
69
|
-
const nr = Math.round(c.r + (luma - c.r) * hi)
|
|
70
|
-
const ng = Math.round(c.g + (luma - c.g) * hi)
|
|
71
|
-
const nb = Math.round(c.b + (luma - c.b) * hi)
|
|
72
|
-
return "#" + [nr, ng, nb].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")).join("")
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Darken a hex colour by multiplying each channel by `factor` (0–1). */
|
|
76
|
-
export function dimColor(hex: string, factor = 0.5): string {
|
|
77
|
-
const c = rgb(hex)
|
|
78
|
-
if (!c) return hex
|
|
79
|
-
const r = Math.round(c.r * factor)
|
|
80
|
-
const g = Math.round(c.g * factor)
|
|
81
|
-
const b = Math.round(c.b * factor)
|
|
82
|
-
return "#" + [r, g, b].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")).join("")
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Morandi fallbacks — used when a theme colour cannot be resolved
|
|
86
|
-
export const FALLBACK = {
|
|
87
|
-
primary: "#8B9DAF",
|
|
88
|
-
text: "#C5C5BB",
|
|
89
|
-
muted: "#7A7A72",
|
|
90
|
-
success: "#9CAF8B",
|
|
91
|
-
warning: "#C5B88D",
|
|
92
|
-
error: "#B08A8A",
|
|
93
|
-
border: "#6B6B63",
|
|
94
|
-
} as const
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Desaturation ceiling for the Morandi-style palette.
|
|
98
|
-
*
|
|
99
|
-
* Morandi colours float around 0.15–0.30 saturation in HSL space.
|
|
100
|
-
* 0.28 sits near the upper end of that range: it strips the aggressive
|
|
101
|
-
* punch from high-saturation themes (Dracula, Solarized …) while
|
|
102
|
-
* preserving enough colour identity that green / orange / red hit-rate
|
|
103
|
-
* coding stays distinguishable.
|
|
104
|
-
*
|
|
105
|
-
* Lower → more grey, harder to tell colours apart.
|
|
106
|
-
* Higher → bright themes bleed through and defeat the muted look.
|
|
107
|
-
*/
|
|
108
|
-
export const MAX_SAT = 0.28
|
|
1
|
+
/** Extract { r, g, b } (0–255) from a hex string or RGBA-like object. */
|
|
2
|
+
export function rgb(raw: unknown): { r: number; g: number; b: number } | null {
|
|
3
|
+
if (typeof raw === "string" && raw.startsWith("#")) {
|
|
4
|
+
const h = raw.slice(1)
|
|
5
|
+
return {
|
|
6
|
+
r: parseInt(h.slice(0, 2), 16),
|
|
7
|
+
g: parseInt(h.slice(2, 4), 16),
|
|
8
|
+
b: parseInt(h.slice(4, 6), 16),
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
if (raw && typeof raw === "object") {
|
|
12
|
+
const o = raw as Record<string, unknown>
|
|
13
|
+
if (typeof o.r === "number" && typeof o.g === "number" && typeof o.b === "number") {
|
|
14
|
+
// RGBA channels may be 0-1 floats; detect and upscale.
|
|
15
|
+
const scale = o.r > 1 || o.g > 1 || o.b > 1 ? 1 : 255
|
|
16
|
+
return {
|
|
17
|
+
r: Math.round(o.r * scale),
|
|
18
|
+
g: Math.round(o.g * scale),
|
|
19
|
+
b: Math.round(o.b * scale),
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** HSL saturation of an RGB color (0–1). */
|
|
27
|
+
export function saturation(r: number, g: number, b: number): number {
|
|
28
|
+
const max = Math.max(r, g, b) / 255
|
|
29
|
+
const min = Math.min(r, g, b) / 255
|
|
30
|
+
const delta = max - min
|
|
31
|
+
if (delta === 0) return 0
|
|
32
|
+
const L = (max + min) / 2
|
|
33
|
+
return L <= 0.5 ? delta / (max + min) : delta / (2 - max - min)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* If the colour's saturation exceeds `maxSat`, pull it toward grey
|
|
38
|
+
* until saturation drops to maxSat. Returns a hex string.
|
|
39
|
+
*/
|
|
40
|
+
export function desaturateTo(raw: unknown, maxSat: number, fallback: string): string {
|
|
41
|
+
const c = rgb(raw)
|
|
42
|
+
if (!c) return fallback
|
|
43
|
+
const sat = saturation(c.r, c.g, c.b)
|
|
44
|
+
if (sat <= maxSat) {
|
|
45
|
+
// already muted — return as hex
|
|
46
|
+
return "#" + [c.r, c.g, c.b].map((v) => v.toString(16).padStart(2, "0")).join("")
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Binary search for the optimal grey-mix ratio α (0…1).
|
|
50
|
+
*
|
|
51
|
+
* 12 iterations → 1/2^12 ≈ 1/4096 resolution. The downstream RGB
|
|
52
|
+
* channels are only 0–255 (8 bit), so 8 iterations (1/256) would
|
|
53
|
+
* technically suffice; 12 is intentionally over-budget — the extra
|
|
54
|
+
* precision costs almost nothing and guarantees the saturation probe
|
|
55
|
+
* converges to within a fraction of an 8‑bit step, eliminating
|
|
56
|
+
* colour banding in edge cases.
|
|
57
|
+
*/
|
|
58
|
+
// Bt.601 luma (perceptual brightness used as the grey anchor)
|
|
59
|
+
const luma = c.r * 0.299 + c.g * 0.587 + c.b * 0.114
|
|
60
|
+
let lo = 0, hi = 1
|
|
61
|
+
for (let i = 0; i < 12; i++) {
|
|
62
|
+
const mid = (lo + hi) / 2
|
|
63
|
+
const nr = Math.round(c.r + (luma - c.r) * mid)
|
|
64
|
+
const ng = Math.round(c.g + (luma - c.g) * mid)
|
|
65
|
+
const nb = Math.round(c.b + (luma - c.b) * mid)
|
|
66
|
+
if (saturation(nr, ng, nb) > maxSat) lo = mid
|
|
67
|
+
else hi = mid
|
|
68
|
+
}
|
|
69
|
+
const nr = Math.round(c.r + (luma - c.r) * hi)
|
|
70
|
+
const ng = Math.round(c.g + (luma - c.g) * hi)
|
|
71
|
+
const nb = Math.round(c.b + (luma - c.b) * hi)
|
|
72
|
+
return "#" + [nr, ng, nb].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")).join("")
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Darken a hex colour by multiplying each channel by `factor` (0–1). */
|
|
76
|
+
export function dimColor(hex: string, factor = 0.5): string {
|
|
77
|
+
const c = rgb(hex)
|
|
78
|
+
if (!c) return hex
|
|
79
|
+
const r = Math.round(c.r * factor)
|
|
80
|
+
const g = Math.round(c.g * factor)
|
|
81
|
+
const b = Math.round(c.b * factor)
|
|
82
|
+
return "#" + [r, g, b].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")).join("")
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Morandi fallbacks — used when a theme colour cannot be resolved
|
|
86
|
+
export const FALLBACK = {
|
|
87
|
+
primary: "#8B9DAF",
|
|
88
|
+
text: "#C5C5BB",
|
|
89
|
+
muted: "#7A7A72",
|
|
90
|
+
success: "#9CAF8B",
|
|
91
|
+
warning: "#C5B88D",
|
|
92
|
+
error: "#B08A8A",
|
|
93
|
+
border: "#6B6B63",
|
|
94
|
+
} as const
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Desaturation ceiling for the Morandi-style palette.
|
|
98
|
+
*
|
|
99
|
+
* Morandi colours float around 0.15–0.30 saturation in HSL space.
|
|
100
|
+
* 0.28 sits near the upper end of that range: it strips the aggressive
|
|
101
|
+
* punch from high-saturation themes (Dracula, Solarized …) while
|
|
102
|
+
* preserving enough colour identity that green / orange / red hit-rate
|
|
103
|
+
* coding stays distinguishable.
|
|
104
|
+
*
|
|
105
|
+
* Lower → more grey, harder to tell colours apart.
|
|
106
|
+
* Higher → bright themes bleed through and defeat the muted look.
|
|
107
|
+
*/
|
|
108
|
+
export const MAX_SAT = 0.28
|
package/src/core/currency.ts
CHANGED
|
@@ -1,46 +1,46 @@
|
|
|
1
|
-
import type { BalanceEntry } from "../balance-providers"
|
|
2
|
-
import { formatBalanceAmount } from "./format"
|
|
3
|
-
|
|
4
|
-
export const CURRENCIES: Record<string, string> = {
|
|
5
|
-
USD: "$", CNY: "¥", EUR: "€", JPY: "JP¥", GBP: "£", KRW: "₩",
|
|
6
|
-
}
|
|
7
|
-
/** Approximate USD exchange rates — used as defaults when switching currency.
|
|
8
|
-
* Users can override via /cache-rate. Last updated 2026-05. */
|
|
9
|
-
export const DEFAULT_RATES: Record<string, number> = {
|
|
10
|
-
USD: 1, CNY: 7.2, EUR: 0.92, JPY: 150, GBP: 0.79, KRW: 1350,
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* 将余额从来源币种换算为目标币种。
|
|
15
|
-
* DEFAULT_RATES 以 USD=1 为基准:先折算为 USD,再换算到目标币种。
|
|
16
|
-
*/
|
|
17
|
-
export function convertBalance(target: string, targetRate: number, amount: number, from: string): number {
|
|
18
|
-
if (from === target) return amount
|
|
19
|
-
const fromRate = DEFAULT_RATES[from] ?? 1
|
|
20
|
-
const usd = from === "USD" ? amount : amount / fromRate
|
|
21
|
-
return target === "USD" ? usd : usd * targetRate
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** 货币符号:优先取 /cache-currency 内置映射,未知币种回退为代码。 */
|
|
25
|
-
export function balanceSymbol(currency: string): string {
|
|
26
|
-
const sym = CURRENCIES[currency]
|
|
27
|
-
return sym ?? currency + " "
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* 将余额列表格式化为单行文本。
|
|
32
|
-
* 优先直接显示偏好币种(CNY/USD…);偏好币种为换算币种时按汇率折算第一条余额。
|
|
33
|
-
*/
|
|
34
|
-
export function formatBalanceText(list: BalanceEntry[], pref: string, rate: number): string {
|
|
35
|
-
const native = pref ? list.find((x) => x.currency === pref) : undefined
|
|
36
|
-
if (native) return balanceSymbol(native.currency) + formatBalanceAmount(native.total)
|
|
37
|
-
const base = list[0]
|
|
38
|
-
const baseAmt = parseFloat(base.total)
|
|
39
|
-
const converted = Number.isFinite(baseAmt)
|
|
40
|
-
? convertBalance(pref || base.currency, rate, baseAmt, base.currency)
|
|
41
|
-
: baseAmt
|
|
42
|
-
const shown = pref && base.currency !== pref
|
|
43
|
-
? converted.toLocaleString("en-US", { maximumFractionDigits: 2 })
|
|
44
|
-
: formatBalanceAmount(base.total)
|
|
45
|
-
return balanceSymbol(pref || base.currency) + shown
|
|
46
|
-
}
|
|
1
|
+
import type { BalanceEntry } from "../balance-providers"
|
|
2
|
+
import { formatBalanceAmount } from "./format"
|
|
3
|
+
|
|
4
|
+
export const CURRENCIES: Record<string, string> = {
|
|
5
|
+
USD: "$", CNY: "¥", EUR: "€", JPY: "JP¥", GBP: "£", KRW: "₩",
|
|
6
|
+
}
|
|
7
|
+
/** Approximate USD exchange rates — used as defaults when switching currency.
|
|
8
|
+
* Users can override via /cache-rate. Last updated 2026-05. */
|
|
9
|
+
export const DEFAULT_RATES: Record<string, number> = {
|
|
10
|
+
USD: 1, CNY: 7.2, EUR: 0.92, JPY: 150, GBP: 0.79, KRW: 1350,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 将余额从来源币种换算为目标币种。
|
|
15
|
+
* DEFAULT_RATES 以 USD=1 为基准:先折算为 USD,再换算到目标币种。
|
|
16
|
+
*/
|
|
17
|
+
export function convertBalance(target: string, targetRate: number, amount: number, from: string): number {
|
|
18
|
+
if (from === target) return amount
|
|
19
|
+
const fromRate = DEFAULT_RATES[from] ?? 1
|
|
20
|
+
const usd = from === "USD" ? amount : amount / fromRate
|
|
21
|
+
return target === "USD" ? usd : usd * targetRate
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 货币符号:优先取 /cache-currency 内置映射,未知币种回退为代码。 */
|
|
25
|
+
export function balanceSymbol(currency: string): string {
|
|
26
|
+
const sym = CURRENCIES[currency]
|
|
27
|
+
return sym ?? currency + " "
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 将余额列表格式化为单行文本。
|
|
32
|
+
* 优先直接显示偏好币种(CNY/USD…);偏好币种为换算币种时按汇率折算第一条余额。
|
|
33
|
+
*/
|
|
34
|
+
export function formatBalanceText(list: BalanceEntry[], pref: string, rate: number): string {
|
|
35
|
+
const native = pref ? list.find((x) => x.currency === pref) : undefined
|
|
36
|
+
if (native) return balanceSymbol(native.currency) + formatBalanceAmount(native.total)
|
|
37
|
+
const base = list[0]
|
|
38
|
+
const baseAmt = parseFloat(base.total)
|
|
39
|
+
const converted = Number.isFinite(baseAmt)
|
|
40
|
+
? convertBalance(pref || base.currency, rate, baseAmt, base.currency)
|
|
41
|
+
: baseAmt
|
|
42
|
+
const shown = pref && base.currency !== pref
|
|
43
|
+
? converted.toLocaleString("en-US", { maximumFractionDigits: 2 })
|
|
44
|
+
: formatBalanceAmount(base.total)
|
|
45
|
+
return balanceSymbol(pref || base.currency) + shown
|
|
46
|
+
}
|
package/src/core/estimate.ts
CHANGED
|
@@ -1,36 +1,36 @@
|
|
|
1
|
-
// ── token estimation ──
|
|
2
|
-
// Character-based BPE approximation. Default ratios (~4 ASCII or ~1.5 CJK
|
|
3
|
-
// chars per token) work well for natural language but systematically
|
|
4
|
-
// under-count tokens in JSON and source code where every punctuation mark
|
|
5
|
-
// tends to be its own token. Detect these cases and tighten the ratio.
|
|
6
|
-
// See: GPT-4 / Claude tokenizer behaviour with structured text.
|
|
7
|
-
|
|
8
|
-
export function estimateTokens(text: string): number {
|
|
9
|
-
if (!text || text.length === 0) return 0
|
|
10
|
-
let ascii = 0
|
|
11
|
-
let cjk = 0
|
|
12
|
-
for (const c of text) {
|
|
13
|
-
const code = c.codePointAt(0) ?? 0
|
|
14
|
-
if (code >= 0x4E00 && code <= 0x9FFF) cjk++ // CJK Unified
|
|
15
|
-
else if (code >= 0x3040 && code <= 0x30FF) cjk++ // Hiragana/Katakana
|
|
16
|
-
else if (code >= 0xAC00 && code <= 0xD7A3) cjk++ // Hangul
|
|
17
|
-
else if (code >= 0x1100 && code <= 0x11FF) cjk++ // Hangul Jamo
|
|
18
|
-
else if (code >= 0x2E80 && code <= 0x2EFF) cjk++ // CJK Radicals
|
|
19
|
-
else ascii++
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// Real BPE tokenizers (cl100k_base, o200k_base) average ~3.5-4.0
|
|
23
|
-
// ASCII chars/token for both JSON and source code — close to prose.
|
|
24
|
-
// The old 2.0 / 2.5 ratios matched minified-JS extremes, not typical
|
|
25
|
-
// payloads, and systematically over-estimated token counts.
|
|
26
|
-
const trimmed = text.trimStart()
|
|
27
|
-
// Strip markdown code-fence prefix so that ```json … is detected as JSON
|
|
28
|
-
const strippedFence = trimmed.replace(/^\x60{3}\w*\s*\n?/, "")
|
|
29
|
-
const jsonLike = (strippedFence.startsWith("{") || strippedFence.startsWith("["))
|
|
30
|
-
&& /"[^"]+"\s*:/.test(text)
|
|
31
|
-
const codeLike = !jsonLike
|
|
32
|
-
&& /```|^import |^export |^function |^const |^let |^var |^class |^interface |^type |^def |^fn |^pub |^use |^mod |^package /m.test(text)
|
|
33
|
-
|
|
34
|
-
const asciiPerToken = jsonLike ? 3.5 : codeLike ? 3.5 : 4
|
|
35
|
-
return Math.max(1, Math.ceil(ascii / asciiPerToken + cjk / 1.0))
|
|
36
|
-
}
|
|
1
|
+
// ── token estimation ──
|
|
2
|
+
// Character-based BPE approximation. Default ratios (~4 ASCII or ~1.5 CJK
|
|
3
|
+
// chars per token) work well for natural language but systematically
|
|
4
|
+
// under-count tokens in JSON and source code where every punctuation mark
|
|
5
|
+
// tends to be its own token. Detect these cases and tighten the ratio.
|
|
6
|
+
// See: GPT-4 / Claude tokenizer behaviour with structured text.
|
|
7
|
+
|
|
8
|
+
export function estimateTokens(text: string): number {
|
|
9
|
+
if (!text || text.length === 0) return 0
|
|
10
|
+
let ascii = 0
|
|
11
|
+
let cjk = 0
|
|
12
|
+
for (const c of text) {
|
|
13
|
+
const code = c.codePointAt(0) ?? 0
|
|
14
|
+
if (code >= 0x4E00 && code <= 0x9FFF) cjk++ // CJK Unified
|
|
15
|
+
else if (code >= 0x3040 && code <= 0x30FF) cjk++ // Hiragana/Katakana
|
|
16
|
+
else if (code >= 0xAC00 && code <= 0xD7A3) cjk++ // Hangul
|
|
17
|
+
else if (code >= 0x1100 && code <= 0x11FF) cjk++ // Hangul Jamo
|
|
18
|
+
else if (code >= 0x2E80 && code <= 0x2EFF) cjk++ // CJK Radicals
|
|
19
|
+
else ascii++
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Real BPE tokenizers (cl100k_base, o200k_base) average ~3.5-4.0
|
|
23
|
+
// ASCII chars/token for both JSON and source code — close to prose.
|
|
24
|
+
// The old 2.0 / 2.5 ratios matched minified-JS extremes, not typical
|
|
25
|
+
// payloads, and systematically over-estimated token counts.
|
|
26
|
+
const trimmed = text.trimStart()
|
|
27
|
+
// Strip markdown code-fence prefix so that ```json … is detected as JSON
|
|
28
|
+
const strippedFence = trimmed.replace(/^\x60{3}\w*\s*\n?/, "")
|
|
29
|
+
const jsonLike = (strippedFence.startsWith("{") || strippedFence.startsWith("["))
|
|
30
|
+
&& /"[^"]+"\s*:/.test(text)
|
|
31
|
+
const codeLike = !jsonLike
|
|
32
|
+
&& /```|^import |^export |^function |^const |^let |^var |^class |^interface |^type |^def |^fn |^pub |^use |^mod |^package /m.test(text)
|
|
33
|
+
|
|
34
|
+
const asciiPerToken = jsonLike ? 3.5 : codeLike ? 3.5 : 4
|
|
35
|
+
return Math.max(1, Math.ceil(ascii / asciiPerToken + cjk / 1.0))
|
|
36
|
+
}
|
package/src/core/format.ts
CHANGED
|
@@ -1,81 +1,81 @@
|
|
|
1
|
-
/** CJK characters occupy 2 terminal columns; padEnd/padStart count
|
|
2
|
-
* string length (=1 per char), which breaks alignment with mixed text. */
|
|
3
|
-
|
|
4
|
-
export function charColumns(c: string): number {
|
|
5
|
-
const code = c.codePointAt(0) ?? 0
|
|
6
|
-
if (code < 0x20) return 0 // control
|
|
7
|
-
if (code < 0x7F) return 1 // ASCII
|
|
8
|
-
if (code < 0xA0) return 0 // C1 controls
|
|
9
|
-
// East-Asian wide / fullwidth ranges
|
|
10
|
-
if ((code >= 0x1100 && code <= 0x115F) || // Hangul Jamo
|
|
11
|
-
(code >= 0x2E80 && code <= 0xA4CF) || // CJK Radicals … Yi
|
|
12
|
-
(code >= 0xAC00 && code <= 0xD7A3) || // Hangul
|
|
13
|
-
(code >= 0xF900 && code <= 0xFAFF) || // CJK Compat
|
|
14
|
-
(code >= 0xFE10 && code <= 0xFE6F) || // Vertical / Compat
|
|
15
|
-
(code >= 0xFF01 && code <= 0xFF60) || // Fullwidth
|
|
16
|
-
(code >= 0xFFE0 && code <= 0xFFE6) || // Fullwidth signs
|
|
17
|
-
(code >= 0x1F300 && code <= 0x1F64F) || // Misc Symbols (emoji)
|
|
18
|
-
(code >= 0x20000 && code <= 0x3FFFD)) // SIP / TIP
|
|
19
|
-
return 2
|
|
20
|
-
return 1
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function visualWidth(s: string): number {
|
|
24
|
-
let w = 0; for (const c of s) w += charColumns(c); return w
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function visualPadEnd(s: string, cols: number): string {
|
|
28
|
-
const pad = cols - visualWidth(s)
|
|
29
|
-
return pad > 0 ? s + " ".repeat(pad) : s
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** Truncate `s` to fit within `maxCols` visual columns, appending "…" when cut. */
|
|
33
|
-
export function truncateVisual(s: string, maxCols: number): string {
|
|
34
|
-
if (visualWidth(s) <= maxCols) return s
|
|
35
|
-
let result = "", w = 0
|
|
36
|
-
for (const c of s) {
|
|
37
|
-
const cw = charColumns(c)
|
|
38
|
-
if (w + cw > maxCols - 1) { result += "\u2026"; break }
|
|
39
|
-
result += c; w += cw
|
|
40
|
-
}
|
|
41
|
-
return result
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function progressBar(percent: number, width: number): string {
|
|
45
|
-
const clamped = Math.max(0, Math.min(100, percent))
|
|
46
|
-
const filled = Math.round((clamped / 100) * width)
|
|
47
|
-
const empty = Math.max(0, width - filled)
|
|
48
|
-
return "\u2588".repeat(filled) + "\u2591".repeat(empty)
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function fmt(n: number): string {
|
|
52
|
-
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M"
|
|
53
|
-
if (n >= 10_000) return (n / 1_000).toFixed(1) + "K"
|
|
54
|
-
return n.toLocaleString("en-US")
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function num(v: unknown): number {
|
|
58
|
-
return typeof v === "number" && Number.isFinite(v) ? v : 0
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function fmtCost(n: number, symbol = "$", rate = 1): string {
|
|
62
|
-
const v = n * rate
|
|
63
|
-
if (v >= 1) return symbol + v.toFixed(2)
|
|
64
|
-
if (v >= 0.01) return symbol + v.toFixed(3)
|
|
65
|
-
return symbol + v.toFixed(4)
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** 紧凑数字缩写(底部状态栏用):1234 → "1.2K",1234567 → "1.2M"。 */
|
|
69
|
-
export function fmtCompact(n: number): string {
|
|
70
|
-
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M"
|
|
71
|
-
if (n >= 1e3) return (n / 1e3).toFixed(1) + "K"
|
|
72
|
-
return String(Math.round(n))
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** 余额数值格式化:≥1 或 0 显示固定 2 位小数;小额(<1)保留精度(最多 6 位),避免抹成 0.00。 */
|
|
76
|
-
export function formatBalanceAmount(total: string): string {
|
|
77
|
-
const n = parseFloat(total)
|
|
78
|
-
if (!Number.isFinite(n)) return total
|
|
79
|
-
if (n === 0 || n >= 1) return n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
|
80
|
-
return n.toLocaleString("en-US", { maximumFractionDigits: 6 })
|
|
81
|
-
}
|
|
1
|
+
/** CJK characters occupy 2 terminal columns; padEnd/padStart count
|
|
2
|
+
* string length (=1 per char), which breaks alignment with mixed text. */
|
|
3
|
+
|
|
4
|
+
export function charColumns(c: string): number {
|
|
5
|
+
const code = c.codePointAt(0) ?? 0
|
|
6
|
+
if (code < 0x20) return 0 // control
|
|
7
|
+
if (code < 0x7F) return 1 // ASCII
|
|
8
|
+
if (code < 0xA0) return 0 // C1 controls
|
|
9
|
+
// East-Asian wide / fullwidth ranges
|
|
10
|
+
if ((code >= 0x1100 && code <= 0x115F) || // Hangul Jamo
|
|
11
|
+
(code >= 0x2E80 && code <= 0xA4CF) || // CJK Radicals … Yi
|
|
12
|
+
(code >= 0xAC00 && code <= 0xD7A3) || // Hangul
|
|
13
|
+
(code >= 0xF900 && code <= 0xFAFF) || // CJK Compat
|
|
14
|
+
(code >= 0xFE10 && code <= 0xFE6F) || // Vertical / Compat
|
|
15
|
+
(code >= 0xFF01 && code <= 0xFF60) || // Fullwidth
|
|
16
|
+
(code >= 0xFFE0 && code <= 0xFFE6) || // Fullwidth signs
|
|
17
|
+
(code >= 0x1F300 && code <= 0x1F64F) || // Misc Symbols (emoji)
|
|
18
|
+
(code >= 0x20000 && code <= 0x3FFFD)) // SIP / TIP
|
|
19
|
+
return 2
|
|
20
|
+
return 1
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function visualWidth(s: string): number {
|
|
24
|
+
let w = 0; for (const c of s) w += charColumns(c); return w
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function visualPadEnd(s: string, cols: number): string {
|
|
28
|
+
const pad = cols - visualWidth(s)
|
|
29
|
+
return pad > 0 ? s + " ".repeat(pad) : s
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Truncate `s` to fit within `maxCols` visual columns, appending "…" when cut. */
|
|
33
|
+
export function truncateVisual(s: string, maxCols: number): string {
|
|
34
|
+
if (visualWidth(s) <= maxCols) return s
|
|
35
|
+
let result = "", w = 0
|
|
36
|
+
for (const c of s) {
|
|
37
|
+
const cw = charColumns(c)
|
|
38
|
+
if (w + cw > maxCols - 1) { result += "\u2026"; break }
|
|
39
|
+
result += c; w += cw
|
|
40
|
+
}
|
|
41
|
+
return result
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function progressBar(percent: number, width: number): string {
|
|
45
|
+
const clamped = Math.max(0, Math.min(100, percent))
|
|
46
|
+
const filled = Math.round((clamped / 100) * width)
|
|
47
|
+
const empty = Math.max(0, width - filled)
|
|
48
|
+
return "\u2588".repeat(filled) + "\u2591".repeat(empty)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function fmt(n: number): string {
|
|
52
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M"
|
|
53
|
+
if (n >= 10_000) return (n / 1_000).toFixed(1) + "K"
|
|
54
|
+
return n.toLocaleString("en-US")
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function num(v: unknown): number {
|
|
58
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function fmtCost(n: number, symbol = "$", rate = 1): string {
|
|
62
|
+
const v = n * rate
|
|
63
|
+
if (v >= 1) return symbol + v.toFixed(2)
|
|
64
|
+
if (v >= 0.01) return symbol + v.toFixed(3)
|
|
65
|
+
return symbol + v.toFixed(4)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 紧凑数字缩写(底部状态栏用):1234 → "1.2K",1234567 → "1.2M"。 */
|
|
69
|
+
export function fmtCompact(n: number): string {
|
|
70
|
+
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M"
|
|
71
|
+
if (n >= 1e3) return (n / 1e3).toFixed(1) + "K"
|
|
72
|
+
return String(Math.round(n))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 余额数值格式化:≥1 或 0 显示固定 2 位小数;小额(<1)保留精度(最多 6 位),避免抹成 0.00。 */
|
|
76
|
+
export function formatBalanceAmount(total: string): string {
|
|
77
|
+
const n = parseFloat(total)
|
|
78
|
+
if (!Number.isFinite(n)) return total
|
|
79
|
+
if (n === 0 || n >= 1) return n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
|
80
|
+
return n.toLocaleString("en-US", { maximumFractionDigits: 6 })
|
|
81
|
+
}
|
package/src/core/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export * from "./color"
|
|
2
|
-
export * from "./currency"
|
|
3
|
-
export * from "./estimate"
|
|
4
|
-
export * from "./format"
|
|
5
|
-
export * from "./types"
|
|
1
|
+
export * from "./color"
|
|
2
|
+
export * from "./currency"
|
|
3
|
+
export * from "./estimate"
|
|
4
|
+
export * from "./format"
|
|
5
|
+
export * from "./types"
|
package/src/core/types.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Token distribution breakdown for a session round.
|
|
3
|
-
* Pure data model shared by V1/V2 shells.
|
|
4
|
-
*/
|
|
5
|
-
export interface TokenDist {
|
|
6
|
-
system: number // UserMessage.system
|
|
7
|
-
user: number // user message text/file parts
|
|
8
|
-
agent: number // task tool input prompt/description (sub-agent delegation)
|
|
9
|
-
toolCall: number // ToolPart.input (actual tool params)
|
|
10
|
-
toolResult: number // ToolPart completed output / error
|
|
11
|
-
output: number // AssistantMessage.tokens.output (API exact, reasoning excluded)
|
|
12
|
-
reasoning: number // AssistantMessage.tokens.reasoning (API exact)
|
|
13
|
-
apiOutput: number // StepFinishPart.tokens.output (API exact, preferred)
|
|
14
|
-
apiInput: number // API exact total input context (input + cache read + cache write)
|
|
15
|
-
stepCost: number // last step-finish part cost (USD) in the current round
|
|
16
|
-
stepCount: number // step-finish parts count across the current round (parentID chain)
|
|
17
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Token distribution breakdown for a session round.
|
|
3
|
+
* Pure data model shared by V1/V2 shells.
|
|
4
|
+
*/
|
|
5
|
+
export interface TokenDist {
|
|
6
|
+
system: number // UserMessage.system
|
|
7
|
+
user: number // user message text/file parts
|
|
8
|
+
agent: number // task tool input prompt/description (sub-agent delegation)
|
|
9
|
+
toolCall: number // ToolPart.input (actual tool params)
|
|
10
|
+
toolResult: number // ToolPart completed output / error
|
|
11
|
+
output: number // AssistantMessage.tokens.output (API exact, reasoning excluded)
|
|
12
|
+
reasoning: number // AssistantMessage.tokens.reasoning (API exact)
|
|
13
|
+
apiOutput: number // StepFinishPart.tokens.output (API exact, preferred)
|
|
14
|
+
apiInput: number // API exact total input context (input + cache read + cache write)
|
|
15
|
+
stepCost: number // last step-finish part cost (USD) in the current round
|
|
16
|
+
stepCount: number // step-finish parts count across the current round (parentID chain)
|
|
17
|
+
}
|
package/src/index.tsx
CHANGED
|
@@ -493,6 +493,9 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
493
493
|
onSubmit={input.on_submit}
|
|
494
494
|
ref={input.ref}
|
|
495
495
|
hint={<BottomStatusBar api={api} signals={signals} sessionId={input.session_id} />}
|
|
496
|
+
// 接管 session_prompt 后需透传宿主的 session_prompt_right 插槽,
|
|
497
|
+
// 否则 oc-tps 等依赖该插槽的插件无法显示;无注册时 Slot 为 null。
|
|
498
|
+
right={<api.ui.Slot name="session_prompt_right" session_id={input.session_id} />}
|
|
496
499
|
/>
|
|
497
500
|
)
|
|
498
501
|
},
|