diff --git a/apps/kimi-code/src/cli/sub/web/remote-control.ts b/apps/kimi-code/src/cli/sub/web/remote-control.ts index d0cf1fe1ca0..73e74f46f96 100644 --- a/apps/kimi-code/src/cli/sub/web/remote-control.ts +++ b/apps/kimi-code/src/cli/sub/web/remote-control.ts @@ -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'; @@ -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 [ @@ -139,17 +141,17 @@ 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 { @@ -157,13 +159,13 @@ export function formatRemoteControlStatus(status: RemoteControlStatus): string { 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`; } } diff --git a/apps/kimi-code/src/utils/terminal-hyperlink.ts b/apps/kimi-code/src/utils/terminal-hyperlink.ts index 43d27f0a327..c82dfe296a8 100644 --- a/apps/kimi-code/src/utils/terminal-hyperlink.ts +++ b/apps/kimi-code/src/utils/terminal-hyperlink.ts @@ -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;;`; } diff --git a/apps/kimi-code/test/cli/web/remote-control.test.ts b/apps/kimi-code/test/cli/web/remote-control.test.ts index 1b7f1d55e8b..cbad20c4c2f 100644 --- a/apps/kimi-code/test/cli/web/remote-control.test.ts +++ b/apps/kimi-code/test/cli/web/remote-control.test.ts @@ -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 { @@ -39,6 +39,7 @@ const TOKEN: TokenInfo = { const cleanups: Array<() => Promise | void> = []; afterEach(async () => { + vi.unstubAllEnvs(); while (cleanups.length > 0) await cleanups.pop()!(); }); @@ -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.'); @@ -91,8 +95,9 @@ 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'); @@ -100,6 +105,14 @@ describe('Remote Control output', () => { 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'); diff --git a/apps/kimi-code/test/cli/web/web.test.ts b/apps/kimi-code/test/cli/web/web.test.ts index 4aa97f9b3a4..5f7f2a6f34c 100644 --- a/apps/kimi-code/test/cli/web/web.test.ts +++ b/apps/kimi-code/test/cli/web/web.test.ts @@ -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'); diff --git a/apps/kimi-code/test/tui/commands/web.test.ts b/apps/kimi-code/test/tui/commands/web.test.ts index 53f721e1234..31d0b7307d7 100644 --- a/apps/kimi-code/test/tui/commands/web.test.ts +++ b/apps/kimi-code/test/tui/commands/web.test.ts @@ -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' }, @@ -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); @@ -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),