my-opencode-toolkit 0.0.0-stage → 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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -2
  3. package/index.ts +3 -0
  4. package/package.json +65 -4
  5. package/src/goal/command.ts +84 -0
  6. package/src/goal/continuation.ts +211 -0
  7. package/src/goal/controller.ts +107 -0
  8. package/src/goal/error.ts +94 -0
  9. package/src/goal/filter.ts +21 -0
  10. package/src/goal/index.ts +43 -0
  11. package/src/goal/parse-command.ts +31 -0
  12. package/src/goal/prompts.ts +84 -0
  13. package/src/goal/session-role.ts +37 -0
  14. package/src/goal/store.ts +91 -0
  15. package/src/goal/tools.ts +131 -0
  16. package/src/goal/types.ts +75 -0
  17. package/src/i18n/en.ts +61 -0
  18. package/src/i18n/index.ts +28 -0
  19. package/src/i18n/types.ts +57 -0
  20. package/src/i18n/zh.ts +61 -0
  21. package/src/index.ts +150 -0
  22. package/src/notification/format.ts +42 -0
  23. package/src/notification/index.ts +165 -0
  24. package/src/notification/send.ts +104 -0
  25. package/src/scheduling/command.ts +181 -0
  26. package/src/scheduling/continuation.ts +164 -0
  27. package/src/scheduling/cron.ts +309 -0
  28. package/src/scheduling/filter.ts +25 -0
  29. package/src/scheduling/index.ts +68 -0
  30. package/src/scheduling/loops.ts +235 -0
  31. package/src/scheduling/store.ts +228 -0
  32. package/src/scheduling/tools.ts +183 -0
  33. package/src/scheduling/types.ts +54 -0
  34. package/src/shared/events.ts +44 -0
  35. package/src/shared/format.ts +16 -0
  36. package/src/shared/json.ts +44 -0
  37. package/src/shared/keys.ts +26 -0
  38. package/src/shared/messages.ts +17 -0
  39. package/src/shared/rpc.ts +214 -0
  40. package/src/shared/scan.ts +21 -0
  41. package/src/shared/settings-store.ts +28 -0
  42. package/src/shared/settings.ts +133 -0
  43. package/src/shared/types.ts +4 -0
  44. package/src/stats.ts +215 -0
  45. package/src/tools/ping.ts +40 -0
  46. package/src/tui/index.tsx +274 -0
  47. package/tui.ts +3 -0
