feat: Claude Code 原生 Windows 通知(C# / .NET 10 + Avalonia 12)
为 Claude Code 提供原生 Windows toast 通知:点击跳回原窗口、切回 Windows Terminal 标签、跨虚拟桌面、调用方图标、非阻塞投递;NativeAOT 单文件分发。
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Notify.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Claude Code 钩子经 stdin 传入的 JSON
|
||||
/// </summary>
|
||||
public sealed class HookInput
|
||||
{
|
||||
[JsonPropertyName("session_id")]
|
||||
public string? SessionId { get; set; }
|
||||
|
||||
[JsonPropertyName("prompt")]
|
||||
public string? Prompt { get; set; }
|
||||
|
||||
[JsonPropertyName("notification_type")]
|
||||
public string? NotificationType { get; set; }
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string? Message { get; set; }
|
||||
|
||||
[JsonPropertyName("tool_name")]
|
||||
public string? ToolName { get; set; }
|
||||
}
|
||||
Reference in New Issue
Block a user