* feat(desktop): add a built-in native terminal PTYs are hosted directly by the Electron main process via node-pty (src/main/terminal.ts) — the embedded server is not involved. The renderer drives them over six kimi:terminal-* IPC channels. - Bottom panel in the conversation column's own grid slot (sidebar and right panel keep full height), height-draggable and persisted - Multi-tab over a singleton store (useNativeTerminal), one PTY per tab - Shell resolution: $SHELL on POSIX (with LANG fallback for GUI launches), pwsh → powershell → cmd on Windows - Terminals follow the workspace: switching roots kills every tab and, while the panel is open, respawns one at the new root - Entries: chat-header icon (floating top-right on the empty composer state), Ctrl+` (VS Code's binding, customizable), View menu item with an accelerator that follows the user's binding - Lifecycle: killAll on before-quit, renderer reload, and renderer crash; the View menu item flows into the Windows title bar - Shared useResizable gains a backwards-compatible axis:'y' option (synced to apps/web); i18n terminal.* keys in en/zh Docs: native-todos entry (with fork registry), AGENTS.md, desktop README, DesignSystemView §07, plan doc. Changeset: patch. * fix(desktop): harden the native terminal from review feedback - Buffer pre-subscription PTY output per tab and replay it on the xterm view's first subscribe (the shell's first prompt no longer drops); cap the buffer at 1000 chunks - Generation token: a create resolving after a workspace reset is killed instead of shown as a stale-cwd tab - Focus the xterm on first mount and on panel reopen, so typing does not keep landing in the chat composer - Collapsed panel region is inert (controls and the xterm textarea leave the tab order; instances and scrollback stay mounted) - Tab strip: no more nested interactives — separate activate/close buttons with the §08 tablist keyboard model (roving tabindex, arrows/Home/End); the height separator is keyboard-operable with aria values exposed - Localize the process-exited line (terminal.processExited*) - Kill PTYs on did-finish-load (new document committed) instead of did-start-navigation, so a failed/aborted reload keeps the old page's terminals alive - Re-clamp the persisted panel height against the viewport cap on window resize; respawn the first tab on boot when the panel restores open * fix(desktop): second review pass on the native terminal - Suspend native menu accelerators while xterm owns focus: on Windows/Linux the edit menu's Ctrl+C/V/A/Z fire before the renderer, so control keys never reached the PTY (SIGINT was un-sendable) - Record PTY exits that arrive before their tab is pushed and apply them on landing; the view also prints the exit line when it subscribes after the fact — an instantly-dead shell no longer sits in a fake running state - Bail out of async xterm setup when the view unmounted mid-import - No-op the terminal toggle in the mobile layout (reachable at extreme zoom) instead of spawning an invisible PTY - Make the panel height cap a reactive computed so it tracks window resizes in both directions - Replace a tool-name reference in the native-todos entry with product-neutral wording (repository naming rule) * fix(desktop): third review pass plus self-review on the native terminal Review round 3: - Reset terminals on a post-load null→root workspace transition (terminal opened with no workspace, then the first one is selected) - Let the panel's toggle binding bubble past xterm to the dispatcher - Read app.getLocale() lazily at terminal creation (pre-ready calls fall back to 'en', which would reintroduce the mojibake) Self-review (with an independent pass): - Menu suspension is now dual-mode: recording keeps its plain-label silence; terminal focus strips every accelerator (Windows/Linux also strips the edit menu's Ctrl-chords, so SIGINT reaches the PTY) but KEEPS clicks — the menu bar no longer goes dead for as long as the terminal holds focus. Driven by a dedicated kimi:menu-terminal-focus channel, independent of the recorder's suspension - xterm lets every registered global shortcut bubble to the dispatcher instead of swallowing the chords as PTY control chars - Suppress late output/exit pushes after close/killAll; write/resize tolerate a child that died mid-call - ensureTab dedupes first-tab creation (rapid toggles / restore race) - Mobile layout hides the panel (display:none) instead of unmounting it, preserving xterm scrollback * fix(desktop): fourth review pass on the native terminal - restartTab creates the replacement before closing the old tab, so a failed spawn no longer strands the panel with a dangling selection - reset clears the in-flight first-create guard, letting the new workspace spawn immediately (the stale create is still killed on landing via the generation token) - Terminal-focus menu suspension now deregisters accelerators (registerAccelerator:false) instead of stripping roles: role-backed items (reload/zoom/copy-paste) keep their native click behavior; macOS needs no suspension at all (⌘-based, no Ctrl conflicts) - Collapsing the panel explicitly clears the main-process terminal-focus flag even if focus is still inside the inert panel - xterm host padding uses the spacing token * fix(desktop): swap the terminal tab status dot for a leading glyph The tab now leads with the terminal icon (muted while the tab is exited) instead of a status dot, and the close affordance renders at 12px. * fix(desktop): pin the xterm viewport layer to the theme background xterm 6 keeps .xterm-viewport at the stylesheet default (#000) instead of following the theme: the canvas covers only the rows area, so a black strip showed below (and beside) the screen in light mode, and the first paint before the themed frame flashed black. The host now carries --term-bg and every layer inherits it. * fix(desktop): close two terminal lifecycle races from review - restartTab re-validates after the spawn resolves: generation first (a workspace switch already swept everything), then a fresh lookup by id — the captured index could otherwise splice a neighbour tab out of the new array - ensureTab's finally only clears the guard when it still points at that very promise, so a stale settle after reset can't disarm the newer create's dedup * feat(desktop): scope terminal state to the session Terminal buckets are now keyed by sessionId instead of global: switching sessions swaps the visible bucket while the others keep their PTYs and xterm views mounted, so a round trip restores tabs and scrollback exactly (including full-screen TUI state). Buckets are bounded at the ten most recently used sessions — evicting the oldest kills its PTYs. This replaces the earlier kill-on-workspace- switch rule: a session only ever sees its own terminals, so a stale cwd still can't follow you anywhere. Creates that resolve into an evicted bucket are killed instead of revived. * fix(desktop): snap the terminal panel on session switches Animating the height from 0 on every round trip made the preserved xterm content read as reloading — the growing panel slowly revealed it. Session switches now suppress the transition (same trick as the right aside's panelSwitching); manual toggles keep the animation. * fix(desktop): fifth review pass on the native terminal - Renderer generations: navigation start bumps a counter and the did-finish-load sweep kills only the previous generation's PTYs, so terminals the fresh page already spawned survive the reload - Terminal menu focus is cleared in the same teardown paths (navigation start, render-process-gone) — a crashed renderer never sends focusout, and the menu must not stay deregistered - macOS terminal-focus mode now deregisters custom Ctrl-only accelerators (user-bound Ctrl chords on menu-synced actions), while role/⌘ items stay armed - Tab keyboard navigation no longer fights xterm focus: the view only autofocuses when focus is outside the panel UI; mouse tab clicks focus the terminal explicitly via the exposed view handle - Root README gains the built-in terminal paragraph (repo rule: structural changes sync AGENTS.md and README.md) * fix(desktop): terminal session lifecycle edge cases - Buckets are created lazily on first terminal use, so browsing sessions no longer counts against the LRU cap - The first message on a new-chat draft migrates the draft bucket's terminals into the new session (migrateDraftTo) instead of orphaning them in a hidden bucket - Archiving a session or deleting a workspace now destroys the matching buckets and kills their PTYs - Terminal toggle is a no-op without the bridge (web / old shell) - Escape inside the terminal reaches the PTY instead of closing the side panel (vim/less/fzf keep working) * fix(desktop): sixth review pass on the native terminal - Draft migration is driven by the two startSessionAndSendPrompt call sites, not by watching for unseen session ids — a historical session picked after the draft no longer inherits draft terminals - Terminal cleanup on archive / workspace delete now waits for the confirm AND the session/workspace actually being gone (cancel and swallowed API errors no longer kill live PTYs); workspace matching falls back to the workspace root for legacy sessions - Store getters peek without creating buckets — browsing sessions must not fill the LRU map - Creates capture their generation at request time, so a navigation during the async spawn still leaves the PTY to the stale sweep - Mouse tab activation focuses xterm after v-show flips (focusing a hidden view was ignored); panel height transition uses the motion tokens; desktop README escapes the shortcut backtick * fix(desktop): seventh review pass on the native terminal - activeSessionId is '' (not null) in the draft state, so bucket keys are now computed App-side and drafts are keyed per workspace (__draft__:<workspaceId>) — the earlier normalization never matched and a draft terminal bled into other workspaces' drafts - Draft migration re-keys the bucket instead of copying it, so an in-flight first create lands in the new session too - Skill-started sessions get the same draft migration; the archive shortcut now runs the same terminal cleanup as the confirm path * fix(desktop): eighth review pass on the native terminal - Draft migration targets the session the await actually created (sessions-set diff), not whatever is active by the time it resolves - Deleting a workspace also destroys its no-session draft bucket - A create superseded by a renderer navigation is killed after spawn instead of registering as an ownerless shell - The xterm palette resolves from live design tokens via getComputedStyle (bg/fg/cursor/selection and the token-backed ANSI colors), keeping only the ramp colors the token set doesn't carry * fix(desktop): ninth review pass on the native terminal - migrate draft terminal buckets on the remaining first-session paths (/goal, /btw, side-chat toggle) that create the session client-side - await the memoized shell-env probe before spawning a PTY so the first terminal inherits the full user PATH on GUI launches - tear down terminal buckets on websocket sessionDeleted, not only on local archive/delete flows - derive the xterm font size from the content token scale * fix(desktop): tenth review pass on the native terminal - anchor draft-bucket migration to the session id returned by the creation calls (synced to web) instead of a sessions-list diff that could pick an unrelated concurrently inserted session - discard the no-workspace draft bucket instead of migrating a PTY rooted at the fallback cwd into the freshly picked folder - wrap the composer create-goal entry in the same migration - tear down terminal buckets on websocket workspaceDeleted too - revalidate the renderer generation after the shell-env probe await so a mid-probe reload cannot orphan a PTY - clamp the resize handle's committed height when the viewport cap shrinks (synced to web) - trim terminal file headers to match the repo comment-density rule * fix(desktop): eleventh review pass on the native terminal - destroy the no-workspace draft bucket whenever a folder is added, not only after a pending first-prompt creation succeeds - stamp terminal buckets on first use so the freshest session is not the first LRU eviction victim - let macOS Ctrl-only chords reach the PTY while xterm is focused, mirroring the menu suspension in the xterm key filter and the App dispatcher (the panel toggle stays reachable) - bump the renderer generation on render-process-gone so pending creates come back superseded instead of orphaning a PTY - route first-spawn plus clicks through ensureTab's in-flight dedup - re-resolve the xterm font size when data-font-scale changes and refit, instead of only at construction * fix(desktop): twelfth review pass on the native terminal - merge draft terminals into the target bucket when the new session already owns one, instead of abandoning the PTYs under the stale key - use motion tokens for the resize handle's hover/drag transition - trim long rationale comments in the terminal patch to local invariants, matching the repo comment-density rule * fix(desktop): thirteenth review pass on the native terminal - keep both in-flight creates when a draft bucket merges into an existing target: the draft object survives and the post-await guard resolves buckets by key instead of identity - let every non-toggle chord reach the PTY on Windows/Linux while the terminal is focused, matching the full menu deregistration there - return the created session id from the first-action helpers even when a post-creation step fails, so migration still follows it - trim more design narrative from the App.vue terminal block * fix(desktop): fourteenth review pass on the native terminal - size terminal panel controls from the space token ramp instead of hardcoded px - re-arm the first-tab dedup guard over every inherited in-flight create when buckets merge, so a still-pending spawn cannot be raced into an extra PTY - tear down terminal buckets on archived sessionUpdated broadcasts, not only on sessionDeleted * docs(desktop): trim the native terminal store comments to invariants * fix(desktop): sixteenth review pass on the native terminal - size the empty-composer terminal button from the token scale - restore the menu's terminal-focus suspension when a reload aborts and the old document (with its focused xterm) survives - focus the xterm after creating a tab from the panel actions - count only buckets with live terminals against the LRU cap, and restamp/recheck when a create lands so the trigger cannot lag - trim more terminal rationale from App.vue comments * fix(desktop): seventeenth review pass on the native terminal - keep a destroyed bucket's late create out of a same-key replacement: land only when the captured object is still registered or was merge-forwarded - size the terminal entry buttons and icons from tokens - reuse a single pending focus watcher for first-tab spawns and stop it on unmount, so failed creates cannot pile watchers up - sync the design-system terminal/grid section to the web copy and correct the xterm palette description * fix(desktop): eighteenth review pass on the native terminal - carry the terminal ANSI magenta/cyan hues in dedicated --color-term-* tokens (same values, all three scheme blocks) - ignore chat-level Esc / find / select-all chords while the event target sits inside the terminal host, so they stay program input (synced to web, where the guard is a no-op) * fix(desktop): nineteenth review pass on the native terminal - suspend the OS-level summon shortcut while the terminal is focused (dual recording/terminal suspension flags), so a Ctrl-bound chord is not eaten before the PTY sees it - map ANSI black to a dedicated --color-term-black token instead of the background token (invisible text on light theme) - drop buffered output/exit when a superseded create is killed - size the terminal resize handle from the token scale - restore the web design spec's single-row grid description, keeping the terminal section marked desktop-only - trim the App.vue terminal template comment * docs(desktop): condense the native terminal comments to local invariants * docs(desktop): simplify the native terminal changeset note |
||
|---|---|---|
| .. | ||
| public | ||
| scripts | ||
| src | ||
| test | ||
| AGENTS.md | ||
| CHANGELOG.md | ||
| index.html | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
| vite.config.ts | ||
| vitest.config.ts | ||
Kimi Web
Browser client for Kimi Code — a peer to the TUI that talks to a local server over REST + WebSocket. Vue 3 + Vite + TypeScript.
Since phase 3 this app is no longer a monolith: it aggregates the three
source-only shared packages (exports → ./src/*, transpiled by this app's
bundler) and keeps only the web-specific glue:
@moonshot-ai/web-ui— presentational components + design tokens.@moonshot-ai/web-markdown— chat Markdown renderer.@moonshot-ai/web-core— daemon transport, session state machine, state container, appearance composables.
Quick start
# from the repo root
pnpm dev:web # vite dev server (proxies /api/v1 to KIMI_SERVER_URL)
pnpm -C apps/web run dev:stub # offline stub faking the server API + event stream
# checks
pnpm -C apps/web run typecheck # vue-tsc --noEmit
pnpm -C apps/web run test # vitest (pure logic only)
pnpm -C apps/web run build # vite build → apps/web/dist
How it connects to the server
The browser cannot reach the server cross-origin (no CORS), so Vite same-origin
proxies /api/v1 (HTTP + WS) to the server (vite.config.ts):
| env var | default | meaning |
|---|---|---|
WEB_PORT |
5175 |
port the dev server listens on |
KIMI_SERVER_URL |
http://127.0.0.1:58627 |
where /api/v1 (and /api/v1/ws) is forwarded |
Behind a corporate HTTP proxy, also set
NO_PROXY=<server-host>(for example,NO_PROXY=127.0.0.1,localhost) so the proxy forward reaches the server directly.
vite.config.ts also carries the two consumer requirements the shared packages
need: the unplugin-icons kimi collection (~icons/kimi/*) and
worker: { format: 'es' } for web-markdown's off-thread KaTeX / Mermaid workers.
Architecture
src/api/bootstrap.ts is the only module that knows both sides: it composes
web-core's DaemonKimiWebApi with this app's bridges — tracer (debug/trace),
credentialStore (lib/serverAuth), and the required projectorFactory
(src/api/daemon/agentEventProjector.ts) — and exposes the shared api
singleton (plus a getKimiWebApi() back-compat accessor).
src/main.ts stays thin: createApp(App).use(i18n), an
app.provide(IconResolverKey, …) that bridges web-ui's <Icon> to this app's
icon registry (lib/icons.ts), and a desktop-only theme-IPC bridge that mirrors
<html data-color-scheme> to the host's nativeTheme. There is no client
factory / provide of an un-singletoned client here — that is deferred to a
later phase.
src/api keeps only the web-specific glue (bootstrap, config,
daemon/agentEventProjector, errors, index, types); the transport,
reducer, and state container live in @moonshot-ai/web-core. i18n and tool
metadata stay in this app.
server (REST + WS)
└─ @moonshot-ai/web-core/api DaemonKimiWebApi (transport + projector + reducer)
└─ src/api/bootstrap.ts injects tracer / credentialStore / projectorFactory → api
└─ @moonshot-ai/web-core createKimiWebClientCore({ api, t }) → reactive state
└─ @moonshot-ai/web-ui <Button> / <Dialog> / <Icon> / tokens
└─ @moonshot-ai/web-markdown <Markdown>
└─ src/components/*.vue render props, emit intents (no transport access)
Server contract — non-obvious notes
The server's wire protocol has a few things that will bite you if forgotten:
- Envelope: every response is
{ code, msg, data, request_id }and the HTTP status is always 200 — checkcode(0 = ok), not the status. - Persisted sessions are directly promptable — selecting an old session and
sending a message just works; there is no
:activatestep. - Creating a session needs a registered workspace.
workspace_idmust be awd_<slug>_<hash>id that exists in the server's registry.
Release & deployment
build → dist produces apps/web/dist. It is consumed two ways, never
published as a standalone package:
- Desktop —
apps/desktop/scripts/copy-web-dist.mjscopiesapps/web/distintoapps/desktop/web-dist/at desktop build time. - SEA / CLI embed —
pnpm run sync:webbuilds the web and syncsdistinto the kimi-code submodule'sapps/kimi-code/dist-web/for SEA embedding.