Skip to content
Merged
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
20 changes: 11 additions & 9 deletions apps/kimi-code/src/cli/sub/web/remote-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import chalk from 'chalk';

import { getVersion } from '../../version';
import { darkColors } from '../../../tui/theme/colors';
import { toTerminalHyperlink } from '../../../utils/terminal-hyperlink';
import { supportsHyperlinks, toTerminalHyperlink } from '../../../utils/terminal-hyperlink';
import { acquireRemoteControlLock } from './remote-control-lock';

export const REMOTE_CONTROL_RELAY_ORIGIN = 'https://code-rc.kimi.com';
Expand Down Expand Up @@ -126,7 +126,9 @@ export function formatRemoteControlOutput(options: RemoteControlOutputOptions):
const muted = (text: string): string => chalk.hex(darkColors.textMuted)(text);
const status = (text: string): string => chalk.hex(darkColors.success)(text);
const link = (url: string): string =>
toTerminalHyperlink(accent(shortRemoteControlUrl(url)), url);
supportsHyperlinks()
? toTerminalHyperlink(accent(shortRemoteControlUrl(url)), url)
: accent(url);
const docs = toTerminalHyperlink('docs', 'https://kimi.com/code/docs/remote-control');
const feedback = toTerminalHyperlink('feedback', 'https://kimi.com/code/feedback');
return [
Expand All @@ -139,31 +141,31 @@ export function formatRemoteControlOutput(options: RemoteControlOutputOptions):
` ${label('3.')} Start chatting — sessions run on this machine`,
'',
` ${status('✓')} ${muted(`Connected to ${new URL(options.url).host}, waiting for remote devices…`)}`,
` ${label('This device: ')}${muted(options.deviceName)} ${label('· Manage devices (max 5): ')}${link(new URL('/devices', new URL(options.url).origin).toString())}`,
` ${label('This device: ')}${muted(options.deviceName)}`,
` ${status('⚠')} ${muted('This link grants control of this machine. Do not share it.')}`,
'',
options.qrCode.trimEnd(),
options.qrCode.trimEnd().replaceAll(/^/gm, ' '),
` ${label('QR code PNG: ')}${options.pngPath} ${muted('(open this if the QR above does not scan)')}`,
` ${label('Local UI: ')}${muted(options.localOrigin)} ${muted('(LAN: --host)')}`,
'',
` ${muted('Experimental —')} ${docs} ${muted('·')} ${feedback}`,
` ${label('Logs: ')}${muted('off (--log-level info)')} ${muted('·')} ${label('Stop: ')}${muted('Ctrl+C')}`,
'',
].join('\\n');
].join('\n');
}

export function formatRemoteControlStatus(status: RemoteControlStatus): string {
const label = (text: string): string => chalk.bold.hex(darkColors.textDim)(text);
const value = (text: string): string => chalk.hex(darkColors.success)(text);
switch (status) {
case 'relay_connected':
return ` ${value('✓')} ${label('Connected to relay, waiting for remote devices…')}\\n`;
return ` ${value('✓')} ${label('Connected to relay, waiting for remote devices…')}\n`;
case 'relay_disconnected':
return ` ${value('!')} ${label('Relay disconnected; reconnecting…')}\\n`;
return ` ${value('!')} ${label('Relay disconnected; reconnecting…')}\n`;
case 'device_connected':
return ` ${value('✓')} ${label('Remote device connected (1 active session)')}\\n`;
return ` ${value('✓')} ${label('Remote device connected (1 active session)')}\n`;
case 'device_disconnected':
return ` ${value('→')} ${label('Remote device disconnected')}\\n`;
return ` ${value('→')} ${label('Remote device disconnected')}\n`;
}
}

Expand Down
23 changes: 22 additions & 1 deletion apps/kimi-code/src/utils/terminal-hyperlink.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,24 @@
const HYPERLINK_TERM_PROGRAMS = new Set([
'iTerm.app',
'WezTerm',
'vscode',
'ghostty',
'WarpTerminal',
'Hyper',
]);
const HYPERLINK_TERMS = new Set(['xterm-kitty', 'xterm-ghostty', 'wezterm', 'foot', 'contour']);

