From a082644d1cdb9572f644bed8c080b04c1c8c9265 Mon Sep 17 00:00:00 2001 From: bj456736 Date: Wed, 19 Aug 2026 03:38:03 +0000 Subject: [PATCH] feat(tui): send native Windows toast notifications in Windows Terminal --- .changeset/great-donkeys-toast.md | 5 + .../src/tui/utils/terminal-notification.ts | 68 +++++++++- .../test/tui/terminal-notification.test.ts | 123 +++++++++++++++++- 3 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 .changeset/great-donkeys-toast.md diff --git a/.changeset/great-donkeys-toast.md b/.changeset/great-donkeys-toast.md new file mode 100644 index 0000000000..b3c142d3ae --- /dev/null +++ b/.changeset/great-donkeys-toast.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Send native Windows toast notifications when running in Windows Terminal. diff --git a/apps/kimi-code/src/tui/utils/terminal-notification.ts b/apps/kimi-code/src/tui/utils/terminal-notification.ts index c71f41df9f..4f9209e706 100644 --- a/apps/kimi-code/src/tui/utils/terminal-notification.ts +++ b/apps/kimi-code/src/tui/utils/terminal-notification.ts @@ -1,3 +1,5 @@ +import { execFile } from 'node:child_process'; + import type { Terminal } from '@moonshot-ai/pi-tui'; import { BEL, ESC, MAX_TERMINAL_NOTIFICATION_MESSAGE_LENGTH, ST } from '#/tui/constant/terminal'; @@ -11,6 +13,7 @@ export interface TerminalNotification { export interface EmitOptions { readonly supportsOsc9?: boolean; readonly insideTmux?: boolean; + readonly windowsTerminal?: boolean; } export interface BuildOptions { @@ -39,13 +42,23 @@ export function emitTerminalNotification( notification: TerminalNotification, options: EmitOptions = {}, ): void { + const supportsOsc9 = options.supportsOsc9 ?? supportsOsc9Notification(); const sequences = buildTerminalNotificationSequences(notification, { - supportsOsc9: options.supportsOsc9 ?? supportsOsc9Notification(), + supportsOsc9, insideTmux: options.insideTmux ?? isInsideTmux(), }); for (const sequence of sequences) { terminal.write(sequence); } + // Windows Terminal does not support OSC 9 (its OSC 9 is taken by ConEmu + // progress semantics), so on the BEL fallback path we additionally pop a + // native toast. The BEL stays as a generic audible fallback. + if (!supportsOsc9 && (options.windowsTerminal ?? isWindowsTerminalSession())) { + const message = formatNotification(notification); + if (message.length > 0) { + sendWindowsToast(message); + } + } } export function formatNotification(notification: TerminalNotification): string { @@ -134,6 +147,59 @@ export function isInsideTmux(env: NodeJS.ProcessEnv = process.env): boolean { return tmux.length > 0; } +/** + * Detect Windows Terminal via the `WT_SESSION` variable it always sets. + * Deliberately env-only: under WSL `process.platform` is `linux` but + * `WT_SESSION` is still inherited and `powershell.exe` works via interop. + */ +export function isWindowsTerminalSession(env: NodeJS.ProcessEnv = process.env): boolean { + return (env['WT_SESSION'] ?? '').length > 0; +} + +const WINDOWS_TOAST_NOTIFIER_ID = 'Kimi Code'; + +/** + * Build the `powershell.exe` invocation that pops a native WinRT toast. + * + * Ported from pi-notify (MIT, https://github.com/ferologics/pi-notify), + * with two fixes over the original: the message is single-quote escaped + * before being interpolated into the script, and the caller swallows all + * spawn errors (see `sendWindowsToast`). The toast uses the single-text + * ToastText01 template; `CreateTextNode` escapes XML for us. The notifier + * id (AUMID) is the fixed string 'Kimi Code', so toasts carry no app + * registration and no click callback. + */ +export function buildWindowsToastCommand(message: string): { file: string; args: string[] } { + const type = 'Windows.UI.Notifications'; + const mgr = `[${type}.ToastNotificationManager, ${type}, ContentType = WindowsRuntime]`; + const template = `[${type}.ToastTemplateType]::ToastText01`; + const escaped = message.replaceAll("'", "''"); + const script = [ + `${mgr} > $null`, + `$xml = [${type}.ToastNotificationManager]::GetTemplateContent(${template})`, + `$xml.GetElementsByTagName('text')[0].AppendChild($xml.CreateTextNode('${escaped}')) > $null`, + `[${type}.ToastNotificationManager]::CreateToastNotifier('${WINDOWS_TOAST_NOTIFIER_ID}').Show([${type}.ToastNotification]::new($xml))`, + ].join('; '); + return { file: 'powershell.exe', args: ['-NoProfile', '-NonInteractive', '-Command', script] }; +} + +/** + * Fire-and-forget toast delivery: spawn without waiting, swallow every + * error (a missing `powershell.exe` must never crash the TUI), and unref + * the child so it can't keep the process alive. + */ +export function sendWindowsToast(message: string): void { + try { + const { file, args } = buildWindowsToastCommand(message); + const child = execFile(file, args, () => { + // Intentionally empty: delivery errors are not actionable here. + }); + child.unref(); + } catch { + // Notification delivery must never take down the TUI. + } +} + function sanitizeNotificationText(value: string): string { return Array.from(value) .map((ch) => (isControlCharacter(ch) ? ' ' : ch)) diff --git a/apps/kimi-code/test/tui/terminal-notification.test.ts b/apps/kimi-code/test/tui/terminal-notification.test.ts index cef7b80868..efba068d0d 100644 --- a/apps/kimi-code/test/tui/terminal-notification.test.ts +++ b/apps/kimi-code/test/tui/terminal-notification.test.ts @@ -1,11 +1,22 @@ -import { describe, expect, it, vi } from 'vitest'; +/* eslint-disable import/first -- vi.mock setup must run before the imports it stubs out. */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + execFile: vi.fn(), +})); + +vi.mock('node:child_process', () => ({ + execFile: mocks.execFile, +})); import type { TUIState } from '#/tui/kimi-tui'; import { buildTerminalNotificationSequences, + buildWindowsToastCommand, emitTerminalNotification, formatNotification, isInsideTmux, + isWindowsTerminalSession, notifyTerminalOnce, supportsOsc9Notification, supportsTerminalProgress, @@ -252,3 +263,113 @@ describe('isInsideTmux', () => { expect(isInsideTmux({})).toBe(false); }); }); + +describe('isWindowsTerminalSession', () => { + it('detects Windows Terminal via the WT_SESSION env var', () => { + expect(isWindowsTerminalSession({ WT_SESSION: 'abc-123' })).toBe(true); + }); + + it('returns false when WT_SESSION is empty or unset', () => { + expect(isWindowsTerminalSession({ WT_SESSION: '' })).toBe(false); + expect(isWindowsTerminalSession({})).toBe(false); + }); +}); + +describe('buildWindowsToastCommand', () => { + it('targets powershell.exe with a non-interactive -Command script', () => { + const { file, args } = buildWindowsToastCommand('Kimi Code: Approval required'); + + expect(file).toBe('powershell.exe'); + expect(args.slice(0, 3)).toEqual(['-NoProfile', '-NonInteractive', '-Command']); + const script = args[3]!; + expect(script).toContain('ToastTemplateType]::ToastText01'); + expect(script).toContain("CreateToastNotifier('Kimi Code')"); + expect(script).toContain("CreateTextNode('Kimi Code: Approval required')"); + }); + + it('escapes single quotes in the message for the PowerShell script', () => { + const { args } = buildWindowsToastCommand("it's done"); + + expect(args[3]).toContain("CreateTextNode('it''s done')"); + }); +}); + +describe('Windows Terminal toast notifications', () => { + beforeEach(() => { + mocks.execFile.mockReset(); + mocks.execFile.mockReturnValue({ unref: vi.fn() }); + }); + + it('spawns a toast alongside BEL in Windows Terminal without OSC 9', () => { + const terminal = { write: vi.fn() }; + + emitTerminalNotification( + terminal, + { title: 'Kimi Code', body: 'Approval required' }, + { supportsOsc9: false, insideTmux: false, windowsTerminal: true }, + ); + + expect(terminal.write).toHaveBeenCalledTimes(1); + expect(terminal.write).toHaveBeenCalledWith('\u0007'); + expect(mocks.execFile).toHaveBeenCalledTimes(1); + const [file, args] = mocks.execFile.mock.calls[0]!; + expect(file).toBe('powershell.exe'); + expect(args[3]).toContain("CreateTextNode('Kimi Code: Approval required')"); + }); + + it('does not spawn a toast when OSC 9 is supported', () => { + const terminal = { write: vi.fn() }; + + emitTerminalNotification( + terminal, + { title: 'Kimi Code', body: 'Approval required' }, + { supportsOsc9: true, insideTmux: false, windowsTerminal: true }, + ); + + expect(mocks.execFile).not.toHaveBeenCalled(); + }); + + it('does not spawn a toast outside Windows Terminal', () => { + const terminal = { write: vi.fn() }; + + emitTerminalNotification( + terminal, + { title: 'Kimi Code', body: 'Approval required' }, + { supportsOsc9: false, insideTmux: false, windowsTerminal: false }, + ); + + expect(terminal.write).toHaveBeenCalledWith('\u0007'); + expect(mocks.execFile).not.toHaveBeenCalled(); + }); + + it('swallows errors passed to the execFile callback', () => { + const terminal = { write: vi.fn() }; + mocks.execFile.mockImplementation( + (_file: string, _args: string[], callback: (error: Error | null) => void) => { + callback(new Error('spawn powershell.exe ENOENT')); + return { unref: vi.fn() }; + }, + ); + + expect(() => { + emitTerminalNotification( + terminal, + { title: 'Kimi Code', body: 'Approval required' }, + { supportsOsc9: false, insideTmux: false, windowsTerminal: true }, + ); + }).not.toThrow(); + }); + + it('does not spawn a toast for an empty message', () => { + const terminal = { write: vi.fn() }; + + emitTerminalNotification( + terminal, + { title: '', body: '' }, + { supportsOsc9: false, insideTmux: false, windowsTerminal: true }, + ); + + expect(terminal.write).not.toHaveBeenCalled(); + expect(mocks.execFile).not.toHaveBeenCalled(); + }); +});