Files
notify/Notify/Cli/CliRunner.cs
T
chuan f390bcd133 feat: 提示音、调用方图标与消息清洗(P2)
- 提示音:winmm 从内存异步播放打包 wav,可在设置开关,带防连环音
- 调用方图标:进程树上溯识别编辑器/终端,ExtractIconEx + GDI 转 Avalonia 位图显示
- 进程树跳过 shell/运行时,取不到图标回退默认 Claude 图标
- 消息清洗:折叠换行/多余空白为单行,避免撑乱 toast 布局
- 全程 LibraryImport/源生成,AOT 友好
2026-06-22 16:00:59 +08:00

239 lines
6.7 KiB
C#
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.IO;
using System.Text;
using System.Text.Json;
using Notify.Interop;
using Notify.Ipc;
using Notify.Models;
using Notify.Serialization;
using Notify.Services;
namespace Notify.Cli;
/// <summary>
/// 钩子子命令实现:纯互操作 / IPC,绝不加载 Avalonia,做完即退出
/// </summary>
public static class CliRunner
{
// UserPromptSubmit:记录前台窗口与 prompt
public static int Save()
{
var input = ReadStdin();
if (string.IsNullOrEmpty(input?.SessionId))
{
return 0;
}
var hwnd = Win32.GetForegroundWindow();
// 前台是 Windows Terminal 时,记录当前标签的 RuntimeId
var wtRuntimeId = WinTerminalTabs.IsWindowsTerminal(hwnd)
? WinTerminalTabs.GetSelectedTabRuntimeId(hwnd)
: "";
StateStore.Save(input.SessionId, new StateData
{
Hwnd = hwnd.ToInt64(),
Prompt = input.Prompt ?? "",
WtRuntimeId = wtRuntimeId,
CallerExePath = ProcessTree.FindCallerExePath(),
});
return 0;
}
// Stop:任务完成通知,正文取本次 prompt
public static int Notify()
{
var input = ReadStdin();
if (string.IsNullOrEmpty(input?.SessionId))
{
return 0;
}
var state = StateStore.Load(input.SessionId);
var message = !string.IsNullOrWhiteSpace(state?.Prompt) ? state!.Prompt : "Task completed";
NotificationSpool.Deliver(new NotifyMessage
{
SessionId = input.SessionId,
Title = "Claude Code",
Message = Sanitize(message),
InputMode = false,
Sticky = false,
TargetHwnd = state?.Hwnd ?? 0,
WtRuntimeId = state?.WtRuntimeId,
IconPath = state?.CallerExePath,
});
return 0;
}
// Notification / PreToolUse:需要输入,常驻显示
public static int Input()
{
var input = ReadStdin();
if (string.IsNullOrEmpty(input?.SessionId))
{
return 0;
}
// 过滤无需打扰的类型
if (input.NotificationType is "auth_success" or "elicitation_complete" or "elicitation_response")
{
return 0;
}
var (title, message) = Resolve(input);
var state = StateStore.Load(input.SessionId);
NotificationSpool.Deliver(new NotifyMessage
{
SessionId = input.SessionId,
Title = title,
Message = Sanitize(message),
InputMode = true,
Sticky = true,
TargetHwnd = state?.Hwnd ?? 0,
WtRuntimeId = state?.WtRuntimeId,
IconPath = state?.CallerExePath,
});
return 0;
}
// 仅用于自测:acttest <hwnd>,尝试激活指定窗口并把结果写入临时日志
public static int ActTest(string[] args)
{
if (args.Length < 2 || !long.TryParse(args[1], out var h))
{
return 1;
}
var before = Win32.GetForegroundWindow().ToInt64();
var ok = WindowActivator.Activate(new IntPtr(h));
var after = Win32.GetForegroundWindow().ToInt64();
try
{
System.IO.File.WriteAllText(
System.IO.Path.Combine(System.IO.Path.GetTempPath(), "notify-acttest.log"),
$"target={h} before={before} after={after} ok={ok}\n");
}
catch
{
// 忽略
}
return ok ? 0 : 1;
}
// 仅用于自测:wttest <hwnd> <runtimeId>,切到该标签后回读当前选中项
public static int WtTest(string[] args)
{
if (args.Length < 3 || !long.TryParse(args[1], out var h))
{
return 1;
}
var hwnd = new IntPtr(h);
var ok = WinTerminalTabs.SelectTab(hwnd, args[2]);
System.Threading.Thread.Sleep(250);
var now = WinTerminalTabs.GetSelectedTabRuntimeId(hwnd);
try
{
System.IO.File.WriteAllText(
System.IO.Path.Combine(System.IO.Path.GetTempPath(), "notify-wttest.log"),
$"select ok={ok} expected={args[2]} nowSelected={now} match={(now == args[2])}\n");
}
catch
{
// 忽略
}
return ok ? 0 : 1;
}
// SessionEnd:清理会话状态
public static int Cleanup()
{
var input = ReadStdin();
if (!string.IsNullOrEmpty(input?.SessionId))
{
StateStore.Delete(input.SessionId);
}
return 0;
}
// 按 tool_name / notification_type 决定标题与正文,对齐原版语义
private static (string Title, string Message) Resolve(HookInput input)
{
if (input.ToolName == "AskUserQuestion")
{
var msg = string.IsNullOrEmpty(input.Message) ? "Claude 在向你提问" : input.Message!;
return ("Claude is Asking", msg);
}
if (input.ToolName == "ExitPlanMode")
{
return ("Plan Ready for Approval", "Claude 提交了一份计划,待批准");
}
var title = input.NotificationType switch
{
"permission_prompt" => "Permission Required",
"idle_prompt" => "Claude is Waiting",
"elicitation_dialog" => "MCP Asks",
_ => "Input Required",
};
var message = string.IsNullOrEmpty(input.Message) ? "Claude needs your input" : input.Message!;
return (title, message);
}
// 折叠换行/制表/多余空白为单行,避免撑乱 toast 布局(截断交给 toast 的省略号)
private static string Sanitize(string s)
{
if (string.IsNullOrEmpty(s))
{
return s;
}
s = s.Replace('\r', ' ').Replace('\n', ' ').Replace('\t', ' ');
while (s.Contains(" "))
{
s = s.Replace(" ", " ");
}
return s.Trim();
}
// 直接读原始字节并按 UTF-8 解码:WinExe 下 Console.In 不可靠,且其代码页
// 会把中文解成乱码(GBK),这里绕开
private static HookInput? ReadStdin()
{
try
{
using var stdin = Console.OpenStandardInput();
using var ms = new MemoryStream();
stdin.CopyTo(ms);
var bytes = ms.ToArray();
if (bytes.Length == 0)
{
return null;
}
var text = Encoding.UTF8.GetString(bytes).TrimStart('');
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
return JsonSerializer.Deserialize(text, AppJsonContext.Default.HookInput);
}
catch
{
return null;
}
}
}