my-opencode-toolkit 0.0.0-stage → 0.2.2
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/LICENSE +21 -0
- package/README.md +112 -2
- package/index.ts +3 -0
- package/package.json +65 -4
- package/src/goal/command.ts +84 -0
- package/src/goal/continuation.ts +211 -0
- package/src/goal/controller.ts +107 -0
- package/src/goal/error.ts +94 -0
- package/src/goal/filter.ts +21 -0
- package/src/goal/index.ts +43 -0
- package/src/goal/parse-command.ts +31 -0
- package/src/goal/prompts.ts +84 -0
- package/src/goal/session-role.ts +37 -0
- package/src/goal/store.ts +91 -0
- package/src/goal/tools.ts +131 -0
- package/src/goal/types.ts +75 -0
- package/src/i18n/en.ts +61 -0
- package/src/i18n/index.ts +28 -0
- package/src/i18n/types.ts +57 -0
- package/src/i18n/zh.ts +61 -0
- package/src/index.ts +150 -0
- package/src/notification/format.ts +42 -0
- package/src/notification/index.ts +165 -0
- package/src/notification/send.ts +104 -0
- package/src/scheduling/command.ts +181 -0
- package/src/scheduling/continuation.ts +164 -0
- package/src/scheduling/cron.ts +309 -0
- package/src/scheduling/filter.ts +25 -0
- package/src/scheduling/index.ts +68 -0
- package/src/scheduling/loops.ts +235 -0
- package/src/scheduling/store.ts +228 -0
- package/src/scheduling/tools.ts +183 -0
- package/src/scheduling/types.ts +54 -0
- package/src/shared/events.ts +44 -0
- package/src/shared/format.ts +16 -0
- package/src/shared/json.ts +44 -0
- package/src/shared/keys.ts +26 -0
- package/src/shared/messages.ts +17 -0
- package/src/shared/rpc.ts +214 -0
- package/src/shared/scan.ts +21 -0
- package/src/shared/settings-store.ts +28 -0
- package/src/shared/settings.ts +133 -0
- package/src/shared/types.ts +4 -0
- package/src/stats.ts +215 -0
- package/src/tools/ping.ts +40 -0
- package/src/tui/index.tsx +274 -0
- 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
|
+
}
|
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