export function supportsHyperlinks(env: NodeJS.ProcessEnv = process.env): boolean {
const force = env['FORCE_HYPERLINK'];
if (force !== undefined) return force !== '0';
if ((env['WT_SESSION'] ?? '').length > 0) return true;
if (HYPERLINK_TERM_PROGRAMS.has(env['TERM_PROGRAM'] ?? '')) return true;
if (HYPERLINK_TERMS.has(env['TERM'] ?? '')) return true;
if (Number(env['VTE_VERSION'] ?? '0') >= 5000) return true;
if ((env['KONSOLE_VERSION'] ?? '').length > 0) return true;
return false;
}

export function toTerminalHyperlink(text: string, url: string): string {
return `\u001B]8;;${url}\u0007${text}\u001B]8;;\u0007`;
return `]8;;${url}${text}]8;;`;
}
33 changes: 23 additions & 10 deletions apps/kimi-code/test/cli/web/remote-control.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
resolveKimiTokenStorageName,
type TokenInfo,
} from '@moonshot-ai/kimi-code-oauth';
import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { WebSocketServer, type RawData, type WebSocket } from 'ws';

import {
Expand Down Expand Up @@ -39,6 +39,7 @@ const TOKEN: TokenInfo = {
const cleanups: Array<() => Promise<void> | void> = [];

afterEach(async () => {
vi.unstubAllEnvs();
while (cleanups.length > 0) await cleanups.pop()!();
});

Expand Down Expand Up @@ -74,15 +75,18 @@ describe('Remote Control URLs', () => {
});

describe('Remote Control output', () => {
const outputOptions = {
url: 'https://example.test/devices/example-device/?rc=1&from=kimi_code_cli',
localOrigin: 'http://127.0.0.1:1234',
deviceName: 'example-device',
qrCode: 'QR\n',
pngPath: '/tmp/example-qr.png',
};

it('keeps the full URL clickable while showing a short link and the setup contract', () => {
const url = 'https://example.test/devices/example-device/?rc=1&from=kimi_code_cli';
const output = formatRemoteControlOutput({
url,
localOrigin: 'http://127.0.0.1:1234',
deviceName: 'example-device',
qrCode: 'QR\n',
pngPath: '/tmp/example-qr.png',
});
vi.stubEnv('FORCE_HYPERLINK', '1');
const output = formatRemoteControlOutput(outputOptions);
const url = outputOptions.url;
expect(output).toContain('Use Kimi Code on this machine');
expect(output).toContain('1.');
expect(output).toContain('2.');
Expand All @@ -91,15 +95,24 @@ describe('Remote Control output', () => {
expect(output).toContain(`\u001B]8;;${url}`);
expect(output).toContain('Connected to example.test');
expect(output).toContain('This device:');
expect(output).toContain('max 5');
expect(output).not.toContain('Manage devices');
expect(output).toContain('PNG:');
expect(output).toContain('\n QR');
expect(output).toContain('grants control of this machine');
expect(output).toContain('docs');
expect(output).toContain('feedback');
expect(output).toContain('Logs: off');
expect(output).not.toContain('stream-1');
});

it('prints the full URL as plain text when the terminal cannot render hyperlinks', () => {
vi.stubEnv('FORCE_HYPERLINK', '0');
const output = formatRemoteControlOutput(outputOptions);
expect(output).toContain(`open ${outputOptions.url}`);
expect(output).not.toContain('exampl…vice');
expect(output).not.toContain('Manage devices');
});

it('formats relay and device lifecycle states', () => {
expect(formatRemoteControlStatus('relay_connected').toLowerCase()).toContain('connected');
expect(formatRemoteControlStatus('relay_disconnected')).toContain('disconnected');
Expand Down
63 changes: 0 additions & 63 deletions apps/kimi-code/test/cli/web/web.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,69 +425,6 @@ describe('`kimi web` opens the browser', () => {
).rejects.toThrow('--remote-control requires a loopback host.');
});

it('opens and saves only the public Remote Control URL without the local server token', async () => {
vi.stubEnv('KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL', '1');
setCapabilities({ images: null, trueColor: true, hyperlinks: false });
const tempRoot = mkdtempSync(join(tmpdir(), 'kimi-rc-qrcode-'));
const dataDir = join(tempRoot, 'custom-home');
vi.stubEnv('KIMI_CODE_HOME', dataDir);
const publicUrl =
'https://code-rc.kimi.com/devices/device-1/?rc=1&from=kimi_code_cli';
const pngPath = join(dataDir, 'rc-qrcode.png');
const { handleWebCommand } = await import('#/cli/sub/web/run');
const { generateRemoteControlQr, renderTerminalQr } = await import('#/utils/remote-control-qr');
const QRCode = await import('qrcode');
const { isAbsolute } = await import('node:path');
await generateRemoteControlQr('https://example.test/previous', dataDir);
const previousPng = readFileSync(pngPath);
const { runner } = makeRunner();
const { stdout, stderr, readStdout } = makeIo();
const openUrl = vi.fn();
const startRemoteControl = vi.fn(async () => ({
deviceId: 'device-1',
deviceName: 'example-device',
url: publicUrl,
close: vi.fn(async () => {}),
}));

try {
await handleWebCommand(
{ remoteControl: true, open: true },
{
startServerForeground: runner,
startRemoteControl,
resolveToken: () => 'local-server-token',
openUrl,
stdout,
stderr,
},
);

expect(startRemoteControl).toHaveBeenCalledWith(
expect.objectContaining({
homeDir: dataDir,
localOrigin: 'http://127.0.0.1:58627',
localServerToken: 'local-server-token',
}),
);
expect(openUrl).toHaveBeenCalledWith(publicUrl);
const written = readStdout();
expect(written).toContain('Kimi Remote Control ready');
expect(written).toContain(renderTerminalQr(publicUrl));
expect(written).not.toContain(renderTerminalQr('http://127.0.0.1:58627'));
expect(isAbsolute(pngPath)).toBe(true);
expect(written).toContain(`QR code PNG: ${pngPath}`);
const png = readFileSync(pngPath);
expect(png.subarray(0, 8)).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
expect(png).not.toEqual(previousPng);
expect(png).toEqual(await QRCode.toBuffer(publicUrl));
expect(written).not.toContain('local-server-token');
expect(written).not.toContain('#token=');
} finally {
rmSync(tempRoot, { recursive: true, force: true });
}
});

it('rejects --remote-control while the experimental flag is off', async () => {
vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '0');
vi.stubEnv('KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL', '0');
Expand Down
9 changes: 6 additions & 3 deletions apps/kimi-code/test/tui/commands/web.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ vi.mock('#/utils/paths', async (importOriginal) => {
return { ...actual, getDataDir: mocks.getDataDir };
});

const indentedQr = (url: string): string =>
renderTerminalQr(url).trimEnd().replaceAll(/^/gm, ' ');

function makeHost() {
const host = {
session: { id: 'ses-1' },
Expand Down Expand Up @@ -233,8 +236,8 @@ describe('handleRemoteControlCommand', () => {
expect(mocks.openUrl).toHaveBeenCalledWith(sessionUrl);
const written = writeSpy.mock.calls.map((call) => String(call[0])).join('');
expect(written).toContain('Kimi Remote Control ready');
expect(written).toContain(renderTerminalQr(sessionUrl));
expect(written).not.toContain(renderTerminalQr(entryUrl));
expect(written).toContain(indentedQr(sessionUrl));
expect(written).not.toContain(indentedQr(entryUrl));
expect(isAbsolute(pngPath)).toBe(true);
expect(written).toContain(`QR code PNG: ${pngPath}`);
const png = readFileSync(pngPath);
Expand Down Expand Up @@ -298,7 +301,7 @@ describe('handleRemoteControlCommand', () => {

expect(mocks.openUrl).toHaveBeenCalledWith(entryUrl);
const written = writeSpy.mock.calls.map((call) => String(call[0])).join('');
expect(written).toContain(renderTerminalQr(entryUrl));
expect(written).toContain(indentedQr(entryUrl));
expect(written).not.toContain('/sessions/');
expect(readFileSync(join(dataDir, 'rc-qrcode.png'))).toEqual(
await QRCode.toBuffer(entryUrl),
Expand Down
Loading