oc-tweaks 0.4.2 → 0.5.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.
Files changed (3) hide show
  1. package/README.md +56 -4
  2. package/dist/index.js +295 -85
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -9,7 +9,7 @@ A collection of runtime enhancement plugins for [OpenCode](https://opencode.ai/)
9
9
  `oc-tweaks` provides a set of plugins to enhance your OpenCode experience. These plugins are activated at runtime and can modify OpenCode's behavior, such as sending notifications, improving multi-language support, and enforcing best practices for sub-agent usage.
10
10
 
11
11
  The currently available plugins are:
12
- - **`notify`**: Sends desktop notifications when a task is completed or an error occurs.
12
+ - **`notify`**: Sends desktop notifications when a task is completed or an error occurs, and can show WPF tool call transparency toasts (stack + queue).
13
13
  - **`compaction`**: Injects a language preference prompt during session compaction to ensure summaries are in your preferred language.
14
14
  - **`autoMemory`**: Smart memory assistant that injects memory context, detects memory trigger phrases, and supports active memory writes via `remember` tool and `/remember` command.
15
15
  - **`backgroundSubagent`**: Adds a system prompt to encourage using background sub-agents for better responsiveness.
@@ -50,6 +50,8 @@ All configurations are optional. By default, most plugins are enabled after runn
50
50
 
51
51
  This plugin sends desktop notifications upon task completion (session idle) or error.
52
52
 
53
+ It can also show **tool call transparency notifications** (WPF only) for each model tool invocation, including tool name and arguments, with vertical stacking and queueing (no drop).
54
+
53
55
  - **Windows**: Uses a custom, non-intrusive WPF window that works across virtual desktops.
54
56
  - **macOS**: Uses `osascript`.
55
57
  - **Linux**: Uses `notify-send`.
@@ -64,6 +66,7 @@ This plugin sends desktop notifications upon task completion (session idle) or e
64
66
  | `notifyOnError` | boolean | `true` | Notify on session errors. |
65
67
  | `command` | string | `null` | A custom command to run for notifications. `$TITLE` and `$MESSAGE` are available as placeholders. |
66
68
  | `style` | object | `{...}` | Custom styles for the Windows WPF notification window. See below. |
69
+ | `toolCall` | object | `{...}` | Tool call transparency notification settings. See below. |
67
70
 
68
71
  #### `notify.style` (Windows WPF)
69
72
 
@@ -80,13 +83,26 @@ Customize the appearance of the WPF notification window.
80
83
  | `height` | `105` | Window height in pixels. |
81
84
  | `titleFontSize` | `14` | Font size for the title in points. |
82
85
  | `contentFontSize` | `11` | Font size for the content in points. |
83
- | `iconFontSize` | `13` | Font size for the icon (✅/❌) in points. |
86
+ | `iconFontSize` | `30` | Font size for the icon (✅/❌) in points. |
84
87
  | `duration` | `10000` | Time in milliseconds before the notification auto-closes. |
85
88
  | `position` | `"center"` | Window position: `"center"`, `"top-right"`, or `"bottom-right"`. |
86
89
  | `shadow` | `true` | Enable or disable the drop shadow effect. |
87
90
  | `idleColor` | `"#4ADE80"` | Accent color for idle (success) notifications. |
88
91
  | `errorColor` | `"#EF4444"` | Accent color for error notifications. |
89
92
 
93
+ #### `notify.toolCall` (Windows WPF)
94
+
95
+ Configure tool call transparency notifications. This feature is designed for visibility: every queued event is eventually shown.
96
+
97
+ | Property | Type | Default | Description |
98
+ |---|---|---|---|
99
+ | `enabled` | boolean | `false` | Enable tool call notifications. |
100
+ | `duration` | number | `3000` | Auto-close delay for each tool call toast (ms). |
101
+ | `position` | string | `"top-right"` | Window position: `"top-right"`, `"bottom-right"`, or `"center"`. |
102
+ | `maxVisible` | number | `3` | Maximum number of visible tool call toasts at the same time. |
103
+ | `maxArgLength` | number | `300` | Max characters for formatted argument JSON before truncation. |
104
+ | `filter.exclude` | string[] | `[]` | Tool names to skip (exact match). |
105
+
90
106
  ### `compaction`
91
107
 
92
108
  This plugin ensures that when OpenCode compacts a session's context, the resulting summary is generated in your preferred language and writing style.
@@ -162,6 +178,16 @@ Here is an example of a `~/.config/opencode/oc-tweaks.json` file with all option
162
178
  "style": {
163
179
  "backgroundColor": "#101018",
164
180
  "duration": 8000
181
+ },
182
+ "toolCall": {
183
+ "enabled": true,
184
+ "duration": 3000,
185
+ "position": "top-right",
186
+ "maxVisible": 3,
187
+ "maxArgLength": 300,
188
+ "filter": {
189
+ "exclude": ["think_sequentialthinking"]
190
+ }
165
191
  }
166
192
  },
