@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/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
- const runSearch = async (query: string, signal: AbortSignal): Promise<SearchResult[]> => {
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) => runSearch(input?.query ?? "", context.signal),
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 results = await runSearch(query, context.signal)
249
+ const oriQuestion = oriBySession.get(context.sessionID)
250
+ const results = await runSearch(query, context.signal, { oriQuestion, localization })
208
251
  return {
209
- output: { provider: PROVIDER_ID, results },
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
- withLedger((ledger): SearchStatus => {
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: { type: "object", properties: {}, additionalProperties: false },
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
 
@@ -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
- /** Body rows: the three usage rows, or the short error reason. */
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 `今日`. The ladder is per language:
154
- * each locale owns the shapes that read well at its widths, so no language is
155
- * squeezed into another one's budget. Counts are right-aligned in their shared
156
- * width and every row picks the same form, so the three rows read as a table.
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 pack(language).usageForms) {
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
- return textColumns(label) <= maxColumns ? label : clip(label, maxColumns)
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
- * An older server only sends pre-formatted strings, and those are always the
181
- * Chinese ones this plugin used to send: the filter below is deliberately
182
- * Chinese-only and stays correct while the UI language changes. Keep the full
183
- * string when it fits, otherwise drop its trailing segments whole: the amount
184
- * and the diagnostic counters go first, then the fetch count.
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 legacyUsageRow(period: Period, text: string, maxColumns: number, language: Language): string {
187
- const label = t(language, LABEL_KEY[period])
188
- const full = `${label} ${text}`
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} ${stats.join(" ")}`.trimEnd(),
193
- `${label} ${stats[0] ?? ""}`.trimEnd(),
194
- label,
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
- return clip(label, maxColumns)
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
- return PERIODS.map((period) => compactUsageRow(period, counts[period], layout, maxColumns, language))
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
- const actionBelow = actionColumns !== undefined
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(state: string, shortState: string, verb: string, maxColumns: number): 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 lines = status === undefined ? [] : usageLines(status, maxColumns, language)
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(t(language, "state.enabled"), t(language, "state.enabledShort"), t(language, "action.disable"), maxColumns),
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(t(language, "state.disabled"), t(language, "state.disabledShort"), t(language, "action.enable"), maxColumns),
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,