5ce2c8a982
为 Claude Code 提供原生 Windows toast 通知:点击跳回原窗口、切回 Windows Terminal 标签、跨虚拟桌面、调用方图标、非阻塞投递;NativeAOT 单文件分发。
186 lines
5.2 KiB
C#
186 lines
5.2 KiB
C#
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;
|
||
}
|
||
}
|
||
}
|