@qthinker/opencode-deepseek-search 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +26 -10
- package/gui-experimental.cjs +1 -1
- package/package.json +3 -2
- package/src/deepseek.ts +65 -47
- package/src/gui/index.tsx +14 -7
- package/src/gui/panel.tsx +13 -8
- package/src/i18n/en.ts +151 -0
- package/src/i18n/index.ts +87 -0
- package/src/i18n/ja.ts +138 -0
- package/src/i18n/zh.ts +133 -0
- package/src/index.ts +58 -41
- package/src/rpc.ts +34 -2
- package/src/status-view.ts +59 -51
- package/src/tui/card.ts +4 -1
- package/src/tui/main.ts +16 -8
- package/src/usage.ts +32 -9
package/src/index.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
validateUrl,
|
|
25
25
|
type SearchResult,
|
|
26
26
|
} from "./deepseek.ts"
|
|
27
|
+
import { resolveLanguage, t } from "./i18n/index.ts"
|
|
27
28
|
import { DeepSeekRpc, type ModeSwitchResult, type SearchStatus } from "./rpc.ts"
|
|
28
29
|
import {
|
|
29
30
|
USAGE_KEY,
|
|
@@ -56,11 +57,13 @@ const SEARCH_TOOL_DESCRIPTION =
|
|
|
56
57
|
"Search the web using DeepSeek Search. Use this for current information beyond knowledge cutoff." +
|
|
57
58
|
`\n\nThe current year is ${new Date().getFullYear()}. Use this year when searching for recent information or current events.`
|
|
58
59
|
|
|
59
|
-
|
|
60
|
+
// Pure parser: the caller decides how to surface an invalid value (tool error
|
|
61
|
+
// vs. RPC result), so the message can be localized at that call site.
|
|
62
|
+
function readModeAction(input: unknown): { action: ModeAction } | { invalid: string } {
|
|
60
63
|
const raw = (input as { mode?: unknown } | undefined)?.mode
|
|
61
|
-
if (raw === undefined || raw === null || raw === "") return "status"
|
|
62
|
-
if (raw === "status" || raw === "deepseek" || raw === "native" || raw === "config") return raw
|
|
63
|
-
|
|
64
|
+
if (raw === undefined || raw === null || raw === "") return { action: "status" }
|
|
65
|
+
if (raw === "status" || raw === "deepseek" || raw === "native" || raw === "config") return { action: raw }
|
|
66
|
+
return { invalid: String(raw) }
|
|
64
67
|
}
|
|
65
68
|
|
|
66
69
|
|
|
@@ -95,6 +98,18 @@ export default Plugin.define({
|
|
|
95
98
|
id: "deepseek-search",
|
|
96
99
|
async setup(ctx) {
|
|
97
100
|
const opt = (ctx.options ?? {}) as Record<string, unknown>
|
|
101
|
+
// Resolved once per setup: every tool output and the RPC status payload use
|
|
102
|
+
// this single language, so surfaces can never disagree.
|
|
103
|
+
const pick = resolveLanguage(opt.language, {
|
|
104
|
+
LC_ALL: readEnvValue("LC_ALL"),
|
|
105
|
+
LANG: readEnvValue("LANG"),
|
|
106
|
+
})
|
|
107
|
+
const language = pick.language
|
|
108
|
+
if (pick.problem !== undefined)
|
|
109
|
+
console.error(
|
|
110
|
+
`[deepseek-search] invalid language option ${JSON.stringify(pick.problem)}; ` +
|
|
111
|
+
`valid values: en / ja / zh-CN. Using ${language}.`,
|
|
112
|
+
)
|
|
98
113
|
const configMode: Mode = str(opt.mode) === "native" ? "native" : "deepseek"
|
|
99
114
|
// Precedence: a persisted `deepseek_mode` switch wins over the config file
|
|
100
115
|
// until it is cleared with `mode: "config"`.
|
|
@@ -143,10 +158,10 @@ export default Plugin.define({
|
|
|
143
158
|
}
|
|
144
159
|
|
|
145
160
|
const runSearch = async (query: string, signal: AbortSignal): Promise<SearchResult[]> => {
|
|
146
|
-
const problem = validateQuery(query)
|
|
161
|
+
const problem = validateQuery(query, language)
|
|
147
162
|
if (problem) throw new Error(problem)
|
|
148
163
|
try {
|
|
149
|
-
const results = await deepseekSearch(query, apiKey, count, signal)
|
|
164
|
+
const results = await deepseekSearch(query, apiKey, count, language, signal)
|
|
150
165
|
tally(results.length > 0 ? "search" : "empty")
|
|
151
166
|
return results
|
|
152
167
|
} catch (e) {
|
|
@@ -176,7 +191,7 @@ export default Plugin.define({
|
|
|
176
191
|
|
|
177
192
|
const deepseekSearchExecute = async (input: unknown, context: ToolContext): Promise<ToolResult> => {
|
|
178
193
|
const query = readQuery(input)
|
|
179
|
-
const problem = validateQuery(query)
|
|
194
|
+
const problem = validateQuery(query, language)
|
|
180
195
|
if (problem) throw new Error(problem)
|
|
181
196
|
await ctx.permission.assert({
|
|
182
197
|
sessionID: context.sessionID,
|
|
@@ -217,38 +232,38 @@ export default Plugin.define({
|
|
|
217
232
|
return true
|
|
218
233
|
}
|
|
219
234
|
|
|
220
|
-
const describeMode = (): string =>
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
?
|
|
225
|
-
|
|
226
|
-
|
|
235
|
+
const describeMode = (): string => {
|
|
236
|
+
const source = overrideMode ? t(language, "mode.source.override") : t(language, "mode.source.config")
|
|
237
|
+
const suffix =
|
|
238
|
+
mode === "deepseek"
|
|
239
|
+
? searchToolPatched
|
|
240
|
+
? t(language, "mode.suffix.patched")
|
|
241
|
+
: t(language, "mode.suffix.notPatched")
|
|
242
|
+
: t(language, "mode.suffix.native")
|
|
243
|
+
return t(language, "mode.report", { mode, source, suffix })
|
|
244
|
+
}
|
|
227
245
|
|
|
228
246
|
// Shared by the `deepseek_mode` tool and the RPC `setMode` method. The
|
|
229
247
|
// returned strings are the tool's documented output, word for word.
|
|
230
248
|
const switchMode = async (action: Exclude<ModeAction, "status">): Promise<string> => {
|
|
231
249
|
if (!pristine) {
|
|
232
|
-
return (
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
)
|
|
250
|
+
return t(language, "mode.notFound", {
|
|
251
|
+
tool: SEARCH_TOOL,
|
|
252
|
+
mode: action === "config" ? configMode : action,
|
|
253
|
+
})
|
|
236
254
|
}
|
|
237
255
|
if (action === "config") {
|
|
238
256
|
await ctx.storage.remove(MODE_KEY)
|
|
239
257
|
overrideMode = undefined
|
|
240
258
|
await applySearchMode(configMode)
|
|
241
259
|
notifyChanged()
|
|
242
|
-
return `${describeMode()}\n\n
|
|
260
|
+
return `${describeMode()}\n\n${t(language, "mode.configCleared", { mode: configMode })}`
|
|
243
261
|
}
|
|
244
262
|
await ctx.storage.set(MODE_KEY, action)
|
|
245
263
|
overrideMode = action
|
|
246
264
|
await applySearchMode(action)
|
|
247
265
|
notifyChanged()
|
|
248
|
-
return (
|
|
249
|
-
`${describeMode()}\n\n已持久化:重启 OpenCode 后仍为 ${action}。` +
|
|
250
|
-
'要恢复配置文件设置,调用 deepseek_mode 并传 mode: "config"。'
|
|
251
|
-
)
|
|
266
|
+
return `${describeMode()}\n\n${t(language, "mode.persisted", { mode: action })}`
|
|
252
267
|
}
|
|
253
268
|
|
|
254
269
|
// ---------- tools ----------
|
|
@@ -289,11 +304,11 @@ export default Plugin.define({
|
|
|
289
304
|
},
|
|
290
305
|
execute: async (input, context) => {
|
|
291
306
|
const url = String((input as { url?: unknown } | undefined)?.url ?? "").trim()
|
|
292
|
-
const problem = validateUrl(url)
|
|
307
|
+
const problem = validateUrl(url, language)
|
|
293
308
|
if (problem) throw new Error(problem)
|
|
294
309
|
try {
|
|
295
|
-
await context.progress({ status:
|
|
296
|
-
const result = await deepseekFetch(url, apiKey, fetchMaxChars, context.signal)
|
|
310
|
+
await context.progress({ status: t(language, "progress.fetch", { url }) })
|
|
311
|
+
const result = await deepseekFetch(url, apiKey, fetchMaxChars, language, context.signal)
|
|
297
312
|
tally(result.empty ? "empty" : "fetch")
|
|
298
313
|
return {
|
|
299
314
|
content: result.markdown,
|
|
@@ -322,10 +337,10 @@ export default Plugin.define({
|
|
|
322
337
|
execute: async (input) => {
|
|
323
338
|
const reset = Boolean((input as { reset?: unknown } | undefined)?.reset)
|
|
324
339
|
const report = await withLedger(async (ledger) => {
|
|
325
|
-
const text = formatUsage(ledger, pricePerThousand, mode)
|
|
340
|
+
const text = formatUsage(ledger, pricePerThousand, mode, language)
|
|
326
341
|
if (!reset) return text
|
|
327
342
|
await ctx.storage.set(USAGE_KEY, emptyLedger())
|
|
328
|
-
return `${text}\n\n
|
|
343
|
+
return `${text}\n\n${t(language, "usage.reset")}`
|
|
329
344
|
})
|
|
330
345
|
return { content: report }
|
|
331
346
|
},
|
|
@@ -351,7 +366,9 @@ export default Plugin.define({
|
|
|
351
366
|
additionalProperties: false,
|
|
352
367
|
},
|
|
353
368
|
execute: async (input) => {
|
|
354
|
-
const
|
|
369
|
+
const parsed = readModeAction(input)
|
|
370
|
+
if ("invalid" in parsed) throw new Error(t(language, "mode.invalid", { raw: parsed.invalid }))
|
|
371
|
+
const action = parsed.action
|
|
355
372
|
if (action === "status") return { content: describeMode() }
|
|
356
373
|
return { content: await switchMode(action) }
|
|
357
374
|
},
|
|
@@ -372,7 +389,8 @@ export default Plugin.define({
|
|
|
372
389
|
patched: searchToolPatched,
|
|
373
390
|
hasKey: Boolean(apiKey),
|
|
374
391
|
pricePerThousand,
|
|
375
|
-
|
|
392
|
+
language,
|
|
393
|
+
...usageSummaryLines(ledger, language, todayKey),
|
|
376
394
|
// Same ledger read as the strings above, exposed as numbers so the
|
|
377
395
|
// sidebars can lay them out for their own width.
|
|
378
396
|
counts: {
|
|
@@ -384,13 +402,12 @@ export default Plugin.define({
|
|
|
384
402
|
}),
|
|
385
403
|
// The input is plain JSON Schema, hence `unknown`: report, never throw.
|
|
386
404
|
setMode: async (input: unknown): Promise<ModeSwitchResult> => {
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
} catch (e) {
|
|
391
|
-
return { ok: false, mode, message: e instanceof Error ? e.message : String(e) }
|
|
405
|
+
const parsed = readModeAction(input)
|
|
406
|
+
if ("invalid" in parsed) {
|
|
407
|
+
return { ok: false, mode, message: t(language, "mode.invalid", { raw: parsed.invalid }) }
|
|
392
408
|
}
|
|
393
|
-
|
|
409
|
+
const action = parsed.action
|
|
410
|
+
if (action === "status") return { ok: false, mode, message: t(language, "mode.statusOnly") }
|
|
394
411
|
const message = await switchMode(action)
|
|
395
412
|
// `!pristine` means switchMode could not apply the change; its message says why.
|
|
396
413
|
return { ok: Boolean(pristine), mode, message }
|
|
@@ -403,16 +420,16 @@ export default Plugin.define({
|
|
|
403
420
|
`apiKey=${apiKey ? "present" : "MISSING"} count=${count} ` +
|
|
404
421
|
`searchTool=${searchToolPatched ? "deepseek" : "untouched"}`,
|
|
405
422
|
)
|
|
406
|
-
if (!apiKey) console.error(`[deepseek-search] ${missingKeyMessage()}`)
|
|
423
|
+
if (!apiKey) console.error(`[deepseek-search] ${missingKeyMessage("en")}`)
|
|
407
424
|
if (overrideMode && overrideMode !== configMode)
|
|
408
425
|
console.error(
|
|
409
|
-
`[deepseek-search]
|
|
410
|
-
|
|
426
|
+
`[deepseek-search] note: a persisted mode=${overrideMode} overrides the config file's ${configMode}; ` +
|
|
427
|
+
`call deepseek_mode with "config" to clear it.`,
|
|
411
428
|
)
|
|
412
429
|
if (mode === "deepseek" && !searchToolPatched)
|
|
413
430
|
console.error(
|
|
414
|
-
`[deepseek-search]
|
|
415
|
-
|
|
431
|
+
`[deepseek-search] warning: built-in ${SEARCH_TOOL} tool not found; search still uses the built-in backend. ` +
|
|
432
|
+
`Set "websearch": { "provider": "${PROVIDER_ID}" } in opencode.jsonc to force the route.`,
|
|
416
433
|
)
|
|
417
434
|
},
|
|
418
435
|
})
|
package/src/rpc.ts
CHANGED
|
@@ -13,6 +13,17 @@ import type { Rpc } from "@opencode/plugin/rpc"
|
|
|
13
13
|
|
|
14
14
|
export const DEEPSEEK_RPC_ID = "deepseek-search"
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* UI languages the plugin ships. The server resolves one of them once and puts
|
|
18
|
+
* it in `status`, so the tools and both sidebars always speak the same language.
|
|
19
|
+
*/
|
|
20
|
+
export type Language = "en" | "ja" | "zh-CN"
|
|
21
|
+
export const LANGUAGE_VALUES = ["en", "ja", "zh-CN"] as const
|
|
22
|
+
|
|
23
|
+
export function isLanguage(value: unknown): value is Language {
|
|
24
|
+
return value === "en" || value === "ja" || value === "zh-CN"
|
|
25
|
+
}
|
|
26
|
+
|
|
16
27
|
// Shared by the today/month/total slots of `status.counts`.
|
|
17
28
|
const USAGE_COUNTS_SCHEMA = {
|
|
18
29
|
type: "object",
|
|
@@ -43,6 +54,9 @@ export const DeepSeekRpc = {
|
|
|
43
54
|
today: { type: "string" },
|
|
44
55
|
month: { type: "string" },
|
|
45
56
|
total: { type: "string" },
|
|
57
|
+
// Declared but not required: older servers omit it, and readStatus
|
|
58
|
+
// falls back to English without failing the rest of the payload.
|
|
59
|
+
language: { type: "string", enum: ["en", "ja", "zh-CN"] },
|
|
46
60
|
counts: {
|
|
47
61
|
type: "object",
|
|
48
62
|
properties: {
|
|
@@ -102,6 +116,12 @@ export interface SearchStatus {
|
|
|
102
116
|
today: string
|
|
103
117
|
month: string
|
|
104
118
|
total: string
|
|
119
|
+
/**
|
|
120
|
+
* UI language resolved by the server. Required by this interface: `readStatus`
|
|
121
|
+
* always fills it (English when the server is older or sends garbage), so
|
|
122
|
+
* every surface has one value to render with.
|
|
123
|
+
*/
|
|
124
|
+
language: Language
|
|
105
125
|
/** Structured counters, so each surface can format for its own width. Optional for older servers. */
|
|
106
126
|
counts?: { today: UsageCounts; month: UsageCounts; total: UsageCounts }
|
|
107
127
|
}
|
|
@@ -134,13 +154,25 @@ function readUsageCounts(value: unknown): UsageCounts | undefined {
|
|
|
134
154
|
/** Defensive read of an RPC `status` result. Never throws. */
|
|
135
155
|
export function readStatus(raw: unknown): SearchStatus | undefined {
|
|
136
156
|
if (!isRecord(raw)) return undefined
|
|
137
|
-
const { mode, source, patched, hasKey, pricePerThousand, today, month, total, counts } = raw
|
|
157
|
+
const { mode, source, patched, hasKey, pricePerThousand, today, month, total, language, counts } = raw
|
|
138
158
|
if (!isMode(mode)) return undefined
|
|
139
159
|
if (source !== "override" && source !== "config") return undefined
|
|
140
160
|
if (typeof patched !== "boolean" || typeof hasKey !== "boolean") return undefined
|
|
141
161
|
if (typeof pricePerThousand !== "number" || !Number.isFinite(pricePerThousand)) return undefined
|
|
142
162
|
if (typeof today !== "string" || typeof month !== "string" || typeof total !== "string") return undefined
|
|
143
|
-
const status: SearchStatus = {
|
|
163
|
+
const status: SearchStatus = {
|
|
164
|
+
mode,
|
|
165
|
+
source,
|
|
166
|
+
patched,
|
|
167
|
+
hasKey,
|
|
168
|
+
pricePerThousand,
|
|
169
|
+
today,
|
|
170
|
+
month,
|
|
171
|
+
total,
|
|
172
|
+
// An older server has no language; a malformed one is not worth failing the
|
|
173
|
+
// whole payload over. Both fall back to English.
|
|
174
|
+
language: isLanguage(language) ? language : "en",
|
|
175
|
+
}
|
|
144
176
|
// Old servers omit `counts`; a malformed triple is dropped without failing the rest.
|
|
145
177
|
if (isRecord(counts)) {
|
|
146
178
|
const todayCounts = readUsageCounts(counts.today)
|
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 { 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 {
|
|
@@ -58,7 +62,11 @@ export const DEFAULT_COLUMNS = 24
|
|
|
58
62
|
|
|
59
63
|
const PERIODS = ["today", "month", "total"] as const
|
|
60
64
|
type Period = (typeof PERIODS)[number]
|
|
61
|
-
const
|
|
65
|
+
const LABEL_KEY = {
|
|
66
|
+
today: "usage.label.today",
|
|
67
|
+
month: "usage.label.month",
|
|
68
|
+
total: "usage.label.total",
|
|
69
|
+
} as const
|
|
62
70
|
|
|
63
71
|
type Counts = NonNullable<SearchStatus["counts"]>
|
|
64
72
|
|
|
@@ -106,9 +114,9 @@ function clip(text: string, maxColumns: number): string {
|
|
|
106
114
|
}
|
|
107
115
|
|
|
108
116
|
/** One short body line: collapse whitespace, then clip to the available columns. */
|
|
109
|
-
function shortMessage(message: string, maxColumns: number): string {
|
|
117
|
+
function shortMessage(message: string, maxColumns: number, language: Language): string {
|
|
110
118
|
const text = message.replace(/\s+/g, " ").trim()
|
|
111
|
-
return clip(text || "
|
|
119
|
+
return clip(text || t(language, "state.failed"), maxColumns)
|
|
112
120
|
}
|
|
113
121
|
|
|
114
122
|
/** Micro-currency as `¥0.015`: three decimals, trailing zeros trimmed to cents. */
|
|
@@ -142,46 +150,41 @@ function usageLayout(counts: Counts): UsageLayout {
|
|
|
142
150
|
|
|
143
151
|
/**
|
|
144
152
|
* One usage row, e.g. `今日 Search 0 Fetch 3 ¥0.015` when the surface is
|
|
145
|
-
* wide, otherwise shorter forms down to `今日`. The
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
* the three rows read as a table.
|
|
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.
|
|
150
157
|
*/
|
|
151
158
|
function compactUsageRow(
|
|
152
159
|
period: Period,
|
|
153
160
|
counts: UsageCounts,
|
|
154
161
|
layout: UsageLayout,
|
|
155
162
|
maxColumns: number,
|
|
163
|
+
language: Language,
|
|
156
164
|
): string {
|
|
157
|
-
const label =
|
|
165
|
+
const label = t(language, LABEL_KEY[period])
|
|
158
166
|
const search = String(counts.search).padStart(layout.search, " ")
|
|
159
167
|
const fetch = String(counts.fetch).padStart(layout.fetch, " ")
|
|
160
168
|
const cost = money(counts.costMicro).padStart(layout.cost, " ")
|
|
169
|
+
const params = { label, search, fetch, cost }
|
|
161
170
|
// Each candidate is a complete row, so nothing is ever left dangling and no
|
|
162
171
|
// 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) {
|
|
172
|
+
for (const form of pack(language).usageForms) {
|
|
173
|
+
const row = format(form, params)
|
|
173
174
|
if (textColumns(row) <= maxColumns) return row
|
|
174
175
|
}
|
|
175
176
|
return textColumns(label) <= maxColumns ? label : clip(label, maxColumns)
|
|
176
177
|
}
|
|
177
178
|
|
|
178
179
|
/**
|
|
179
|
-
* An older server only sends pre-formatted strings
|
|
180
|
-
*
|
|
181
|
-
*
|
|
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.
|
|
182
185
|
*/
|
|
183
|
-
function legacyUsageRow(period: Period, text: string, maxColumns: number): string {
|
|
184
|
-
const label =
|
|
186
|
+
function legacyUsageRow(period: Period, text: string, maxColumns: number, language: Language): string {
|
|
187
|
+
const label = t(language, LABEL_KEY[period])
|
|
185
188
|
const full = `${label} ${text}`
|
|
186
189
|
if (textColumns(full) <= maxColumns) return full
|
|
187
190
|
const stats = text.split(" · ").filter((part) => !part.startsWith("¥") && !part.startsWith("空/失"))
|
|
@@ -196,18 +199,22 @@ function legacyUsageRow(period: Period, text: string, maxColumns: number): strin
|
|
|
196
199
|
return clip(label, maxColumns)
|
|
197
200
|
}
|
|
198
201
|
|
|
199
|
-
function usageLines(status: SearchStatus, maxColumns: number): readonly string[] {
|
|
202
|
+
function usageLines(status: SearchStatus, maxColumns: number, language: Language): readonly string[] {
|
|
200
203
|
// The desktop panel lays the original verbose copy out as ` · ` chunks of its
|
|
201
204
|
// own table; only the terminal needs the compact form.
|
|
202
205
|
if (maxColumns === Number.POSITIVE_INFINITY) {
|
|
203
|
-
return [
|
|
206
|
+
return [
|
|
207
|
+
`${t(language, "usage.label.today")} ${status.today}`,
|
|
208
|
+
`${t(language, "usage.label.month")} ${status.month}`,
|
|
209
|
+
`${t(language, "usage.label.total")} ${status.total}`,
|
|
210
|
+
]
|
|
204
211
|
}
|
|
205
212
|
const counts = status.counts
|
|
206
213
|
if (counts) {
|
|
207
214
|
const layout = usageLayout(counts)
|
|
208
|
-
return PERIODS.map((period) => compactUsageRow(period, counts[period], layout, maxColumns))
|
|
215
|
+
return PERIODS.map((period) => compactUsageRow(period, counts[period], layout, maxColumns, language))
|
|
209
216
|
}
|
|
210
|
-
return PERIODS.map((period) => legacyUsageRow(period, status[period], maxColumns))
|
|
217
|
+
return PERIODS.map((period) => legacyUsageRow(period, status[period], maxColumns, language))
|
|
211
218
|
}
|
|
212
219
|
|
|
213
220
|
/**
|
|
@@ -275,18 +282,13 @@ function fitTitle(
|
|
|
275
282
|
}
|
|
276
283
|
|
|
277
284
|
/** 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
|
|
285
|
+
function fitHint(maxColumns: number, language: Language): string {
|
|
286
|
+
const forms = pack(language).hintForms
|
|
287
|
+
if (maxColumns === Number.POSITIVE_INFINITY) return forms[0] ?? ""
|
|
288
|
+
for (const form of forms) {
|
|
289
|
+
if (textColumns(form) <= maxColumns) return form
|
|
288
290
|
}
|
|
289
|
-
return clip(
|
|
291
|
+
return clip(forms[forms.length - 1] ?? "", maxColumns)
|
|
290
292
|
}
|
|
291
293
|
|
|
292
294
|
interface ActionRows extends TitleFit {
|
|
@@ -300,33 +302,34 @@ function actionRows(state: string, shortState: string, verb: string, maxColumns:
|
|
|
300
302
|
}
|
|
301
303
|
|
|
302
304
|
export function sidebarRows(input: SidebarInput): SidebarRows {
|
|
303
|
-
const { status, error } = input
|
|
305
|
+
const { status, error, language } = input
|
|
304
306
|
const maxColumns = input.maxColumns ?? DEFAULT_COLUMNS
|
|
305
|
-
const lines = status === undefined ? [] : usageLines(status, maxColumns)
|
|
307
|
+
const lines = status === undefined ? [] : usageLines(status, maxColumns, language)
|
|
306
308
|
|
|
307
309
|
// No status yet: the body has no numbers to show, only the reason (or that
|
|
308
310
|
// the first read is still in flight).
|
|
309
311
|
if (status === undefined) {
|
|
312
|
+
const retry = t(language, "action.retry")
|
|
310
313
|
return error === undefined || error.trim() === ""
|
|
311
314
|
? {
|
|
312
|
-
...actionRows(
|
|
315
|
+
...actionRows(t(language, "state.loading"), t(language, "state.loadingShort"), retry, maxColumns),
|
|
313
316
|
hint: undefined,
|
|
314
317
|
clickable: true,
|
|
315
318
|
lines,
|
|
316
319
|
tone: "idle",
|
|
317
320
|
}
|
|
318
321
|
: {
|
|
319
|
-
...actionRows(
|
|
322
|
+
...actionRows(t(language, "state.failed"), t(language, "state.failedShort"), retry, maxColumns),
|
|
320
323
|
hint: undefined,
|
|
321
324
|
clickable: true,
|
|
322
|
-
lines: [shortMessage(error, maxColumns)],
|
|
325
|
+
lines: [shortMessage(error, maxColumns, language)],
|
|
323
326
|
tone: "error",
|
|
324
327
|
}
|
|
325
328
|
}
|
|
326
329
|
|
|
327
330
|
if (status.patched && status.hasKey) {
|
|
328
331
|
return {
|
|
329
|
-
...actionRows("
|
|
332
|
+
...actionRows(t(language, "state.enabled"), t(language, "state.enabledShort"), t(language, "action.disable"), maxColumns),
|
|
330
333
|
hint: undefined,
|
|
331
334
|
clickable: true,
|
|
332
335
|
lines,
|
|
@@ -337,7 +340,12 @@ export function sidebarRows(input: SidebarInput): SidebarRows {
|
|
|
337
340
|
// DeepSeek was requested but the built-in tool was not found: nothing is
|
|
338
341
|
// actually taken over, so the only honest action is to release the mode.
|
|
339
342
|
return {
|
|
340
|
-
...actionRows(
|
|
343
|
+
...actionRows(
|
|
344
|
+
t(language, "state.unpatched"),
|
|
345
|
+
t(language, "state.unpatchedShort"),
|
|
346
|
+
t(language, "action.release"),
|
|
347
|
+
maxColumns,
|
|
348
|
+
),
|
|
341
349
|
hint: undefined,
|
|
342
350
|
clickable: true,
|
|
343
351
|
lines,
|
|
@@ -346,7 +354,7 @@ export function sidebarRows(input: SidebarInput): SidebarRows {
|
|
|
346
354
|
}
|
|
347
355
|
if (status.mode === "native") {
|
|
348
356
|
return {
|
|
349
|
-
...actionRows("
|
|
357
|
+
...actionRows(t(language, "state.disabled"), t(language, "state.disabledShort"), t(language, "action.enable"), maxColumns),
|
|
350
358
|
hint: undefined,
|
|
351
359
|
clickable: true,
|
|
352
360
|
lines,
|
|
@@ -356,9 +364,9 @@ export function sidebarRows(input: SidebarInput): SidebarRows {
|
|
|
356
364
|
// A missing key is not something a click can fix, so the title row stands
|
|
357
365
|
// alone and the hint gets its own row directly under it. The env var name
|
|
358
366
|
// only survives on surfaces wide enough for it.
|
|
359
|
-
const hint = fitHint(maxColumns)
|
|
367
|
+
const hint = fitHint(maxColumns, language)
|
|
360
368
|
return {
|
|
361
|
-
...fitTitle(
|
|
369
|
+
...fitTitle(t(language, "state.missingKey"), t(language, "state.missingKeyShort"), maxColumns, undefined),
|
|
362
370
|
action: hint,
|
|
363
371
|
hint,
|
|
364
372
|
clickable: false,
|
package/src/tui/card.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
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
|
|
|
@@ -72,6 +72,8 @@ function element(tag: string, props: Props | undefined, children: readonly Child
|
|
|
72
72
|
export function sidebarCard(input: {
|
|
73
73
|
readonly status: SearchStatus | undefined
|
|
74
74
|
readonly error: string | undefined
|
|
75
|
+
/** UI language resolved by the server; the card never picks one itself. */
|
|
76
|
+
readonly language: Language
|
|
75
77
|
readonly theme: ResolvedTheme
|
|
76
78
|
readonly onToggle: () => void
|
|
77
79
|
/**
|
|
@@ -87,6 +89,7 @@ export function sidebarCard(input: {
|
|
|
87
89
|
status: input.status,
|
|
88
90
|
error: input.error,
|
|
89
91
|
maxColumns: cardColumns(input.maxColumns),
|
|
92
|
+
language: input.language,
|
|
90
93
|
})
|
|
91
94
|
const theme = input.theme
|
|
92
95
|
const state = toneColor(theme, rows.tone)
|
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,6 +26,12 @@ 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.
|
|
@@ -35,7 +42,7 @@ export default Plugin.define({
|
|
|
35
42
|
setStatus(next)
|
|
36
43
|
setError(undefined)
|
|
37
44
|
} else {
|
|
38
|
-
setError("
|
|
45
|
+
setError(t(language(), "toast.statusMalformed"))
|
|
39
46
|
}
|
|
40
47
|
} catch (e) {
|
|
41
48
|
setError(describeError(e))
|
|
@@ -46,7 +53,7 @@ export default Plugin.define({
|
|
|
46
53
|
async function applyMode(mode: "deepseek" | "native") {
|
|
47
54
|
try {
|
|
48
55
|
const result = readModeResult(await rpc.setMode({ mode }))
|
|
49
|
-
if (!result) ctx.ui.toast.show({ message: "
|
|
56
|
+
if (!result) ctx.ui.toast.show({ message: t(language(), "toast.modeMalformed"), variant: "error" })
|
|
50
57
|
else if (!result.ok) ctx.ui.toast.show({ message: result.message, variant: "error" })
|
|
51
58
|
} catch (e) {
|
|
52
59
|
ctx.ui.toast.show({ message: describeError(e), variant: "error" })
|
|
@@ -72,11 +79,11 @@ export default Plugin.define({
|
|
|
72
79
|
// entry point that always works (keymap command + `/deepseek` slash).
|
|
73
80
|
async function openMenu() {
|
|
74
81
|
const choice = await ctx.ui.dialog.select<"deepseek" | "native" | "refresh">({
|
|
75
|
-
title:
|
|
82
|
+
title: t(language(), "dialog.title"),
|
|
76
83
|
options: [
|
|
77
|
-
{ title: "
|
|
78
|
-
{ title: "
|
|
79
|
-
{ title: "
|
|
84
|
+
{ title: t(language(), "dialog.enable"), value: "deepseek", description: t(language(), "dialog.enableDesc") },
|
|
85
|
+
{ title: t(language(), "dialog.disable"), value: "native", description: t(language(), "dialog.disableDesc") },
|
|
86
|
+
{ title: t(language(), "dialog.refresh"), value: "refresh", description: t(language(), "dialog.refreshDesc") },
|
|
80
87
|
],
|
|
81
88
|
current: status()?.mode,
|
|
82
89
|
})
|
|
@@ -106,6 +113,7 @@ export default Plugin.define({
|
|
|
106
113
|
sidebarCard({
|
|
107
114
|
status: status(),
|
|
108
115
|
error: error(),
|
|
116
|
+
language: language(),
|
|
109
117
|
theme: ctx.theme,
|
|
110
118
|
onToggle: toggle,
|
|
111
119
|
maxColumns: width(),
|
|
@@ -121,7 +129,7 @@ export default Plugin.define({
|
|
|
121
129
|
commands: [
|
|
122
130
|
{
|
|
123
131
|
id: "deepseek-search.toggle",
|
|
124
|
-
title:
|
|
132
|
+
title: t(language(), "command.toggle"),
|
|
125
133
|
slash: { name: "deepseek" },
|
|
126
134
|
run: () => void openMenu(),
|
|
127
135
|
},
|