Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/great-donkeys-toast.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Send native Windows toast notifications when running in Windows Terminal.
68 changes: 67 additions & 1 deletion apps/kimi-code/src/tui/utils/terminal-notification.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -11,6 +13,7 @@ export interface TerminalNotification {
export interface EmitOptions {
readonly supportsOsc9?: boolean;
readonly insideTmux?: boolean;
readonly windowsTerminal?: boolean;
}

export interface BuildOptions {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Comment on lines +194 to +197

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Detach the toast subprocess's stdio before unref

When a user exits while PowerShell is still running—or PowerShell wedges—this is not actually fire-and-forget: execFile creates referenced stdout/stderr pipes for its callback, and child.unref() only unreferences the child-process handle, so those pipes keep Node's event loop alive until the subprocess exits. Spawn the notifier with ignored/detached stdio (while retaining an error handler), or otherwise close/unref the streams and impose a timeout, so toast delivery cannot delay or hang TUI shutdown.

Useful? React with 👍 / 👎.

} catch {
// Notification delivery must never take down the TUI.
}
}

function sanitizeNotificationText(value: string): string {
return Array.from(value)
.map((ch) => (isControlCharacter(ch) ? ' ' : ch))
Expand Down
123 changes: 122 additions & 1 deletion apps/kimi-code/test/tui/terminal-notification.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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();
});
});
Loading