Files
chuan 5ce2c8a982 feat: Claude Code 原生 Windows 通知(C# / .NET 10 + Avalonia 12)
为 Claude Code 提供原生 Windows toast 通知:点击跳回原窗口、切回 Windows
Terminal 标签、跨虚拟桌面、调用方图标、非阻塞投递;NativeAOT 单文件分发。
2026-06-22 18:05:15 +08:00

186 lines
5.2 KiB
C#
Raw Permalink 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;
}
// 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;
}
}
}