@@ -0,0 +1,133 @@
1
+ import { asRecord } from "./json.ts"
2
+
3
+ /** 界面语言;`auto` 跟随系统。 */
4
+ export type Lang = "auto" | "zh" | "en"
5
+
6
+ /** 系统通知开关。 */
7
+ export type NotificationSettings = {
8
+ /** 总开关,关掉后其余字段都不生效。 */
9
+ readonly enabled: boolean
10
+ /** 会话完成(空闲)时通知。 */
11
+ readonly onComplete: boolean
12
+ /** 代理提问时通知。 */
13
+ readonly onQuestion: boolean
14
+ /** 请求权限时通知。 */
15
+ readonly onPermission: boolean
16
+ /** 只通知主会话,忽略子代理。 */
17
+ readonly onlyMainSession: boolean
18
+ }
19
+
20
+ /** 持久目标开关。 */
21
+ export type GoalSettings = {
22
+ readonly enabled: boolean
23
+ }
24
+
25
+ /** 定时任务开关。 */
26
+ export type SchedulingSettings = {
27
+ readonly enabled: boolean
28
+ }
29
+
30
+ /** TUI 侧栏显示项。 */
31
+ export type SidebarSettings = {
32
+ readonly enabled: boolean
33
+ readonly showCache: boolean
34
+ readonly showTools: boolean
35
+ readonly showSubagents: boolean
36
+ readonly collapsed: boolean
37
+ }
38
+
39
+ /** 插件全部设置。 */
40
+ export type Settings = {
41
+ readonly lang: Lang
42
+ /** 插件总开关;关掉后不注册工具、不注入、不通知。 */
43
+ readonly enabled: boolean
44
+ readonly notification: NotificationSettings
45
+ readonly goal: GoalSettings
46
+ readonly scheduling: SchedulingSettings
47
+ readonly sidebar: SidebarSettings
48
+ }
49
+
50
+ /**
51
+ * 默认设置。
52
+ *
53
+ * 预期:通知默认开、Goal 默认开、定时默认开、侧栏默认开;
54
+ * 语言跟随系统;侧栏默认展开。
55
+ */
56
+ export const DEFAULT_SETTINGS: Settings = {
57
+ lang: "auto",
58
+ enabled: true,
59
+ notification: {
60
+ enabled: true,
61
+ onComplete: true,
62
+ onQuestion: true,
63
+ onPermission: true,
64
+ onlyMainSession: true,
65
+ },
66
+ goal: { enabled: true },
67
+ scheduling: { enabled: true },
68
+ sidebar: {
69
+ enabled: true,
70
+ showCache: true,
71
+ showTools: true,
72
+ showSubagents: true,
73
+ collapsed: false,
74
+ },
75
+ }
76
+
77
+ /**
78
+ * 输入:任意值(通常来自存储或插件 options)。
79
+ * 输出:字段完整、类型合法的设置。
80
+ * 预期:缺失或类型错误的字段回退到默认值;未知字段被丢弃;不抛异常。
81
+ */
82
+ export function normalizeSettings(raw: unknown): Settings {
83
+ const record = asRecord(raw) ?? {}
84
+ const notification = asRecord(record["notification"]) ?? {}
85
+ const goal = asRecord(record["goal"]) ?? {}
86
+ const scheduling = asRecord(record["scheduling"]) ?? {}
87
+ const sidebar = asRecord(record["sidebar"]) ?? {}
88
+
89
+ return {
90
+ lang: readLang(record["lang"], DEFAULT_SETTINGS.lang),
91
+ enabled: readBoolean(record["enabled"], DEFAULT_SETTINGS.enabled),
92
+ notification: {
93
+ enabled: readBoolean(notification["enabled"], DEFAULT_SETTINGS.notification.enabled),
94
+ onComplete: readBoolean(notification["onComplete"], DEFAULT_SETTINGS.notification.onComplete),
95
+ onQuestion: readBoolean(notification["onQuestion"], DEFAULT_SETTINGS.notification.onQuestion),
96
+ onPermission: readBoolean(notification["onPermission"], DEFAULT_SETTINGS.notification.onPermission),
97
+ onlyMainSession: readBoolean(
98
+ notification["onlyMainSession"],
99
+ DEFAULT_SETTINGS.notification.onlyMainSession,
100
+ ),
101
+ },
102
+ goal: {
103
+ enabled: readBoolean(goal["enabled"], DEFAULT_SETTINGS.goal.enabled),
104
+ },
105
+ scheduling: {
106
+ enabled: readBoolean(scheduling["enabled"], DEFAULT_SETTINGS.scheduling.enabled),
107
+ },
108
+ sidebar: {
109
+ enabled: readBoolean(sidebar["enabled"], DEFAULT_SETTINGS.sidebar.enabled),
110
+ showCache: readBoolean(sidebar["showCache"], DEFAULT_SETTINGS.sidebar.showCache),
111
+ showTools: readBoolean(sidebar["showTools"], DEFAULT_SETTINGS.sidebar.showTools),
112
+ showSubagents: readBoolean(sidebar["showSubagents"], DEFAULT_SETTINGS.sidebar.showSubagents),
113
+ collapsed: readBoolean(sidebar["collapsed"], DEFAULT_SETTINGS.sidebar.collapsed),
114
+ },
115
+ }
116
+ }
117
+
118
+ /**
119
+ * 输入:任意值 + 默认值。
120
+ * 输出:布尔值,非布尔时用默认值。
121
+ * 预期:不做字符串/数字到布尔的隐式转换。
122
+ */
123
+ function readBoolean(value: unknown, fallback: boolean): boolean {
124
+ return typeof value === "boolean" ? value : fallback
125
+ }
126
+
127
+ /**
128
+ * 输入:任意值 + 默认语言。
129
+ * 输出:合法语言枚举,其余回退默认值。
130
+ */
131
+ function readLang(value: unknown, fallback: Lang): Lang {
132
+ return value === "auto" || value === "zh" || value === "en" ? value : fallback
133
+ }
@@ -0,0 +1,4 @@
1
+ /** 可注销句柄。 */
2
+ export type Disposable = {
3
+ dispose(): Promise<void>
4
+ }
package/src/stats.ts ADDED
@@ -0,0 +1,215 @@
1
+ import type { Plugin } from "@opencode/plugin"
2
+
3
+ import { asRecord } from "./shared/json.ts"
4
+
5
+ /** 单个会话的进程内统计。 */
6
+ export type SessionStats = {
7
+ readonly steps: number
8
+ readonly input: number
9
+ readonly output: number
10
+ readonly cached: number
11
+ readonly tools: Readonly<Record<string, number>>
12
+ readonly subagents: number
13
+ }
14
+
15
+ /** 统计累加器。 */
16
+ export type StatsTracker = {
17
+ /** 输入:会话 + 本步 token;输出:无。预期:input/output/缓存分别累加。 */
18
+ recordStep(sessionID: string, tokens: unknown): void
19
+ /** 输入:会话 + 工具名;输出:无。预期:按工具名计数。 */
20
+ recordTool(sessionID: string, tool: string): void
21
+ /** 输入:父会话;输出:无。预期:子代理数量 +1。 */
22
+ recordSubagent(parentID: string): void
23
+ /** 输入:会话;输出:统计快照。 */
24
+ get(sessionID: string): SessionStats
25
+ /** 输入:会话;输出:是否已从 OpenCode 消息重建过。 */
26
+ isSeeded(sessionID: string): boolean
27
+ /** 输入:会话 + 重建结果;输出:无。预期:覆盖当前值并标记已 seed。 */
28
+ seed(sessionID: string, stats: SessionStats): void
29
+ /** 输入:会话;输出:无。预期:删除该会话统计。 */
30
+ forget(sessionID: string): void
31
+ /** 输入:无;输出:无。预期:清空全部统计。 */
32
+ clear(): void
33
+ }
34
+
35
+ type MutableStats = {
36
+ steps: number
37
+ input: number
38
+ output: number
39
+ cached: number
40
+ tools: Record<string, number>
41
+ subagents: number
42
+ seeded: boolean
43
+ }
44
+
45
+ /**
46
+ * 输入:无。
47
+ * 输出:内存统计累加器。
48
+ * 预期:只统计当前进程生命周期;重启后从 0 开始。
49
+ */
50
+ export function createStatsTracker(): StatsTracker {
51
+ const sessions = new Map<string, MutableStats>()
52
+
53
+ /** 输入:会话;输出:可写统计。预期:不存在时创建空统计。 */
54
+ const of = (sessionID: string): MutableStats => {
55
+ const existing = sessions.get(sessionID)
56
+ if (existing !== undefined) return existing
57
+ const created: MutableStats = { steps: 0, input: 0, output: 0, cached: 0, tools: {}, subagents: 0, seeded: false }
58
+ sessions.set(sessionID, created)
59
+ return created
60
+ }
61
+
62
+ /** 输入:任意值;输出:非负有限数,否则 0。 */
63
+ const count = (value: unknown): number =>
64
+ typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0
65
+
66
+ return {
67
+ recordStep(sessionID, tokens) {
68
+ const record = asRecord(tokens)
69
+ if (record === undefined) return
70
+ const cache = asRecord(record["cache"]) ?? {}
71
+ const stats = of(sessionID)
72
+ stats.steps += 1
73
+ stats.input += count(record["input"])
74
+ stats.output += count(record["output"])
75
+ stats.cached += count(cache["read"]) + count(cache["write"])
76
+ },
77
+
78
+ recordTool(sessionID, tool) {
79
+ if (tool.length === 0) return
80
+ const stats = of(sessionID)
81
+ stats.tools[tool] = (stats.tools[tool] ?? 0) + 1
82
+ },
83
+
84
+ recordSubagent(parentID) {
85
+ if (parentID.length === 0) return
86
+ of(parentID).subagents += 1
87
+ },
88
+
89
+ get(sessionID) {
90
+ const stats = sessions.get(sessionID)
91
+ if (stats === undefined) {
92
+ return { steps: 0, input: 0, output: 0, cached: 0, tools: {}, subagents: 0 }
93
+ }
94
+ return {
95
+ steps: stats.steps,
96
+ input: stats.input,
97
+ output: stats.output,
98
+ cached: stats.cached,
99
+ tools: { ...stats.tools },
100
+ subagents: stats.subagents,
101
+ }
102
+ },
103
+
104
+ isSeeded(sessionID) {
105
+ return sessions.get(sessionID)?.seeded ?? false
106
+ },
107
+
108
+ seed(sessionID, stats) {
109
+ sessions.set(sessionID, { ...stats, tools: { ...stats.tools }, seeded: true })
110
+ },
111
+
112
+ forget(sessionID) {
113
+ sessions.delete(sessionID)
114
+ },
115
+
116
+ clear() {
117
+ sessions.clear()
118
+ },
119
+ }
120
+ }
121
+
122
+ /**
123
+ * 输入:统计累加器 + 会话事件。
124
+ * 输出:无。
125
+ * 预期:step 结束记 token,会话删除清统计,新建子代理给父会话 +1。
126
+ */
127
+ export function trackEvent(
128
+ tracker: StatsTracker,
129
+ event: { readonly type: string; readonly data: unknown },
130
+ ): void {
131
+ const data = asRecord(event.data)
132
+ if (data === undefined) return
133
+ const sessionID = data["sessionID"]
134
+ if (typeof sessionID !== "string" || sessionID.length === 0) return
135
+
136
+ if (event.type === "session.step.ended") {
137
+ tracker.recordStep(sessionID, data["tokens"])
138
+ return
139
+ }
140
+ if (event.type === "session.deleted") {
141
+ tracker.forget(sessionID)
142
+ return
143
+ }
144
+ if (event.type === "session.created") {
145
+ const parentID = data["parentID"]
146
+ if (typeof parentID === "string" && parentID.length > 0) tracker.recordSubagent(parentID)
147
+ }
148
+ }
149
+
150
+ /**
151
+ * 输入:插件上下文 + 父会话 ID。
152
+ * 输出:该会话的子代理数量。
153
+ * 预期:按 parentID 过滤并逐页翻完;任何一页读取失败就返回已统计的数量,不抛异常。
154
+ */
155
+ async function countSubagents(ctx: Plugin.Context, sessionID: string): Promise<number> {
156
+ let cursor: string | undefined
157
+ let total = 0
158
+ // 翻页上限:防止异常服务端返回循环 cursor 时无限循环。
159
+ for (let page = 0; page < 200; page += 1) {
160
+ const listed = await ctx.session
161
+ .list({ parentID: sessionID, ...(cursor === undefined ? {} : { cursor }) })
162
+ .catch(() => undefined)
163
+ if (listed === undefined) return total
164
+ total += listed.data.length
165
+
166
+ const next = listed.cursor.next
167
+ if (next === undefined || next === null || next === cursor) return total
168
+ cursor = next
169
+ }
170
+ return total
171
+ }
172
+
173
+ /**
174
+ * 输入:插件上下文 + 会话 ID。
175
+ * 输出:从 OpenCode 已存消息重建的统计。
176
+ * 预期:只统计 assistant 消息的 tokens 与 tool 片段;子代理数来自会话列表的 parentID 过滤。
177
+ * 读取失败按空统计处理,不抛异常。
178
+ */
179
+ export async function buildStats(ctx: Plugin.Context, sessionID: string): Promise<SessionStats> {
180
+ const messages = await ctx.session.context({ sessionID }).catch(() => [])
181
+ let steps = 0
182
+ let input = 0
183
+ let output = 0
184
+ let cached = 0
185
+ const tools: Record<string, number> = {}
186
+
187
+ for (const message of messages) {
188
+ if (message.type !== "assistant") continue
189
+ steps += 1
190
+ const tokens = message.tokens
191
+ if (tokens !== undefined) {
192
+ input += tokens.input
193
+ output += tokens.output
194
+ cached += tokens.cache.read + tokens.cache.write
195
+ }
196
+ for (const part of message.content) {
197
+ if (part.type !== "tool") continue
198
+ tools[part.name] = (tools[part.name] ?? 0) + 1
199
+ }
200
+ }
201
+
202
+ const subagents = await countSubagents(ctx, sessionID)
203
+ return { steps, input, output, cached, tools, subagents }
204
+ }
205
+
206
+ /**
207
+ * 输入:输入 token 与缓存 token。
208
+ * 输出:缓存命中率百分比,向下截断到两位小数。
209
+ * 预期:分母为 0 时返回 0;`100` 只代表真实全命中。
210
+ */
211
+ export function hitRatePercent(input: number, cached: number): number {
212
+ const total = input + cached
213
+ if (total <= 0) return 0
214
+ return Math.floor((cached / total) * 10_000) / 100
215
+ }
@@ -0,0 +1,40 @@
1
+ import type { Plugin } from "@opencode/plugin"
2
+ import { Schema } from "effect"
3
+
4
+ import { resolveMessages } from "../i18n/index.ts"
5
+ import { loadSettings } from "../shared/settings-store.ts"
6
+
7
+ const DESCRIPTION =
8
+ "Returns the my-opencode-toolkit status: whether the plugin is enabled and which language it uses. " +
9
+ "Use it to verify the plugin is loaded."
10
+
11
+ const PingInput = Schema.Struct({})
12
+ const PingOutput = Schema.Struct({
13
+ enabled: Schema.Boolean,
14
+ lang: Schema.String,
15
+ })
16
+
17
+ /**
18
+ * 输入:服务端插件上下文。
19
+ * 输出:注册 `toolkit_ping` 工具,返回注销句柄。
20
+ * 预期:只读——读取设置并按当前语言返回一行状态,不修改任何状态。
21
+ */
22
+ export async function registerPingTool(ctx: Plugin.Context): Promise<{ dispose(): Promise<void> }> {
23
+ return ctx.tool.transform((tools) => {
24
+ tools.add({
25
+ name: "toolkit_ping",
26
+ options: { codemode: false },
27
+ description: DESCRIPTION,
28
+ input: PingInput,
29
+ output: PingOutput,
30
+ execute: async () => {
31
+ const settings = await loadSettings(ctx.storage)
32
+ const messages = resolveMessages(settings.lang === "auto" ? undefined : settings.lang)
33
+ return {
34
+ output: { enabled: settings.enabled, lang: settings.lang },
35
+ content: messages.tool.pingResult(settings.enabled, settings.lang),
36
+ }
37
+ },
38
+ })
39
+ })
40
+ }
@@ -0,0 +1,274 @@
1
+ import { Plugin } from "@opencode/plugin/tui"
2
+ import type { Context } from "@opencode/plugin/tui/context"
3
+ import { createSignal, onCleanup, onMount } from "solid-js"
4
+
5
+ import { resolveMessages, type Messages } from "../i18n/index.ts"
6
+ import { formatTokens } from "../shared/format.ts"
7
+ import { DEFAULT_SETTINGS, type Lang, type Settings } from "../shared/settings.ts"
8
+ import { toolkitRpc, type PanelData } from "../shared/rpc.ts"
9
+
10
+ /** 主菜单条目。 */
11
+ type MenuChoice = "enabled" | "language" | "notifications" | "goal" | "scheduling" | "sidebar"
12
+
13
+ /** 侧栏子菜单条目。 */
14
+ type SidebarChoice = "enabled" | "showCache" | "showTools" | "showSubagents" | "collapsed"
15
+
16
+ /** 输入:设置里的语言枚举;输出:`resolveMessages` 需要的 locale。 */
17
+ function localeOf(lang: Lang): string | undefined {
18
+ return lang === "auto" ? undefined : lang
19
+ }
20
+
21
+ /**
22
+ * 输入:TUI 上下文、会话 ID、设置访问器、文案访问器。
23
+ * 输出:侧栏面板。
24
+ * 预期:挂载时拉一次数据,之后按会话事件节流刷新。
25
+ */
26
+ export function Sidebar(props: {
27
+ readonly context: Context
28
+ readonly sessionID: string
29
+ readonly settings: () => Settings
30
+ readonly copy: () => Messages
31
+ }) {
32
+ const rpc = toolkitRpc(props.context.client)
33
+ const [panel, setPanel] = createSignal<PanelData | null>(null)
34
+ let timer: ReturnType<typeof setTimeout> | undefined
35
+
36
+ /** 拉取面板数据;失败保留上一次结果。 */
37
+ const refresh = (): void => {
38
+ void rpc
39
+ .panelGet({ sessionID: props.sessionID })
40
+ .then((data) => {
41
+ setPanel(data)
42
+ })
43
+ .catch(() => undefined)
44
+ }
45
+
46
+ onMount(() => {
47
+ refresh()
48
+ const off = props.context.data.listen(({ details }) => {
49
+ if (
50
+ details.type !== "session.idle" &&
51
+ details.type !== "session.step.ended" &&
52
+ details.type !== "session.usage.updated"
53
+ ) {
54
+ return
55
+ }
56
+ if (timer !== undefined) clearTimeout(timer)
57
+ timer = setTimeout(refresh, 300)
58
+ })
59
+ onCleanup(() => {
60
+ off()
61
+ if (timer !== undefined) clearTimeout(timer)
62
+ })
63
+ })
64
+
65
+ /** 输入:状态字符串;输出:本地化状态名。 */
66
+ const statusLabel = (text: Messages, status: string): string => text.panel.status[status] ?? status
67
+
68
+ const goalLine = (): string => {
69
+ const text = props.copy()
70
+ const goal = panel()?.goal
71
+ if (goal === null || goal === undefined) return `${text.panel.goal}: ${text.panel.noGoal}`
72
+ const budget = goal.tokenBudget === undefined ? "" : ` / ${formatTokens(goal.tokenBudget)}`
73
+ return `${text.panel.goal}: ${goal.objective} · ${statusLabel(text, goal.status)} · ${formatTokens(goal.tokensUsed)}${budget}`
74
+ }
75
+
76
+ const cacheLine = (): string => {
77
+ const text = props.copy()
78
+ const stats = panel()?.stats
79
+ if (stats === undefined) return `${text.panel.cache}: --`
80
+ return `${text.panel.cache}: ${stats.hitRate.toFixed(2)}% · ${formatTokens(stats.input)} / ${formatTokens(stats.output)}`
81
+ }
82
+
83
+ const toolsLine = (): string => {
84
+ const text = props.copy()
85
+ const stats = panel()?.stats
86
+ if (stats === undefined || stats.tools.length === 0) return `${text.panel.tools}: --`
87
+ const top = stats.tools
88
+ .slice(0, 3)
89
+ .map((tool) => `${tool.name} ${String(tool.count)}`)
90
+ .join(" · ")
91
+ return `${text.panel.tools}: ${top}`
92
+ }
93
+
94
+ const subagentsLine = (): string => {
95
+ const text = props.copy()
96
+ return `${text.panel.subagents}: ${String(panel()?.stats.subagents ?? 0)}`
97
+ }
98
+
99
+ const scheduleLine = (): string => {
100
+ const text = props.copy()
101
+ const loops = panel()?.loops.length ?? 0
102
+ const jobs = panel()?.jobs.length ?? 0
103
+ return `${text.panel.loops}: ${String(loops)} · ${text.panel.jobs}: ${String(jobs)}`
104
+ }
105
+
106
+ return (
107
+ <box flexDirection="column">
108
+ <text>{props.copy().panel.title}</text>
109
+ {props.settings().sidebar.enabled && !props.settings().sidebar.collapsed ? (
110
+ <box flexDirection="column">
111
+ <text>{goalLine()}</text>
112
+ {props.settings().sidebar.showCache ? <text>{cacheLine()}</text> : null}
113
+ {props.settings().sidebar.showTools ? <text>{toolsLine()}</text> : null}
114
+ {props.settings().sidebar.showSubagents ? <text>{subagentsLine()}</text> : null}
115
+ <text>{scheduleLine()}</text>
116
+ </box>
117
+ ) : null}
118
+ </box>
119
+ )
120
+ }
121
+
122
+ /**
123
+ * 输入:TUI 插件上下文(含插件 options)。
124
+ * 输出:侧栏面板 + `/my-opencode-toolkit` 设置命令。
125
+ * 预期:设置以服务端存储为准,启动时通过 RPC 读取;写入失败时回滚本地值。
126
+ * keymap 层在 setup 作用域注册,生命周期跟随插件,不依赖某个 slot 组件是否挂载。
127
+ */
128
+ export default Plugin.define({
129
+ id: "my-opencode-toolkit.tui",
130
+
131
+ async setup(context) {
132
+ const rpc = toolkitRpc(context.client)
133
+ const initial = await rpc.settingsGet({}).catch((): Settings => DEFAULT_SETTINGS)
134
+ const [settings, setSettings] = createSignal<Settings>(initial)
135
+
136
+ /**
137
+ * 输入:新设置。
138
+ * 输出:无。
139
+ * 预期:先乐观更新界面,服务端确认后用返回值覆盖;失败则回滚。
140
+ */
141
+ const update = async (next: Settings): Promise<void> => {
142
+ const previous = settings()
143
+ setSettings(next)
144
+ try {
145
+ setSettings(await rpc.settingsSet(next))
146
+ } catch {
147
+ setSettings(previous)
148
+ }
149
+ }
150
+
151
+ const copy = (): Messages => resolveMessages(localeOf(settings().lang))
152
+
153
+ /** 选择语言并写回。 */
154
+ const chooseLanguage = async (): Promise<void> => {
155
+ const picked = await context.ui.dialog.select<Lang>({
156
+ title: copy().menu.language,
157
+ current: settings().lang,
158
+ options: [
159
+ { title: copy().menu.auto, value: "auto" },
160
+ { title: copy().menu.chinese, value: "zh" },
161
+ { title: copy().menu.english, value: "en" },
162
+ ],
163
+ })
164
+ if (picked === undefined) return
165
+ await update({ ...settings(), lang: picked })
166
+ }
167
+
168
+ /** 侧栏子菜单:逐项切换显示开关。 */
169
+ const chooseSidebar = async (): Promise<void> => {
170
+ for (;;) {
171
+ const current = settings()
172
+ const text = copy()
173
+ const onOff = (value: boolean): string => (value ? text.menu.on : text.menu.off)
174
+ const choice = await context.ui.dialog.select<SidebarChoice>({
175
+ title: text.menu.sidebar,
176
+ options: [
177
+ { title: text.menu.enabled, value: "enabled", footer: onOff(current.sidebar.enabled) },
178
+ { title: text.menu.showCache, value: "showCache", footer: onOff(current.sidebar.showCache) },
179
+ { title: text.menu.showTools, value: "showTools", footer: onOff(current.sidebar.showTools) },
180
+ { title: text.menu.showSubagents, value: "showSubagents", footer: onOff(current.sidebar.showSubagents) },
181
+ { title: text.menu.collapsed, value: "collapsed", footer: onOff(current.sidebar.collapsed) },
182
+ ],
183
+ })
184
+ if (choice === undefined) return
185
+ const next = { ...current.sidebar, [choice]: !current.sidebar[choice] }
186
+ await update({ ...current, sidebar: next })
187
+ }
188
+ }
189
+
190
+ /**
191
+ * 输入:要切换的条目。
192
+ * 输出:无。
193
+ * 预期:只改对应字段,其余设置保持不变。
194
+ */
195
+ const toggle = async (choice: Exclude<MenuChoice, "language" | "sidebar">): Promise<void> => {
196
+ const current = settings()
197
+ switch (choice) {
198
+ case "enabled":
199
+ await update({ ...current, enabled: !current.enabled })
200
+ return
201
+ case "notifications":
202
+ await update({
203
+ ...current,
204
+ notification: { ...current.notification, enabled: !current.notification.enabled },
205
+ })
206
+ return
207
+ case "goal":
208
+ await update({ ...current, goal: { enabled: !current.goal.enabled } })
209
+ return
210
+ case "scheduling":
211
+ await update({ ...current, scheduling: { enabled: !current.scheduling.enabled } })
212
+ return
213
+ }
214
+ }
215
+
216
+ /** 打开菜单;选择语言/侧栏进入子菜单,其余条目原地切换后重开。 */
217
+ const openMenu = async (): Promise<void> => {
218
+ for (;;) {
219
+ const current = settings()
220
+ const text = copy()
221
+ const onOff = (value: boolean): string => (value ? text.menu.on : text.menu.off)
222
+ const choice = await context.ui.dialog.select<MenuChoice>({
223
+ title: text.menu.title,
224
+ options: [
225
+ { title: text.menu.enabled, value: "enabled", footer: onOff(current.enabled) },
226
+ { title: text.menu.language, value: "language", footer: current.lang },
227
+ { title: text.menu.notifications, value: "notifications", footer: onOff(current.notification.enabled) },
228
+ { title: text.menu.goal, value: "goal", footer: onOff(current.goal.enabled) },
229
+ { title: text.menu.scheduling, value: "scheduling", footer: onOff(current.scheduling.enabled) },
230
+ { title: text.menu.sidebar, value: "sidebar", footer: onOff(current.sidebar.enabled) },
231
+ ],
232
+ })
233
+ if (choice === undefined) return
234
+ if (choice === "language") {
235
+ await chooseLanguage()
236
+ continue
237
+ }
238
+ if (choice === "sidebar") {
239
+ await chooseSidebar()
240
+ continue
241
+ }
242
+ await toggle(choice)
243
+ }
244
+ }
245
+
246
+ context.keymap.layer(() => ({
247
+ mode: "global",
248
+ commands: [
249
+ {
250
+ id: "my-opencode-toolkit.settings",
251
+ title: copy().command.title,
252
+ description: copy().command.description,
253
+ group: copy().command.group,
254
+ slash: { name: "my-opencode-toolkit" },
255
+ palette: true,
256
+ run() {
257
+ void openMenu()
258
+ },
259
+ },
260
+ ],
261
+ }))
262
+
263
+ const disposePanel = context.ui.slot({
264
+ append: "sidebar.content",
265
+ render: (input) => (
266
+ <Sidebar context={context} sessionID={input.sessionID} settings={settings} copy={copy} />
267
+ ),
268
+ })
269
+
270
+ return () => {
271
+ disposePanel()
272
+ }
273
+ },
274
+ })
package/tui.ts ADDED
@@ -0,0 +1,3 @@
1
+ // 本地插件目录入口:OpenCode 的 TUI 插件解析会找 <dir>/tui,
2
+ // 这里只做转发,真正实现放在 src/tui/index.tsx。
3
+ export { default } from "./src/tui/index.tsx"