kimi-code/apps/web
liruifengv 9a5e75ba3d
feat(desktop): add a built-in native terminal (#132)
* 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
2026-07-29 11:47:03 +08:00
..
public feat: stepped font scale, kimi skin palette, and macOS frosted sidebar (#91) 2026-07-24 10:48:20 +08:00
scripts feat: frosted-glass menus (#121) 2026-07-27 14:22:20 +08:00
src feat(desktop): add a built-in native terminal (#132) 2026-07-29 11:47:03 +08:00
test feat(desktop): add a built-in native terminal (#132) 2026-07-29 11:47:03 +08:00
AGENTS.md feat(chat): transcript find bar + native editing context menu (#114) 2026-07-27 18:03:20 +08:00
CHANGELOG.md chore: import kimi-web as apps/web 2026-07-10 11:38:49 +08:00
index.html feat: stepped font scale, kimi skin palette, and macOS frosted sidebar (#91) 2026-07-24 10:48:20 +08:00
package.json feat(chat): preview image attachments with PhotoSwipe, zooming from the thumbnail (#144) 2026-07-28 21:27:25 +08:00
README.md docs: document web package split and consumer configuration 2026-07-10 18:14:49 +08:00
tsconfig.json feat(chat): add on-demand subagent transcripts (#137) 2026-07-28 23:06:53 +08:00
vite.config.ts feat(desktop): rework desktop identity and client version defines 2026-07-15 13:47:02 +08:00
vitest.config.ts feat(web): sync upstream kimi-web changes through 09e85540 2026-07-16 12:16:06 +00:00

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 — check code (0 = ok), not the status.
  • Persisted sessions are directly promptable — selecting an old session and sending a message just works; there is no :activate step.
  • Creating a session needs a registered workspace. workspace_id must be a wd_<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:

  1. Desktopapps/desktop/scripts/copy-web-dist.mjs copies apps/web/dist into apps/desktop/web-dist/ at desktop build time.
  2. SEA / CLI embedpnpm run sync:web builds the web and syncs dist into the kimi-code submodule's apps/kimi-code/dist-web/ for SEA embedding.