@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.
- package/CHANGELOG.md +45 -0
- package/README.md +43 -37
- package/gui-experimental.cjs +1 -1
- package/package.json +86 -85
- package/src/deepseek.ts +146 -51
- package/src/gui/index.tsx +27 -14
- package/src/gui/panel.tsx +91 -20
- package/src/i18n/en.ts +170 -0
- package/src/i18n/index.ts +89 -0
- package/src/i18n/ja.ts +147 -0
- package/src/i18n/zh.ts +143 -0
- package/src/index.ts +106 -45
- package/src/rpc.ts +100 -3
- package/src/status-view.ts +189 -66
- package/src/tui/card.ts +75 -9
- package/src/tui/main.ts +29 -13
- package/src/usage.ts +32 -9
package/src/status-view.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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 || "
|
|
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
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
* right-aligned in their shared width and every row picks the same form, so
|
|
149
|
-
*
|
|
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
|
-
|
|
157
|
-
|
|
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
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
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
|
|
184
|
-
const label =
|
|
185
|
-
const
|
|
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} ${
|
|
190
|
-
`${label} ${
|
|
191
|
-
|
|
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
|
-
|
|
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 [
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
//
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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: "
|
|
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:
|
|
88
|
+
title: t(language(), "dialog.title"),
|
|
76
89
|
options: [
|
|
77
|
-
{ title: "
|
|
78
|
-
{ title: "
|
|
79
|
-
{ title: "
|
|
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
|
-
|
|
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:
|
|
140
|
+
title: t(language(), "command.toggle"),
|
|
125
141
|
slash: { name: "deepseek" },
|
|
126
142
|
run: () => void openMenu(),
|
|
127
143
|
},
|