@qthinker/opencode-deepseek-search 0.3.0 → 0.4.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/CHANGELOG.md +42 -0
- package/README.md +50 -31
- package/gui-experimental.cjs +1 -1
- package/package.json +86 -86
- package/src/deepseek.ts +137 -11
- package/src/gui/index.tsx +13 -7
- package/src/gui/panel.tsx +79 -13
- package/src/i18n/en.ts +22 -3
- package/src/i18n/index.ts +8 -6
- package/src/i18n/ja.ts +13 -4
- package/src/i18n/zh.ts +14 -4
- package/src/index.ts +108 -11
- package/src/rpc.ts +67 -2
- package/src/status-view.ts +147 -32
- package/src/tui/card.ts +71 -8
- package/src/tui/main.ts +13 -5
package/src/index.ts
CHANGED
|
@@ -16,16 +16,19 @@ import { Plugin } from "@opencode/plugin"
|
|
|
16
16
|
import type { Result as ToolResult, ToolContext } from "@opencode/plugin/promise/tool"
|
|
17
17
|
import type { RpcRegistration } from "@opencode/plugin/promise/rpc"
|
|
18
18
|
import {
|
|
19
|
+
deepseekBalance,
|
|
19
20
|
deepseekFetch,
|
|
20
21
|
deepseekSearch,
|
|
21
22
|
formatSearchResults,
|
|
23
|
+
MAX_ORI_CHARS,
|
|
22
24
|
missingKeyMessage,
|
|
23
25
|
validateQuery,
|
|
24
26
|
validateUrl,
|
|
27
|
+
type SearchExtras,
|
|
25
28
|
type SearchResult,
|
|
26
29
|
} from "./deepseek.ts"
|
|
27
30
|
import { resolveLanguage, t } from "./i18n/index.ts"
|
|
28
|
-
import { DeepSeekRpc, type ModeSwitchResult, type SearchStatus } from "./rpc.ts"
|
|
31
|
+
import { DeepSeekRpc, type BalanceRead, type ModeSwitchResult, type SearchStatus } from "./rpc.ts"
|
|
29
32
|
import {
|
|
30
33
|
USAGE_KEY,
|
|
31
34
|
applyTally,
|
|
@@ -55,7 +58,12 @@ type ModeAction = "status" | Mode | "config"
|
|
|
55
58
|
|
|
56
59
|
const SEARCH_TOOL_DESCRIPTION =
|
|
57
60
|
"Search the web using DeepSeek Search. Use this for current information beyond knowledge cutoff." +
|
|
58
|
-
`\n\nThe current year is ${new Date().getFullYear()}. Use this year when searching for recent information or current events.`
|
|
61
|
+
`\n\nThe current year is ${new Date().getFullYear()}. Use this year when searching for recent information or current events.` +
|
|
62
|
+
"\n\nThe query supports search operators (they count toward the 128-character limit): " +
|
|
63
|
+
"site:github.com limits results to a site (combine with OR, e.g. site:github.com OR site:npmjs.com); " +
|
|
64
|
+
'-site:example.com excludes a site; bare word or -"quoted phrase" excludes matches; ' +
|
|
65
|
+
'"exact phrase" matches exactly; lang:zh,en limits languages; ' +
|
|
66
|
+
"after:2025-01-01 and before:2026 limit by publish date. Combine several operators in one query."
|
|
59
67
|
|
|
60
68
|
// Pure parser: the caller decides how to surface an invalid value (tool error
|
|
61
69
|
// vs. RPC result), so the message can be localized at that call site.
|
|
@@ -127,6 +135,7 @@ export default Plugin.define({
|
|
|
127
135
|
const count = int(opt.count, 5, 1, 50)
|
|
128
136
|
const fetchMaxChars = int(opt.fetchMaxChars, 30_000, 500, 200_000)
|
|
129
137
|
const pricePerThousand = int(opt.pricePerThousand, 5, 0, 10_000)
|
|
138
|
+
const localization = str(opt.localization)
|
|
130
139
|
|
|
131
140
|
// ctx.storage offers no compare-and-swap, so every read-modify-write goes
|
|
132
141
|
// through this single in-process chain. Stats must never break a search, so
|
|
@@ -157,11 +166,40 @@ export default Plugin.define({
|
|
|
157
166
|
}).catch(() => {})
|
|
158
167
|
}
|
|
159
168
|
|
|
160
|
-
|
|
169
|
+
// Best-effort capture of each session's most recent user prompt, sent as the
|
|
170
|
+
// DeepSeek `ori_question` intent hint. Clamped here at capture time so long
|
|
171
|
+
// pasted prompts cannot inflate the cache; empty prompts never overwrite a
|
|
172
|
+
// stored value. The map is capped, and JS Map re-assignment keeps insertion
|
|
173
|
+
// order, so eviction drops the oldest session.
|
|
174
|
+
const ORI_SESSION_CAP = 64
|
|
175
|
+
const oriBySession = new Map<string, string>()
|
|
176
|
+
let promptHook: { dispose: () => Promise<void> } | undefined
|
|
177
|
+
try {
|
|
178
|
+
promptHook = await ctx.session.hook("prompt", (p) => {
|
|
179
|
+
const text = p.prompt?.text?.trim()
|
|
180
|
+
if (!text) return
|
|
181
|
+
if (oriBySession.size >= ORI_SESSION_CAP && !oriBySession.has(p.sessionID)) {
|
|
182
|
+
const oldest = oriBySession.keys().next().value
|
|
183
|
+
if (oldest !== undefined) oriBySession.delete(oldest)
|
|
184
|
+
}
|
|
185
|
+
oriBySession.set(p.sessionID, text.slice(0, MAX_ORI_CHARS))
|
|
186
|
+
})
|
|
187
|
+
} catch (e) {
|
|
188
|
+
console.error(
|
|
189
|
+
`[deepseek-search] prompt hook unavailable; ori_question will be omitted ` +
|
|
190
|
+
`(${e instanceof Error ? e.message : String(e)})`,
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const runSearch = async (
|
|
195
|
+
query: string,
|
|
196
|
+
signal: AbortSignal,
|
|
197
|
+
extras?: SearchExtras,
|
|
198
|
+
): Promise<SearchResult[]> => {
|
|
161
199
|
const problem = validateQuery(query, language)
|
|
162
200
|
if (problem) throw new Error(problem)
|
|
163
201
|
try {
|
|
164
|
-
const results = await deepseekSearch(query, apiKey, count, language, signal)
|
|
202
|
+
const results = await deepseekSearch(query, apiKey, count, language, signal, extras)
|
|
165
203
|
tally(results.length > 0 ? "search" : "empty")
|
|
166
204
|
return results
|
|
167
205
|
} catch (e) {
|
|
@@ -175,7 +213,11 @@ export default Plugin.define({
|
|
|
175
213
|
editor.add({
|
|
176
214
|
id: PROVIDER_ID,
|
|
177
215
|
name: "DeepSeek Search",
|
|
178
|
-
execute: async (input, context) =>
|
|
216
|
+
execute: async (input, context) => {
|
|
217
|
+
// This provider-fallback context carries no sessionID, so `ori_question`
|
|
218
|
+
// is omitted here by design (see plan D2).
|
|
219
|
+
return runSearch(input?.query ?? "", context.signal, { localization })
|
|
220
|
+
},
|
|
179
221
|
})
|
|
180
222
|
if (mode === "deepseek") editor.default.set(PROVIDER_ID)
|
|
181
223
|
})
|
|
@@ -204,9 +246,16 @@ export default Plugin.define({
|
|
|
204
246
|
})
|
|
205
247
|
// Mirror the built-in tool's progress update so long searches are not silent.
|
|
206
248
|
await context.progress({ provider: PROVIDER_ID })
|
|
207
|
-
const
|
|
249
|
+
const oriQuestion = oriBySession.get(context.sessionID)
|
|
250
|
+
const results = await runSearch(query, context.signal, { oriQuestion, localization })
|
|
208
251
|
return {
|
|
209
|
-
output: {
|
|
252
|
+
output: {
|
|
253
|
+
provider: PROVIDER_ID,
|
|
254
|
+
// Project back to the built-in tool's declared result shape: internal
|
|
255
|
+
// quality fields (authority/rerankScore) stay out of the tool output.
|
|
256
|
+
results: results.map((r) => ({ url: r.url, title: r.title, content: r.content, time: r.time })),
|
|
257
|
+
},
|
|
258
|
+
// Rendered from the full objects: the [authoritative] marker needs authority.
|
|
210
259
|
content: formatSearchResults(results),
|
|
211
260
|
metadata: { provider: PROVIDER_ID },
|
|
212
261
|
}
|
|
@@ -375,13 +424,52 @@ export default Plugin.define({
|
|
|
375
424
|
})
|
|
376
425
|
})
|
|
377
426
|
|
|
427
|
+
// ---------- account balance cache ----------
|
|
428
|
+
// The sidebars poll `status`, so the balance is cached for a minute: at most
|
|
429
|
+
// one upstream probe per TTL, shared by every caller. Failures keep the last
|
|
430
|
+
// good snapshot and back off instead of retrying on every poll.
|
|
431
|
+
const BALANCE_TTL_MS = 60_000
|
|
432
|
+
let balanceCache: { value: BalanceRead; at: number } | undefined
|
|
433
|
+
let balanceInFlight: Promise<void> | undefined
|
|
434
|
+
let lastBalanceAttemptAt = 0
|
|
435
|
+
// Never rejects: the balance is decoration on `status`, not a feature that
|
|
436
|
+
// may break it, so every failure is swallowed here.
|
|
437
|
+
const refreshBalance = (force: boolean): Promise<void> => {
|
|
438
|
+
if (!apiKey) return Promise.resolve()
|
|
439
|
+
if (balanceInFlight) return balanceInFlight
|
|
440
|
+
const startedAt = Date.now()
|
|
441
|
+
if (!force) {
|
|
442
|
+
if (balanceCache && startedAt - balanceCache.at < BALANCE_TTL_MS) return Promise.resolve()
|
|
443
|
+
if (startedAt - lastBalanceAttemptAt < BALANCE_TTL_MS) return Promise.resolve()
|
|
444
|
+
}
|
|
445
|
+
lastBalanceAttemptAt = startedAt
|
|
446
|
+
const run = (async (): Promise<void> => {
|
|
447
|
+
try {
|
|
448
|
+
const value = await deepseekBalance(apiKey, language)
|
|
449
|
+
balanceCache = { value, at: Date.now() }
|
|
450
|
+
notifyChanged()
|
|
451
|
+
} catch {
|
|
452
|
+
/* keep the previous snapshot */
|
|
453
|
+
} finally {
|
|
454
|
+
balanceInFlight = undefined
|
|
455
|
+
}
|
|
456
|
+
})()
|
|
457
|
+
balanceInFlight = run
|
|
458
|
+
return run
|
|
459
|
+
}
|
|
460
|
+
|
|
378
461
|
// ---------- RPC contract (TUI / desktop sidebars) ----------
|
|
379
462
|
// Both sidebars read `status` and call `setMode`; nothing else crosses the
|
|
380
463
|
// process boundary, so the plugin's state stays the single source of truth.
|
|
381
464
|
rpcRegistration = await ctx.rpc.register(DeepSeekRpc, {
|
|
382
465
|
// Reads through `withLedger` so it observes the same serialized chain.
|
|
383
|
-
status: async (): Promise<SearchStatus> =>
|
|
384
|
-
|
|
466
|
+
status: async (input: unknown): Promise<SearchStatus> => {
|
|
467
|
+
// A manual refresh (the sidebar's refresh button) may ask for a fresh
|
|
468
|
+
// probe; timer paths send `{}` so old servers still accept the input.
|
|
469
|
+
const force = (input as { force?: unknown } | undefined)?.force === true
|
|
470
|
+
// Kicked, not awaited: `status` must stay fast even when the probe hangs.
|
|
471
|
+
void refreshBalance(force)
|
|
472
|
+
return withLedger((ledger): SearchStatus => {
|
|
385
473
|
const todayKey = dayKey()
|
|
386
474
|
return {
|
|
387
475
|
mode,
|
|
@@ -398,8 +486,12 @@ export default Plugin.define({
|
|
|
398
486
|
month: sumDays(ledger, (k) => k.startsWith(todayKey.slice(0, 7))),
|
|
399
487
|
total: sumDays(ledger, () => true),
|
|
400
488
|
},
|
|
489
|
+
// Absent until the first successful probe; `fetchedAt` is stamped by
|
|
490
|
+
// the cache, so clients can render staleness.
|
|
491
|
+
balance: balanceCache ? { ...balanceCache.value, fetchedAt: balanceCache.at } : undefined,
|
|
401
492
|
}
|
|
402
|
-
})
|
|
493
|
+
})
|
|
494
|
+
},
|
|
403
495
|
// The input is plain JSON Schema, hence `unknown`: report, never throw.
|
|
404
496
|
setMode: async (input: unknown): Promise<ModeSwitchResult> => {
|
|
405
497
|
const parsed = readModeAction(input)
|
|
@@ -418,7 +510,8 @@ export default Plugin.define({
|
|
|
418
510
|
console.error(
|
|
419
511
|
`[deepseek-search] loaded: mode=${mode} source=${overrideMode ? "override" : "config"} ` +
|
|
420
512
|
`apiKey=${apiKey ? "present" : "MISSING"} count=${count} ` +
|
|
421
|
-
`searchTool=${searchToolPatched ? "deepseek" : "untouched"}
|
|
513
|
+
`searchTool=${searchToolPatched ? "deepseek" : "untouched"}` +
|
|
514
|
+
` localization=${localization ? "set" : "unset"}`,
|
|
422
515
|
)
|
|
423
516
|
if (!apiKey) console.error(`[deepseek-search] ${missingKeyMessage("en")}`)
|
|
424
517
|
if (overrideMode && overrideMode !== configMode)
|
|
@@ -431,5 +524,9 @@ export default Plugin.define({
|
|
|
431
524
|
`[deepseek-search] warning: built-in ${SEARCH_TOOL} tool not found; search still uses the built-in backend. ` +
|
|
432
525
|
`Set "websearch": { "provider": "${PROVIDER_ID}" } in opencode.jsonc to force the route.`,
|
|
433
526
|
)
|
|
527
|
+
|
|
528
|
+
return async () => {
|
|
529
|
+
await promptHook?.dispose()
|
|
530
|
+
}
|
|
434
531
|
},
|
|
435
532
|
})
|
package/src/rpc.ts
CHANGED
|
@@ -38,11 +38,36 @@ const USAGE_COUNTS_SCHEMA = {
|
|
|
38
38
|
additionalProperties: false,
|
|
39
39
|
} as const
|
|
40
40
|
|
|
41
|
+
// Account balance snapshot in `status.balance`. `currency` deliberately has no
|
|
42
|
+
// enum: a value the server could not normalize must not fail the whole payload
|
|
43
|
+
// at output-schema validation time.
|
|
44
|
+
const BALANCE_SCHEMA = {
|
|
45
|
+
type: "object",
|
|
46
|
+
properties: {
|
|
47
|
+
isAvailable: { type: "boolean" },
|
|
48
|
+
currency: { type: "string" },
|
|
49
|
+
totalBalance: { type: "string" },
|
|
50
|
+
grantedBalance: { type: "string" },
|
|
51
|
+
toppedUpBalance: { type: "string" },
|
|
52
|
+
fetchedAt: { type: "number" },
|
|
53
|
+
},
|
|
54
|
+
required: ["isAvailable", "currency", "totalBalance", "grantedBalance", "toppedUpBalance", "fetchedAt"],
|
|
55
|
+
additionalProperties: false,
|
|
56
|
+
} as const
|
|
57
|
+
|
|
41
58
|
export const DeepSeekRpc = {
|
|
42
59
|
id: DEEPSEEK_RPC_ID,
|
|
43
60
|
methods: {
|
|
44
61
|
status: {
|
|
45
|
-
input: {
|
|
62
|
+
input: {
|
|
63
|
+
type: "object",
|
|
64
|
+
properties: {
|
|
65
|
+
// Only a manual refresh sends `{ force: true }`; timer paths MUST send
|
|
66
|
+
// `{}`, because older servers reject unknown input properties.
|
|
67
|
+
force: { type: "boolean" },
|
|
68
|
+
},
|
|
69
|
+
additionalProperties: false,
|
|
70
|
+
},
|
|
46
71
|
output: {
|
|
47
72
|
type: "object",
|
|
48
73
|
properties: {
|
|
@@ -67,6 +92,9 @@ export const DeepSeekRpc = {
|
|
|
67
92
|
required: ["today", "month", "total"],
|
|
68
93
|
additionalProperties: false,
|
|
69
94
|
},
|
|
95
|
+
// Optional like `counts`: absent while the first probe is in flight, on
|
|
96
|
+
// servers without the endpoint, or when the API key is missing.
|
|
97
|
+
balance: BALANCE_SCHEMA,
|
|
70
98
|
},
|
|
71
99
|
required: ["mode", "source", "patched", "hasKey", "pricePerThousand", "today", "month", "total"],
|
|
72
100
|
additionalProperties: false,
|
|
@@ -107,6 +135,24 @@ export interface UsageCounts {
|
|
|
107
135
|
costMicro: number
|
|
108
136
|
}
|
|
109
137
|
|
|
138
|
+
/**
|
|
139
|
+
* DeepSeek account balance as reported by `GET /user/balance`.
|
|
140
|
+
*
|
|
141
|
+
* `fetchedAt` is epoch milliseconds attached by the server when it caches the
|
|
142
|
+
* snapshot, so sidebars can show staleness without trusting client clocks.
|
|
143
|
+
*/
|
|
144
|
+
export interface BalanceStatus {
|
|
145
|
+
isAvailable: boolean
|
|
146
|
+
currency: string
|
|
147
|
+
totalBalance: string
|
|
148
|
+
grantedBalance: string
|
|
149
|
+
toppedUpBalance: string
|
|
150
|
+
fetchedAt: number
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The wire shape of a balance probe, before the cache stamps `fetchedAt`. */
|
|
154
|
+
export type BalanceRead = Omit<BalanceStatus, "fetchedAt">
|
|
155
|
+
|
|
110
156
|
export interface SearchStatus {
|
|
111
157
|
mode: "deepseek" | "native"
|
|
112
158
|
source: "override" | "config"
|
|
@@ -124,6 +170,8 @@ export interface SearchStatus {
|
|
|
124
170
|
language: Language
|
|
125
171
|
/** Structured counters, so each surface can format for its own width. Optional for older servers. */
|
|
126
172
|
counts?: { today: UsageCounts; month: UsageCounts; total: UsageCounts }
|
|
173
|
+
/** Last cached balance probe. Absent until the first successful fetch. */
|
|
174
|
+
balance?: BalanceStatus
|
|
127
175
|
}
|
|
128
176
|
|
|
129
177
|
export interface ModeSwitchResult {
|
|
@@ -151,10 +199,23 @@ function readUsageCounts(value: unknown): UsageCounts | undefined {
|
|
|
151
199
|
return { search, fetch, empty, failed, costMicro } as UsageCounts
|
|
152
200
|
}
|
|
153
201
|
|
|
202
|
+
/** Strict read of a cached balance snapshot: all six fields, currency normalized. */
|
|
203
|
+
function readBalance(value: unknown): BalanceStatus | undefined {
|
|
204
|
+
if (!isRecord(value)) return undefined
|
|
205
|
+
const { isAvailable, currency, totalBalance, grantedBalance, toppedUpBalance, fetchedAt } = value
|
|
206
|
+
if (typeof isAvailable !== "boolean") return undefined
|
|
207
|
+
if (currency !== "CNY" && currency !== "USD") return undefined
|
|
208
|
+
for (const text of [totalBalance, grantedBalance, toppedUpBalance]) {
|
|
209
|
+
if (typeof text !== "string" || text.length === 0) return undefined
|
|
210
|
+
}
|
|
211
|
+
if (typeof fetchedAt !== "number" || !Number.isFinite(fetchedAt) || fetchedAt < 0) return undefined
|
|
212
|
+
return { isAvailable, currency, totalBalance, grantedBalance, toppedUpBalance, fetchedAt } as BalanceStatus
|
|
213
|
+
}
|
|
214
|
+
|
|
154
215
|
/** Defensive read of an RPC `status` result. Never throws. */
|
|
155
216
|
export function readStatus(raw: unknown): SearchStatus | undefined {
|
|
156
217
|
if (!isRecord(raw)) return undefined
|
|
157
|
-
const { mode, source, patched, hasKey, pricePerThousand, today, month, total, language, counts } = raw
|
|
218
|
+
const { mode, source, patched, hasKey, pricePerThousand, today, month, total, language, counts, balance } = raw
|
|
158
219
|
if (!isMode(mode)) return undefined
|
|
159
220
|
if (source !== "override" && source !== "config") return undefined
|
|
160
221
|
if (typeof patched !== "boolean" || typeof hasKey !== "boolean") return undefined
|
|
@@ -180,6 +241,10 @@ export function readStatus(raw: unknown): SearchStatus | undefined {
|
|
|
180
241
|
const totalCounts = readUsageCounts(counts.total)
|
|
181
242
|
if (todayCounts && monthCounts && totalCounts) status.counts = { today: todayCounts, month: monthCounts, total: totalCounts }
|
|
182
243
|
}
|
|
244
|
+
// A missing balance stays absent (no probe yet, no key, older server); a
|
|
245
|
+
// malformed one is dropped without failing the rest of the payload.
|
|
246
|
+
const balanceStatus = readBalance(balance)
|
|
247
|
+
if (balanceStatus) status.balance = balanceStatus
|
|
183
248
|
return status
|
|
184
249
|
}
|
|
185
250
|
|
package/src/status-view.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Pure view model for the sidebar card. No renderer, no runtime dependency:
|
|
2
2
|
// the card and the tests both derive their text from here.
|
|
3
3
|
import { format, pack, t } from "./i18n/index.ts"
|
|
4
|
-
import type { SearchStatus, UsageCounts, Language } from "./rpc.ts"
|
|
4
|
+
import type { BalanceStatus, SearchStatus, UsageCounts, Language } from "./rpc.ts"
|
|
5
5
|
|
|
6
6
|
export interface SidebarInput {
|
|
7
7
|
readonly status: SearchStatus | undefined
|
|
@@ -41,7 +41,12 @@ export interface SidebarRows {
|
|
|
41
41
|
readonly hint: string | undefined
|
|
42
42
|
/** Whether a click on `action` really performs it (some states have no action). */
|
|
43
43
|
readonly clickable: boolean
|
|
44
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* Body rows: the three usage rows (or the single collapsed usage row when
|
|
46
|
+
* not even the shortest fits), or the short error reason, then the optional
|
|
47
|
+
* balance row last. A row starting with `!` is an unavailable balance; the
|
|
48
|
+
* marker is a color directive for the renderer and never part of the copy.
|
|
49
|
+
*/
|
|
45
50
|
readonly lines: readonly string[]
|
|
46
51
|
/** Drives the state color in card.ts. */
|
|
47
52
|
readonly tone: "on" | "off" | "warn" | "error" | "idle"
|
|
@@ -150,10 +155,12 @@ function usageLayout(counts: Counts): UsageLayout {
|
|
|
150
155
|
|
|
151
156
|
/**
|
|
152
157
|
* One usage row, e.g. `今日 Search 0 Fetch 3 ¥0.015` when the surface is
|
|
153
|
-
* wide, otherwise shorter forms down to
|
|
154
|
-
* each locale owns the shapes that read well at its
|
|
155
|
-
* squeezed into another one's budget. Counts are
|
|
156
|
-
* width and every row picks the same form, so the
|
|
158
|
+
* wide, otherwise shorter forms down to the amount or the search count alone.
|
|
159
|
+
* The ladder is per language: each locale owns the shapes that read well at its
|
|
160
|
+
* widths, so no language is squeezed into another one's budget. Counts are
|
|
161
|
+
* right-aligned in their shared width and every row picks the same form, so the
|
|
162
|
+
* three rows read as a table. `undefined` means not even the shortest rung fits;
|
|
163
|
+
* the caller then collapses the three rows instead of emitting blanks.
|
|
157
164
|
*/
|
|
158
165
|
function compactUsageRow(
|
|
159
166
|
period: Period,
|
|
@@ -161,42 +168,54 @@ function compactUsageRow(
|
|
|
161
168
|
layout: UsageLayout,
|
|
162
169
|
maxColumns: number,
|
|
163
170
|
language: Language,
|
|
164
|
-
): string {
|
|
171
|
+
): string | undefined {
|
|
165
172
|
const label = t(language, LABEL_KEY[period])
|
|
166
173
|
const search = String(counts.search).padStart(layout.search, " ")
|
|
167
174
|
const fetch = String(counts.fetch).padStart(layout.fetch, " ")
|
|
168
175
|
const cost = money(counts.costMicro).padStart(layout.cost, " ")
|
|
169
176
|
const params = { label, search, fetch, cost }
|
|
177
|
+
const forms = pack(language).usageForms
|
|
170
178
|
// Each candidate is a complete row, so nothing is ever left dangling and no
|
|
171
179
|
// number is ever cut from its unit.
|
|
172
|
-
for (const form of
|
|
180
|
+
for (const form of forms.slice(0, -1)) {
|
|
173
181
|
const row = format(form, params)
|
|
174
182
|
if (textColumns(row) <= maxColumns) return row
|
|
175
183
|
}
|
|
176
|
-
|
|
184
|
+
// The amount alone outranks the ladder's last rung, a lone search count: money
|
|
185
|
+
// is what this card is for, and a single counter is not. The row is composed
|
|
186
|
+
// here because it carries no count, so it is not a locale ladder entry — the
|
|
187
|
+
// label still comes from the locale table.
|
|
188
|
+
const amountRow = `${label} ${cost}`
|
|
189
|
+
if (textColumns(amountRow) <= maxColumns) return amountRow
|
|
190
|
+
const narrowest = forms[forms.length - 1]
|
|
191
|
+
if (narrowest !== undefined) {
|
|
192
|
+
const row = format(narrowest, params)
|
|
193
|
+
if (textColumns(row) <= maxColumns) return row
|
|
194
|
+
}
|
|
195
|
+
return undefined
|
|
177
196
|
}
|
|
178
197
|
|
|
179
198
|
/**
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
* and
|
|
199
|
+
* Below the shortest rung no usage row can carry a number, so the three rows
|
|
200
|
+
* become one that still does: the total cost first (the number this card is
|
|
201
|
+
* opened for), then the total search count, then the bare amount, which fits
|
|
202
|
+
* the card floor on its own. A label with no number behind it is a row of noise
|
|
203
|
+
* and is never emitted.
|
|
185
204
|
*/
|
|
186
|
-
function
|
|
187
|
-
const label = t(language, LABEL_KEY
|
|
188
|
-
const
|
|
189
|
-
if (textColumns(full) <= maxColumns) return full
|
|
190
|
-
const stats = text.split(" · ").filter((part) => !part.startsWith("¥") && !part.startsWith("空/失"))
|
|
205
|
+
function collapseUsageRow(counts: UsageCounts, layout: UsageLayout, maxColumns: number, language: Language): string {
|
|
206
|
+
const label = t(language, LABEL_KEY.total)
|
|
207
|
+
const cost = money(counts.costMicro)
|
|
191
208
|
const candidates = [
|
|
192
|
-
`${label} ${
|
|
193
|
-
`${label} ${
|
|
194
|
-
|
|
209
|
+
`${label} ${cost.padStart(layout.cost, " ")}`,
|
|
210
|
+
`${label} ${String(counts.search).padStart(layout.search, " ")}S`,
|
|
211
|
+
cost,
|
|
195
212
|
]
|
|
196
213
|
for (const row of candidates) {
|
|
197
214
|
if (textColumns(row) <= maxColumns) return row
|
|
198
215
|
}
|
|
199
|
-
|
|
216
|
+
// Nothing unclipped fits: only a clipped amount is left, and it still shows a
|
|
217
|
+
// digit as far as the columns allow.
|
|
218
|
+
return clip(cost, maxColumns)
|
|
200
219
|
}
|
|
201
220
|
|
|
202
221
|
function usageLines(status: SearchStatus, maxColumns: number, language: Language): readonly string[] {
|
|
@@ -212,11 +231,67 @@ function usageLines(status: SearchStatus, maxColumns: number, language: Language
|
|
|
212
231
|
const counts = status.counts
|
|
213
232
|
if (counts) {
|
|
214
233
|
const layout = usageLayout(counts)
|
|
215
|
-
|
|
234
|
+
const rows: string[] = []
|
|
235
|
+
for (const period of PERIODS) {
|
|
236
|
+
const row = compactUsageRow(period, counts[period], layout, maxColumns, language)
|
|
237
|
+
// All three rows share their padded widths, so they either all fit or none
|
|
238
|
+
// does; the first miss is enough to collapse rather than half a table.
|
|
239
|
+
if (row === undefined) return [collapseUsageRow(counts.total, layout, maxColumns, language)]
|
|
240
|
+
rows.push(row)
|
|
241
|
+
}
|
|
242
|
+
return rows
|
|
216
243
|
}
|
|
217
244
|
return PERIODS.map((period) => legacyUsageRow(period, status[period], maxColumns, language))
|
|
218
245
|
}
|
|
219
246
|
|
|
247
|
+
// The server normalizes `currency` to one of these codes, so the symbol is a
|
|
248
|
+
// lookup rather than a formatting library. An unknown code contributes nothing
|
|
249
|
+
// instead of a wrong symbol. The desktop panel reuses it for its account block.
|
|
250
|
+
export const CURRENCY_SYMBOL: Readonly<Record<string, string>> = { CNY: "¥", USD: "$" }
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The account-balance row, or `undefined` when it does not fit. The amount is
|
|
254
|
+
* the server's string verbatim (`110.00`), never re-rounded: DeepSeek's account
|
|
255
|
+
* balance is authoritative and this row only mirrors it.
|
|
256
|
+
*
|
|
257
|
+
* `!` marks an unavailable balance and costs one column; it is the only marker,
|
|
258
|
+
* so the renderer can turn it into a color and every other consumer keeps a
|
|
259
|
+
* plain string. The row is never compressed into another one: below its width it
|
|
260
|
+
* is dropped, because half an amount is worse than no amount.
|
|
261
|
+
*/
|
|
262
|
+
function balanceRow(balance: BalanceStatus, maxColumns: number, language: Language): string | undefined {
|
|
263
|
+
const marker = balance.isAvailable ? "" : "!"
|
|
264
|
+
const amount = `${CURRENCY_SYMBOL[balance.currency] ?? ""}${balance.totalBalance}`
|
|
265
|
+
for (const form of pack(language).balanceForms) {
|
|
266
|
+
const row = `${marker}${format(form, { amount })}`
|
|
267
|
+
if (textColumns(row) <= maxColumns) return row
|
|
268
|
+
}
|
|
269
|
+
return undefined
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* An older server only sends pre-formatted strings, and those are always the
|
|
274
|
+
* Chinese ones this plugin used to send: the filter below is deliberately
|
|
275
|
+
* Chinese-only and stays correct while the UI language changes. Keep the full
|
|
276
|
+
* string when it fits, otherwise drop its trailing segments whole: the amount
|
|
277
|
+
* and the diagnostic counters go first, then the fetch count.
|
|
278
|
+
*/
|
|
279
|
+
function legacyUsageRow(period: Period, text: string, maxColumns: number, language: Language): string {
|
|
280
|
+
const label = t(language, LABEL_KEY[period])
|
|
281
|
+
const full = `${label} ${text}`
|
|
282
|
+
if (textColumns(full) <= maxColumns) return full
|
|
283
|
+
const stats = text.split(" · ").filter((part) => !part.startsWith("¥") && !part.startsWith("空/失"))
|
|
284
|
+
const candidates = [
|
|
285
|
+
`${label} ${stats.join(" ")}`.trimEnd(),
|
|
286
|
+
`${label} ${stats[0] ?? ""}`.trimEnd(),
|
|
287
|
+
label,
|
|
288
|
+
]
|
|
289
|
+
for (const row of candidates) {
|
|
290
|
+
if (textColumns(row) <= maxColumns) return row
|
|
291
|
+
}
|
|
292
|
+
return clip(label, maxColumns)
|
|
293
|
+
}
|
|
294
|
+
|
|
220
295
|
/**
|
|
221
296
|
* The clickable label, always bracketed: a bare two-character verb is an easy
|
|
222
297
|
* target to miss. At pathological widths the verb is clipped but the brackets
|
|
@@ -243,12 +318,17 @@ interface TitleFit {
|
|
|
243
318
|
* and the title keeps the full width, and only then do the product name and the
|
|
244
319
|
* wordy state shrink. Within one wording the glyph is the first thing to go,
|
|
245
320
|
* because it costs two columns and the state word is right beside it.
|
|
321
|
+
*
|
|
322
|
+
* `allowActionBelow` is false on a card whose body is already full: giving the
|
|
323
|
+
* action its own row would push the card past its height budget, so the title
|
|
324
|
+
* shrinks instead — the action row always wins, because it is the only control.
|
|
246
325
|
*/
|
|
247
326
|
function fitTitle(
|
|
248
327
|
state: string,
|
|
249
328
|
shortState: string,
|
|
250
329
|
maxColumns: number,
|
|
251
330
|
actionColumns: number | undefined,
|
|
331
|
+
allowActionBelow = true,
|
|
252
332
|
): TitleFit {
|
|
253
333
|
const budget = (glyph: boolean, actionBelow: boolean): number => {
|
|
254
334
|
if (maxColumns === Number.POSITIVE_INFINITY) return Number.POSITIVE_INFINITY
|
|
@@ -265,7 +345,7 @@ function fitTitle(
|
|
|
265
345
|
]
|
|
266
346
|
// Inline first, then the action below it; a wording is never traded for a
|
|
267
347
|
// shorter one that happens to fit inline, because the wording is the point.
|
|
268
|
-
const placements = actionColumns === undefined ? [false] : [false, true]
|
|
348
|
+
const placements = actionColumns === undefined || !allowActionBelow ? [false] : [false, true]
|
|
269
349
|
for (const candidate of candidates) {
|
|
270
350
|
for (const actionBelow of placements) {
|
|
271
351
|
for (const glyph of [true, false]) {
|
|
@@ -276,8 +356,9 @@ function fitTitle(
|
|
|
276
356
|
}
|
|
277
357
|
}
|
|
278
358
|
// Nothing fits whole at a pathological width: clip the state and give the
|
|
279
|
-
// action its own row so the hit area never disappears
|
|
280
|
-
|
|
359
|
+
// action its own row so the hit area never disappears — unless the body has
|
|
360
|
+
// already spent that row, in which case the clipped title shares the width.
|
|
361
|
+
const actionBelow = allowActionBelow && actionColumns !== undefined
|
|
281
362
|
return { title: clip(shortState, budget(false, actionBelow)), glyph: false, actionBelow }
|
|
282
363
|
}
|
|
283
364
|
|
|
@@ -296,15 +377,36 @@ interface ActionRows extends TitleFit {
|
|
|
296
377
|
}
|
|
297
378
|
|
|
298
379
|
/** Fit the action first: the title can only use the columns it leaves over. */
|
|
299
|
-
function actionRows(
|
|
380
|
+
function actionRows(
|
|
381
|
+
state: string,
|
|
382
|
+
shortState: string,
|
|
383
|
+
verb: string,
|
|
384
|
+
maxColumns: number,
|
|
385
|
+
allowActionBelow = true,
|
|
386
|
+
): ActionRows {
|
|
300
387
|
const action = fitAction(verb, maxColumns)
|
|
301
|
-
return { ...fitTitle(state, shortState, maxColumns, textColumns(action)), action }
|
|
388
|
+
return { ...fitTitle(state, shortState, maxColumns, textColumns(action), allowActionBelow), action }
|
|
302
389
|
}
|
|
303
390
|
|
|
304
391
|
export function sidebarRows(input: SidebarInput): SidebarRows {
|
|
305
392
|
const { status, error, language } = input
|
|
306
393
|
const maxColumns = input.maxColumns ?? DEFAULT_COLUMNS
|
|
307
|
-
const
|
|
394
|
+
const usage = status === undefined ? [] : usageLines(status, maxColumns, language)
|
|
395
|
+
// Only the two states that really point at the account show it: an enabled
|
|
396
|
+
// takeover or an explicit native mode. A state where nothing is taken over, a
|
|
397
|
+
// missing key, or a failed read leaves the body to usage and the error reason.
|
|
398
|
+
const balance =
|
|
399
|
+
status !== undefined &&
|
|
400
|
+
((status.patched && status.hasKey) || status.mode === "native") &&
|
|
401
|
+
status.balance !== undefined
|
|
402
|
+
? balanceRow(status.balance, maxColumns, language)
|
|
403
|
+
: undefined
|
|
404
|
+
// The balance row closes the body: the three usage rows read as the receipt
|
|
405
|
+
// (what this machine spent) and the account truth lands under them (what is
|
|
406
|
+
// left to spend). It still costs one body row, so the header must keep the
|
|
407
|
+
// action inline to stay within five rows.
|
|
408
|
+
const lines = balance === undefined ? usage : [...usage, balance]
|
|
409
|
+
const allowActionBelow = balance === undefined
|
|
308
410
|
|
|
309
411
|
// No status yet: the body has no numbers to show, only the reason (or that
|
|
310
412
|
// the first read is still in flight).
|
|
@@ -329,7 +431,13 @@ export function sidebarRows(input: SidebarInput): SidebarRows {
|
|
|
329
431
|
|
|
330
432
|
if (status.patched && status.hasKey) {
|
|
331
433
|
return {
|
|
332
|
-
...actionRows(
|
|
434
|
+
...actionRows(
|
|
435
|
+
t(language, "state.enabled"),
|
|
436
|
+
t(language, "state.enabledShort"),
|
|
437
|
+
t(language, "action.disable"),
|
|
438
|
+
maxColumns,
|
|
439
|
+
allowActionBelow,
|
|
440
|
+
),
|
|
333
441
|
hint: undefined,
|
|
334
442
|
clickable: true,
|
|
335
443
|
lines,
|
|
@@ -345,6 +453,7 @@ export function sidebarRows(input: SidebarInput): SidebarRows {
|
|
|
345
453
|
t(language, "state.unpatchedShort"),
|
|
346
454
|
t(language, "action.release"),
|
|
347
455
|
maxColumns,
|
|
456
|
+
allowActionBelow,
|
|
348
457
|
),
|
|
349
458
|
hint: undefined,
|
|
350
459
|
clickable: true,
|
|
@@ -354,7 +463,13 @@ export function sidebarRows(input: SidebarInput): SidebarRows {
|
|
|
354
463
|
}
|
|
355
464
|
if (status.mode === "native") {
|
|
356
465
|
return {
|
|
357
|
-
...actionRows(
|
|
466
|
+
...actionRows(
|
|
467
|
+
t(language, "state.disabled"),
|
|
468
|
+
t(language, "state.disabledShort"),
|
|
469
|
+
t(language, "action.enable"),
|
|
470
|
+
maxColumns,
|
|
471
|
+
allowActionBelow,
|
|
472
|
+
),
|
|
358
473
|
hint: undefined,
|
|
359
474
|
clickable: true,
|
|
360
475
|
lines,
|