@qthinker/opencode-deepseek-search 0.2.1 → 0.3.2

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.
@@ -1,16 +1,20 @@
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
- import type { SearchStatus, UsageCounts } from "./rpc.ts"
3
+ import { format, pack, t } from "./i18n/index.ts"
4
+ import type { BalanceStatus, SearchStatus, UsageCounts, Language } from "./rpc.ts"
4
5
 
5
6
  export interface SidebarInput {
6
7
  readonly status: SearchStatus | undefined
7
- readonly error: string | undefined
8
+ /** The last read failure, if any; undefined while the first read is in flight. */
9
+ readonly error?: string | undefined
8
10
  /**
9
11
  * Columns one row may occupy. Defaults to the narrow sidebar budget; a host
10
12
  * that has measured its width may pass it, and the desktop panel passes
11
13
  * `Infinity` because it has room for the verbose copy.
12
14
  */
13
15
  readonly maxColumns?: number
16
+ /** Resolved by the server and carried in `status`; both sidebars read it there. */
17
+ readonly language: Language
14
18
  }
15
19
 
16
20
  export interface SidebarRows {
@@ -37,7 +41,12 @@ export interface SidebarRows {
37
41
  readonly hint: string | undefined
38
42
  /** Whether a click on `action` really performs it (some states have no action). */
39
43
  readonly clickable: boolean
40
- /** 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
+ */
41
50
  readonly lines: readonly string[]
42
51
  /** Drives the state color in card.ts. */
43
52
  readonly tone: "on" | "off" | "warn" | "error" | "idle"
@@ -58,7 +67,11 @@ export const DEFAULT_COLUMNS = 24
58
67
 
59
68
  const PERIODS = ["today", "month", "total"] as const
60
69
  type Period = (typeof PERIODS)[number]
61
- const LABELS: Readonly<Record<Period, string>> = { today: "今日", month: "本月", total: "累计" }
70
+ const LABEL_KEY = {
71
+ today: "usage.label.today",
72
+ month: "usage.label.month",
73
+ total: "usage.label.total",
74
+ } as const
62
75
 
63
76
  type Counts = NonNullable<SearchStatus["counts"]>
64
77
 
@@ -106,9 +119,9 @@ function clip(text: string, maxColumns: number): string {
106
119
  }
107
120
 
108
121
  /** One short body line: collapse whitespace, then clip to the available columns. */
109
- function shortMessage(message: string, maxColumns: number): string {
122
+ function shortMessage(message: string, maxColumns: number, language: Language): string {
110
123
  const text = message.replace(/\s+/g, " ").trim()
111
- return clip(text || "读取失败", maxColumns)
124
+ return clip(text || t(language, "state.failed"), maxColumns)
112
125
  }
113
126
 
114
127
  /** Micro-currency as `¥0.015`: three decimals, trailing zeros trimmed to cents. */
@@ -142,72 +155,141 @@ function usageLayout(counts: Counts): UsageLayout {
142
155
 
143
156
  /**
144
157
  * One usage row, e.g. `今日 Search 0 Fetch 3 ¥0.015` when the surface is
145
- * wide, otherwise shorter forms down to `今日`. The words are the product's
146
- * copy, so they survive as long as any shape can hold them: the amount goes
147
- * first, then the tighter grouping, and only then `0S` / `3F`. Counts are
148
- * right-aligned in their shared width and every row picks the same form, so
149
- * 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.
150
164
  */
151
165
  function compactUsageRow(
152
166
  period: Period,
153
167
  counts: UsageCounts,
154
168
  layout: UsageLayout,
155
169
  maxColumns: number,
156
- ): string {
157
- const label = LABELS[period]
170
+ language: Language,
171
+ ): string | undefined {
172
+ const label = t(language, LABEL_KEY[period])
158
173
  const search = String(counts.search).padStart(layout.search, " ")
159
174
  const fetch = String(counts.fetch).padStart(layout.fetch, " ")
160
175
  const cost = money(counts.costMicro).padStart(layout.cost, " ")
176
+ const params = { label, search, fetch, cost }
177
+ const forms = pack(language).usageForms
161
178
  // Each candidate is a complete row, so nothing is ever left dangling and no
162
179
  // number is ever cut from its unit.
163
- const candidates = [
164
- `${label} Search ${search} Fetch ${fetch} ${cost}`,
165
- `${label} Search ${search} Fetch ${fetch} ${cost}`,
166
- `${label} Search ${search} Fetch ${fetch}`,
167
- `${label} Search ${search} Fetch ${fetch}`,
168
- `${label} ${search}S ${fetch}F ${cost}`,
169
- `${label} ${search}S ${fetch}F`,
170
- `${label} ${search}S`,
171
- ]
172
- for (const row of candidates) {
180
+ for (const form of forms.slice(0, -1)) {
181
+ const row = format(form, params)
173
182
  if (textColumns(row) <= maxColumns) return row
174
183
  }
175
- 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
176
196
  }
177
197
 
178
198
  /**
179
- * An older server only sends pre-formatted strings. Keep the full string when
180
- * it fits, otherwise drop its trailing segments whole: the amount and the
181
- * 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.
182
204
  */
183
- function legacyUsageRow(period: Period, text: string, maxColumns: number): string {
184
- const label = LABELS[period]
185
- const full = `${label} ${text}`
186
- if (textColumns(full) <= maxColumns) return full
187
- 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)
188
208
  const candidates = [
189
- `${label} ${stats.join(" ")}`.trimEnd(),
190
- `${label} ${stats[0] ?? ""}`.trimEnd(),
191
- label,
209
+ `${label} ${cost.padStart(layout.cost, " ")}`,
210
+ `${label} ${String(counts.search).padStart(layout.search, " ")}S`,
211
+ cost,
192
212
  ]
193
213
  for (const row of candidates) {
194
214
  if (textColumns(row) <= maxColumns) return row
195
215
  }
196
- 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)
197
219
  }
198
220
 
199
- function usageLines(status: SearchStatus, maxColumns: number): readonly string[] {
221
+ function usageLines(status: SearchStatus, maxColumns: number, language: Language): readonly string[] {
200
222
  // The desktop panel lays the original verbose copy out as ` · ` chunks of its
201
223
  // own table; only the terminal needs the compact form.
202
224
  if (maxColumns === Number.POSITIVE_INFINITY) {
203
- return [`今日 ${status.today}`, `本月 ${status.month}`, `累计 ${status.total}`]
225
+ return [
226
+ `${t(language, "usage.label.today")} ${status.today}`,
227
+ `${t(language, "usage.label.month")} ${status.month}`,
228
+ `${t(language, "usage.label.total")} ${status.total}`,
229
+ ]
204
230
  }
205
231
  const counts = status.counts
206
232
  if (counts) {
207
233
  const layout = usageLayout(counts)
208
- return PERIODS.map((period) => compactUsageRow(period, counts[period], layout, maxColumns))
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
243
+ }
244
+ return PERIODS.map((period) => legacyUsageRow(period, status[period], maxColumns, language))
245
+ }
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
209
268
  }
210
- return PERIODS.map((period) => legacyUsageRow(period, status[period], maxColumns))
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)
211
293
  }
212
294
 
213
295
  /**
@@ -236,12 +318,17 @@ interface TitleFit {
236
318
  * and the title keeps the full width, and only then do the product name and the
237
319
  * wordy state shrink. Within one wording the glyph is the first thing to go,
238
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.
239
325
  */
240
326
  function fitTitle(
241
327
  state: string,
242
328
  shortState: string,
243
329
  maxColumns: number,
244
330
  actionColumns: number | undefined,
331
+ allowActionBelow = true,
245
332
  ): TitleFit {
246
333
  const budget = (glyph: boolean, actionBelow: boolean): number => {
247
334
  if (maxColumns === Number.POSITIVE_INFINITY) return Number.POSITIVE_INFINITY
@@ -258,7 +345,7 @@ function fitTitle(
258
345
  ]
259
346
  // Inline first, then the action below it; a wording is never traded for a
260
347
  // shorter one that happens to fit inline, because the wording is the point.
261
- const placements = actionColumns === undefined ? [false] : [false, true]
348
+ const placements = actionColumns === undefined || !allowActionBelow ? [false] : [false, true]
262
349
  for (const candidate of candidates) {
263
350
  for (const actionBelow of placements) {
264
351
  for (const glyph of [true, false]) {
@@ -269,24 +356,20 @@ function fitTitle(
269
356
  }
270
357
  }
271
358
  // Nothing fits whole at a pathological width: clip the state and give the
272
- // action its own row so the hit area never disappears.
273
- 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
274
362
  return { title: clip(shortState, budget(false, actionBelow)), glyph: false, actionBelow }
275
363
  }
276
364
 
277
365
  /** The fix hint for a missing key; it gets its own row because no click can fix it. */
278
- function fitHint(maxColumns: number): string {
279
- const candidates = [
280
- "设置 DEEPSEEK_API_KEY 后重启",
281
- "设置 API KEY 后重启",
282
- "设置 API KEY",
283
- "设置 KEY",
284
- ]
285
- if (maxColumns === Number.POSITIVE_INFINITY) return candidates[0]
286
- for (const candidate of candidates) {
287
- if (textColumns(candidate) <= maxColumns) return candidate
366
+ function fitHint(maxColumns: number, language: Language): string {
367
+ const forms = pack(language).hintForms
368
+ if (maxColumns === Number.POSITIVE_INFINITY) return forms[0] ?? ""
369
+ for (const form of forms) {
370
+ if (textColumns(form) <= maxColumns) return form
288
371
  }
289
- return clip(candidates[candidates.length - 1], maxColumns)
372
+ return clip(forms[forms.length - 1] ?? "", maxColumns)
290
373
  }
291
374
 
292
375
  interface ActionRows extends TitleFit {
@@ -294,39 +377,67 @@ interface ActionRows extends TitleFit {
294
377
  }
295
378
 
296
379
  /** Fit the action first: the title can only use the columns it leaves over. */
297
- 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 {
298
387
  const action = fitAction(verb, maxColumns)
299
- return { ...fitTitle(state, shortState, maxColumns, textColumns(action)), action }
388
+ return { ...fitTitle(state, shortState, maxColumns, textColumns(action), allowActionBelow), action }
300
389
  }
301
390
 
302
391
  export function sidebarRows(input: SidebarInput): SidebarRows {
303
- const { status, error } = input
392
+ const { status, error, language } = input
304
393
  const maxColumns = input.maxColumns ?? DEFAULT_COLUMNS
305
- const lines = status === undefined ? [] : usageLines(status, maxColumns)
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
306
410
 
307
411
  // No status yet: the body has no numbers to show, only the reason (or that
308
412
  // the first read is still in flight).
309
413
  if (status === undefined) {
414
+ const retry = t(language, "action.retry")
310
415
  return error === undefined || error.trim() === ""
311
416
  ? {
312
- ...actionRows("正在读取…", "正在读取…", "重试", maxColumns),
417
+ ...actionRows(t(language, "state.loading"), t(language, "state.loadingShort"), retry, maxColumns),
313
418
  hint: undefined,
314
419
  clickable: true,
315
420
  lines,
316
421
  tone: "idle",
317
422
  }
318
423
  : {
319
- ...actionRows("读取失败", "读取失败", "重试", maxColumns),
424
+ ...actionRows(t(language, "state.failed"), t(language, "state.failedShort"), retry, maxColumns),
320
425
  hint: undefined,
321
426
  clickable: true,
322
- lines: [shortMessage(error, maxColumns)],
427
+ lines: [shortMessage(error, maxColumns, language)],
323
428
  tone: "error",
324
429
  }
325
430
  }
326
431
 
327
432
  if (status.patched && status.hasKey) {
328
433
  return {
329
- ...actionRows("已启用", "已启用", "停用", maxColumns),
434
+ ...actionRows(
435
+ t(language, "state.enabled"),
436
+ t(language, "state.enabledShort"),
437
+ t(language, "action.disable"),
438
+ maxColumns,
439
+ allowActionBelow,
440
+ ),
330
441
  hint: undefined,
331
442
  clickable: true,
332
443
  lines,
@@ -337,7 +448,13 @@ export function sidebarRows(input: SidebarInput): SidebarRows {
337
448
  // DeepSeek was requested but the built-in tool was not found: nothing is
338
449
  // actually taken over, so the only honest action is to release the mode.
339
450
  return {
340
- ...actionRows("未接管内置 websearch", "未接管", "交还", maxColumns),
451
+ ...actionRows(
452
+ t(language, "state.unpatched"),
453
+ t(language, "state.unpatchedShort"),
454
+ t(language, "action.release"),
455
+ maxColumns,
456
+ allowActionBelow,
457
+ ),
341
458
  hint: undefined,
342
459
  clickable: true,
343
460
  lines,
@@ -346,7 +463,13 @@ export function sidebarRows(input: SidebarInput): SidebarRows {
346
463
  }
347
464
  if (status.mode === "native") {
348
465
  return {
349
- ...actionRows("已停用", "已停用", "启用", maxColumns),
466
+ ...actionRows(
467
+ t(language, "state.disabled"),
468
+ t(language, "state.disabledShort"),
469
+ t(language, "action.enable"),
470
+ maxColumns,
471
+ allowActionBelow,
472
+ ),
350
473
  hint: undefined,
351
474
  clickable: true,
352
475
  lines,
@@ -356,9 +479,9 @@ export function sidebarRows(input: SidebarInput): SidebarRows {
356
479
  // A missing key is not something a click can fix, so the title row stands
357
480
  // alone and the hint gets its own row directly under it. The env var name
358
481
  // only survives on surfaces wide enough for it.
359
- const hint = fitHint(maxColumns)
482
+ const hint = fitHint(maxColumns, language)
360
483
  return {
361
- ...fitTitle("未配置 API Key", "缺 API Key", maxColumns, undefined),
484
+ ...fitTitle(t(language, "state.missingKey"), t(language, "state.missingKeyShort"), maxColumns, undefined),
362
485
  action: hint,
363
486
  hint,
364
487
  clickable: false,
package/src/tui/card.ts CHANGED
@@ -3,12 +3,16 @@
3
3
  import type { JSX } from "@opentui/solid"
4
4
  import type { ResolvedTheme } from "@opencode/theme/tui"
5
5
  import { createElement, insert, setProp } from "@opentui/solid"
6
- import type { SearchStatus } from "../rpc.ts"
6
+ import type { Language, SearchStatus } from "../rpc.ts"
7
7
  import { DEFAULT_COLUMNS, sidebarRows } from "../status-view.ts"
8
8
  import type { SidebarRows } from "../status-view.ts"
9
9
 
10
10
  /** A measured width under this cannot show even the usage label; treat it as unmeasured. */
11
11
  const MIN_CARD_COLUMNS = 8
12
+ /** Body rows are inset this far from the title, so the card reads as one block. */
13
+ const INDENT = " "
14
+ /** Under this many columns the indent costs more than it explains. */
15
+ const INDENT_MIN_COLUMNS = 16
12
16
 
13
17
  /**
14
18
  * Columns the card may use. Before the first layout pass the host reports 0 (or
@@ -69,9 +73,45 @@ function element(tag: string, props: Props | undefined, children: readonly Child
69
73
  return node
70
74
  }
71
75
 
76
+ /** The amount at the end of a row, e.g. `¥0.015` / `$6.17`; it gets its own color. */
77
+ const MONEY_TAIL = /([¥$][\d.,]+)$/u
78
+
79
+ /**
80
+ * One body row split into the three tiers the card colors. The view model emits
81
+ * plain strings — one row per line, deliberately not structured — so the row is
82
+ * split here: the leading label reads faint, the counters in the middle muted,
83
+ * and the amount at the end is the number the user came for, so it gets full
84
+ * contrast.
85
+ */
86
+ interface BodyParts {
87
+ readonly label: string
88
+ readonly middle: string
89
+ readonly amount: string
90
+ /** The row starts with `!`: the balance could not be read. */
91
+ readonly unavailable: boolean
92
+ }
93
+
94
+ function bodyParts(line: string): BodyParts {
95
+ // `!` is the only unavailable marker in the row text and is consumed here as a
96
+ // color directive: it is never drawn. The label and the amount still are.
97
+ const unavailable = line.startsWith("!")
98
+ const text = unavailable ? line.slice(1) : line
99
+ const amount = MONEY_TAIL.exec(text)?.[1] ?? ""
100
+ const head = amount === "" ? text : text.slice(0, text.length - amount.length)
101
+ const gap = head.indexOf(" ")
102
+ return {
103
+ label: gap < 0 ? head : head.slice(0, gap),
104
+ middle: gap < 0 ? "" : head.slice(gap),
105
+ amount,
106
+ unavailable,
107
+ }
108
+ }
109
+
72
110
  export function sidebarCard(input: {
73
111
  readonly status: SearchStatus | undefined
74
112
  readonly error: string | undefined
113
+ /** UI language resolved by the server; the card never picks one itself. */
114
+ readonly language: Language
75
115
  readonly theme: ResolvedTheme
76
116
  readonly onToggle: () => void
77
117
  /**
@@ -83,10 +123,15 @@ export function sidebarCard(input: {
83
123
  }): JSX.Element {
84
124
  // The terminal sidebar is the surface this ladder is tuned for: the measured
85
125
  // width, not a guessed constant, decides whether the words fit.
126
+ const columns = cardColumns(input.maxColumns)
127
+ // The indent is part of the drawn row, so it is subtracted from the budget the
128
+ // model composes against: every row still ends inside the measured width.
129
+ const indent = columns >= INDENT_MIN_COLUMNS ? INDENT : ""
86
130
  const rows = sidebarRows({
87
131
  status: input.status,
88
132
  error: input.error,
89
- maxColumns: cardColumns(input.maxColumns),
133
+ maxColumns: columns - indent.length,
134
+ language: input.language,
90
135
  })
91
136
  const theme = input.theme
92
137
  const state = toneColor(theme, rows.tone)
@@ -105,14 +150,18 @@ export function sidebarCard(input: {
105
150
  //
106
151
  // The state word carries the tone color, so a narrow sidebar that has to drop
107
152
  // the glyph still shows the state at a glance (the desktop panel does the same).
153
+ // When the glyph is gone the word also goes bold — the emphasis has to live
154
+ // somewhere. Bold rides in `style`, because the reconciler ignores an
155
+ // `attributes` prop on text nodes and only maps `style` (fg/bg/attributes).
156
+ const stateStyle = rows.glyph ? { fg: state } : { fg: state, bold: true }
108
157
  const separator = rows.title.indexOf(" · ")
109
158
  const titleSpans: Child[] =
110
159
  separator < 0
111
- ? [element("span", { style: { fg: state } }, [rows.title])]
160
+ ? [element("span", { style: stateStyle }, [rows.title])]
112
161
  : [
113
162
  element("span", { style: { fg: theme.text.base } }, [rows.title.slice(0, separator)]),
114
163
  element("span", { style: { fg: theme.text.faint } }, [" · "]),
115
- element("span", { style: { fg: state } }, [rows.title.slice(separator + 3)]),
164
+ element("span", { style: stateStyle }, [rows.title.slice(separator + 3)]),
116
165
  ]
117
166
  if (rows.glyph) {
118
167
  titleSpans.unshift(element("span", { style: { fg: state } }, [`${GLYPH[rows.tone]} `]))
@@ -154,11 +203,28 @@ export function sidebarCard(input: {
154
203
  [action],
155
204
  )
156
205
 
157
- // The model already composed every row to fit the sidebar, so each body row
158
- // is one text node: the host must never get the chance to wrap it.
159
- const body = rows.lines.map((line) =>
160
- element("text", { content: line, fg: theme.text.muted, wrapMode: "none", width: "100%" }),
161
- )
206
+ // The model already composed every row to fit the sidebar, so each body row is
207
+ // one text node: the host must never get the chance to wrap it. The row's own
208
+ // text is split into colored spans, so the label, the counters and the amount
209
+ // each read at their own weight without a second row or a box per line.
210
+ const body = rows.lines.map((line) => {
211
+ const parts = bodyParts(line)
212
+ const spans: Child[] = []
213
+ if (indent !== "") spans.push(element("span", { style: { fg: theme.text.faint } }, [indent]))
214
+ if (parts.label !== "") {
215
+ spans.push(element("span", { style: { fg: theme.text.faint } }, [parts.label]))
216
+ }
217
+ if (parts.middle !== "") {
218
+ spans.push(element("span", { style: { fg: theme.text.muted } }, [parts.middle]))
219
+ }
220
+ if (parts.amount !== "") {
221
+ // An unavailable balance is the one thing on the card that must not look
222
+ // like an ordinary amount: it is the reason the next call will fail.
223
+ const fg = parts.unavailable ? theme.text.feedback.warning.base : theme.text.base
224
+ spans.push(element("span", { style: { fg } }, [parts.amount]))
225
+ }
226
+ return element("text", { selectable: false, wrapMode: "none", width: "100%" }, spans)
227
+ })
162
228
  // A missing key has no action, so the fix hint gets the row right under the
163
229
  // title. It is plain text: there is nothing to click.
164
230
  const hint =
package/src/tui/main.ts CHANGED
@@ -9,7 +9,8 @@ import { Plugin } from "@opencode/plugin/tui"
9
9
  import { createElement, insert, setProp, type JSX } from "@opentui/solid"
10
10
  import type { Renderable } from "@opentui/core"
11
11
  import { createSignal } from "solid-js"
12
- import { DeepSeekRpc, readModeResult, readStatus, type SearchStatus } from "../rpc.ts"
12
+ import { DeepSeekRpc, readModeResult, readStatus, type Language, type SearchStatus } from "../rpc.ts"
13
+ import { t } from "../i18n/index.ts"
13
14
  import { sidebarCard } from "./card.ts"
14
15
 
15
16
  const REFRESH_MS = 10_000
@@ -25,17 +26,28 @@ export default Plugin.define({
25
26
 
26
27
  const describeError = (e: unknown) => (e instanceof Error ? e.message : String(e)).slice(0, ERROR_MAX)
27
28
 
29
+ // One language for every string this half renders. Before the first status
30
+ // arrives there is nothing to read it from, so the first frame is English;
31
+ // the reactive card and the host-re-run keymap factory pick up the real one
32
+ // as soon as the server reports it.
33
+ const language = (): Language => status()?.language ?? "en"
34
+
28
35
  // `status` takes a required first argument: plain JSON Schema degrades the
29
36
  // input type to `unknown`, and the host validates the payload against the
30
37
  // method's object schema, so an omitted input is rejected as invalid.
31
- async function refresh() {
38
+ //
39
+ // `force` asks the server to re-probe the account balance instead of serving
40
+ // the cache; only a user action may spend that request. The timer and the
41
+ // `changed` event therefore send `{}` exactly — an older server rejects
42
+ // unknown input properties, so a stray `force: false` would break it.
43
+ async function refresh(force = false) {
32
44
  try {
33
- const next = readStatus(await rpc.status({}))
45
+ const next = readStatus(await rpc.status(force ? { force: true } : {}))
34
46
  if (next) {
35
47
  setStatus(next)
36
48
  setError(undefined)
37
49
  } else {
38
- setError("状态数据格式不符")
50
+ setError(t(language(), "toast.statusMalformed"))
39
51
  }
40
52
  } catch (e) {
41
53
  setError(describeError(e))
@@ -46,7 +58,7 @@ export default Plugin.define({
46
58
  async function applyMode(mode: "deepseek" | "native") {
47
59
  try {
48
60
  const result = readModeResult(await rpc.setMode({ mode }))
49
- if (!result) ctx.ui.toast.show({ message: "模式切换结果格式不符", variant: "error" })
61
+ if (!result) ctx.ui.toast.show({ message: t(language(), "toast.modeMalformed"), variant: "error" })
50
62
  else if (!result.ok) ctx.ui.toast.show({ message: result.message, variant: "error" })
51
63
  } catch (e) {
52
64
  ctx.ui.toast.show({ message: describeError(e), variant: "error" })
@@ -58,11 +70,12 @@ export default Plugin.define({
58
70
  }
59
71
 
60
72
  // The card labels the action it can really perform: switching the takeover
61
- // when a status is known, retrying the read when it is not.
73
+ // when a status is known, retrying the read when it is not. A retry is a user
74
+ // action, so it refreshes the cached balance snapshot as well.
62
75
  function toggle() {
63
76
  const current = status()
64
77
  if (!current || error()) {
65
- void refresh()
78
+ void refresh(true)
66
79
  return
67
80
  }
68
81
  void applyMode(current.mode === "deepseek" ? "native" : "deepseek")
@@ -72,16 +85,18 @@ export default Plugin.define({
72
85
  // entry point that always works (keymap command + `/deepseek` slash).
73
86
  async function openMenu() {
74
87
  const choice = await ctx.ui.dialog.select<"deepseek" | "native" | "refresh">({
75
- title: "DeepSeek 搜索",
88
+ title: t(language(), "dialog.title"),
76
89
  options: [
77
- { title: "启用", value: "deepseek", description: "接管内置 websearch" },
78
- { title: "停用", value: "native", description: "把内置 websearch 交还宿主" },
79
- { title: "刷新", value: "refresh", description: "重新读取状态与统计" },
90
+ { title: t(language(), "dialog.enable"), value: "deepseek", description: t(language(), "dialog.enableDesc") },
91
+ { title: t(language(), "dialog.disable"), value: "native", description: t(language(), "dialog.disableDesc") },
92
+ { title: t(language(), "dialog.refresh"), value: "refresh", description: t(language(), "dialog.refreshDesc") },
80
93
  ],
81
94
  current: status()?.mode,
82
95
  })
83
96
  if (choice === undefined) return
84
- if (choice === "refresh") await refresh()
97
+ // The dialog's refresh is an explicit "show me now", so it bypasses the
98
+ // server's balance cache; enabling/disabling refreshes through applyMode.
99
+ if (choice === "refresh") await refresh(true)
85
100
  else await applyMode(choice)
86
101
  }
87
102
 
@@ -106,6 +121,7 @@ export default Plugin.define({
106
121
  sidebarCard({
107
122
  status: status(),
108
123
  error: error(),
124
+ language: language(),
109
125
  theme: ctx.theme,
110
126
  onToggle: toggle,
111
127
  maxColumns: width(),
@@ -121,7 +137,7 @@ export default Plugin.define({
121
137
  commands: [
122
138
  {
123
139
  id: "deepseek-search.toggle",
124
- title: "DeepSeek: 启用/停用搜索接管",
140
+ title: t(language(), "command.toggle"),
125
141
  slash: { name: "deepseek" },
126
142
  run: () => void openMenu(),
127
143
  },