167
193
  "compaction": {
@@ -199,7 +225,7 @@ Here is an example of a `~/.config/opencode/oc-tweaks.json` file with all option
199
225
  `oc-tweaks` 提供了一系列插件来增强你的 OpenCode 使用体验。这些插件在运行时激活,可以调整 OpenCode 的行为,例如发送桌面通知、改善多语言支持以及强制执行子代理使用的最佳实践。
200
226
 
201
227
  目前可用的插件包括:
202
- - **`notify`**: 在任务完成或发生错误时发送桌面通知。
228
+ - **`notify`**: 在任务完成或发生错误时发送桌面通知,并支持 WPF 工具调用透明度弹窗(堆叠 + 排队)。
203
229
  - **`compaction`**: 在会话上下文压缩期间注入语言偏好提示,以确保摘要使用你的首选语言。
204
230
  - **`autoMemory`**: 智能记忆助手——自动注入 memory 上下文、识别触发词,并支持 `remember` tool 与 `/remember` 命令主动写入。
205
231
  - **`backgroundSubagent`**: 添加系统提示,鼓励使用后台子代理以获得更好的响应性。
@@ -240,6 +266,8 @@ bunx oc-tweaks init
240
266
 
241
267
  此插件在任务完成(会话空闲)或出错时发送桌面通知。
242
268
 
269
+ 它也支持**工具调用透明度通知**(仅 WPF):每次模型调用工具都会显示工具名与参数,并支持垂直堆叠和排队(不丢通知)。
270
+
243
271
  - **Windows**: 使用一个自定义的、无侵入性的 WPF 窗口,该窗口可跨虚拟桌面工作。
244
272
  - **macOS**: 使用 `osascript`。
245
273
  - **Linux**: 使用 `notify-send`。
@@ -254,6 +282,7 @@ bunx oc-tweaks init
254
282
  | `notifyOnError` | boolean | `true` | 在会话出错时通知。 |
255
283
  | `command` | string | `null` | 用于发送通知的自定义命令。`$TITLE` 和 `$MESSAGE` 可作为占位符。 |
256
284
  | `style` | object | `{...}` | 用于 Windows WPF 通知窗口的自定义样式。详见下文。 |
285
+ | `toolCall` | object | `{...}` | 工具调用透明度通知配置。详见下文。 |
257
286
 
258
287
  #### `notify.style` (Windows WPF)
259
288
 
@@ -270,13 +299,26 @@ bunx oc-tweaks init
270
299
  | `height` | `105` | 窗口高度 (像素)。 |
271
300
  | `titleFontSize` | `14` | 标题的字体大小 (pt)。 |
272
301
  | `contentFontSize` | `11` | 内容的字体大小 (pt)。 |
273
- | `iconFontSize` | `13` | 图标 (✅/❌) 的字体大小 (pt)。 |
302
+ | `iconFontSize` | `30` | 图标 (✅/❌) 的字体大小 (pt)。 |
274
303
  | `duration` | `10000` | 通知自动关闭前的延迟时间 (毫秒)。 |
275
304
  | `position` | `"center"` | 窗口位置: `"center"`, `"top-right"`, 或 `"bottom-right"`。 |
276
305
  | `shadow` | `true` | 启用或禁用下拉阴影效果。 |
277
306
  | `idleColor` | `"#4ADE80"` | 空闲 (成功) 通知的强调色。 |
278
307
  | `errorColor` | `"#EF4444"` | 错误通知的强调色。 |
279
308
 
309
+ #### `notify.toolCall` (Windows WPF)
310
+
311
+ 配置工具调用透明度通知。该能力以“可见性优先”为目标:进入队列的事件最终都会显示。
312
+
313
+ | 属性 | 类型 | 默认值 | 描述 |
314
+ |---|---|---|---|
315
+ | `enabled` | boolean | `false` | 启用工具调用通知。 |
316
+ | `duration` | number | `3000` | 每条工具调用弹窗自动关闭延迟(毫秒)。 |
317
+ | `position` | string | `"top-right"` | 窗口位置:`"top-right"`、`"bottom-right"` 或 `"center"`。 |
318
+ | `maxVisible` | number | `3` | 同时可见的工具调用弹窗最大数量。 |
319
+ | `maxArgLength` | number | `300` | 参数 JSON 格式化后截断前的最大字符数。 |
320
+ | `filter.exclude` | string[] | `[]` | 要跳过的工具名(精确匹配)。 |
321
+
280
322
  ### `compaction`
281
323
 
282
324
  此插件确保当 OpenCode 压缩会话上下文时,生成的摘要使用你的首选语言和写作风格。
@@ -352,6 +394,16 @@ bunx oc-tweaks init
352
394
  "style": {
353
395
  "backgroundColor": "#101018",
354
396
  "duration": 8000
397
+ },
398
+ "toolCall": {
399
+ "enabled": true,
400
+ "duration": 3000,
401
+ "position": "top-right",
402
+ "maxVisible": 3,
403
+ "maxArgLength": 300,
404
+ "filter": {
405
+ "exclude": ["think_sequentialthinking"]
406
+ }
355
407
  }
356
408
  },
