@qthinker/opencode-deepseek-search 0.1.1 → 0.2.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 +53 -0
- package/README.md +64 -11
- package/gui-experimental.cjs +1 -0
- package/package.json +38 -9
- package/src/gui/index.tsx +137 -0
- package/src/gui/panel.tsx +154 -0
- package/src/index.ts +79 -21
- package/src/rpc.ts +160 -0
- package/src/status-view.ts +368 -0
- package/src/tui/card.ts +186 -0
- package/src/tui/main.ts +143 -0
- package/src/usage.ts +19 -0
- package/tui.ts +6 -0
package/src/tui/card.ts
ADDED
|
@@ -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
|
+
}
|
package/src/tui/main.ts
ADDED
|
@@ -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
|
+
})
|
package/src/usage.ts
CHANGED
|
@@ -114,3 +114,22 @@ export function formatUsage(ledger: UsageLedger, pricePerThousand: number, mode:
|
|
|
114
114
|
"说明:空结果与失败请求不计费;DeepSeek 以账号侧账单为准,本统计不含其他机器或其他工具的调用。",
|
|
115
115
|
].join("\n")
|
|
116
116
|
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Compact one-line summaries for a status display, keyed by period. Callers add
|
|
120
|
+
* the 今日/本月/累计 labels themselves. Costs are read from the ledger, which
|
|
121
|
+
* already accumulated them at tally time.
|
|
122
|
+
*/
|
|
123
|
+
export function usageSummaryLines(
|
|
124
|
+
ledger: UsageLedger,
|
|
125
|
+
today = dayKey(),
|
|
126
|
+
): { today: string; month: string; total: string } {
|
|
127
|
+
const month = today.slice(0, 7)
|
|
128
|
+
const line = (d: UsageDay) =>
|
|
129
|
+
`搜索 ${d.search} · 抓取 ${d.fetch} · 空/失 ${d.empty + d.failed} · ${formatYuan(d.costMicro)}`
|
|
130
|
+
return {
|
|
131
|
+
today: line(sumDays(ledger, (k) => k === today)),
|
|
132
|
+
month: line(sumDays(ledger, (k) => k.startsWith(month))),
|
|
133
|
+
total: line(sumDays(ledger, () => true)),
|
|
134
|
+
}
|
|
135
|
+
}
|
package/tui.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// TUI entrypoint. OpenCode resolves a configured plugin *directory* by its root
|
|
2
|
+
// conventional file names (`tui` for the TUI half), so this re-export keeps the
|
|
3
|
+
// TUI half loadable without a package `exports` map (a `name`+`exports`
|
|
4
|
+
// manifest switches resolution to the package name, which OpenCode drops when
|
|
5
|
+
// the result falls outside the directory).
|
|
6
|
+
export { default } from "./src/tui/main.ts"
|