oc-tweaks 0.4.2 → 0.5.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 (3) hide show
  1. package/README.md +56 -4
  2. package/dist/index.js +303 -89
  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,106 @@ 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 screenLeftExpr = typeof config.screenLeft === "number" ? `${config.screenLeft}` : "([System.Windows.Forms.Screen]::FromPoint([System.Windows.Forms.Cursor]::Position).WorkingArea.Left)";
556
+ const screenTopExpr = typeof config.screenTop === "number" ? `${config.screenTop}` : "([System.Windows.Forms.Screen]::FromPoint([System.Windows.Forms.Cursor]::Position).WorkingArea.Top)";
557
+ const screenWidthExpr = typeof config.screenWidth === "number" ? `${config.screenWidth}` : "([System.Windows.Forms.Screen]::FromPoint([System.Windows.Forms.Cursor]::Position).WorkingArea.Width)";
558
+ const screenHeightExpr = typeof config.screenHeight === "number" ? `${config.screenHeight}` : "([System.Windows.Forms.Screen]::FromPoint([System.Windows.Forms.Cursor]::Position).WorkingArea.Height)";
559
+ const leftExpr = `(${screenLeftExpr} + ${screenWidthExpr} - ${width} - ${margin})`;
560
+ if (position === "bottom-right") {
561
+ const topExpr2 = `(${screenTopExpr} + ${screenHeightExpr} - ${margin} - ((${slotIndex} + 1) * (${height} + ${gap})))`;
562
+ return {
563
+ startupLocation: "Manual",
564
+ leftExpr,
565
+ topExpr: topExpr2
566
+ };
567
+ }
568
+ const topExpr = `(${screenTopExpr} + ${margin} + (${slotIndex} * (${height} + ${gap})))`;
569
+ return {
570
+ startupLocation: "Manual",
571
+ leftExpr,
572
+ topExpr
573
+ };
543
574
  }
