opencode-deepseek-peak 0.1.0 → 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/install.mjs CHANGED
@@ -24,28 +24,32 @@ async function install() {
24
24
  await mkdir(directory, { recursive: true })
25
25
  const jsoncPath = join(directory, "opencode.jsonc")
26
26
  const jsonc = await readOptional(jsoncPath)
27
+ const tuiJsoncPath = join(directory, "tui.jsonc")
28
+ const tuiJsonc = await readOptional(tuiJsoncPath)
29
+ // [path, $schema, list key]: V1 TUI plugins live in tui.json under `plugin`.
27
30
  const targets = [
28
- [jsonc !== undefined ? jsoncPath : join(directory, "opencode.json"), "https://opencode.ai/config.json"],
29
- [join(directory, "cli.json"), "https://opencode.ai/v2/cli.json"],
31
+ [jsonc !== undefined ? jsoncPath : join(directory, "opencode.json"), "https://opencode.ai/config.json", "plugins"],
32
+ [join(directory, "cli.json"), "https://opencode.ai/v2/cli.json", "plugins"],
33
+ [tuiJsonc !== undefined ? tuiJsoncPath : join(directory, "tui.json"), "https://opencode.ai/tui.json", "plugin"],
30
34
  ]
31
- // Validate both documents before writing either one. JSONC edits preserve
35
+ // Validate every document before writing any of them. JSONC edits preserve
32
36
  // comments, trailing commas and unrelated settings.
33
37
  const updates = []
34
- for (const [path, schema] of targets) {
35
- const text = await readOptional(path) ?? JSON.stringify({ $schema: schema, plugins: [] }, null, 2) + "\n"
38
+ for (const [path, schema, key] of targets) {
39
+ const text = await readOptional(path) ?? JSON.stringify({ $schema: schema, [key]: [] }, null, 2) + "\n"
36
40
  const errors = []
37
41
  const config = parse(text, errors, { allowTrailingComma: true })
38
42
  if (errors.length || !config || typeof config !== "object" || Array.isArray(config)) {
39
43
  throw new Error(`Invalid configuration: ${path}`)
40
44
  }
41
- const plugins = config.plugins ?? []
42
- if (!Array.isArray(plugins)) throw new Error(`Expected a plugins array: ${path}`)
45
+ const plugins = config[key] ?? []
46
+ if (!Array.isArray(plugins)) throw new Error(`Expected a ${key} array: ${path}`)
43
47
  const present = plugins.some((item) => {
44
48
  const spec = typeof item === "string" ? item : item?.package
45
49
  return spec === PLUGIN_SPEC || spec?.startsWith(`${PLUGIN_SPEC}@`)
46
50
  })
47
51
  if (present) continue
48
- const result = applyEdits(text, modify(text, ["plugins"], [...plugins, PLUGIN_SPEC], {
52
+ const result = applyEdits(text, modify(text, [key], [...plugins, PLUGIN_SPEC], {
49
53
  formattingOptions: { insertSpaces: true, tabSize: 2 },
50
54
  }))
51
55
  updates.push([path, result])
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-deepseek-peak",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "OpenCode V2 plugin showing DeepSeek peak and off-peak pricing periods",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -51,6 +51,8 @@
51
51
  "jsonc-parser": "^3.3.1"
52
52
  },
53
53
  "devDependencies": {
54
+ "@opencode-ai/plugin": "^1.18.16",
55
+ "@opencode-ai/sdk": "^1.18.16",
54
56
  "@opencode/plugin": "^2.0.16",
55
57
  "@opentui/core": "^0.5.10",
56
58
  "@opentui/solid": "^0.5.10",
package/src/v1/tui.tsx ADDED
@@ -0,0 +1,275 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+
3
+ import type { TuiDialogStack, TuiPlugin, TuiPluginApi, TuiSlotContext, TuiThemeCurrent } from "@opencode-ai/plugin/tui"
4
+ import { createMemo, createSignal, For, Show } from "solid-js"
5
+ import { evidenceLabel, statusLabel } from "../core/display.js"
6
+ import { createT, detectLang, LANG_META, type LangCode } from "../i18n.js"
7
+ import { scheduleInfo } from "../schedule.js"
8
+ import type { PeakStatus } from "../types.js"
9
+
10
+ const KV = {
11
+ lang: "deepseek-peak.lang",
12
+ sidebar: "deepseek-peak.sidebar",
13
+ evidence: "deepseek-peak.evidence",
14
+ footer: "deepseek-peak.footer",
15
+ toast: "deepseek-peak.toast",
16
+ } as const
17
+
18
+ const REFRESH_MS = 30_000
19
+
20
+ function stringList(value: unknown, fallback: readonly string[]): string[] {
21
+ return Array.isArray(value) && value.every((item) => typeof item === "string") ? value : [...fallback]
22
+ }
23
+
24
+ function createStatus(sessionID: string): PeakStatus {
25
+ const schedule = scheduleInfo(new Date())
26
+ return {
27
+ sessionID,
28
+ providerID: "deepseek",
29
+ modelID: "deepseek",
30
+ period: schedule.period,
31
+ scheduledPeriod: schedule.period,
32
+ source: "official-schedule",
33
+ phase: "request",
34
+ mismatch: false,
35
+ observedAt: Date.now(),
36
+ evidence: schedule.holiday ? `Chinese public holiday: ${schedule.holiday}` : "DeepSeek official UTC pricing schedule",
37
+ holiday: schedule.holiday,
38
+ }
39
+ }
40
+
41
+ function StatusPanel(props: {
42
+ colors: () => TuiThemeCurrent
43
+ status: () => PeakStatus
44
+ lang: () => LangCode
45
+ evidence: () => boolean
46
+ }) {
47
+ const t = createT(props.lang)
48
+ const segments = createMemo(() => {
49
+ const current = props.status()
50
+ const theme = props.colors()
51
+ const peak = current.period === "peak"
52
+ const out = [
53
+ { text: "DeepSeek ", color: theme.textMuted },
54
+ { text: `● ${t(peak ? "period.peak" : "period.offPeak")}`, color: peak ? theme.text : theme.textMuted },
55
+ ]
56
+ if (current.mismatch) out.push({ text: ` · ${t("label.mismatch")} ⚠`, color: theme.warning })
57
+ return out
58
+ })
59
+ return (
60
+ <box flexDirection="column" alignItems="flex-start" alignSelf="flex-start">
61
+ <text>
62
+ <For each={segments()}>{(segment) => <span style={{ fg: segment.color }}>{segment.text}</span>}</For>
63
+ </text>
64
+ <Show when={props.evidence()}>
65
+ <text fg={props.colors().textMuted}>{evidenceLabel(props.status(), t)}</text>
66
+ </Show>
67
+ </box>
68
+ )
69
+ }
70
+
71
+ function StatusFooter(props: { colors: () => TuiThemeCurrent; status: () => PeakStatus; lang: () => LangCode }) {
72
+ const t = createT(props.lang)
73
+ const segments = createMemo(() => {
74
+ const current = props.status()
75
+ const theme = props.colors()
76
+ const peak = current.period === "peak"
77
+ return [
78
+ { text: `DS ${t(peak ? "period.peak" : "period.offPeak")}`, color: peak ? theme.text : theme.textMuted },
79
+ { text: " ·", color: theme.textMuted },
80
+ ]
81
+ })
82
+ return (
83
+ <text>
84
+ <For each={segments()}>{(segment) => <span style={{ fg: segment.color }}>{segment.text}</span>}</For>
85
+ </text>
86
+ )
87
+ }
88
+
89
+ /**
90
+ * V1 TUI plugin: OpenCode V1 has no server-side hooks here, so the official
91
+ * schedule alone drives the indicator (no API header source of truth).
92
+ */
93
+ export const tui: TuiPlugin = async (api: TuiPluginApi, options) => {
94
+ const providerIDs = new Set(stringList((options as Record<string, unknown> | undefined)?.providerIDs, ["deepseek"]))
95
+
96
+ const [lang, setLang] = createSignal<LangCode>(detectLang())
97
+ const [showSidebar, setShowSidebar] = createSignal(true)
98
+ const [showEvidence, setShowEvidence] = createSignal(true)
99
+ const [showFooter, setShowFooter] = createSignal(true)
100
+ const [showToast, setShowToast] = createSignal(true)
101
+ const [status, setStatus] = createSignal<PeakStatus>(createStatus(""))
102
+ const [revision, setRevision] = createSignal(0)
103
+ const t = createT(lang)
104
+
105
+ // KV may not be ready during setup; restore persisted preferences when it is.
106
+ const restore = () => {
107
+ const stored = String(api.kv.get(KV.lang, "") ?? "")
108
+ if (LANG_META.some((meta) => meta.code === stored)) setLang(stored as LangCode)
109
+ setShowSidebar(api.kv.get<boolean>(KV.sidebar, true) !== false)
110
+ setShowEvidence(api.kv.get<boolean>(KV.evidence, true) !== false)
111
+ setShowFooter(api.kv.get<boolean>(KV.footer, true) !== false)
112
+ setShowToast(api.kv.get<boolean>(KV.toast, true) !== false)
113
+ }
114
+ if (api.kv.ready) {
115
+ restore()
116
+ } else {
117
+ const timer = setInterval(() => {
118
+ if (!api.kv.ready) return
119
+ clearInterval(timer)
120
+ restore()
121
+ setRevision((value) => value + 1)
122
+ }, 100)
123
+ api.lifecycle.onDispose(() => clearInterval(timer))
124
+ }
125
+
126
+ let lastPeriod = status().period
127
+ const refresh = () => {
128
+ const next = createStatus(status().sessionID)
129
+ setStatus(next)
130
+ setRevision((value) => value + 1)
131
+ if (next.period !== lastPeriod) {
132
+ lastPeriod = next.period
133
+ if (showToast()) {
134
+ api.ui.toast({
135
+ title: t("toast.title"),
136
+ message: statusLabel(next, t),
137
+ variant: next.period === "peak" ? "warning" : "success",
138
+ duration: 4500,
139
+ })
140
+ }
141
+ }
142
+ }
143
+ const timer = setInterval(refresh, REFRESH_MS)
144
+ api.lifecycle.onDispose(() => clearInterval(timer))
145
+ const offSession = api.event.on("session.updated", refresh)
146
+ api.lifecycle.onDispose(offSession)
147
+
148
+ const sessionProvider = (sessionID: string) => {
149
+ if (!sessionID) return undefined
150
+ try {
151
+ return api.state.session.get(sessionID)?.model?.providerID ?? undefined
152
+ } catch {
153
+ return undefined
154
+ }
155
+ }
156
+ const relevant = (sessionID: string) => {
157
+ revision()
158
+ const providerID = sessionProvider(sessionID)
159
+ return providerID === undefined || providerIDs.has(providerID)
160
+ }
161
+
162
+ api.slots.register({
163
+ order: 60,
164
+ slots: {
165
+ sidebar_content(ctx: TuiSlotContext, input: { session_id: string }) {
166
+ const sessionID = input.session_id
167
+ return (
168
+ <Show when={showSidebar() && relevant(sessionID)}>
169
+ <StatusPanel
170
+ colors={() => ctx.theme.current}
171
+ status={() => status()}
172
+ lang={lang}
173
+ evidence={() => showEvidence()}
174
+ />
175
+ </Show>
176
+ )
177
+ },
178
+ },
179
+ })
180
+
181
+ api.slots.register({
182
+ order: 60,
183
+ slots: {
184
+ session_prompt_right(ctx: TuiSlotContext, input: { session_id: string }) {
185
+ const sessionID = input.session_id
186
+ return (
187
+ <Show when={showFooter() && relevant(sessionID)}>
188
+ <StatusFooter colors={() => ctx.theme.current} status={() => status()} lang={lang} />
189
+ </Show>
190
+ )
191
+ },
192
+ },
193
+ })
194
+
195
+ const onOff = (value: boolean) => (value ? t("settings.on") : t("settings.off"))
196
+ const openSections = (dialog?: TuiDialogStack) => {
197
+ dialog?.replace(() => (
198
+ <api.ui.DialogSelect
199
+ title={t("settings.title")}
200
+ options={[
201
+ { title: `${t("settings.sidebar")}: ${onOff(showSidebar())}`, value: "sidebar" },
202
+ { title: `${t("settings.evidence")}: ${onOff(showEvidence())}`, value: "evidence" },
203
+ { title: `${t("settings.footer")}: ${onOff(showFooter())}`, value: "footer" },
204
+ { title: `${t("settings.toast")}: ${onOff(showToast())}`, value: "toast" },
205
+ ]}
206
+ onSelect={(option) => {
207
+ if (option.value === "sidebar") {
208
+ const value = !showSidebar()
209
+ setShowSidebar(value)
210
+ api.kv.set(KV.sidebar, value)
211
+ } else if (option.value === "evidence") {
212
+ const value = !showEvidence()
213
+ setShowEvidence(value)
214
+ api.kv.set(KV.evidence, value)
215
+ } else if (option.value === "footer") {
216
+ const value = !showFooter()
217
+ setShowFooter(value)
218
+ api.kv.set(KV.footer, value)
219
+ } else if (option.value === "toast") {
220
+ const value = !showToast()
221
+ setShowToast(value)
222
+ api.kv.set(KV.toast, value)
223
+ }
224
+ openSections(dialog)
225
+ }}
226
+ />
227
+ ))
228
+ }
229
+
230
+ api.command?.register(() => [
231
+ {
232
+ title: t("command.status.title"),
233
+ value: "deepseek-peak.status",
234
+ description: t("command.status.description"),
235
+ slash: { name: "deepseek-peak" },
236
+ onSelect: (dialog) => {
237
+ const current = status()
238
+ dialog?.replace(() => (
239
+ <api.ui.DialogAlert
240
+ title={t("status.title")}
241
+ message={`${statusLabel(current, t)}\n${t("status.source")}: ${current.source}\n${t("status.evidence")}: ${current.evidence}`}
242
+ />
243
+ ))
244
+ },
245
+ },
246
+ {
247
+ title: t("command.sections.title"),
248
+ value: "deepseek-peak.sections",
249
+ description: t("command.sections.description"),
250
+ slash: { name: "deepseek-peak-sections" },
251
+ onSelect: (dialog) => openSections(dialog),
252
+ },
253
+ {
254
+ title: t("command.lang.title"),
255
+ value: "deepseek-peak.lang",
256
+ description: t("command.lang.description"),
257
+ slash: { name: "deepseek-peak-lang" },
258
+ onSelect: (dialog) => {
259
+ dialog?.replace(() => (
260
+ <api.ui.DialogSelect
261
+ title={t("settings.lang")}
262
+ options={LANG_META.map((meta) => ({ title: meta.label, value: meta.code }))}
263
+ current={lang()}
264
+ onSelect={(option) => {
265
+ const picked = option.value as LangCode
266
+ setLang(picked)
267
+ api.kv.set(KV.lang, picked)
268
+ dialog?.clear()
269
+ }}
270
+ />
271
+ ))
272
+ },
273
+ },
274
+ ])
275
+ }
package/tui/index.js CHANGED
@@ -1,6 +1,11 @@
1
- import mod from "./../dist/tui.js"
1
+ // Dual-format TUI entry:
2
+ // - OpenCode V1 reads `tui` (TuiPluginModule)
3
+ // - OpenCode V2 reads `setup`
4
+ import { tui as v1Tui } from "./../dist/tui-v1.js"
5
+ import v2Mod from "./../dist/tui.js"
2
6
 
3
7
  export default {
4
8
  id: "opencode-deepseek-peak-tui",
5
- setup: mod.setup,
9
+ tui: v1Tui,
10
+ setup: v2Mod.setup,
6
11
  }