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/pwa-app-window.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

web: Ship the web UI as an installable PWA with light/dark manifests, icons, and service worker. Add `kimi web --pwa` and `/web pwa` to open the PWA-enabled web UI.
6 changes: 6 additions & 0 deletions apps/kimi-code/src/cli/sub/web/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ interface RoutedServer {

export interface WebCliOptions extends ServerCliOptions {
open?: boolean;
pwa?: boolean;
}

export interface StartForegroundHooks {
Expand Down Expand Up @@ -150,6 +151,11 @@ export function buildWebCommand(cmd: Command): Command {
false,
)
.option('--no-open', 'Do not open the web UI in the default browser.', true)
.option(
'--pwa',

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 Wire --pwa before documenting it

When kimi web --pwa is passed, Commander records opts.pwa, but handleWebCommand never reads it and still calls deps.openUrl(...) with the same URL as the default path. In the advertised Chromium/app-window scenario, the flag is therefore a no-op and users get an ordinary browser tab despite the new help text promising otherwise.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

My first iteration did that; actually, I was doing --app instead of --pwa, but the PR was bigger, and I am not really interested in an "app for desktop", only making the web app more comfortable for phones.

'Open the Web UI in an app window when a Chromium-based browser is available (no address bar, full height).',
false,
)
.action(async (opts: WebCliOptions) => {
try {
await handleWebCommand(opts);
Expand Down
2 changes: 1 addition & 1 deletion apps/kimi-code/src/tui/commands/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,7 @@ async function handleBuiltInSlashCommand(
await handleUndoCommand(host, args);
return;
case 'web':
await handleWebCommand(host);
await handleWebCommand(host, args);
return;
default:
host.showError(`Unknown slash command: /${String(name)}`);
Expand Down
3 changes: 2 additions & 1 deletion apps/kimi-code/src/tui/commands/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,9 +394,10 @@ export const BUILTIN_SLASH_COMMANDS = [
name: 'web',
aliases: [],
description:
'Open the current session in the Web UI — pick a running server or start a new one',
'Open the current session in the Web UI — pick a running server or start a new one. Add "pwa" to open the PWA-enabled web UI.',
priority: 40,
availability: 'always',
argumentHint: '[pwa]',
},
{
name: 'exit',
Expand Down
5 changes: 4 additions & 1 deletion apps/kimi-code/src/tui/commands/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ const HEALTH_TIMEOUT_MS = 1500;
* directly. Either way the TUI shuts down once the session deep link is
* opened.
*/
export async function handleWebCommand(host: SlashCommandHost): Promise<void> {
export async function handleWebCommand(
host: SlashCommandHost,
args = '',

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 Handle the /web pwa argument

/web pwa now reaches this handler, but the new args parameter is never inspected, so both the existing-server path and the start-new-server path behave exactly like /web; even unknown arguments are silently ignored. This makes the documented slash-command variant unable to request the PWA/app-window behavior.

Useful? React with 👍 / 👎.

): Promise<void> {
const session = host.session;
if (session === undefined) {
host.showError(NO_ACTIVE_SESSION_MESSAGE);
Expand Down
3 changes: 3 additions & 0 deletions apps/kimi-web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" sizes="64x64" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />
<link rel="manifest" href="/manifest-light.json" media="(prefers-color-scheme: light)" />
<link rel="manifest" href="/manifest-dark.json" media="(prefers-color-scheme: dark)" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
Expand Down
2 changes: 2 additions & 0 deletions apps/kimi-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@
"typescript": "6.0.2",
"unplugin-icons": "^23.0.0",
"vite": "^6.3.3",
"vite-plugin-pwa": "^1.3.0",
"vitest": "4.1.4",
"vue-tsc": "~3.2.0",
"workbox-window": "^7.4.1",
"ws": "^8.18.0"
}
}
Binary file added apps/kimi-web/public/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 15 additions & 0 deletions apps/kimi-web/public/boot.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,21 @@
var v = localStorage.getItem('kimi-web.color-scheme');
if (v === 'light' || v === 'dark' || v === 'system') {
document.documentElement.dataset.colorScheme = v;

var light = document.querySelector('link[rel="manifest"][href="/manifest-light.json"]');
var dark = document.querySelector('link[rel="manifest"][href="/manifest-dark.json"]');
if (light && dark) {
if (v === 'system') {
light.media = '(prefers-color-scheme: light)';
dark.media = '(prefers-color-scheme: dark)';
} else if (v === 'light') {
light.media = '';
dark.media = 'not all';
} else {
light.media = 'not all';
dark.media = '';
}
}
}
} catch {
/* ignore */
Expand Down
32 changes: 32 additions & 0 deletions apps/kimi-web/public/manifest-dark.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"name": "Kimi Code",
"short_name": "Kimi",
"description": "Kimi Code Web — AI coding agent in your browser.",
"start_url": "/",
"display": "standalone",
"display_override": ["standalone", "minimal-ui", "browser"],
"scope": "/",
"background_color": "#0d1117",
"theme_color": "#0d1117",
"orientation": "any",
"icons": [
{
"src": "/pwa-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/pwa-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/maskable-icon.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
32 changes: 32 additions & 0 deletions apps/kimi-web/public/manifest-light.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"name": "Kimi Code",
"short_name": "Kimi",
"description": "Kimi Code Web — AI coding agent in your browser.",
"start_url": "/",
"display": "standalone",
"display_override": ["standalone", "minimal-ui", "browser"],
"scope": "/",
"background_color": "#ffffff",
"theme_color": "#ffffff",
"orientation": "any",
"icons": [
{
"src": "/pwa-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/pwa-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/maskable-icon.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
Binary file added apps/kimi-web/public/maskable-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/kimi-web/public/pwa-192x192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/kimi-web/public/pwa-512x512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
23 changes: 22 additions & 1 deletion apps/kimi-web/src/composables/client/useAppearance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,34 @@ function applyColorScheme(c: ColorScheme): void {

// Mobile browser chrome (status/address bar) follows <meta name=theme-color>.
const metas = document.querySelectorAll<HTMLMetaElement>('meta[name="theme-color"]');
if (metas.length === 0) return;
const pinned = c === 'dark' ? '#0d1117' : c === 'light' ? '#ffffff' : null;
metas.forEach((meta) => {
const media = meta.getAttribute('media') ?? '';
const systemValue = media.includes('dark') ? '#0d1117' : '#ffffff';
meta.setAttribute('content', pinned ?? systemValue);
});

// PWA installed apps use <link rel="manifest">. Switch the active manifest
// when the user pins a theme; leave both media-query links alive for 'system'.
applyManifest(c);
}

function applyManifest(c: ColorScheme): void {
if (typeof document === 'undefined') return;
const light = document.querySelector<HTMLLinkElement>('link[rel="manifest"][href="/manifest-light.json"]');
const dark = document.querySelector<HTMLLinkElement>('link[rel="manifest"][href="/manifest-dark.json"]');
if (light === null || dark === null) return;

if (c === 'system') {
light.media = '(prefers-color-scheme: light)';
dark.media = '(prefers-color-scheme: dark)';
} else if (c === 'light') {
light.media = '';
dark.media = 'not all';
} else {
light.media = 'not all';
dark.media = '';
}
}

function clampUiFontSize(value: number): number {
Expand Down
5 changes: 5 additions & 0 deletions apps/kimi-web/test/index-html.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,9 @@ describe('index.html CSP hygiene', () => {
expect(indexHtml).toContain('<script src="/boot.js"></script>');
expect(existsSync(bootJsPath)).toBe(true);
});

it('declares light and dark PWA manifests with media queries', () => {
expect(indexHtml).toContain('<link rel="manifest" href="/manifest-light.json" media="(prefers-color-scheme: light)"');
expect(indexHtml).toContain('<link rel="manifest" href="/manifest-dark.json" media="(prefers-color-scheme: dark)"');
});
});
18 changes: 18 additions & 0 deletions apps/kimi-web/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { defineConfig, type Plugin } from 'vite';
import vue from '@vitejs/plugin-vue';
import Icons from 'unplugin-icons/vite';
import { VitePWA } from 'vite-plugin-pwa';
import { FileSystemIconLoader } from 'unplugin-icons/loaders';
import { readFileSync } from 'node:fs';
import type { IncomingMessage, ServerResponse } from 'node:http';
Expand Down Expand Up @@ -122,6 +123,23 @@ export default defineConfig({
plugins: [
vue(),
backendSwitcherPlugin(),
VitePWA({
registerType: 'autoUpdate',
injectRegister: 'auto',
workbox: {
// Cache the static build assets so the installed PWA launches offline
// or on slow connections. API calls under /api/v1 are runtime routes
// and are left to the network (default NetworkOnly strategy).
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2,woff,ttf}'],
maximumFileSizeToCacheInBytes: 6 * 1024 * 1024,
navigateFallback: 'index.html',
navigateFallbackDenylist: [/^\/api\//],
},
// The manifests are served from public/manifest-{light,dark}.json so they
// are reachable at the root and editable without rebuilding the plugin
// config. The app switches the active link based on the user's theme.
manifest: false,
}),
Icons({
compiler: 'vue3',
// Local Kimi Design System icons (24×24 outlined, fill="currentColor"),
Expand Down
2 changes: 2 additions & 0 deletions docs/en/reference/kimi-command.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ When the server is running, `GET /openapi.json` returns the REST OpenAPI documen

```sh
kimi web # run the server in the foreground and open the browser
kimi web --pwa # open the PWA-enabled web UI
kimi web --no-open # don't open the browser
kimi web --port 58628 # pick a specific bind port
```
Expand All @@ -163,6 +164,7 @@ Multiple instances can share one home directory: each registers itself under `~/
| `--debug-endpoints` | Mount `/api/v1/debug/*` routes (off by default) |
| `--dangerous-bypass-auth` | Disable bearer-token auth on all REST and WebSocket routes so the web UI connects without a token; only for trusted networks or behind an authenticating proxy |
| `--no-open` | Do not open the browser once the server is ready |
| `--pwa` | Open the PWA-enabled web UI in the default browser |

`kimi web` binds to local loopback only by default and prints the bearer token in the startup banner; the web UI authenticates automatically via the `#token=` URL fragment.

Expand Down
1 change: 1 addition & 0 deletions docs/zh/reference/kimi-command.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ kimi acp

```sh
kimi web # 前台运行服务并打开浏览器
kimi web --pwa # 打开支持 PWA 的 web UI
kimi web --no-open # 不打开浏览器
kimi web --port 58628 # 指定绑定端口
```
Expand Down
Loading