575
+
576
+ // src/utils/wpf-notify.ts
544
577
  async function detectNotifySender($, client, logConfig) {
545
- if (await commandExists($, "pwsh")) {
578
+ const isWindows = globalThis?.process?.platform === "win32";
579
+ if (isWindows && await commandExists($, "pwsh")) {
546
580
  return { kind: "wpf", command: "pwsh" };
547
581
  }
548
582
  if (await commandExists($, "powershell.exe")) {
@@ -560,10 +594,7 @@ async function detectNotifySender($, client, logConfig) {
560
594
  await log(logConfig, "WARN", "[oc-tweaks] notify: no available notifier, set notify.command to override");
561
595
  return { kind: "none" };
562
596
  }
563
- async function commandExists(_$, command) {
564
- return Bun.which(command) !== null;
565
- }
566
- async function notifyWithSender($, sender, title, message, tag, style, logConfig) {
597
+ async function notifyWithSender($, sender, title, message, tag, style, _logConfig, position, renderOptions) {
567
598
  try {
568
599
  if (sender.kind === "custom") {
569
600
  const command = sender.commandTemplate.replace(/\$TITLE/g, title).replace(/\$MESSAGE/g, message);
@@ -571,7 +602,7 @@ async function notifyWithSender($, sender, title, message, tag, style, logConfig
571
602
  return;
572
603
  }
573
604
  if (sender.kind === "wpf") {
574
- await runWpfNotification($, sender.command, title, message, tag, style);
605
+ await runWpfNotification($, sender.command, title, message, tag, style, position, renderOptions);
575
606
  return;
576
607
  }
577
608
  if (sender.kind === "osascript") {
@@ -588,11 +619,34 @@ async function notifyWithSender($, sender, title, message, tag, style, logConfig
588
619
  }
589
620
  } catch {}
590
621
  }
622
+ async function sendWpfToast(options) {
623
+ await notifyWithSender(options.$, options.sender, options.title, options.message, options.tag, options.style, options.logConfig, options.position, {
624
+ icon: options.icon,
625
+ accentColor: options.accentColor,
626
+ showDismissHint: options.showDismissHint,
627
+ maxMessageLength: options.maxMessageLength,
628
+ preserveMessageFormatting: options.preserveMessageFormatting
629
+ });
630
+ }
631
+ function escapeForPowerShell(text) {
632
+ return Buffer.from(text, "utf8").toString("base64");
633
+ }
634
+ function truncateText(text, maxChars) {
635
+ if (text.length <= maxChars)
636
+ return text;
637
+ return `${text.slice(0, maxChars)}...`;
638
+ }
639
+ function cleanMarkdown(text) {
640
+ return text.replace(/[`*#]/g, "").replace(/\n+/g, " ").replace(/\s+/g, " ").trim();
641
+ }
642
+ async function commandExists(_$, command) {
643
+ return Bun.which(command) !== null;
644
+ }
591
645
  async function runCustomCommand($, command) {
592
646
  const escaped = command.replace(/"/g, "\\\"");
593
647
  await $`bun -e ${`const { exec } = require("node:child_process"); exec("${escaped}")`}`;
594
648
  }
595
- async function runWpfNotification($, shellCommand, title, message, tag, style) {
649
+ async function runWpfNotification($, shellCommand, title, message, tag, style, position, renderOptions) {
596
650
  const backgroundColor = style?.backgroundColor ?? "#101018";
597
651
  const backgroundOpacity = style?.backgroundOpacity ?? 0.95;
598
652
  const textColor = style?.textColor ?? "#AAAAAA";
@@ -604,23 +658,34 @@ async function runWpfNotification($, shellCommand, title, message, tag, style) {
604
658
  const contentFontSize = style?.contentFontSize ?? 11;
605
659
  const iconFontSize = style?.iconFontSize ?? 30;
606
660
  const duration = style?.duration ?? 1e4;
607
- const position = style?.position ?? "center";
661
+ const stylePosition = style?.position ?? "center";
608
662
  const shadow = style?.shadow !== false;
609
663
  const idleColor = style?.idleColor ?? "#4ADE80";
610
664
  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, "''");
665
+ const contentMaxWidth = Math.max(160, width - 120);
666
+ const accentColor = renderOptions?.accentColor ?? (tag === "Error" ? errorColor : idleColor);
667
+ const icon = renderOptions?.icon ?? (tag === "Error" ? "" : "✅");
668
+ const showDismissHint = renderOptions?.showDismissHint !== false;
669
+ const normalizedStylePosition = stylePosition === "top-right" || stylePosition === "bottom-right" || stylePosition === "center" ? stylePosition : "center";
670
+ const resolvedPosition = position ?? (normalizedStylePosition === "center" ? { startupLocation: "CenterScreen" } : calculatePosition(0, {
671
+ position: normalizedStylePosition,
672
+ width,
673
+ height
674
+ }));
675
+ const startupLocation = resolvedPosition.startupLocation;
676
+ const titleB64 = escapeForPowerShell(title);
677
+ const messageLimit = typeof renderOptions?.maxMessageLength === "number" && renderOptions.maxMessageLength > 0 ? renderOptions.maxMessageLength : 400;
678
+ const normalizedMessage = renderOptions?.preserveMessageFormatting ? message : cleanMarkdown(message);
679
+ const textB64 = escapeForPowerShell(truncateText(normalizedMessage, messageLimit));
616
680
  const shadowXaml = shadow ? '<Border.Effect><DropShadowEffect BlurRadius="20" ShadowDepth="2" Opacity="0.7" Color="Black"/></Border.Effect>' : "";
681
+ const dismissHintXaml = showDismissHint ? ` <TextBlock Text="Click to dismiss" Foreground="#555555" FontSize="9" Margin="0,4,0,0"/>` : "";
617
682
  const xaml = [
618
683
  `<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"`,
619
684
  ` xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"`,
620
685
  ` WindowStyle="None" AllowsTransparency="True" Background="Transparent"`,
621
686
  ` Topmost="True" ShowInTaskbar="False" ShowActivated="False"`,
622
687
  ` WindowStartupLocation="${startupLocation}"`,
623
- ` Width="${width}" Height="${height}">`,
688
+ ` SizeToContent="Height" Width="${width}" MinHeight="${height}">`,
624
689
  ` <Border CornerRadius="${borderRadius}" Margin="10">`,
625
690
  ` <Border.Background>`,
626
691
  ` <SolidColorBrush Color="${backgroundColor}" Opacity="${backgroundOpacity}"/>`,
@@ -628,12 +693,12 @@ async function runWpfNotification($, shellCommand, title, message, tag, style) {
628
693
  ` ${shadowXaml}`,
629
694
  ` <Grid>`,
630
695
  ` <Border CornerRadius="${borderRadius},0,0,${borderRadius}" Width="${colorBarWidth}" HorizontalAlignment="Left" Name="ColorBar"/>`,
631
- ` <StackPanel Orientation="Horizontal" Margin="22,0,16,0" VerticalAlignment="Center">`,
696
+ ` <StackPanel Orientation="Horizontal" Margin="22,12,16,12" VerticalAlignment="Center">`,
632
697
  ` <TextBlock Name="IconText" FontSize="${iconFontSize}" VerticalAlignment="Center" Margin="0,0,15,0" Foreground="White"/>`,
633
- ` <StackPanel VerticalAlignment="Center" MaxWidth="320">`,
634
- ` <TextBlock Name="TitleText" FontSize="${titleFontSize}" FontWeight="SemiBold"/>`,
698
+ ` <StackPanel VerticalAlignment="Center" MaxWidth="${contentMaxWidth}">`,
699
+ ` <TextBlock Name="TitleText" FontSize="${titleFontSize}" FontWeight="SemiBold" TextWrapping="Wrap"/>`,
635
700
  ` <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"/>`,
701
+ dismissHintXaml,
637
702
  ` </StackPanel>`,
638
703
  ` </StackPanel>`,
639
704
  ` </Grid>`,
@@ -641,10 +706,16 @@ async function runWpfNotification($, shellCommand, title, message, tag, style) {
641
706
  `</Window>`
642
707
  ].join(`
643
708
  `);
709
+ const manualPositionLines = resolvedPosition.startupLocation === "Manual" && resolvedPosition.leftExpr && resolvedPosition.topExpr ? [
710
+ "$window.WindowStartupLocation = 'Manual'",
711
+ `$window.Left = ${resolvedPosition.leftExpr}`,
712
+ `$window.Top = ${resolvedPosition.topExpr}`
713
+ ] : [];
644
714
  const psScript = [
645
715
  "Add-Type -AssemblyName PresentationFramework",
646
716
  "Add-Type -AssemblyName PresentationCore",
647
717
  "Add-Type -AssemblyName WindowsBase",
718
+ "Add-Type -AssemblyName System.Windows.Forms",
648
719
  "",
649
720
  "Add-Type -TypeDefinition @'",
650
721
  "using System;",
@@ -671,8 +742,8 @@ async function runWpfNotification($, shellCommand, title, message, tag, style) {
671
742
  "}",
672
743
  "'@ -ErrorAction SilentlyContinue",
673
744
  "",
674
- `$title = '${psTitle}'`,
675
- `$text = '${psText}'`,
745
+ `$title = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${titleB64}'))`,
746
+ `$text = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${textB64}'))`,
676
747
  `$accentColor = '${accentColor}'`,
677
748
  `$icon = '${icon}'`,
678
749
  `$duration = ${duration}`,
@@ -683,6 +754,7 @@ async function runWpfNotification($, shellCommand, title, message, tag, style) {
683
754
  "",
684
755
  "$reader = New-Object System.Xml.XmlNodeReader $xaml",
685
756
  "$window = [Windows.Markup.XamlReader]::Load($reader)",
757
+ ...manualPositionLines,
686
758
  "",
687
759
  "$colorBar = $window.FindName('ColorBar')",
688
760
  "$iconText = $window.FindName('IconText')",
@@ -741,18 +813,160 @@ async function showToastWithFallback(showToast, title, message) {
741
813
  } catch {}
742
814
  await Promise.resolve(showToast(title, message));
743
815
  }
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
816
  function escapeAppleScript(text) {
753
817
  return text.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
754
818
  }
819
+
820
+ // src/plugins/notify.ts
821
+ var notifyPlugin = async ({ $, directory, client }) => {
822
+ let cachedSender = null;
823
+ return {
824
+ event: safeHook("notify:event", async ({ event }) => {
825
+ const config = await loadOcTweaksConfig();
826
+ if (!config || config.notify?.enabled !== true)
827
+ return;
828
+ const logConfig = config.logging;
829
+ const notifyOnIdle = config.notify?.notifyOnIdle !== false;
830
+ const notifyOnError = config.notify?.notifyOnError !== false;
831
+ const configuredCommand = typeof config.notify?.command === "string" && config.notify.command.trim().length > 0 ? config.notify.command.trim() : null;
832
+ const style = config.notify?.style;
833
+ const sender = configuredCommand ? { kind: "custom", commandTemplate: configuredCommand } : cachedSender ??= await detectNotifySender($, client, logConfig);
834
+ const sendToast = async (projectName, message, tag) => {
835
+ const title = `oc: ${projectName}`;
836
+ await notifyWithSender($, sender, title, message, tag, style, logConfig);
837
+ };
838
+ if (event?.type === "session.idle") {
839
+ if (!notifyOnIdle)
840
+ return;
841
+ const projectName = getProjectName(directory);
842
+ const sessionId = event.properties?.sessionID ?? event.properties?.sessionId;
843
+ const message = await extractIdleMessage(client, sessionId);
844
+ await sendToast(projectName, message, "Stop");
845
+ return;
846
+ }
847
+ if (event?.type === "session.error") {
848
+ if (!notifyOnError)
849
+ return;
850
+ const projectName = getProjectName(directory);
851
+ await sendToast(projectName, "❌ Session error", "Error");
852
+ }
853
+ })
854
+ };
855
+ };
856
+ function getProjectName(directory) {
857
+ const normalized = directory.replace(/\\/g, "/");
858
+ const segments = normalized.split("/").filter(Boolean);
859
+ return segments[segments.length - 1] || "opencode";
860
+ }
861
+ async function extractIdleMessage(client, sessionId) {
862
+ let message = "✓ Task completed";
863
+ if (!sessionId || !client?.session?.messages)
864
+ return message;
865
+ try {
866
+ const result = await client.session.messages({ path: { id: sessionId } });
867
+ const messages = result?.data;
868
+ if (!Array.isArray(messages))
869
+ return message;
870
+ for (let i = messages.length - 1;i >= 0; i -= 1) {
871
+ const msg = messages[i];
872
+ if (msg?.info?.role !== "assistant" || !Array.isArray(msg?.parts))
873
+ continue;
874
+ for (const part of msg.parts) {
875
+ if (part?.type === "text" && typeof part.text === "string") {
876
+ message = `✓ ${truncateText(cleanMarkdown(part.text), 400)}`;
877
+ return message;
878
+ }
879
+ }
880
+ }
881
+ return message;
882
+ } catch {
883
+ return message;
884
+ }
885
+ }
886
+ // src/plugins/tool-call-notify.ts
887
+ var DEFAULT_DURATION = 3000;
888
+ var DEFAULT_MAX_VISIBLE = 3;
889
+ var DEFAULT_MAX_ARG_LENGTH = 300;
890
+ var DEFAULT_POSITION = "top-right";
891
+ var DEFAULT_ACCENT_COLOR = "#60A5FA";
892
+ var toolCallNotifyPlugin = async ({ $, client }) => {
893
+ let cachedSender = null;
894
+ let managerMaxVisible = 0;
895
+ let manager = null;
896
+ const getManager = (maxVisible) => {
897
+ if (!manager || managerMaxVisible !== maxVisible) {
898
+ manager = new WpfPositionManager(maxVisible);
899
+ managerMaxVisible = maxVisible;
900
+ }
901
+ return manager;
902
+ };
903
+ return {
904
+ "tool.execute.before": safeHook("tool-call-notify:tool.execute.before", async (input, output) => {
905
+ const config = await loadOcTweaksConfig();
906
+ if (!config || config.notify?.enabled !== true)
907
+ return;
908
+ const toolCallConfig = config.notify?.toolCall;
909
+ if (!toolCallConfig || toolCallConfig.enabled !== true)
910
+ return;
911
+ const excludeTools = toolCallConfig.filter?.exclude ?? [];
912
+ if (Array.isArray(excludeTools) && excludeTools.includes(input.tool))
913
+ return;
914
+ const sender = cachedSender ??= await detectNotifySender($, client, config.logging);
915
+ if (sender.kind !== "wpf")
916
+ return;
917
+ const duration = typeof toolCallConfig.duration === "number" && toolCallConfig.duration > 0 ? toolCallConfig.duration : DEFAULT_DURATION;
918
+ const maxVisible = typeof toolCallConfig.maxVisible === "number" && toolCallConfig.maxVisible > 0 ? toolCallConfig.maxVisible : DEFAULT_MAX_VISIBLE;
919
+ const maxArgLength = typeof toolCallConfig.maxArgLength === "number" && toolCallConfig.maxArgLength > 0 ? toolCallConfig.maxArgLength : DEFAULT_MAX_ARG_LENGTH;
920
+ const position = typeof toolCallConfig.position === "string" && toolCallConfig.position.trim().length > 0 ? toolCallConfig.position.trim() : DEFAULT_POSITION;
921
+ let serializedArgs = "{}";
922
+ try {
923
+ serializedArgs = JSON.stringify(output.args ?? {}, null, 2) ?? "{}";
924
+ } catch {
925
+ serializedArgs = "[unserializable args]";
926
+ }
927
+ const argsText = truncateText(serializedArgs, maxArgLength);
928
+ const positionManager = getManager(maxVisible);
929
+ const enqueueDisplay = () => {
930
+ const slot = positionManager.allocateSlot(duration);
931
+ if (!slot) {
932
+ positionManager.enqueue(enqueueDisplay);
933
+ return;
934
+ }
935
+ const baseStyle = config.notify?.style ?? {};
936
+ const width = baseStyle.width ?? 420;
937
+ const height = baseStyle.height ?? 105;
938
+ const calculatedPosition = calculatePosition(slot.slotIndex, {
939
+ position,
940
+ width,
941
+ height
942
+ });
943
+ sendWpfToast({
944
+ $,
945
+ sender,
946
+ title: `\uD83D\uDD27 ${input.tool}`,
947
+ message: argsText,
948
+ tag: "ToolCall",
949
+ style: {
950
+ ...baseStyle,
951
+ duration
952
+ },
953
+ position: calculatedPosition,
954
+ icon: "\uD83D\uDD27",
955
+ accentColor: DEFAULT_ACCENT_COLOR,
956
+ showDismissHint: false,
957
+ maxMessageLength: maxArgLength,
958
+ preserveMessageFormatting: true
959
+ }).catch(() => {
960
+ slot.release();
961
+ });
962
+ };
963
+ positionManager.enqueue(enqueueDisplay);
964
+ positionManager.processQueue();
965
+ })
966
+ };
967
+ };
755
968
  export {
969
+ toolCallNotifyPlugin,
756
970
  notifyPlugin,
757
971
  leaderboardPlugin,
758
972
  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.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./dist/index.js"