oc-tweaks 0.1.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/README.md +333 -0
- package/package.json +27 -0
- package/src/__tests__/.gitkeep +0 -0
- package/src/__tests__/background-subagent.test.ts +106 -0
- package/src/__tests__/cli-init.test.ts +70 -0
- package/src/__tests__/compaction.test.ts +113 -0
- package/src/__tests__/index.test.ts +180 -0
- package/src/__tests__/leaderboard.test.ts +244 -0
- package/src/__tests__/logger.test.ts +84 -0
- package/src/__tests__/notify.test.ts +318 -0
- package/src/__tests__/utils.test.ts +164 -0
- package/src/bun-test.d.ts +12 -0
- package/src/cli/init.ts +44 -0
- package/src/index.ts +4 -0
- package/src/plugins/.gitkeep +0 -0
- package/src/plugins/background-subagent.ts +59 -0
- package/src/plugins/compaction.ts +28 -0
- package/src/plugins/leaderboard.ts +184 -0
- package/src/plugins/notify.ts +383 -0
- package/src/types.ts +2 -0
- package/src/utils/.gitkeep +0 -0
- package/src/utils/config.ts +71 -0
- package/src/utils/index.ts +5 -0
- package/src/utils/logger.ts +52 -0
- package/src/utils/safe-hook.ts +16 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode-ai/plugin"
|
|
2
|
+
|
|
3
|
+
import { loadJsonConfig, loadOcTweaksConfig, safeHook } from "../utils"
|
|
4
|
+
import { log as sharedLog } from "../utils/logger"
|
|
5
|
+
|
|
6
|
+
declare const Bun: any
|
|
7
|
+
|
|
8
|
+
function getHome(): string {
|
|
9
|
+
return Bun.env?.HOME ?? ((globalThis as any)?.process?.env?.HOME ?? "") ?? ""
|
|
10
|
+
}
|
|
11
|
+
const API_ENDPOINT = "https://api.claudecount.com/api/usage/hook"
|
|
12
|
+
|
|
13
|
+
interface LeaderboardConfig {
|
|
14
|
+
twitter_handle: string
|
|
15
|
+
twitter_user_id: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface AssistantMessageInfo {
|
|
19
|
+
id: string
|
|
20
|
+
sessionID: string
|
|
21
|
+
role: "assistant"
|
|
22
|
+
time: { created: number; completed?: number }
|
|
23
|
+
modelID: string
|
|
24
|
+
providerID: string
|
|
25
|
+
cost: number
|
|
26
|
+
tokens: {
|
|
27
|
+
input: number
|
|
28
|
+
output: number
|
|
29
|
+
reasoning: number
|
|
30
|
+
cache: { read: number; write: number }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// claudecount.com API only accepts official Anthropic model IDs with date suffixes.
|
|
35
|
+
// Map opencode model IDs (which may be custom names or non-Claude models) to accepted format.
|
|
36
|
+
const MODEL_MAP: Record<string, string> = {
|
|
37
|
+
// Claude 4.x family → closest accepted ID
|
|
38
|
+
"claude-opus-4.6": "claude-opus-4-20250514",
|
|
39
|
+
"claude-opus-4.5": "claude-opus-4-20250514",
|
|
40
|
+
"claude-sonnet-4.5": "claude-sonnet-4-20250514",
|
|
41
|
+
"claude-sonnet-4": "claude-sonnet-4-20250514",
|
|
42
|
+
"claude-haiku-4.5": "claude-3.5-haiku-20241022",
|
|
43
|
+
// GPT family → mapped by performance tier
|
|
44
|
+
"gpt-5.2-codex": "claude-sonnet-4-20250514",
|
|
45
|
+
"gpt-5.1-codex": "claude-sonnet-4-20250514",
|
|
46
|
+
"gpt-5.1-codex-max": "claude-opus-4-20250514",
|
|
47
|
+
"gpt-5.2": "claude-sonnet-4-20250514",
|
|
48
|
+
"gpt-5.1": "claude-sonnet-4-20250514",
|
|
49
|
+
"gpt-5-mini": "claude-3.5-haiku-20241022",
|
|
50
|
+
"gpt-5": "claude-sonnet-4-20250514",
|
|
51
|
+
// Gemini family
|
|
52
|
+
"gemini-3-pro-preview": "claude-sonnet-4-20250514",
|
|
53
|
+
"gemini-2.5-pro": "claude-sonnet-4-20250514",
|
|
54
|
+
// Grok family
|
|
55
|
+
"grok-code-fast-1": "claude-3.5-haiku-20241022",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Default fallback for unknown models
|
|
59
|
+
const DEFAULT_MODEL = "claude-sonnet-4-20250514"
|
|
60
|
+
|
|
61
|
+
function mapModel(modelID: string): string {
|
|
62
|
+
// Direct match in map
|
|
63
|
+
if (MODEL_MAP[modelID]) return MODEL_MAP[modelID]
|
|
64
|
+
// Already an accepted Anthropic format (contains date suffix like -20250514)
|
|
65
|
+
if (/claude-.*-\d{8}$/.test(modelID)) return modelID
|
|
66
|
+
// Fallback
|
|
67
|
+
return DEFAULT_MODEL
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
function parseLeaderboardConfig(parsed: Record<string, any>): LeaderboardConfig | null {
|
|
72
|
+
// Support both field naming conventions (snake_case from old format, camelCase from new)
|
|
73
|
+
const handle = parsed.twitter_handle ?? parsed.twitterUrl
|
|
74
|
+
const userId = parsed.twitter_user_id ?? parsed.twitterUserId ?? handle
|
|
75
|
+
if (!handle || !userId) return null
|
|
76
|
+
return { twitter_handle: handle, twitter_user_id: userId }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function readLeaderboardConfig(path: string): Promise<LeaderboardConfig | null> {
|
|
80
|
+
const parsed = await loadJsonConfig<Record<string, any>>(path, {})
|
|
81
|
+
return parseLeaderboardConfig(parsed)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function loadLeaderboardConfig(
|
|
85
|
+
configPath: string | null | undefined,
|
|
86
|
+
): Promise<LeaderboardConfig | null> {
|
|
87
|
+
if (typeof configPath === "string") {
|
|
88
|
+
return readLeaderboardConfig(configPath)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const paths = [`${getHome()}/.claude/leaderboard.json`, `${getHome()}/.config/claude/leaderboard.json`]
|
|
92
|
+
for (const path of paths) {
|
|
93
|
+
const config = await readLeaderboardConfig(path)
|
|
94
|
+
if (config) return config
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function submitUsage(config: LeaderboardConfig, msg: AssistantMessageInfo): Promise<void> {
|
|
101
|
+
const timestamp = new Date(msg.time.created).toISOString()
|
|
102
|
+
const hashInput = `${msg.time.created}${msg.id}${msg.sessionID}`
|
|
103
|
+
const interactionHash = new Bun.CryptoHasher("sha256").update(hashInput).digest("hex")
|
|
104
|
+
|
|
105
|
+
const payload = {
|
|
106
|
+
twitter_handle: config.twitter_handle,
|
|
107
|
+
twitter_user_id: config.twitter_user_id,
|
|
108
|
+
timestamp,
|
|
109
|
+
tokens: {
|
|
110
|
+
input: msg.tokens.input,
|
|
111
|
+
output: msg.tokens.output,
|
|
112
|
+
cache_creation: msg.tokens.cache?.write ?? 0,
|
|
113
|
+
cache_read: msg.tokens.cache?.read ?? 0,
|
|
114
|
+
},
|
|
115
|
+
model: mapModel(msg.modelID),
|
|
116
|
+
interaction_id: interactionHash,
|
|
117
|
+
interaction_hash: interactionHash,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const res = await fetch(API_ENDPOINT, {
|
|
121
|
+
method: "POST",
|
|
122
|
+
headers: { "Content-Type": "application/json" },
|
|
123
|
+
body: JSON.stringify(payload),
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
if (!res.ok) {
|
|
127
|
+
const body = await res.text().catch(() => "")
|
|
128
|
+
throw new Error(`API ${res.status}: ${body}`)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export const leaderboardPlugin: Plugin = async () => {
|
|
133
|
+
const ocTweaks = await loadOcTweaksConfig()
|
|
134
|
+
if (!ocTweaks || ocTweaks.leaderboard?.enabled !== true) return {}
|
|
135
|
+
|
|
136
|
+
const config = await loadLeaderboardConfig(ocTweaks.leaderboard?.configPath)
|
|
137
|
+
if (!config) {
|
|
138
|
+
await sharedLog(ocTweaks.logging, "INFO", "No leaderboard config found, plugin disabled")
|
|
139
|
+
return {}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
await sharedLog(ocTweaks.logging, "INFO", `Plugin loaded, handle=${config.twitter_handle}`)
|
|
143
|
+
|
|
144
|
+
// Track submitted message IDs to avoid duplicates within this process
|
|
145
|
+
const submitted = new Set<string>()
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
event: safeHook(
|
|
149
|
+
"leaderboard:event",
|
|
150
|
+
async ({ event }: { event: unknown }) => {
|
|
151
|
+
try {
|
|
152
|
+
if (!event || typeof event !== "object") return
|
|
153
|
+
const eventRecord = event as Record<string, unknown>
|
|
154
|
+
if (eventRecord.type !== "message.updated") return
|
|
155
|
+
|
|
156
|
+
const properties = eventRecord.properties
|
|
157
|
+
if (!properties || typeof properties !== "object") return
|
|
158
|
+
const infoUnknown = (properties as Record<string, unknown>).info
|
|
159
|
+
if (!infoUnknown || typeof infoUnknown !== "object") return
|
|
160
|
+
|
|
161
|
+
const info = infoUnknown as Record<string, any>
|
|
162
|
+
if (info.role !== "assistant") return
|
|
163
|
+
if (!info.time?.completed) return
|
|
164
|
+
if (submitted.has(info.id)) return
|
|
165
|
+
|
|
166
|
+
const msg = info as AssistantMessageInfo
|
|
167
|
+
if (!msg.tokens || msg.tokens.input === 0) return
|
|
168
|
+
|
|
169
|
+
submitted.add(msg.id)
|
|
170
|
+
await sharedLog(
|
|
171
|
+
ocTweaks.logging,
|
|
172
|
+
"INFO",
|
|
173
|
+
`Submitting: msg=${msg.id.slice(0, 16)} model=${msg.modelID}→${mapModel(msg.modelID)} in=${msg.tokens.input} out=${msg.tokens.output} cache_r=${msg.tokens.cache?.read ?? 0} cache_w=${msg.tokens.cache?.write ?? 0}`,
|
|
174
|
+
)
|
|
175
|
+
await submitUsage(config, msg)
|
|
176
|
+
await sharedLog(ocTweaks.logging, "INFO", "Submitted OK")
|
|
177
|
+
} catch (err) {
|
|
178
|
+
await sharedLog(ocTweaks.logging, "ERROR", `Submit failed: ${err}`)
|
|
179
|
+
// Silently ignore — never disrupt the user's workflow
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
),
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode-ai/plugin"
|
|
2
|
+
|
|
3
|
+
import { loadOcTweaksConfig, safeHook, log } from "../utils"
|
|
4
|
+
import type { LoggerConfig } from "../utils"
|
|
5
|
+
import type { NotifyStyle } from "../utils/config"
|
|
6
|
+
|
|
7
|
+
type ShellExecutor = (strings: TemplateStringsArray, ...values: any[]) => Promise<any>
|
|
8
|
+
|
|
9
|
+
type NotifySender =
|
|
10
|
+
| { kind: "custom"; commandTemplate: string }
|
|
11
|
+
| { kind: "wpf"; command: string }
|
|
12
|
+
| { kind: "osascript" }
|
|
13
|
+
| { kind: "notify-send" }
|
|
14
|
+
| { kind: "tui"; showToast: (...args: any[]) => any }
|
|
15
|
+
| { kind: "none" }
|
|
16
|
+
|
|
17
|
+
export const notifyPlugin: Plugin = async ({ $, directory, client }) => {
|
|
18
|
+
let logConfig: LoggerConfig | undefined
|
|
19
|
+
try {
|
|
20
|
+
const config = await loadOcTweaksConfig()
|
|
21
|
+
if (!config || config.notify?.enabled !== true) return {}
|
|
22
|
+
|
|
23
|
+
const notifyOnIdle = config.notify?.notifyOnIdle !== false
|
|
24
|
+
const notifyOnError = config.notify?.notifyOnError !== false
|
|
25
|
+
const configuredCommand =
|
|
26
|
+
typeof config.notify?.command === "string" && config.notify.command.trim().length > 0
|
|
27
|
+
? config.notify.command.trim()
|
|
28
|
+
: null
|
|
29
|
+
const style = config.notify?.style
|
|
30
|
+
logConfig = config.logging
|
|
31
|
+
|
|
32
|
+
const sender = configuredCommand
|
|
33
|
+
? ({ kind: "custom", commandTemplate: configuredCommand } as const)
|
|
34
|
+
: await detectNotifySender($ as ShellExecutor, client, logConfig)
|
|
35
|
+
|
|
36
|
+
const sendToast = async (projectName: string, message: string, tag: string) => {
|
|
37
|
+
const title = `oc: ${projectName}`
|
|
38
|
+
await notifyWithSender($ as ShellExecutor, sender, title, message, tag, style, logConfig)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
event: safeHook("notify:event", async ({ event }: { event: any }) => {
|
|
43
|
+
if (event?.type === "session.idle") {
|
|
44
|
+
if (!notifyOnIdle) return
|
|
45
|
+
|
|
46
|
+
const projectName = getProjectName(directory)
|
|
47
|
+
const sessionId =
|
|
48
|
+
(event.properties as { sessionID?: string; sessionId?: string } | undefined)
|
|
49
|
+
?.sessionID ??
|
|
50
|
+
(event.properties as { sessionID?: string; sessionId?: string } | undefined)
|
|
51
|
+
?.sessionId
|
|
52
|
+
|
|
53
|
+
const message = await extractIdleMessage(client, sessionId)
|
|
54
|
+
await sendToast(projectName, message, "Stop")
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (event?.type === "session.error") {
|
|
59
|
+
if (!notifyOnError) return
|
|
60
|
+
const projectName = getProjectName(directory)
|
|
61
|
+
await sendToast(projectName, "❌ Session error", "Error")
|
|
62
|
+
}
|
|
63
|
+
}),
|
|
64
|
+
}
|
|
65
|
+
} catch (error) {
|
|
66
|
+
await log(logConfig, "WARN", "[oc-tweaks] notify:init: " + String(error))
|
|
67
|
+
return {}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function getProjectName(directory: string): string {
|
|
72
|
+
const normalized = directory.replace(/\\/g, "/")
|
|
73
|
+
const segments = normalized.split("/").filter(Boolean)
|
|
74
|
+
return segments[segments.length - 1] || "opencode"
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function extractIdleMessage(client: any, sessionId?: string): Promise<string> {
|
|
78
|
+
let message = "✓ Task completed"
|
|
79
|
+
if (!sessionId || !client?.session?.messages) return message
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const result = await client.session.messages({ path: { id: sessionId } })
|
|
83
|
+
const messages = result?.data
|
|
84
|
+
if (!Array.isArray(messages)) return message
|
|
85
|
+
|
|
86
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
87
|
+
const msg = messages[i]
|
|
88
|
+
if (msg?.info?.role !== "assistant" || !Array.isArray(msg?.parts)) continue
|
|
89
|
+
for (const part of msg.parts) {
|
|
90
|
+
if (part?.type === "text" && typeof part.text === "string") {
|
|
91
|
+
message = `✓ ${truncateText(cleanMarkdown(part.text), 400)}`
|
|
92
|
+
return message
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return message
|
|
98
|
+
} catch {
|
|
99
|
+
return message
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function detectNotifySender(
|
|
104
|
+
$: ShellExecutor,
|
|
105
|
+
client: any,
|
|
106
|
+
logConfig?: LoggerConfig,
|
|
107
|
+
): Promise<NotifySender> {
|
|
108
|
+
if (await commandExists($, "pwsh")) {
|
|
109
|
+
return { kind: "wpf", command: "pwsh" }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (await commandExists($, "powershell.exe")) {
|
|
113
|
+
return { kind: "wpf", command: "powershell.exe" }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (await commandExists($, "osascript")) {
|
|
117
|
+
return { kind: "osascript" }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (await commandExists($, "notify-send")) {
|
|
121
|
+
return { kind: "notify-send" }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (typeof client?.tui?.showToast === "function") {
|
|
125
|
+
return { kind: "tui", showToast: client.tui.showToast }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
await log(
|
|
129
|
+
logConfig,
|
|
130
|
+
"WARN",
|
|
131
|
+
"[oc-tweaks] notify: no available notifier, set notify.command to override",
|
|
132
|
+
)
|
|
133
|
+
return { kind: "none" }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function commandExists($: ShellExecutor, command: string): Promise<boolean> {
|
|
137
|
+
try {
|
|
138
|
+
await $`which ${command}`
|
|
139
|
+
return true
|
|
140
|
+
} catch {
|
|
141
|
+
return false
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function notifyWithSender(
|
|
146
|
+
$: ShellExecutor,
|
|
147
|
+
sender: NotifySender,
|
|
148
|
+
title: string,
|
|
149
|
+
message: string,
|
|
150
|
+
tag: string,
|
|
151
|
+
style?: NotifyStyle,
|
|
152
|
+
logConfig?: LoggerConfig,
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
try {
|
|
155
|
+
if (sender.kind === "custom") {
|
|
156
|
+
const command = sender.commandTemplate
|
|
157
|
+
.replace(/\$TITLE/g, title)
|
|
158
|
+
.replace(/\$MESSAGE/g, message)
|
|
159
|
+
await runCustomCommand($, command)
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (sender.kind === "wpf") {
|
|
164
|
+
await runWpfNotification($, sender.command, title, message, tag, style)
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (sender.kind === "osascript") {
|
|
169
|
+
const script = `display notification "${escapeAppleScript(message)}" with title "${escapeAppleScript(title)}"`
|
|
170
|
+
await $`osascript -e ${script}`
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (sender.kind === "notify-send") {
|
|
175
|
+
await $`notify-send ${title} ${message}`
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (sender.kind === "tui") {
|
|
180
|
+
await showToastWithFallback(sender.showToast, title, message)
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
// Notification flow must stay non-blocking.
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function runCustomCommand($: ShellExecutor, command: string): Promise<void> {
|
|
188
|
+
const escaped = command.replace(/"/g, '\\"')
|
|
189
|
+
await $`bun -e ${`const { exec } = require("node:child_process"); exec("${escaped}")`}`
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function runWpfNotification(
|
|
193
|
+
$: ShellExecutor,
|
|
194
|
+
shellCommand: string,
|
|
195
|
+
title: string,
|
|
196
|
+
message: string,
|
|
197
|
+
tag: string,
|
|
198
|
+
style?: NotifyStyle,
|
|
199
|
+
): Promise<void> {
|
|
200
|
+
const backgroundColor = style?.backgroundColor ?? "#101018"
|
|
201
|
+
const backgroundOpacity = style?.backgroundOpacity ?? 0.95
|
|
202
|
+
const textColor = style?.textColor ?? "#AAAAAA"
|
|
203
|
+
const borderRadius = style?.borderRadius ?? 14
|
|
204
|
+
const colorBarWidth = style?.colorBarWidth ?? 5
|
|
205
|
+
const width = style?.width ?? 420
|
|
206
|
+
const height = style?.height ?? 105
|
|
207
|
+
const titleFontSize = style?.titleFontSize ?? 14
|
|
208
|
+
const contentFontSize = style?.contentFontSize ?? 11
|
|
209
|
+
const iconFontSize = style?.iconFontSize ?? 30
|
|
210
|
+
const duration = style?.duration ?? 10000
|
|
211
|
+
const position = style?.position ?? "center"
|
|
212
|
+
const shadow = style?.shadow !== false
|
|
213
|
+
const idleColor = style?.idleColor ?? "#4ADE80"
|
|
214
|
+
const errorColor = style?.errorColor ?? "#EF4444"
|
|
215
|
+
|
|
216
|
+
const accentColor = tag === "Error" ? errorColor : idleColor
|
|
217
|
+
const icon = tag === "Error" ? "❌" : "✅"
|
|
218
|
+
const startupLocation = position === "center" ? "CenterScreen" : position
|
|
219
|
+
|
|
220
|
+
const psTitle = title.replace(/'/g, "''")
|
|
221
|
+
const psText = truncateText(cleanMarkdown(message), 400).replace(/'/g, "''")
|
|
222
|
+
|
|
223
|
+
const shadowXaml = shadow
|
|
224
|
+
? '<Border.Effect><DropShadowEffect BlurRadius="20" ShadowDepth="2" Opacity="0.7" Color="Black"/></Border.Effect>'
|
|
225
|
+
: ""
|
|
226
|
+
|
|
227
|
+
const xaml = [
|
|
228
|
+
`<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"`,
|
|
229
|
+
` xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"`,
|
|
230
|
+
` WindowStyle="None" AllowsTransparency="True" Background="Transparent"`,
|
|
231
|
+
` Topmost="True" ShowInTaskbar="False" ShowActivated="False"`,
|
|
232
|
+
` WindowStartupLocation="${startupLocation}"`,
|
|
233
|
+
` Width="${width}" Height="${height}">`,
|
|
234
|
+
` <Border CornerRadius="${borderRadius}" Margin="10">`,
|
|
235
|
+
` <Border.Background>`,
|
|
236
|
+
` <SolidColorBrush Color="${backgroundColor}" Opacity="${backgroundOpacity}"/>`,
|
|
237
|
+
` </Border.Background>`,
|
|
238
|
+
` ${shadowXaml}`,
|
|
239
|
+
` <Grid>`,
|
|
240
|
+
` <Border CornerRadius="${borderRadius},0,0,${borderRadius}" Width="${colorBarWidth}" HorizontalAlignment="Left" Name="ColorBar"/>`,
|
|
241
|
+
` <StackPanel Orientation="Horizontal" Margin="22,0,16,0" VerticalAlignment="Center">`,
|
|
242
|
+
` <TextBlock Name="IconText" FontSize="${iconFontSize}" VerticalAlignment="Center" Margin="0,0,15,0" Foreground="White"/>`,
|
|
243
|
+
` <StackPanel VerticalAlignment="Center" MaxWidth="320">`,
|
|
244
|
+
` <TextBlock Name="TitleText" FontSize="${titleFontSize}" FontWeight="SemiBold"/>`,
|
|
245
|
+
` <TextBlock Name="ContentText" Foreground="${textColor}" FontSize="${contentFontSize}" Margin="0,4,0,0" TextWrapping="Wrap"/>`,
|
|
246
|
+
` <TextBlock Text="Click to dismiss" Foreground="#555555" FontSize="9" Margin="0,4,0,0"/>`,
|
|
247
|
+
` </StackPanel>`,
|
|
248
|
+
` </StackPanel>`,
|
|
249
|
+
` </Grid>`,
|
|
250
|
+
` </Border>`,
|
|
251
|
+
`</Window>`,
|
|
252
|
+
].join("\n")
|
|
253
|
+
|
|
254
|
+
const psScript = [
|
|
255
|
+
"Add-Type -AssemblyName PresentationFramework",
|
|
256
|
+
"Add-Type -AssemblyName PresentationCore",
|
|
257
|
+
"Add-Type -AssemblyName WindowsBase",
|
|
258
|
+
"",
|
|
259
|
+
"Add-Type -TypeDefinition @'",
|
|
260
|
+
"using System;",
|
|
261
|
+
"using System.Runtime.InteropServices;",
|
|
262
|
+
"",
|
|
263
|
+
"public static class VDesktop {",
|
|
264
|
+
' [DllImport("user32.dll", SetLastError = true)]',
|
|
265
|
+
" public static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);",
|
|
266
|
+
"",
|
|
267
|
+
' [DllImport("user32.dll", SetLastError = true)]',
|
|
268
|
+
" public static extern int GetWindowLong(IntPtr hWnd, int nIndex);",
|
|
269
|
+
"",
|
|
270
|
+
" public const int GWL_EXSTYLE = -20;",
|
|
271
|
+
" public const int WS_EX_TOOLWINDOW = 0x00000080;",
|
|
272
|
+
" public const int WS_EX_NOACTIVATE = 0x08000000;",
|
|
273
|
+
" public const int WS_EX_APPWINDOW = 0x00040000;",
|
|
274
|
+
"",
|
|
275
|
+
" public static void MakeGlobalWindow(IntPtr hwnd) {",
|
|
276
|
+
" int style = GetWindowLong(hwnd, GWL_EXSTYLE);",
|
|
277
|
+
" style = style | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE;",
|
|
278
|
+
" style = style & ~WS_EX_APPWINDOW;",
|
|
279
|
+
" SetWindowLong(hwnd, GWL_EXSTYLE, style);",
|
|
280
|
+
" }",
|
|
281
|
+
"}",
|
|
282
|
+
"'@ -ErrorAction SilentlyContinue",
|
|
283
|
+
"",
|
|
284
|
+
`$title = '${psTitle}'`,
|
|
285
|
+
`$text = '${psText}'`,
|
|
286
|
+
`$accentColor = '${accentColor}'`,
|
|
287
|
+
`$icon = '${icon}'`,
|
|
288
|
+
`$duration = ${duration}`,
|
|
289
|
+
"",
|
|
290
|
+
"[xml]$xaml = @'",
|
|
291
|
+
xaml,
|
|
292
|
+
"'@",
|
|
293
|
+
"",
|
|
294
|
+
"$reader = New-Object System.Xml.XmlNodeReader $xaml",
|
|
295
|
+
"$window = [Windows.Markup.XamlReader]::Load($reader)",
|
|
296
|
+
"",
|
|
297
|
+
"$colorBar = $window.FindName('ColorBar')",
|
|
298
|
+
"$iconText = $window.FindName('IconText')",
|
|
299
|
+
"$titleText = $window.FindName('TitleText')",
|
|
300
|
+
"$contentText = $window.FindName('ContentText')",
|
|
301
|
+
"",
|
|
302
|
+
"$colorBar.Background = [System.Windows.Media.BrushConverter]::new().ConvertFromString($accentColor)",
|
|
303
|
+
"$iconText.Text = $icon",
|
|
304
|
+
"$titleText.Text = $title",
|
|
305
|
+
"$titleText.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString($accentColor)",
|
|
306
|
+
"$contentText.Text = $text",
|
|
307
|
+
"",
|
|
308
|
+
"$window.Add_MouseLeftButtonDown({ $window.Close() })",
|
|
309
|
+
"",
|
|
310
|
+
"$window.Add_Loaded({",
|
|
311
|
+
" $hwnd = (New-Object System.Windows.Interop.WindowInteropHelper($window)).Handle",
|
|
312
|
+
" [VDesktop]::MakeGlobalWindow($hwnd)",
|
|
313
|
+
"})",
|
|
314
|
+
"",
|
|
315
|
+
"if ($duration -gt 0) {",
|
|
316
|
+
" $timer = New-Object System.Windows.Threading.DispatcherTimer",
|
|
317
|
+
" $timer.Interval = [TimeSpan]::FromMilliseconds($duration)",
|
|
318
|
+
" $timer.Add_Tick({",
|
|
319
|
+
" $window.Close()",
|
|
320
|
+
" $timer.Stop()",
|
|
321
|
+
" })",
|
|
322
|
+
" $timer.Start()",
|
|
323
|
+
"}",
|
|
324
|
+
"",
|
|
325
|
+
"$window.ShowActivated = $false",
|
|
326
|
+
"$window.Show()",
|
|
327
|
+
"$frame = New-Object System.Windows.Threading.DispatcherFrame",
|
|
328
|
+
"$window.Add_Closed({ $frame.Continue = $false })",
|
|
329
|
+
"[System.Windows.Threading.Dispatcher]::PushFrame($frame)",
|
|
330
|
+
].join("\n")
|
|
331
|
+
|
|
332
|
+
const jsCode = [
|
|
333
|
+
"const proc = require('node:child_process').spawn(",
|
|
334
|
+
` ${JSON.stringify(shellCommand)},`,
|
|
335
|
+
` ['-NoProfile', '-Command', ${JSON.stringify(psScript)}],`,
|
|
336
|
+
" { detached: true, stdio: 'ignore' }",
|
|
337
|
+
");",
|
|
338
|
+
"proc.unref();",
|
|
339
|
+
].join("\n")
|
|
340
|
+
|
|
341
|
+
await $`bun -e ${jsCode}`
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function showToastWithFallback(
|
|
345
|
+
showToast: (...args: any[]) => any,
|
|
346
|
+
title: string,
|
|
347
|
+
message: string,
|
|
348
|
+
): Promise<void> {
|
|
349
|
+
try {
|
|
350
|
+
await Promise.resolve(showToast({ title, message }))
|
|
351
|
+
return
|
|
352
|
+
} catch {
|
|
353
|
+
// Try alternative signatures below.
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
try {
|
|
357
|
+
await Promise.resolve(showToast({ title, description: message }))
|
|
358
|
+
return
|
|
359
|
+
} catch {
|
|
360
|
+
// Try alternative signatures below.
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
await Promise.resolve(showToast(title, message))
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function truncateText(text: string, maxChars: number): string {
|
|
367
|
+
if (text.length <= maxChars) return text
|
|
368
|
+
return `${text.slice(0, maxChars)}...`
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function cleanMarkdown(text: string): string {
|
|
372
|
+
return text
|
|
373
|
+
.replace(/[`*#]/g, "")
|
|
374
|
+
.replace(/\n+/g, " ")
|
|
375
|
+
.replace(/\s+/g, " ")
|
|
376
|
+
.trim()
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
function escapeAppleScript(text: string): string {
|
|
382
|
+
return text.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")
|
|
383
|
+
}
|
package/src/types.ts
ADDED
|
File without changes
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
declare const Bun: any
|
|
2
|
+
|
|
3
|
+
export async function loadJsonConfig<T extends Record<string, unknown>>(
|
|
4
|
+
path: string,
|
|
5
|
+
defaults: T
|
|
6
|
+
): Promise<T> {
|
|
7
|
+
try {
|
|
8
|
+
const file = Bun.file(path)
|
|
9
|
+
if (!(await file.exists())) return defaults
|
|
10
|
+
const parsed = await file.json()
|
|
11
|
+
return { ...defaults, ...parsed }
|
|
12
|
+
} catch {
|
|
13
|
+
return defaults
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface NotifyStyle {
|
|
18
|
+
backgroundColor?: string // default: "#101018"
|
|
19
|
+
backgroundOpacity?: number // default: 0.95
|
|
20
|
+
textColor?: string // default: "#AAAAAA"
|
|
21
|
+
borderRadius?: number // default: 14
|
|
22
|
+
colorBarWidth?: number // default: 5
|
|
23
|
+
width?: number // default: 420
|
|
24
|
+
height?: number // default: 105
|
|
25
|
+
titleFontSize?: number // default: 14
|
|
26
|
+
contentFontSize?: number // default: 11
|
|
27
|
+
iconFontSize?: number // default: 30
|
|
28
|
+
duration?: number // default: 10000
|
|
29
|
+
position?: string // default: "center"
|
|
30
|
+
shadow?: boolean // default: true
|
|
31
|
+
idleColor?: string // default: "#4ADE80"
|
|
32
|
+
errorColor?: string // default: "#EF4444"
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface OcTweaksConfig extends Record<string, unknown> {
|
|
36
|
+
compaction: { enabled?: boolean }
|
|
37
|
+
backgroundSubagent: { enabled?: boolean }
|
|
38
|
+
leaderboard: { enabled?: boolean; configPath?: string | null }
|
|
39
|
+
logging?: {
|
|
40
|
+
enabled?: boolean
|
|
41
|
+
maxLines?: number
|
|
42
|
+
}
|
|
43
|
+
notify: {
|
|
44
|
+
enabled?: boolean
|
|
45
|
+
notifyOnIdle?: boolean
|
|
46
|
+
notifyOnError?: boolean
|
|
47
|
+
command?: string | null
|
|
48
|
+
style?: NotifyStyle
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const DEFAULT_CONFIG: OcTweaksConfig = {
|
|
53
|
+
compaction: {},
|
|
54
|
+
backgroundSubagent: {},
|
|
55
|
+
leaderboard: {},
|
|
56
|
+
notify: {},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function loadOcTweaksConfig(): Promise<OcTweaksConfig | null> {
|
|
60
|
+
const home =
|
|
61
|
+
Bun.env?.HOME ?? ((globalThis as any)?.process?.env?.HOME ?? "") ?? ""
|
|
62
|
+
const path = `${home}/.config/opencode/oc-tweaks.json`
|
|
63
|
+
try {
|
|
64
|
+
const file = Bun.file(path)
|
|
65
|
+
if (!(await file.exists())) return null
|
|
66
|
+
const parsed = await file.json()
|
|
67
|
+
return { ...DEFAULT_CONFIG, ...parsed }
|
|
68
|
+
} catch {
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
}
|