357
409
  "compaction": {
package/dist/index.js CHANGED
@@ -65,7 +65,9 @@ var DEFAULT_CONFIG = {
65
65
  autoMemory: {},
66
66
  backgroundSubagent: {},
67
67
  leaderboard: {},
68
- notify: {}
68
+ notify: {
69
+ toolCall: {}
70
+ }
69
71
  };
70
72
  async function loadOcTweaksConfig() {
71
73
  const home = Bun.env?.HOME ?? (globalThis?.process?.env?.HOME ?? "") ?? "";
@@ -475,74 +477,104 @@ var leaderboardPlugin = async () => {
475
477
  })
476
478
  };
477
479
  };
478
- // src/plugins/notify.ts
479
- var notifyPlugin = async ({ $, directory, client }) => {
480
- let cachedSender = null;
481
- return {
482
- event: safeHook("notify:event", async ({ event }) => {
483
- const config = await loadOcTweaksConfig();
484
- if (!config || config.notify?.enabled !== true)
485
- return;
486
- const logConfig = config.logging;
487
- const notifyOnIdle = config.notify?.notifyOnIdle !== false;
488
- const notifyOnError = config.notify?.notifyOnError !== false;
489
- const configuredCommand = typeof config.notify?.command === "string" && config.notify.command.trim().length > 0 ? config.notify.command.trim() : null;
490
- const style = config.notify?.style;
491
- const sender = configuredCommand ? { kind: "custom", commandTemplate: configuredCommand } : cachedSender ??= await detectNotifySender($, client, logConfig);
492
- const sendToast = async (projectName, message, tag) => {
493
- const title = `oc: ${projectName}`;
494
- await notifyWithSender($, sender, title, message, tag, style, logConfig);
480
+ // src/utils/wpf-position.ts
481
+ class WpfPositionManager {
482
+ maxVisible;
483
+ slots = new Map;
484
+ queue = [];
485
+ constructor(maxVisible) {
486
+ this.maxVisible = maxVisible;
487
+ }
488
+ allocateSlot(duration) {
489
+ this.cleanupExpiredSlots();
490
+ for (let slotIndex = 0;slotIndex < this.maxVisible; slotIndex += 1) {
491
+ if (this.slots.has(slotIndex))
492
+ continue;
493
+ const ttl = Math.max(0, duration) + 500;
494
+ const timer = setTimeout(() => {
495
+ this.releaseSlot(slotIndex);
496
+ }, ttl);
497
+ const expiresAt = Date.now() + ttl;
498
+ this.slots.set(slotIndex, { expiresAt, timer });
499
+ let released = false;
500
+ return {
501
+ slotIndex,
502
+ release: () => {
503
+ if (released)
504
+ return;
505
+ released = true;
506
+ this.releaseSlot(slotIndex);
507
+ }
495
508
  };
496
- if (event?.type === "session.idle") {
497
- if (!notifyOnIdle)
498
- return;
499
- const projectName = getProjectName(directory);
500
- const sessionId = event.properties?.sessionID ?? event.properties?.sessionId;
501
- const message = await extractIdleMessage(client, sessionId);
502
- await sendToast(projectName, message, "Stop");
509
+ }
510
+ return null;
511
+ }
512
+ enqueue(callback) {
513
+ this.queue.push(callback);
514
+ }
515
+ processQueue() {
516
+ this.cleanupExpiredSlots();
517
+ while (this.queue.length > 0 && this.hasAvailableSlot()) {
518
+ const callback = this.queue.shift();
519
+ if (!callback)
503
520
  return;
504
- }
505
- if (event?.type === "session.error") {
506
- if (!notifyOnError)
507
- return;
508
- const projectName = getProjectName(directory);
509
- await sendToast(projectName, "❌ Session error", "Error");
510
- }
511
- })
512
- };
513
- };
514
- function getProjectName(directory) {
515
- const normalized = directory.replace(/\\/g, "/");
516
- const segments = normalized.split("/").filter(Boolean);
517
- return segments[segments.length - 1] || "opencode";
518
- }
519
- async function extractIdleMessage(client, sessionId) {
520
- let message = "✓ Task completed";
521
- if (!sessionId || !client?.session?.messages)
522
- return message;
523
- try {
524
- const result = await client.session.messages({ path: { id: sessionId } });
525
- const messages = result?.data;
526
- if (!Array.isArray(messages))
527
- return message;
528
- for (let i = messages.length - 1;i >= 0; i -= 1) {
529
- const msg = messages[i];
530
- if (msg?.info?.role !== "assistant" || !Array.isArray(msg?.parts))
521
+ callback();
522
+ }
523
+ }
524
+ hasAvailableSlot() {
525
+ this.cleanupExpiredSlots();
526
+ return this.slots.size < this.maxVisible;
527
+ }
528
+ cleanupExpiredSlots() {
529
+ const now = Date.now();
530
+ for (const [slotIndex, state] of this.slots.entries()) {
531
+ if (state.expiresAt > now)
531
532
  continue;
532
- for (const part of msg.parts) {
533
- if (part?.type === "text" && typeof part.text === "string") {
534
- message = `✓ ${truncateText(cleanMarkdown(part.text), 400)}`;
535
- return message;
536
- }
537
- }
533
+ clearTimeout(state.timer);
534
+ this.slots.delete(slotIndex);
538
535
  }
539
- return message;
540
- } catch {
541
- return message;
542
536
  }
537
+ releaseSlot(slotIndex) {
538
+ const state = this.slots.get(slotIndex);
539
+ if (!state)
540
+ return;
541
+ clearTimeout(state.timer);
542
+ this.slots.delete(slotIndex);
543
+ this.processQueue();
544
+ }
545
+ }
546
+ function calculatePosition(slotIndex, config) {
547
+ const position = config.position ?? "center";
548
+ if (position === "center") {
549
+ return { startupLocation: "CenterScreen" };
550
+ }
551
+ const width = config.width;
552
+ const height = config.height;
553
+ const gap = config.gap ?? 12;
554
+ const margin = config.screenMargin ?? 20;
555
+ const screenWidthExpr = typeof config.screenWidth === "number" ? `${config.screenWidth}` : "[System.Windows.SystemParameters]::PrimaryScreenWidth";
556
+ const screenHeightExpr = typeof config.screenHeight === "number" ? `${config.screenHeight}` : "[System.Windows.SystemParameters]::PrimaryScreenHeight";
557
+ const leftExpr = `(${screenWidthExpr} - ${width} - ${margin})`;
558
+ if (position === "bottom-right") {
559
+ const topExpr2 = `(${screenHeightExpr} - ${margin} - ((${slotIndex} + 1) * (${height} + ${gap})))`;
560
+ return {
561
+ startupLocation: "Manual",
562
+ leftExpr,
563
+ topExpr: topExpr2
564
+ };
565
+ }
566
+ const topExpr = `(${margin} + (${slotIndex} * (${height} + ${gap})))`;
567
+ return {
568
+ startupLocation: "Manual",
569
+ leftExpr,
570
+ topExpr
571
+ };
543
572
  }
573
+
574
+ // src/utils/wpf-notify.ts
544
575
  async function detectNotifySender($, client, logConfig) {
545
- if (await commandExists($, "pwsh")) {
576
+ const isWindows = globalThis?.process?.platform === "win32";
577
+ if (isWindows && await commandExists($, "pwsh")) {
546
578
  return { kind: "wpf", command: "pwsh" };
547
579
  }
548
580
  if (await commandExists($, "powershell.exe")) {
@@ -560,10 +592,7 @@ async function detectNotifySender($, client, logConfig) {
560
592
  await log(logConfig, "WARN", "[oc-tweaks] notify: no available notifier, set notify.command to override");
561
593
  return { kind: "none" };
562
594
  }
563
- async function commandExists(_$, command) {
564
- return Bun.which(command) !== null;
565
- }
566
- async function notifyWithSender($, sender, title, message, tag, style, logConfig) {
595
+ async function notifyWithSender($, sender, title, message, tag, style, _logConfig, position, renderOptions) {
567
596
  try {
568
597
  if (sender.kind === "custom") {
569
598
  const command = sender.commandTemplate.replace(/\$TITLE/g, title).replace(/\$MESSAGE/g, message);
@@ -571,7 +600,7 @@ async function notifyWithSender($, sender, title, message, tag, style, logConfig
571
600
  return;
572
601
  }
573
602
  if (sender.kind === "wpf") {
574
- await runWpfNotification($, sender.command, title, message, tag, style);
603
+ await runWpfNotification($, sender.command, title, message, tag, style, position, renderOptions);
575
604
  return;
576
605
  }
577
606
  if (sender.kind === "osascript") {
@@ -588,11 +617,34 @@ async function notifyWithSender($, sender, title, message, tag, style, logConfig
588
617
  }
589
618
  } catch {}
590
619
  }
620
+ async function sendWpfToast(options) {
621
+ await notifyWithSender(options.$, options.sender, options.title, options.message, options.tag, options.style, options.logConfig, options.position, {
622
+ icon: options.icon,
623
+ accentColor: options.accentColor,
624
+ showDismissHint: options.showDismissHint,
625
+ maxMessageLength: options.maxMessageLength,
626
+ preserveMessageFormatting: options.preserveMessageFormatting
627
+ });
628
+ }
629
+ function escapeForPowerShell(text) {
630
+ return Buffer.from(text, "utf8").toString("base64");
631
+ }
632
+ function truncateText(text, maxChars) {
633
+ if (text.length <= maxChars)
634
+ return text;
635
+ return `${text.slice(0, maxChars)}...`;
636
+ }
637
+ function cleanMarkdown(text) {
638
+ return text.replace(/[`*#]/g, "").replace(/\n+/g, " ").replace(/\s+/g, " ").trim();
639
+ }
640
+ async function commandExists(_$, command) {
641
+ return Bun.which(command) !== null;
642
+ }
591
643
  async function runCustomCommand($, command) {
592
644
  const escaped = command.replace(/"/g, "\\\"");
593
645
  await $`bun -e ${`const { exec } = require("node:child_process"); exec("${escaped}")`}`;
594
646
  }
595
- async function runWpfNotification($, shellCommand, title, message, tag, style) {
647
+ async function runWpfNotification($, shellCommand, title, message, tag, style, position, renderOptions) {
596
648
  const backgroundColor = style?.backgroundColor ?? "#101018";
597
649
  const backgroundOpacity = style?.backgroundOpacity ?? 0.95;
598
650
  const textColor = style?.textColor ?? "#AAAAAA";
@@ -604,16 +656,26 @@ async function runWpfNotification($, shellCommand, title, message, tag, style) {
604
656
  const contentFontSize = style?.contentFontSize ?? 11;
605
657
  const iconFontSize = style?.iconFontSize ?? 30;
606
658
  const duration = style?.duration ?? 1e4;
607
- const position = style?.position ?? "center";
659
+ const stylePosition = style?.position ?? "center";
608
660
  const shadow = style?.shadow !== false;
609
661
  const idleColor = style?.idleColor ?? "#4ADE80";
610
662
  const errorColor = style?.errorColor ?? "#EF4444";
611
- const accentColor = tag === "Error" ? errorColor : idleColor;
612
- const icon = tag === "Error" ? "❌" : "✅";
613
- const startupLocation = position === "center" ? "CenterScreen" : position;
614
- const psTitle = title.replace(/'/g, "''");
615
- const psText = truncateText(cleanMarkdown(message), 400).replace(/'/g, "''");
663
+ const accentColor = renderOptions?.accentColor ?? (tag === "Error" ? errorColor : idleColor);
664
+ const icon = renderOptions?.icon ?? (tag === "Error" ? "❌" : "✅");
665
+ const showDismissHint = renderOptions?.showDismissHint !== false;
666
+ const normalizedStylePosition = stylePosition === "top-right" || stylePosition === "bottom-right" || stylePosition === "center" ? stylePosition : "center";
667
+ const resolvedPosition = position ?? (normalizedStylePosition === "center" ? { startupLocation: "CenterScreen" } : calculatePosition(0, {
668
+ position: normalizedStylePosition,
669
+ width,
670
+ height
671
+ }));
672
+ const startupLocation = resolvedPosition.startupLocation;
673
+ const titleB64 = escapeForPowerShell(title);
674
+ const messageLimit = typeof renderOptions?.maxMessageLength === "number" && renderOptions.maxMessageLength > 0 ? renderOptions.maxMessageLength : 400;
675
+ const normalizedMessage = renderOptions?.preserveMessageFormatting ? message : cleanMarkdown(message);
676
+ const textB64 = escapeForPowerShell(truncateText(normalizedMessage, messageLimit));
616
677
  const shadowXaml = shadow ? '<Border.Effect><DropShadowEffect BlurRadius="20" ShadowDepth="2" Opacity="0.7" Color="Black"/></Border.Effect>' : "";
678
+ const dismissHintXaml = showDismissHint ? ` <TextBlock Text="Click to dismiss" Foreground="#555555" FontSize="9" Margin="0,4,0,0"/>` : "";
617
679
  const xaml = [
618
680
  `<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"`,
619
681
  ` xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"`,
@@ -633,7 +695,7 @@ async function runWpfNotification($, shellCommand, title, message, tag, style) {
633
695
  ` <StackPanel VerticalAlignment="Center" MaxWidth="320">`,
634
696
  ` <TextBlock Name="TitleText" FontSize="${titleFontSize}" FontWeight="SemiBold"/>`,
635
697
  ` <TextBlock Name="ContentText" Foreground="${textColor}" FontSize="${contentFontSize}" Margin="0,4,0,0" TextWrapping="Wrap"/>`,
636
- ` <TextBlock Text="Click to dismiss" Foreground="#555555" FontSize="9" Margin="0,4,0,0"/>`,
698
+ dismissHintXaml,
637
699
  ` </StackPanel>`,
638
700
  ` </StackPanel>`,
639
701
  ` </Grid>`,
@@ -641,6 +703,11 @@ async function runWpfNotification($, shellCommand, title, message, tag, style) {
641
703
  `</Window>`
642
704
  ].join(`
643
705
  `);
706
+ const manualPositionLines = resolvedPosition.startupLocation === "Manual" && resolvedPosition.leftExpr && resolvedPosition.topExpr ? [
707
+ "$window.WindowStartupLocation = 'Manual'",
708
+ `$window.Left = ${resolvedPosition.leftExpr}`,
709
+ `$window.Top = ${resolvedPosition.topExpr}`
710
+ ] : [];
644
711
  const psScript = [
645
712
  "Add-Type -AssemblyName PresentationFramework",
646
713
  "Add-Type -AssemblyName PresentationCore",
@@ -671,8 +738,8 @@ async function runWpfNotification($, shellCommand, title, message, tag, style) {
671
738
  "}",
672
739
  "'@ -ErrorAction SilentlyContinue",
673
740
  "",
674
- `$title = '${psTitle}'`,
675
- `$text = '${psText}'`,
741
+ `$title = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${titleB64}'))`,
742
+ `$text = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${textB64}'))`,
676
743
  `$accentColor = '${accentColor}'`,
677
744
  `$icon = '${icon}'`,
678
745
  `$duration = ${duration}`,
@@ -683,6 +750,7 @@ async function runWpfNotification($, shellCommand, title, message, tag, style) {
683
750
  "",
684
751
  "$reader = New-Object System.Xml.XmlNodeReader $xaml",
685
752
  "$window = [Windows.Markup.XamlReader]::Load($reader)",
753
+ ...manualPositionLines,
686
754
  "",
687
755
  "$colorBar = $window.FindName('ColorBar')",
688
756
  "$iconText = $window.FindName('IconText')",
@@ -741,18 +809,160 @@ async function showToastWithFallback(showToast, title, message) {
741
809
  } catch {}
742
810
  await Promise.resolve(showToast(title, message));
743
811
  }
744
- function truncateText(text, maxChars) {
745
- if (text.length <= maxChars)
746
- return text;
747
- return `${text.slice(0, maxChars)}...`;
748
- }
749
- function cleanMarkdown(text) {
750
- return text.replace(/[`*#]/g, "").replace(/\n+/g, " ").replace(/\s+/g, " ").trim();
751
- }
752
812
  function escapeAppleScript(text) {
753
813
  return text.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
754
814
  }
815
+
816
+ // src/plugins/notify.ts
817
+ var notifyPlugin = async ({ $, directory, client }) => {
818
+ let cachedSender = null;
819
+ return {
820
+ event: safeHook("notify:event", async ({ event }) => {
821
+ const config = await loadOcTweaksConfig();
822
+ if (!config || config.notify?.enabled !== true)
823
+ return;
824
+ const logConfig = config.logging;
825
+ const notifyOnIdle = config.notify?.notifyOnIdle !== false;
826
+ const notifyOnError = config.notify?.notifyOnError !== false;
827
+ const configuredCommand = typeof config.notify?.command === "string" && config.notify.command.trim().length > 0 ? config.notify.command.trim() : null;
828
+ const style = config.notify?.style;
829
+ const sender = configuredCommand ? { kind: "custom", commandTemplate: configuredCommand } : cachedSender ??= await detectNotifySender($, client, logConfig);
830
+ const sendToast = async (projectName, message, tag) => {
831
+ const title = `oc: ${projectName}`;
832
+ await notifyWithSender($, sender, title, message, tag, style, logConfig);
833
+ };
834
+ if (event?.type === "session.idle") {
835
+ if (!notifyOnIdle)
836
+ return;
837
+ const projectName = getProjectName(directory);
838
+ const sessionId = event.properties?.sessionID ?? event.properties?.sessionId;
839
+ const message = await extractIdleMessage(client, sessionId);
840
+ await sendToast(projectName, message, "Stop");
841
+ return;
842
+ }
843
+ if (event?.type === "session.error") {
844
+ if (!notifyOnError)
845
+ return;
846
+ const projectName = getProjectName(directory);
847
+ await sendToast(projectName, "❌ Session error", "Error");
848
+ }
849
+ })
850
+ };
851
+ };
852
+ function getProjectName(directory) {
853
+ const normalized = directory.replace(/\\/g, "/");
854
+ const segments = normalized.split("/").filter(Boolean);
855
+ return segments[segments.length - 1] || "opencode";
856
+ }
857
+ async function extractIdleMessage(client, sessionId) {
858
+ let message = "✓ Task completed";
859
+ if (!sessionId || !client?.session?.messages)
860
+ return message;
861
+ try {
862
+ const result = await client.session.messages({ path: { id: sessionId } });
863
+ const messages = result?.data;
864
+ if (!Array.isArray(messages))
865
+ return message;
866
+ for (let i = messages.length - 1;i >= 0; i -= 1) {
867
+ const msg = messages[i];
868
+ if (msg?.info?.role !== "assistant" || !Array.isArray(msg?.parts))
869
+ continue;
870
+ for (const part of msg.parts) {
871
+ if (part?.type === "text" && typeof part.text === "string") {
872
+ message = `✓ ${truncateText(cleanMarkdown(part.text), 400)}`;
873
+ return message;
874
+ }
875
+ }
876
+ }
877
+ return message;
878
+ } catch {
879
+ return message;
880
+ }
881
+ }
882
+ // src/plugins/tool-call-notify.ts
883
+ var DEFAULT_DURATION = 3000;
884
+ var DEFAULT_MAX_VISIBLE = 3;
885
+ var DEFAULT_MAX_ARG_LENGTH = 300;
886
+ var DEFAULT_POSITION = "top-right";
887
+ var DEFAULT_ACCENT_COLOR = "#60A5FA";
888
+ var toolCallNotifyPlugin = async ({ $, client }) => {
889
+ let cachedSender = null;
890
+ let managerMaxVisible = 0;
891
+ let manager = null;
892
+ const getManager = (maxVisible) => {
893
+ if (!manager || managerMaxVisible !== maxVisible) {
894
+ manager = new WpfPositionManager(maxVisible);
895
+ managerMaxVisible = maxVisible;
896
+ }
897
+ return manager;
898
+ };
899
+ return {
900
+ "tool.execute.before": safeHook("tool-call-notify:tool.execute.before", async (input, output) => {
901
+ const config = await loadOcTweaksConfig();
902
+ if (!config || config.notify?.enabled !== true)
903
+ return;
904
+ const toolCallConfig = config.notify?.toolCall;
905
+ if (!toolCallConfig || toolCallConfig.enabled !== true)
906
+ return;
907
+ const excludeTools = toolCallConfig.filter?.exclude ?? [];
908
+ if (Array.isArray(excludeTools) && excludeTools.includes(input.tool))
909
+ return;
910
+ const sender = cachedSender ??= await detectNotifySender($, client, config.logging);
911
+ if (sender.kind !== "wpf")
912
+ return;
913
+ const duration = typeof toolCallConfig.duration === "number" && toolCallConfig.duration > 0 ? toolCallConfig.duration : DEFAULT_DURATION;
914
+ const maxVisible = typeof toolCallConfig.maxVisible === "number" && toolCallConfig.maxVisible > 0 ? toolCallConfig.maxVisible : DEFAULT_MAX_VISIBLE;
915
+ const maxArgLength = typeof toolCallConfig.maxArgLength === "number" && toolCallConfig.maxArgLength > 0 ? toolCallConfig.maxArgLength : DEFAULT_MAX_ARG_LENGTH;
916
+ const position = typeof toolCallConfig.position === "string" && toolCallConfig.position.trim().length > 0 ? toolCallConfig.position.trim() : DEFAULT_POSITION;
917
+ let serializedArgs = "{}";
918
+ try {
919
+ serializedArgs = JSON.stringify(output.args ?? {}, null, 2) ?? "{}";
920
+ } catch {
921
+ serializedArgs = "[unserializable args]";
922
+ }
923
+ const argsText = truncateText(serializedArgs, maxArgLength);
924
+ const positionManager = getManager(maxVisible);
925
+ const enqueueDisplay = () => {
926
+ const slot = positionManager.allocateSlot(duration);
927
+ if (!slot) {
928
+ positionManager.enqueue(enqueueDisplay);
929
+ return;
930
+ }
931
+ const baseStyle = config.notify?.style ?? {};
932
+ const width = baseStyle.width ?? 420;
933
+ const height = baseStyle.height ?? 105;
934
+ const calculatedPosition = calculatePosition(slot.slotIndex, {
935
+ position,
936
+ width,
937
+ height
938
+ });
939
+ sendWpfToast({
940
+ $,
941
+ sender,
942
+ title: `\uD83D\uDD27 ${input.tool}`,
943
+ message: argsText,
944
+ tag: "ToolCall",
945
+ style: {
946
+ ...baseStyle,
947
+ duration
948
+ },
949
+ position: calculatedPosition,
950
+ icon: "\uD83D\uDD27",
951
+ accentColor: DEFAULT_ACCENT_COLOR,
952
+ showDismissHint: false,
953
+ maxMessageLength: maxArgLength,
954
+ preserveMessageFormatting: true
955
+ }).catch(() => {
956
+ slot.release();
957
+ });
958
+ };
959
+ positionManager.enqueue(enqueueDisplay);
960
+ positionManager.processQueue();
961
+ })
962
+ };
963
+ };
755
964
  export {
965
+ toolCallNotifyPlugin,
756
966
  notifyPlugin,
757
967
  leaderboardPlugin,
758
968
  compactionPlugin,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oc-tweaks",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./dist/index.js"