@qthinker/opencode-deepseek-search 0.1.1 → 0.2.1

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.
@@ -0,0 +1,368 @@
1
+ // Pure view model for the sidebar card. No renderer, no runtime dependency:
2
+ // the card and the tests both derive their text from here.
3
+ import type { SearchStatus, UsageCounts } from "./rpc.ts"
4
+
5
+ export interface SidebarInput {
6
+ readonly status: SearchStatus | undefined
7
+ readonly error: string | undefined
8
+ /**
9
+ * Columns one row may occupy. Defaults to the narrow sidebar budget; a host
10
+ * that has measured its width may pass it, and the desktop panel passes
11
+ * `Infinity` because it has room for the verbose copy.
12
+ */
13
+ readonly maxColumns?: number
14
+ }
15
+
16
+ export interface SidebarRows {
17
+ /** State headline, e.g. `已启用`; the full name only survives on wide surfaces. */
18
+ readonly title: string
19
+ /**
20
+ * Whether the title row starts with the tone glyph. False only when even the
21
+ * state alone cannot share the row with the action.
22
+ */
23
+ readonly glyph: boolean
24
+ /**
25
+ * Clickable label including its hit-area brackets, e.g. `[停用]`. A state
26
+ * without an action carries its fix copy here instead, where the desktop
27
+ * panel reads it; the terminal renders that copy as `hint`.
28
+ */
29
+ readonly action: string
30
+ /**
31
+ * Whether the action must take a row of its own under the title. True only
32
+ * when the title row cannot hold it: shortening the title is the worse trade,
33
+ * because the full wording is what the user asked for.
34
+ */
35
+ readonly actionBelow: boolean
36
+ /** Non-interactive fix row under the title; only states without an action have one. */
37
+ readonly hint: string | undefined
38
+ /** Whether a click on `action` really performs it (some states have no action). */
39
+ readonly clickable: boolean
40
+ /** Body rows: the three usage rows, or the short error reason. */
41
+ readonly lines: readonly string[]
42
+ /** Drives the state color in card.ts. */
43
+ readonly tone: "on" | "off" | "warn" | "error" | "idle"
44
+ }
45
+
46
+ // The product name in the headline: the user's wording, case included.
47
+ const NAME = "DeepSeek search/fetch"
48
+ // The narrowest headline that still identifies the card; used when the product
49
+ // name above would cost the row too much space.
50
+ const SHORT_NAME = "DeepSeek"
51
+ // The card renders a glyph and a space before the title.
52
+ const TITLE_GLYPH_COLUMNS = 2
53
+ // Minimum space between the title and the action so they never touch.
54
+ const TITLE_ACTION_GAP = 1
55
+ // One card row of the narrowest surface, host padding already subtracted. Also
56
+ // the fallback budget before the host has reported a measured width.
57
+ export const DEFAULT_COLUMNS = 24
58
+
59
+ const PERIODS = ["today", "month", "total"] as const
60
+ type Period = (typeof PERIODS)[number]
61
+ const LABELS: Readonly<Record<Period, string>> = { today: "今日", month: "本月", total: "累计" }
62
+
63
+ type Counts = NonNullable<SearchStatus["counts"]>
64
+
65
+ /** Terminal columns of one code point; only CJK-style wide ranges matter here. */
66
+ function columns(char: string): number {
67
+ const cp = char.codePointAt(0) ?? 0
68
+ if (cp === 0) return 0
69
+ if (
70
+ (cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
71
+ (cp >= 0x2e80 && cp <= 0xa4cf) || // CJK radicals … Yi
72
+ (cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
73
+ (cp >= 0xf900 && cp <= 0xfaff) || // CJK compatibility ideographs
74
+ (cp >= 0xfe10 && cp <= 0xfe19) ||
75
+ (cp >= 0xfe30 && cp <= 0xfe6f) ||
76
+ (cp >= 0xff00 && cp <= 0xff60) || // Fullwidth forms
77
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
78
+ (cp >= 0x1f300 && cp <= 0x1faff) ||
79
+ (cp >= 0x20000 && cp <= 0x3fffd)
80
+ ) {
81
+ return 2
82
+ }
83
+ return 1
84
+ }
85
+
86
+ /** Terminal columns of a string: the measure every card row must fit. */
87
+ export function textColumns(text: string): number {
88
+ let total = 0
89
+ for (const char of text) total += columns(char)
90
+ return total
91
+ }
92
+
93
+ /** Clip to the budget with a single ellipsis; used only as a last resort. */
94
+ function clip(text: string, maxColumns: number): string {
95
+ if (maxColumns < 1) return ""
96
+ if (textColumns(text) <= maxColumns) return text
97
+ let out = ""
98
+ let used = 0
99
+ for (const char of text) {
100
+ const width = columns(char)
101
+ if (used + width > maxColumns - 1) return `${out}…`
102
+ out += char
103
+ used += width
104
+ }
105
+ return out
106
+ }
107
+
108
+ /** One short body line: collapse whitespace, then clip to the available columns. */
109
+ function shortMessage(message: string, maxColumns: number): string {
110
+ const text = message.replace(/\s+/g, " ").trim()
111
+ return clip(text || "读取失败", maxColumns)
112
+ }
113
+
114
+ /** Micro-currency as `¥0.015`: three decimals, trailing zeros trimmed to cents. */
115
+ function money(costMicro: number): string {
116
+ const value = Number.isFinite(costMicro) && costMicro > 0 ? costMicro / 1_000_000 : 0
117
+ let text = value.toFixed(3)
118
+ // `0.040` → `0.04`, `0.015` unchanged; keep two decimals so amounts stay currency-shaped.
119
+ while (text.endsWith("0") && text.length > 4) text = text.slice(0, -1)
120
+ return `¥${text}`
121
+ }
122
+
123
+ interface UsageLayout {
124
+ readonly search: number
125
+ readonly fetch: number
126
+ readonly cost: number
127
+ }
128
+
129
+ /**
130
+ * Column widths shared by the three usage rows. Counting digits is enough:
131
+ * every count is a non-negative integer, so its width is its digit count. The
132
+ * amount is padded too, so all three rows end on the same column.
133
+ */
134
+ function usageLayout(counts: Counts): UsageLayout {
135
+ const rows = PERIODS.map((period) => counts[period])
136
+ return {
137
+ search: Math.max(...rows.map((row) => String(row.search).length)),
138
+ fetch: Math.max(...rows.map((row) => String(row.fetch).length)),
139
+ cost: Math.max(...rows.map((row) => money(row.costMicro).length)),
140
+ }
141
+ }
142
+
143
+ /**
144
+ * 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.
150
+ */
151
+ function compactUsageRow(
152
+ period: Period,
153
+ counts: UsageCounts,
154
+ layout: UsageLayout,
155
+ maxColumns: number,
156
+ ): string {
157
+ const label = LABELS[period]
158
+ const search = String(counts.search).padStart(layout.search, " ")
159
+ const fetch = String(counts.fetch).padStart(layout.fetch, " ")
160
+ const cost = money(counts.costMicro).padStart(layout.cost, " ")
161
+ // Each candidate is a complete row, so nothing is ever left dangling and no
162
+ // 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) {
173
+ if (textColumns(row) <= maxColumns) return row
174
+ }
175
+ return textColumns(label) <= maxColumns ? label : clip(label, maxColumns)
176
+ }
177
+
178
+ /**
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.
182
+ */
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("空/失"))
188
+ const candidates = [
189
+ `${label} ${stats.join(" ")}`.trimEnd(),
190
+ `${label} ${stats[0] ?? ""}`.trimEnd(),
191
+ label,
192
+ ]
193
+ for (const row of candidates) {
194
+ if (textColumns(row) <= maxColumns) return row
195
+ }
196
+ return clip(label, maxColumns)
197
+ }
198
+
199
+ function usageLines(status: SearchStatus, maxColumns: number): readonly string[] {
200
+ // The desktop panel lays the original verbose copy out as ` · ` chunks of its
201
+ // own table; only the terminal needs the compact form.
202
+ if (maxColumns === Number.POSITIVE_INFINITY) {
203
+ return [`今日 ${status.today}`, `本月 ${status.month}`, `累计 ${status.total}`]
204
+ }
205
+ const counts = status.counts
206
+ if (counts) {
207
+ const layout = usageLayout(counts)
208
+ return PERIODS.map((period) => compactUsageRow(period, counts[period], layout, maxColumns))
209
+ }
210
+ return PERIODS.map((period) => legacyUsageRow(period, status[period], maxColumns))
211
+ }
212
+
213
+ /**
214
+ * The clickable label, always bracketed: a bare two-character verb is an easy
215
+ * target to miss. At pathological widths the verb is clipped but the brackets
216
+ * stay, so the hit area never turns into plain text.
217
+ */
218
+ function fitAction(verb: string, maxColumns: number): string {
219
+ const label = `[${verb}]`
220
+ if (maxColumns === Number.POSITIVE_INFINITY || textColumns(label) <= maxColumns) return label
221
+ const inner = maxColumns - 2
222
+ return inner >= 1 ? `[${clip(verb, inner)}]` : clip(label, maxColumns)
223
+ }
224
+
225
+ interface TitleFit {
226
+ readonly title: string
227
+ readonly glyph: boolean
228
+ /** The action takes its own row, right-aligned, instead of sharing this one. */
229
+ readonly actionBelow: boolean
230
+ }
231
+
232
+ /**
233
+ * The headline and where the action goes. The wording is the user's, so it is
234
+ * only shortened once no placement can hold it: first the title and the action
235
+ * share a row (the preferred layout), then the action moves to a row of its own
236
+ * and the title keeps the full width, and only then do the product name and the
237
+ * wordy state shrink. Within one wording the glyph is the first thing to go,
238
+ * because it costs two columns and the state word is right beside it.
239
+ */
240
+ function fitTitle(
241
+ state: string,
242
+ shortState: string,
243
+ maxColumns: number,
244
+ actionColumns: number | undefined,
245
+ ): TitleFit {
246
+ const budget = (glyph: boolean, actionBelow: boolean): number => {
247
+ if (maxColumns === Number.POSITIVE_INFINITY) return Number.POSITIVE_INFINITY
248
+ const prefix = glyph ? TITLE_GLYPH_COLUMNS : 0
249
+ const tail = actionColumns === undefined || actionBelow ? 0 : actionColumns + TITLE_ACTION_GAP
250
+ return Math.max(0, maxColumns - prefix - tail)
251
+ }
252
+ const candidates = [
253
+ `${NAME} · ${state}`,
254
+ `${SHORT_NAME} · ${state}`,
255
+ `${SHORT_NAME} · ${shortState}`,
256
+ state,
257
+ shortState,
258
+ ]
259
+ // Inline first, then the action below it; a wording is never traded for a
260
+ // shorter one that happens to fit inline, because the wording is the point.
261
+ const placements = actionColumns === undefined ? [false] : [false, true]
262
+ for (const candidate of candidates) {
263
+ for (const actionBelow of placements) {
264
+ for (const glyph of [true, false]) {
265
+ if (textColumns(candidate) <= budget(glyph, actionBelow)) {
266
+ return { title: candidate, glyph, actionBelow }
267
+ }
268
+ }
269
+ }
270
+ }
271
+ // 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
274
+ return { title: clip(shortState, budget(false, actionBelow)), glyph: false, actionBelow }
275
+ }
276
+
277
+ /** 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
288
+ }
289
+ return clip(candidates[candidates.length - 1], maxColumns)
290
+ }
291
+
292
+ interface ActionRows extends TitleFit {
293
+ readonly action: string
294
+ }
295
+
296
+ /** 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 {
298
+ const action = fitAction(verb, maxColumns)
299
+ return { ...fitTitle(state, shortState, maxColumns, textColumns(action)), action }
300
+ }
301
+
302
+ export function sidebarRows(input: SidebarInput): SidebarRows {
303
+ const { status, error } = input
304
+ const maxColumns = input.maxColumns ?? DEFAULT_COLUMNS
305
+ const lines = status === undefined ? [] : usageLines(status, maxColumns)
306
+
307
+ // No status yet: the body has no numbers to show, only the reason (or that
308
+ // the first read is still in flight).
309
+ if (status === undefined) {
310
+ return error === undefined || error.trim() === ""
311
+ ? {
312
+ ...actionRows("正在读取…", "正在读取…", "重试", maxColumns),
313
+ hint: undefined,
314
+ clickable: true,
315
+ lines,
316
+ tone: "idle",
317
+ }
318
+ : {
319
+ ...actionRows("读取失败", "读取失败", "重试", maxColumns),
320
+ hint: undefined,
321
+ clickable: true,
322
+ lines: [shortMessage(error, maxColumns)],
323
+ tone: "error",
324
+ }
325
+ }
326
+
327
+ if (status.patched && status.hasKey) {
328
+ return {
329
+ ...actionRows("已启用", "已启用", "停用", maxColumns),
330
+ hint: undefined,
331
+ clickable: true,
332
+ lines,
333
+ tone: "on",
334
+ }
335
+ }
336
+ if (!status.patched && status.mode === "deepseek") {
337
+ // DeepSeek was requested but the built-in tool was not found: nothing is
338
+ // actually taken over, so the only honest action is to release the mode.
339
+ return {
340
+ ...actionRows("未接管内置 websearch", "未接管", "交还", maxColumns),
341
+ hint: undefined,
342
+ clickable: true,
343
+ lines,
344
+ tone: "warn",
345
+ }
346
+ }
347
+ if (status.mode === "native") {
348
+ return {
349
+ ...actionRows("已停用", "已停用", "启用", maxColumns),
350
+ hint: undefined,
351
+ clickable: true,
352
+ lines,
353
+ tone: "off",
354
+ }
355
+ }
356
+ // A missing key is not something a click can fix, so the title row stands
357
+ // alone and the hint gets its own row directly under it. The env var name
358
+ // only survives on surfaces wide enough for it.
359
+ const hint = fitHint(maxColumns)
360
+ return {
361
+ ...fitTitle("未配置 API Key", "缺 API Key", maxColumns, undefined),
362
+ action: hint,
363
+ hint,
364
+ clickable: false,
365
+ lines,
366
+ tone: "error",
367
+ }
368
+ }
@@ -0,0 +1,186 @@
1
+ // The `sidebar.content` card: no frame of its own (the host sidebar already has
2
+ // one), one column, full width, and only the action label is interactive.
3
+ import type { JSX } from "@opentui/solid"
4
+ import type { ResolvedTheme } from "@opencode/theme/tui"
5
+ import { createElement, insert, setProp } from "@opentui/solid"
6
+ import type { SearchStatus } from "../rpc.ts"
7
+ import { DEFAULT_COLUMNS, sidebarRows } from "../status-view.ts"
8
+ import type { SidebarRows } from "../status-view.ts"
9
+
10
+ /** A measured width under this cannot show even the usage label; treat it as unmeasured. */
11
+ const MIN_CARD_COLUMNS = 8
12
+
13
+ /**
14
+ * Columns the card may use. Before the first layout pass the host reports 0 (or
15
+ * nothing at all), so both — and any other unusable value — fall back to the
16
+ * narrow default: the first frame is a whole card rather than a one-column
17
+ * strip, and the next measurement replaces it.
18
+ */
19
+ export function cardColumns(measured: number | undefined): number {
20
+ if (measured === undefined || !Number.isFinite(measured) || measured < MIN_CARD_COLUMNS) {
21
+ return DEFAULT_COLUMNS
22
+ }
23
+ return Math.floor(measured)
24
+ }
25
+
26
+ type Node = ReturnType<typeof createElement>
27
+ type Child = string | Node
28
+ type Props = Readonly<Record<string, unknown>>
29
+ type Color = ResolvedTheme["text"]["base"]
30
+ type Tone = SidebarRows["tone"]
31
+
32
+ /** Text-presentation symbols, never emoji. */
33
+ const GLYPH: Readonly<Record<Tone, string>> = {
34
+ on: "●",
35
+ off: "○",
36
+ warn: "◐",
37
+ error: "!",
38
+ idle: "…",
39
+ }
40
+
41
+ function toneColor(theme: ResolvedTheme, tone: Tone): Color {
42
+ switch (tone) {
43
+ case "on":
44
+ return theme.text.feedback.success.base
45
+ case "warn":
46
+ return theme.text.feedback.warning.base
47
+ case "error":
48
+ return theme.text.feedback.error.base
49
+ case "off":
50
+ return theme.text.muted
51
+ case "idle":
52
+ return theme.text.faint
53
+ }
54
+ }
55
+
56
+ /**
57
+ * JSX-free element builder. Children go in one `insert` call: a second call
58
+ * would replace the first, because Solid tracks no anchor between calls.
59
+ */
60
+ function element(tag: string, props: Props | undefined, children: readonly Child[] = []): Node {
61
+ const node = createElement(tag)
62
+ if (props) {
63
+ for (const key of Object.keys(props)) {
64
+ const value = props[key]
65
+ if (value !== undefined) setProp(node, key, value)
66
+ }
67
+ }
68
+ if (children.length > 0) insert(node, children)
69
+ return node
70
+ }
71
+
72
+ export function sidebarCard(input: {
73
+ readonly status: SearchStatus | undefined
74
+ readonly error: string | undefined
75
+ readonly theme: ResolvedTheme
76
+ readonly onToggle: () => void
77
+ /**
78
+ * Columns the sidebar really gives the card, measured by the host. Optional
79
+ * so the slot still builds before that measurement exists; the fallback in
80
+ * `cardColumns` covers 0 and any unusable value.
81
+ */
82
+ readonly maxColumns?: number
83
+ }): JSX.Element {
84
+ // The terminal sidebar is the surface this ladder is tuned for: the measured
85
+ // width, not a guessed constant, decides whether the words fit.
86
+ const rows = sidebarRows({
87
+ status: input.status,
88
+ error: input.error,
89
+ maxColumns: cardColumns(input.maxColumns),
90
+ })
91
+ const theme = input.theme
92
+ const state = toneColor(theme, rows.tone)
93
+
94
+ const onAction = (event: { preventDefault(): void }) => {
95
+ event.preventDefault()
96
+ input.onToggle()
97
+ }
98
+
99
+ // The title row is a flex row so the action sits at the card's right edge
100
+ // instead of trailing the title. `space-between` is what guarantees that: the
101
+ // title grows into the gap, and if the host ever ignores `flexGrow` the action
102
+ // is still pushed to the far edge. The title is measured by the view model and
103
+ // never wraps; the action keeps its own width and owns the only click handler,
104
+ // so a click can never toggle twice.
105
+ //
106
+ // The state word carries the tone color, so a narrow sidebar that has to drop
107
+ // the glyph still shows the state at a glance (the desktop panel does the same).
108
+ const separator = rows.title.indexOf(" · ")
109
+ const titleSpans: Child[] =
110
+ separator < 0
111
+ ? [element("span", { style: { fg: state } }, [rows.title])]
112
+ : [
113
+ element("span", { style: { fg: theme.text.base } }, [rows.title.slice(0, separator)]),
114
+ element("span", { style: { fg: theme.text.faint } }, [" · "]),
115
+ element("span", { style: { fg: state } }, [rows.title.slice(separator + 3)]),
116
+ ]
117
+ if (rows.glyph) {
118
+ titleSpans.unshift(element("span", { style: { fg: state } }, [`${GLYPH[rows.tone]} `]))
119
+ }
120
+ const title = element(
121
+ "text",
122
+ { selectable: false, wrapMode: "none", flexGrow: 1, flexShrink: 1 },
123
+ titleSpans,
124
+ )
125
+ // A state without an action (a missing key) keeps the title row to itself;
126
+ // its fix copy is rendered as the hint row instead.
127
+ const action =
128
+ rows.clickable && rows.action.length > 0
129
+ ? element(
130
+ "text",
131
+ {
132
+ selectable: false,
133
+ wrapMode: "none",
134
+ flexShrink: 0,
135
+ onMouseUp: onAction,
136
+ },
137
+ [element("span", { style: { fg: theme.text.muted } }, [rows.action])],
138
+ )
139
+ : undefined
140
+ // The model decides whether the action can share the title row. When it
141
+ // cannot, the action gets a row of its own but keeps the same right edge: the
142
+ // title is never shortened just to squeeze the action in beside it.
143
+ const titleRow = element(
144
+ "box",
145
+ { flexDirection: "row", justifyContent: "space-between", flexShrink: 0, width: "100%" },
146
+ action === undefined || rows.actionBelow ? [title] : [title, action],
147
+ )
148
+ const actionRow =
149
+ action === undefined || !rows.actionBelow
150
+ ? undefined
151
+ : element(
152
+ "box",
153
+ { flexDirection: "row", justifyContent: "flex-end", flexShrink: 0, width: "100%" },
154
+ [action],
155
+ )
156
+
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
+ )
162
+ // A missing key has no action, so the fix hint gets the row right under the
163
+ // title. It is plain text: there is nothing to click.
164
+ const hint =
165
+ rows.hint === undefined
166
+ ? []
167
+ : [
168
+ element("text", {
169
+ content: rows.hint,
170
+ fg: theme.text.muted,
171
+ wrapMode: "none",
172
+ width: "100%",
173
+ }),
174
+ ]
175
+
176
+ const root = element("box", { flexDirection: "column", flexShrink: 0, width: "100%" }, [
177
+ titleRow,
178
+ ...(actionRow === undefined ? [] : [actionRow]),
179
+ ...hint,
180
+ ...body,
181
+ ])
182
+
183
+ // createElement returns a renderable; the slot API is typed with Solid's
184
+ // DOM-flavoured JSX.Element. This is the one boundary between the two.
185
+ return root as unknown as JSX.Element
186
+ }
@@ -0,0 +1,143 @@
1
+ // TUI half of the plugin: renders the DeepSeek status card in the session
2
+ // sidebar and toggles the search takeover through the shared RPC contract.
3
+ //
4
+ // No JSX and no build step: the card is a hand-written element tree built from
5
+ // `@opentui/solid`'s `createElement` / `setProp` / `insert`, so this file loads
6
+ // through Node's type stripping like the server half.
7
+
8
+ import { Plugin } from "@opencode/plugin/tui"
9
+ import { createElement, insert, setProp, type JSX } from "@opentui/solid"
10
+ import type { Renderable } from "@opentui/core"
11
+ import { createSignal } from "solid-js"
12
+ import { DeepSeekRpc, readModeResult, readStatus, type SearchStatus } from "../rpc.ts"
13
+ import { sidebarCard } from "./card.ts"
14
+
15
+ const REFRESH_MS = 10_000
16
+ const ERROR_MAX = 80
17
+
18
+ export default Plugin.define({
19
+ // Must match the server plugin id: slot keys and the RPC id derive from it.
20
+ id: "deepseek-search",
21
+ async setup(ctx) {
22
+ const rpc = ctx.client.rpc(DeepSeekRpc)
23
+ const [status, setStatus] = createSignal<SearchStatus | undefined>()
24
+ const [error, setError] = createSignal<string | undefined>()
25
+
26
+ const describeError = (e: unknown) => (e instanceof Error ? e.message : String(e)).slice(0, ERROR_MAX)
27
+
28
+ // `status` takes a required first argument: plain JSON Schema degrades the
29
+ // input type to `unknown`, and the host validates the payload against the
30
+ // method's object schema, so an omitted input is rejected as invalid.
31
+ async function refresh() {
32
+ try {
33
+ const next = readStatus(await rpc.status({}))
34
+ if (next) {
35
+ setStatus(next)
36
+ setError(undefined)
37
+ } else {
38
+ setError("状态数据格式不符")
39
+ }
40
+ } catch (e) {
41
+ setError(describeError(e))
42
+ }
43
+ ctx.renderer.requestRender()
44
+ }
45
+
46
+ async function applyMode(mode: "deepseek" | "native") {
47
+ try {
48
+ const result = readModeResult(await rpc.setMode({ mode }))
49
+ if (!result) ctx.ui.toast.show({ message: "模式切换结果格式不符", variant: "error" })
50
+ else if (!result.ok) ctx.ui.toast.show({ message: result.message, variant: "error" })
51
+ } catch (e) {
52
+ ctx.ui.toast.show({ message: describeError(e), variant: "error" })
53
+ } finally {
54
+ // The `changed` event and the timer also refresh; doing it here keeps
55
+ // the card correct even if event delivery is unavailable.
56
+ await refresh()
57
+ }
58
+ }
59
+
60
+ // 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.
62
+ function toggle() {
63
+ const current = status()
64
+ if (!current || error()) {
65
+ void refresh()
66
+ return
67
+ }
68
+ void applyMode(current.mode === "deepseek" ? "native" : "deepseek")
69
+ }
70
+
71
+ // Mouse row on the card is the primary switch; this menu is the fallback
72
+ // entry point that always works (keymap command + `/deepseek` slash).
73
+ async function openMenu() {
74
+ const choice = await ctx.ui.dialog.select<"deepseek" | "native" | "refresh">({
75
+ title: "DeepSeek 搜索",
76
+ options: [
77
+ { title: "启用", value: "deepseek", description: "接管内置 websearch" },
78
+ { title: "停用", value: "native", description: "把内置 websearch 交还宿主" },
79
+ { title: "刷新", value: "refresh", description: "重新读取状态与统计" },
80
+ ],
81
+ current: status()?.mode,
82
+ })
83
+ if (choice === undefined) return
84
+ if (choice === "refresh") await refresh()
85
+ else await applyMode(choice)
86
+ }
87
+
88
+ // The host calls `render` each time it mounts this slot, so it builds a
89
+ // fresh root and lets `insert` own the reactive child that repaints the card
90
+ // when the signals change. A root created once in setup cannot be mounted
91
+ // by a second render call (the published plugins build per call too).
92
+ // The sidebar's real column count decides whether the card can show the
93
+ // wordy usage rows; the host only reports it after layout, so the first
94
+ // render falls back to the model's own budget and this repaints on resize.
95
+ const [width, setWidth] = createSignal(0)
96
+ const disposeSlot = ctx.ui.slot({
97
+ append: "sidebar.content",
98
+ render: () => {
99
+ // `createElement` is typed as the base class; the laid-out node is a
100
+ // Renderable, whose `width` is what the card needs. One cast here.
101
+ const root = createElement("box") as unknown as Renderable
102
+ setProp(root, "width", "100%")
103
+ setProp(root, "flexDirection", "column")
104
+ setProp(root, "onSizeChange", () => setWidth(root.width))
105
+ insert(root, () =>
106
+ sidebarCard({
107
+ status: status(),
108
+ error: error(),
109
+ theme: ctx.theme,
110
+ onToggle: toggle,
111
+ maxColumns: width(),
112
+ }),
113
+ )
114
+ // `render` is declared to return JSX.Element while the tree here is a
115
+ // hand-built renderable: cast once, at the single boundary.
116
+ return root as unknown as JSX.Element
117
+ },
118
+ })
119
+
120
+ ctx.keymap.layer(() => ({
121
+ commands: [
122
+ {
123
+ id: "deepseek-search.toggle",
124
+ title: "DeepSeek: 启用/停用搜索接管",
125
+ slash: { name: "deepseek" },
126
+ run: () => void openMenu(),
127
+ },
128
+ ],
129
+ }))
130
+
131
+ // `changed` is the primary refresh path; the timer is the fallback because
132
+ // cross-half event delivery cannot be verified inside this repository.
133
+ const off = rpc.events.on("changed", () => void refresh())
134
+ const timer = setInterval(() => void refresh(), REFRESH_MS)
135
+ void refresh()
136
+
137
+ return () => {
138
+ disposeSlot()
139
+ off()
140
+ clearInterval(timer)
141
+ }
142
+ },
143
+ })