Merge remote-tracking branch 'origin/kimi-code-v2' into kimi-code-v2

This commit is contained in:
haozhe.yang 2026-07-03 17:54:05 +08:00
commit 33fbbcb451
787 changed files with 66468 additions and 13587 deletions

View file

@ -11,6 +11,8 @@ description: Use when generating changesets in the kimi-code repository, includi
All other `@moonshot-ai/*` packages are treated as internal packages, including `@moonshot-ai/kimi-code-sdk`, `agent-core`, `kosong`, `kaos`, `kimi-code-oauth`, `kimi-telemetry`, and `migration-legacy`.
`@moonshot-ai/pi-tui` is a special internal package: it is a private fork (`private: true`) that is never published, but it keeps its own changelog through changesets. It is an exception to Core Rule 4 — see the dedicated section below.
## Core Rules
1. **Inspect the actual changes first.** Use `git status` / `git diff --name-only` to identify which packages were actually changed.
@ -176,6 +178,41 @@ Add the server-hosted web UI, including chat layout and session list behaviors.
Add the server REST and WebSocket APIs that power the web UI.
```
## `@moonshot-ai/pi-tui` changes
`@moonshot-ai/pi-tui` is a vendored fork that lives in `packages/pi-tui`. It is `private: true` and is never published, but it is **not** ignored by changesets: changesets versions it and writes `packages/pi-tui/CHANGELOG.md` so the fork keeps its own history. Because it is bundled into the CLI like other internal packages, it is an exception to Core Rule 4 — do **not** list `@moonshot-ai/kimi-code` for a change that only touches pi-tui.
- Changes that only affect pi-tui (build, package, strict-mode cleanup, renderer fixes): list `@moonshot-ai/pi-tui` only. No CLI changeset.
- If the same change is also user-visible in the CLI (for example a terminal rendering fix that CLI users can see), add a **separate** changeset that lists `@moonshot-ai/kimi-code` with CLI-focused wording, in addition to the pi-tui changeset. Do not mix both packages in one frontmatter — the two changelogs need different wording.
pi-tui-only change:
```markdown
---
"@moonshot-ai/pi-tui": patch
---
Export the package manifest so the bundled binary can locate its native assets.
```
pi-tui change that is also visible in the CLI (two separate changesets):
```markdown
---
"@moonshot-ai/pi-tui": patch
---
Clamp the differential render to the visible viewport so scrolling up during streaming no longer jumps to the top.
```
```markdown
---
"@moonshot-ai/kimi-code": patch
---
Fix the transcript jumping to the top when scrolling up through history during streaming output.
```
## Red Flags
- You are about to write `major` without asking the user.
@ -188,3 +225,4 @@ Add the server REST and WebSocket APIs that power the web UI.
- The wording claims more than the diff actually did.
- The CLI wording mentions internal package names, class names, or PR numbers.
- The entry includes real internal identifiers instead of neutral placeholders.
- A change that only touches `@moonshot-ai/pi-tui` lists `@moonshot-ai/kimi-code` instead of `@moonshot-ai/pi-tui`, or mixes both packages in one frontmatter.

View file

@ -1,7 +0,0 @@
---
"@moonshot-ai/protocol": patch
"@moonshot-ai/server": patch
"@moonshot-ai/kimi-code": patch
---
Add a server-side key-value store API for persisting web UI preferences to the user's data directory.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Fix sessions silently dropping later user messages after a turn was interrupted between a tool call and its result.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Have context-compaction notes capture a forward plan for the remaining work — upcoming steps, settled decisions, and foreseeable obstacles — instead of only the immediate next step, so the agent continues more coherently after auto-compaction.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Fix requests being rejected by strict providers when the model emits duplicate tool call ids.

View file

@ -1,5 +0,0 @@
---
"@moonshot-ai/kimi-code": patch
---
Add a double-Esc shortcut to open the undo selector. Press Esc twice while idle to undo.

View file

@ -1,5 +0,0 @@
---
"@moonshot-ai/kimi-code": patch
---
Fix @ file mentions not opening when typed inside a slash command argument.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Enrich PATH from the user's login shell at startup, so shell commands find user-installed tools (e.g. Homebrew's `gh`) even when kimi-code was launched without the full profile PATH.

View file

@ -1,5 +0,0 @@
---
"@moonshot-ai/kimi-code": patch
---
Show file path completions when typing `/` in shell mode (`!`).

View file

@ -1,6 +0,0 @@
---
"@moonshot-ai/agent-core": patch
"@moonshot-ai/kimi-code": patch
---
Fix duplicate workspaces showing in the web sidebar when the same folder is registered more than once.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Keep automatic background updates from flashing a console window on Windows.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Fix `kimi upgrade` failing on Windows with a spawn error when installing the new version.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Promote the language-matching rule to a dedicated section in the system prompt, so replies and reasoning consistently follow the user's language through long English tool output, while repository artifacts keep project conventions.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/pi-tui": patch
---
Add a paste-burst fallback that treats Enter as a newline during rapid non-bracketed multi-line input bursts.

View file

@ -1,5 +0,0 @@
---
"@moonshot-ai/kimi-code": minor
---
Plugins can now provide slash commands via a `commands` field in their manifest, registered as `<plugin>:<command>` and invoked with `$ARGUMENTS` expansion.

View file

@ -1,5 +0,0 @@
---
"@moonshot-ai/kimi-code": patch
---
Split LLM streaming timing in the session log and `KIMI_CODE_DEBUG=1` output into client vs. API-server portions, so slow turns can be attributed without parsing the wire log. Time-to-first-token splits into the API-server portion (network + server) and the client portion (in-process request building); the decode window splits into time awaiting tokens from the server and time the client spends processing each streamed chunk.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Add a TUI preference to keep rapid multi-line pastes from submitting line by line when bracketed paste is unavailable. Set `disable_paste_burst = true` in `tui.toml` to turn it off.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Rebuild the web design-system easter egg as an in-app overlay that uses the app's real design tokens, so it stays in sync instead of drifting as a separate copy.

View file

@ -1,5 +0,0 @@
---
"@moonshot-ai/kimi-code": patch
---
Always show the usage-data opt-out toggle in the web settings with a clearer label and description.

170
.github/workflows/desktop-build.yml vendored Normal file
View file

@ -0,0 +1,170 @@
name: desktop-build
# Builds the Kimi Desktop (Electron) installers for macOS, Windows and Linux.
# Each runner builds the matching-platform SEA backend first, then packages it
# with electron-builder.
#
# macOS is signed with a Developer ID certificate + notarized (so it opens on
# any Mac without the "app is damaged" Gatekeeper block) when `sign-macos` is
# true and the Apple secrets are configured. Windows/Linux ship unsigned in v1.
#
# Triggered two ways:
# - workflow_dispatch: manual ad-hoc builds from the Actions tab.
# - workflow_call: called by release.yml to attach installers to a release.
on:
workflow_dispatch:
inputs:
sign-macos:
description: 'Sign + notarize macOS (needs Apple secrets)'
required: false
type: boolean
default: true
retention-days:
description: 'Artifact retention in days'
required: false
type: number
default: 5
upload-artifact-prefix:
description: 'Prefix for uploaded artifact name'
required: false
type: string
default: 'kimi-desktop'
workflow_call:
inputs:
sign-macos:
description: 'Sign + notarize macOS (needs Apple secrets)'
required: false
type: boolean
default: false
retention-days:
description: 'Artifact retention in days'
required: false
type: number
default: 7
upload-artifact-prefix:
description: 'Prefix for uploaded artifact name'
required: false
type: string
default: 'kimi-desktop'
secrets:
APPLE_CERTIFICATE_P12:
required: false
APPLE_CERTIFICATE_PASSWORD:
required: false
APPLE_NOTARIZATION_KEY_P8:
required: false
APPLE_NOTARIZATION_KEY_ID:
required: false
APPLE_NOTARIZATION_ISSUER_ID:
required: false
permissions:
contents: read
jobs:
desktop:
name: Desktop installer (${{ matrix.target }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: macos-15
target: darwin-arm64
- os: macos-15-intel
target: darwin-x64
- os: windows-2025-vs2026
target: win32-x64
- os: ubuntu-24.04
target: linux-x64
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Kimi web assets
# The SEA blob embeds apps/kimi-code/dist-web; build the web app and
# stage its assets before producing the native executable.
# KIMI_WEB_DESKTOP=1 bakes the internal-build banner into the web bundle
# (see apps/kimi-web/src/components/InternalBuildBanner.vue); only the
# desktop sets this flag, so the CLI `kimi web` stays banner-free.
env:
KIMI_WEB_DESKTOP: '1'
run: |
pnpm --filter @moonshot-ai/kimi-web run build
node apps/kimi-code/scripts/copy-web-assets.mjs
- name: Build native executable (local profile)
# The Electron app signs the SEA itself (electron-builder, inside-out),
# so the native build stays unsigned here.
run: pnpm --filter @moonshot-ai/kimi-code run build:native:sea
- name: Setup macOS keychain (Developer ID)
if: runner.os == 'macOS' && inputs.sign-macos
uses: ./.github/actions/macos-keychain-setup
with:
certificate-p12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
certificate-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- name: Prepare CSC_NAME for electron-builder (macOS)
if: runner.os == 'macOS' && inputs.sign-macos
shell: bash
run: |
# electron-builder rejects the "Developer ID Application: " prefix in
# CSC_NAME; strip it so the certificate matches by team name + ID.
name="${APPLE_SIGNING_IDENTITY}"
name="${name#Developer ID Application: }"
echo "CSC_NAME=$name" >> "$GITHUB_ENV"
- name: Prepare notarization API key (macOS)
if: runner.os == 'macOS' && inputs.sign-macos
shell: bash
env:
APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }}
run: |
set -euo pipefail
key_path="$RUNNER_TEMP/notary-AuthKey.p8"
printf '%s' "$APPLE_NOTARIZATION_KEY_P8" | base64 -d > "$key_path"
echo "APPLE_API_KEY=$key_path" >> "$GITHUB_ENV"
- name: Build & package desktop app
shell: bash
env:
# macOS signing is driven by env: when sign-macos, electron-builder
# signs with the keychain's Developer ID and notarizes via the notary
# API key; otherwise it builds unsigned.
CSC_IDENTITY_AUTO_DISCOVERY: ${{ (runner.os == 'macOS' && inputs.sign-macos) && 'true' || 'false' }}
CSC_KEYCHAIN: ${{ env.APPLE_KEYCHAIN_PATH }}
KIMI_DESKTOP_NOTARIZE: ${{ (runner.os == 'macOS' && inputs.sign-macos) && 'true' || 'false' }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}
run: pnpm --filter @moonshot-ai/kimi-desktop run dist
- name: Cleanup macOS keychain
if: always() && runner.os == 'macOS' && inputs.sign-macos
uses: ./.github/actions/macos-keychain-cleanup
- name: Upload installers
uses: actions/upload-artifact@v7
with:
name: ${{ inputs.upload-artifact-prefix }}-${{ matrix.target }}
retention-days: ${{ inputs.retention-days }}
path: |
apps/kimi-desktop/dist-app/*.dmg
apps/kimi-desktop/dist-app/*.zip
apps/kimi-desktop/dist-app/*.exe
apps/kimi-desktop/dist-app/*.AppImage
apps/kimi-desktop/dist-app/*.deb
if-no-files-found: ignore

View file

@ -97,6 +97,22 @@ jobs:
APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }}
APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}
desktop-artifacts:
name: Desktop release artifact
needs: release
if: needs.release.outputs.kimi_native_release == 'true'
uses: ./.github/workflows/desktop-build.yml
with:
upload-artifact-prefix: kimi-desktop
retention-days: 7
sign-macos: true
secrets:
APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }}
APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }}
APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}
publish-native-assets:
name: Publish native release assets
needs:

View file

@ -150,6 +150,7 @@
"node_modules/",
"apps/*/scripts/",
"docs/smoke-archive/",
"packages/pi-tui/",
"*.generated.ts"
]
}

View file

@ -0,0 +1,149 @@
# 接手文档kimi-web 聊天表格宽度
> **当前状态(未提交,用户先用着)**表格在阅读列≈760px内**单元格换行**显示——
> 不横向滚动、不溢出、不裁切,但**没有比正文更宽**。这是目前效果最干净、可以先用的版本。
>
> **仍未达成的目标**:让宽表格像 Manus 那样**突破到比正文更宽的列**再换行。两次突破尝试
> (容器单位 `cqw`、固定 px `@container` 断点)都没做出满意效果,已回退/停用,见 §4、§6。
>
> **只改了 1 个文件**`apps/kimi-web/src/components/chat/Markdown.vue`(表格样式块)。
> typecheck / check:style 通过。
## 1. 背景与目标
`apps/kimi-web` 聊天里 markstream 渲染的 markdown 表格,默认被钉在阅读列宽内
`width:100%` + `table-layout:fixed`),宽表格要么被挤成很窄的列、要么表格内横向滚动。
用户想要的最终效果(参照 Manus 截图):
1. 表格**比正文阅读列更宽**(横向铺开),但
2. 单元格**换行**,所以表格**永不横向滚动**(表格自身和对话面板都不滚),且
3. 正文 / 代码 / diff / 工具块 / thinking 等其它块仍限在 760px 阅读列内。
> 第 1 点(撑得比正文宽)目前**没做到**——见 §6。当前上线的是"列内换行"的降级版。
## 2. 当前实现(列内换行版,正在用)
`Markdown.vue` `<style scoped>` 表格块:
```css
.md :deep(.table-node) { /* <table>:列宽按内容、可换行 */
width: auto !important;
max-width: 100% !important;
table-layout: auto !important;
}
/* 表格在阅读列内换行;装不下(不可断的长内容)才滚动兜底 */
.md :deep(.table-node-wrapper) {
width: max-content;
max-width: 100% !important; /* 100% = 阅读列宽,封顶 → 换行 */
overflow-x: auto !important;
}
.md :deep(.table-node th),
.md :deep(.table-node td) {
text-align: left;
vertical-align: top;
}
```
要点:
- **换行靠的是"去掉旧代码的 `white-space:nowrap`"**。markstream 单元格默认就是
`white-space:normal`(见 `node_modules/markstream-vue/dist/index.px.css`),旧代码特意加
`nowrap !important` 才不换行;现在不设 white-space就回到 markstream 默认的换行。
- `max-content` + `max-width:100%`:列宽按内容撑,但整体不超过阅读列 → 到列宽就换行。
- `.a-msg` 保持 HEAD 的 `width:94%``.content-wrap` 保持 `max-width:760px`,都没动。
## 3. DOM / CSS 结构与关键约束
```
.con (container-type:inline-size; position:relative) ← 不裁剪;@container 的查询根
└ .chat-layout
└ .panes.chat-scroll { overflow-y:auto } ← 决定性裁剪点
└ .content-wrap { max-width:760px; margin:auto } ← 居中阅读列(桌面恒 align-center
└ .chat (flex column; padding:16px 14px)
└ .a-msg (width:94%; align-self:flex-start) ← 左对齐
├ .msg → Markdown(.md) → .markdown-renderer
│ └ .node-slot { width:100% } ← markstream不裁剪
│ └ .node-content { width:100% } ← markstream不裁剪
│ └ .table-node-wrapper ← 表格(突破点,若要撑宽)
│ └ table.table-node
└ .think / .tool-group / .tool / .a-msg-ft
```
**两条硬约束(踩过的坑):**
1. **`.panes` 是裁剪点。** 它有 `overflow-y:auto`,按 CSS 规则另一方向的 `visible` 会被强制算成
`auto`,所以 `.panes` 实际两个方向都裁剪。表格若比面板宽,`.panes` 就会横向滚动
macOS 悬浮滚动条静止时看不见,容易误以为是"被裁掉")。后代无法靠 CSS 逃出它
`position:fixed` 除外,但脱流、不随消息滚动,不可用)。
2. **`.node-content` / `.node-slot` 不裁剪**(只有 `width:100%`,无 overflow所以更宽的表格
wrapper 能正常"顶出"它们向右显示——如果 wrapper 宽度真的生效的话(见 §4 两次失败)。
markstream 的 `.table-node-wrapper` 原始样式:
`{ position:relative; max-width:100%; overflow-x:auto; ... }`,所以覆盖它的 `max-width` / `overflow-x`
需要 `!important`
## 4. 撑宽尝试为什么失败(避免重复试错)
目标是"表格突破 760 列、显示得比正文宽、再换行"。试过两种给 wrapper 更大宽度的写法,都不行:
1. **容器单位 `cqw`**`max-width: calc((100cqw + 760px)/2 - 48px)`
`cqw`(容器宽度单位)在本仓库**别处从没用过**,实测**没生效**——`max-width` 没卡住,
`width:max-content` 就一路撑到不换行的完整宽度、溢出面板(`.panes` 横滚)。
(注意:更早还踩过 `(100cqw+760)/2``760` 不带单位 → `长度+纯数字` 非法 → 整条声明被丢弃,
但即便修正带单位,`cqw` 依旧没做出效果。)
2. **固定 px `@container (min-width)` 断点**`@container (min-width:960px){ ... max-width:800px }`
等按面板宽分档给固定上限。容器查询本身在本仓库可靠Sidebar 等在用),语法也过了 lint
但实际视觉效果用户仍不满意(表格没有明显变宽 / 或档位不合适),已撤掉。
> 结论:单纯改 `.table-node-wrapper``max-width` 这条路,没能稳定做出"比正文宽且换行填满"的效果。
> 下次重做建议换思路(见 §6
## 5. 涉及文件
| 文件 | 关键位置 |
|---|---|
| `apps/kimi-web/src/components/chat/Markdown.vue` | `<style scoped>``.table-node` / `.table-node-wrapper` / 单元格块(**本任务唯一改动** |
| `apps/kimi-web/src/components/chat/ConversationPane.vue` | `.con { container-type:inline-size; --read-max:760px }``.content-wrap``.panes`(裁剪源)——**未改** |
| `apps/kimi-web/src/components/chat/ChatPane.vue` | `.a-msg { width:94% }`——**未改**(本任务) |
## 6. 若要重做"撑得比正文宽"
难点:让表格 wrapper 拿到一个"比 760 列宽、但不超过面板"的宽度,且这个宽度要**真的生效**并触发换行。
几个可试方向(按推荐度):
| 方向 | 说明 | 风险 |
|---|---|---|
| A. 撑开 `.a-msg` + 让表格 `max-content` | `.a-msg``width:fit-content; min-width:100%`,其余直接子元素限 760px表格 `max-content` 顶宽。曾实现过,问题是超面板时 `.panes` 横滚、且正文与宽表格同屏排版怪。 | 中:需解决"超面板横滚"和排版 |
| B. JS 量面板宽写进 CSS 变量 | `ResizeObserver``.con` 宽度,写 `--pane-w`,表格 `max-width: calc((var(--pane-w)+760px)/2 - …)`。绕开 `cqw` 不生效的问题。 | 中:引入 JS但最可控 |
| C. Teleport 到 `.panes` 外 | 宽表格用 Vue Teleport 渲到滚动容器外、JS 定位覆盖到消息处。 | 高:定位+滚动同步脆;且表格是 markstream 第三方 DOM流式重渲染会打架 |
| D. 页面级滚动重构 | 去掉 `.panes` 内滚动,整页纵向滚。 | 高:要重写滚动到底/加载更多/新消息提示等 |
**重做第一步**:先在浏览器量清楚——用户常见宽表格的自然宽度、以及侧栏展开/收起时
`.con`(面板)实际多宽。若表格自然宽度基本 ≤ 面板宽,方向 A/B 就够;否则必然要在
"表格内滚动 / 面板滚动 / 换行压缩"三者里选一个让步§3 硬约束决定纯 CSS 无法三者全免)。
## 7. 验证
```bash
export PATH="$HOME/.nvm/versions/node/v24.16.0/bin:$PATH"
pnpm --filter kimi-web typecheck # 通过
pnpm --filter kimi-web check:style # baseline本文件无新增告警
```
浏览器肉眼(本环境不能渲染):
- 让 agent 输出一个多列宽表格。
- 当前版本预期:表格在阅读列内**换行**显示,不横向滚动、不溢出;但**不会比正文宽**。
- macOS 是悬浮滚动条,判断"有没有横滚"别只看静止截图,拖一下或缩小窗口确认。
## 8. 与本任务无关的未提交改动(不要误改 / 误回退)
`git diff` 里还有这些**非本任务**的改动:
- `ConversationPane.vue`:平滑滚动守卫(`smoothScrollUntil` / `handleEditMessage` / `scrollToBottom` 调整)。
- `ChatPane.vue``.u-copy``justify-content:center` + `.u-copy svg``translateY(1.3px)`
- `apps/kimi-web/src/i18n/…``lib/desktopFlag.ts``components/chat/ConversationToc.vue` 等仓库里早已存在的杂项。
## 9. 收尾
表格改动只在 `Markdown.vue` 一个文件。若就用当前"列内换行"版收尾,按 `gen-changesets` 技能
生成 changesetscope: web描述markdown 表格单元格换行、避免被挤成窄列)即可。

View file

@ -6,3 +6,6 @@ agents/
# next to it keeps `#/generated/vis-web-asset` type-resolvable on a fresh
# clone (before any build has produced the `.ts`).
src/generated/vis-web-asset.ts
# Copied from packages/pi-tui/native at build time by scripts/copy-native-assets.mjs
native/

View file

@ -1,5 +1,144 @@
# @moonshot-ai/kimi-code
## 0.22.1
### Patch Changes
- [#1304](https://github.com/MoonshotAI/kimi-code/pull/1304) [`0fc0ae3`](https://github.com/MoonshotAI/kimi-code/commit/0fc0ae380b09aa96aad0eff1ae66f239e061d01a) - When large images are compressed, tell the model the original and delivered image details. Keep the original image available, and support cropped or full-resolution reads for fine details.
- [#1315](https://github.com/MoonshotAI/kimi-code/pull/1315) [`b40bb71`](https://github.com/MoonshotAI/kimi-code/commit/b40bb7139939eb2ba734ce5dd4871b894d7033e8) - Fix TUI rendering bugs that caused the screen to go blank and the input box to disappear.
- [#1303](https://github.com/MoonshotAI/kimi-code/pull/1303) [`2639786`](https://github.com/MoonshotAI/kimi-code/commit/2639786ce578f15c020a2c11c344797dae18de61) - Fix the TUI crashing when the terminal is resized to a very narrow width while the input contains CJK or emoji text.
- [#1315](https://github.com/MoonshotAI/kimi-code/pull/1315) [`b40bb71`](https://github.com/MoonshotAI/kimi-code/commit/b40bb7139939eb2ba734ce5dd4871b894d7033e8) - Clear the screen fully when starting a new session via /new, /clear, or a session switch.
- [#1301](https://github.com/MoonshotAI/kimi-code/pull/1301) [`c3653a1`](https://github.com/MoonshotAI/kimi-code/commit/c3653a1c50ffa3856484599e132980628eb9fca4) - Show an up arrow on the web composer send button.
- [#1290](https://github.com/MoonshotAI/kimi-code/pull/1290) [`3ea84a5`](https://github.com/MoonshotAI/kimi-code/commit/3ea84a56e4dfdeaddd58add5b269be0342f3f986) - Fix the session search dialog showing a horizontal scrollbar for long session titles or snippets.
- [#1316](https://github.com/MoonshotAI/kimi-code/pull/1316) [`5322c63`](https://github.com/MoonshotAI/kimi-code/commit/5322c638895a934c1ce220fefed54f5077d2a49e) - Fix web tooltips that could get stuck on screen when their trigger element is removed while open.
- [#1319](https://github.com/MoonshotAI/kimi-code/pull/1319) [`e8ab7ca`](https://github.com/MoonshotAI/kimi-code/commit/e8ab7ca78661de7f00a8196444be1db93e7c14b4) - Fix the sidebar session row shifting its title and status badges when hovered.
- [#1293](https://github.com/MoonshotAI/kimi-code/pull/1293) [`6a469b3`](https://github.com/MoonshotAI/kimi-code/commit/6a469b3e07022e56b29b1fd8a7c58df36b2111fe) - Refresh the web UI icon set and unify the message copy and undo button hover states and tooltips.
- [#1311](https://github.com/MoonshotAI/kimi-code/pull/1311) [`b40649b`](https://github.com/MoonshotAI/kimi-code/commit/b40649b2ae7a4b6a0aea04e32eba200555393064) - Remove duplicate newline-shortcut handling from the prompt editor.
- [#1317](https://github.com/MoonshotAI/kimi-code/pull/1317) [`78a058a`](https://github.com/MoonshotAI/kimi-code/commit/78a058acd2fc91de5cca0c1d66d415ee35884889) - Remove the experimental micro compaction feature and its toggle from the experiments panel.
- [#1283](https://github.com/MoonshotAI/kimi-code/pull/1283) [`ea55911`](https://github.com/MoonshotAI/kimi-code/commit/ea55911062eefcb0414cfddb84c8a4494c45f363) - Improve compaction handoff summaries for more reliable resumed sessions. They now keep the latest intent, key tool results, decisions, open questions, and context to re-check.
- [#1295](https://github.com/MoonshotAI/kimi-code/pull/1295) [`77eb3a9`](https://github.com/MoonshotAI/kimi-code/commit/77eb3a9fe40c93fa32e335f07160b8128355bab6) - Save shell commands to input history and recall them in bash mode. Press Up on an empty `!` prompt to browse previous shell commands.
- [#1316](https://github.com/MoonshotAI/kimi-code/pull/1316) [`5322c63`](https://github.com/MoonshotAI/kimi-code/commit/5322c638895a934c1ce220fefed54f5077d2a49e) - Trim redundant and incorrect tooltips in the web UI.
- [#1320](https://github.com/MoonshotAI/kimi-code/pull/1320) [`444e6b1`](https://github.com/MoonshotAI/kimi-code/commit/444e6b15f0e53b6c4d75d1bfdc0b35639dce6f4c) - Fix the web UI becoming sluggish after opening many sessions.
- [#1322](https://github.com/MoonshotAI/kimi-code/pull/1322) [`5441ad1`](https://github.com/MoonshotAI/kimi-code/commit/5441ad1838a5cfa1f3df0ca2ee1524e1433fb513) - Let the web sidebar collapse an expanded workspace session list back to its first page.
## 0.22.0
### Minor Changes
- [#1243](https://github.com/MoonshotAI/kimi-code/pull/1243) [`ace7901`](https://github.com/MoonshotAI/kimi-code/commit/ace79010669d19ad175bc25443b6efb41ca2e2ac) - Automatically compress oversized images before they reach the model. Whatever the source — pasted into the CLI, uploaded from the web/desktop client, sent over ACP, read via `ReadMediaFile`, or returned by an MCP tool — images are downsampled (longest edge ≤ 2000px) and re-encoded to fit a per-image byte budget, cutting vision-token cost and avoiding provider image-size errors. Screenshots stay lossless PNG and only degrade to JPEG when the byte budget cannot otherwise be met. Compression runs as an input-stage step at each ingestion point (while the content part is built), and guards against decompression bombs by skipping absurdly large pixel/byte payloads before decoding. Best-effort: if it fails for any reason the original image is sent unchanged.
- [#1262](https://github.com/MoonshotAI/kimi-code/pull/1262) [`c070fbe`](https://github.com/MoonshotAI/kimi-code/commit/c070fbeddeb1c147d8859a76046f9465f696c9cb) - Add model alias overrides so manual thinking effort levels and model metadata survive provider catalog refreshes. Set them under `[models."<alias>".overrides]`.
- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Refresh the web UI with a new design system, including updated colors, typography, spacing, light and dark palettes, restyled tooltips, and subtle enter/exit and expand/collapse animations.
### Patch Changes
- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Show draft pull requests with a distinct draft status instead of displaying them as open.
- [#1254](https://github.com/MoonshotAI/kimi-code/pull/1254) [`7859b0a`](https://github.com/MoonshotAI/kimi-code/commit/7859b0afe8898852806e5a0c21b9dd52cb82f834) - Fix the transcript jumping to the top when scrolling up through history during streaming output.
- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Fix plan, swarm, and goal modes being shared across sessions in the web UI; each session now keeps its own toggles.
- [#1264](https://github.com/MoonshotAI/kimi-code/pull/1264) [`003733c`](https://github.com/MoonshotAI/kimi-code/commit/003733c751584ce30d8ebae4f5e608f0df049d32) - Hide the unsupported Off option in the /model thinking switcher for always-on models that already expose multiple effort levels.
- [#1272](https://github.com/MoonshotAI/kimi-code/pull/1272) [`54703d9`](https://github.com/MoonshotAI/kimi-code/commit/54703d9457dcda7bc782301fc2dbb41a2c8d7293) - Release pasted images and streaming timers once they are no longer shown, so memory stops growing in long sessions.
- [#1272](https://github.com/MoonshotAI/kimi-code/pull/1272) [`54703d9`](https://github.com/MoonshotAI/kimi-code/commit/54703d9457dcda7bc782301fc2dbb41a2c8d7293) - Fix the terminal being left in raw mode with a hidden cursor and disabled flow control after a crash or abrupt exit.
- [#1265](https://github.com/MoonshotAI/kimi-code/pull/1265) [`8cfb165`](https://github.com/MoonshotAI/kimi-code/commit/8cfb1657ad7bf525269df4ab6cf5c12aa1d406a9) - Reduce the default TUI transcript window to keep long sessions responsive.
- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Replace the Explore and Native theme options with a single chat layout and a Blue or Black accent-color setting.
- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Show time, duration, connection, and stack details in web error and warning toasts.
- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Fix an active workspace showing only its five most recent sessions on load, so it now keeps loading older sessions from the last 12 hours.
- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Reduce the web composer's default height for a more compact empty state, and fix ArrowUp recalling the previous message while editing a multi-line draft; ArrowUp now recalls only from the very start of the text and is disabled in the expanded editor.
- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Fix the Thinking-by-default setting not taking effect, so new sessions correctly start with thinking enabled.
- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Fix spurious errors from the web question, approval, and task actions when the action was already complete, and add loading feedback so each click is acknowledged immediately.
- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Show queued prompts inline below the running turn in the web chat, and split Stop into its own button so Send no longer interrupts.
- [#1278](https://github.com/MoonshotAI/kimi-code/pull/1278) [`bbda90a`](https://github.com/MoonshotAI/kimi-code/commit/bbda90af846ca66232158d2e9605d3d59a7e3a49) - Hide the conversation outline when there is not enough room to expand its labels, so it no longer clips against the window edge.
- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Show the conversation outline as one entry per user query that expands into a labeled list on hover.
- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Remove the fade-out animation when undoing a message in the web chat.
- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Improve session search with a Cmd/Ctrl+K palette that filters by title, workspace, and last prompt with highlighted matches. Press Cmd+K or Ctrl+K to open it.
- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Group consecutive tool calls into a collapsible stack with per-tool renderers, including diff line-count chips for edits and inline previews for image, video, and audio results.
- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Use one consistent modal dialog for confirmations in the web UI (archive session, delete workspace, delete provider, undo message, and mode toggles).
- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Add workspace sorting by manual order or last-edited time, plus collapse-all and expand-all controls, to the sidebar.
## 0.21.1
### Patch Changes
- [#1256](https://github.com/MoonshotAI/kimi-code/pull/1256) [`0cc02ac`](https://github.com/MoonshotAI/kimi-code/commit/0cc02ac67d465d1d4d7fe070422bab17053cdaa3) - Keep the waiting spinner visible while encrypted reasoning streams, fixing a blank spinner-less gap before the first response text appears.
## 0.21.0
### Minor Changes
- [#1204](https://github.com/MoonshotAI/kimi-code/pull/1204) [`5cb80ce`](https://github.com/MoonshotAI/kimi-code/commit/5cb80ce879406d239048c32d61202778cb860e58) - Plugins can now provide slash commands via a `commands` field in their manifest, registered as `<plugin>:<command>` and invoked with `$ARGUMENTS` expansion.
- [#1214](https://github.com/MoonshotAI/kimi-code/pull/1214) [`86e0c92`](https://github.com/MoonshotAI/kimi-code/commit/86e0c9201ed58c7c1ce5543b1dfb47a4cf5117f6) - Rework conversation compaction:
- Keep only recent user prompts plus a single user-role summary; drop assistant and tool messages.
- Repair tool_use/tool_result adjacency before sending, fixing a strict-provider HTTP 400 when a tool call and its result became non-adjacent.
- Merge consecutive user turns for strict providers (Gemini/Vertex), fixing an HTTP 400 ("roles must alternate") after compaction or when a turn is steered in right after a tool result.
- Micro-compaction now defaults off.
- [#1132](https://github.com/MoonshotAI/kimi-code/pull/1132) [`108299b`](https://github.com/MoonshotAI/kimi-code/commit/108299be3cdffc31a23f64efd3ff5ba50976b412) - Refactor the thinking effort system
### Patch Changes
- [#1231](https://github.com/MoonshotAI/kimi-code/pull/1231) [`ceb27f5`](https://github.com/MoonshotAI/kimi-code/commit/ceb27f5e449e177493f320d90e292487a8fc3410) - Add a server-side key-value store API for persisting web UI preferences to the user's data directory.
- [#1220](https://github.com/MoonshotAI/kimi-code/pull/1220) [`ec51324`](https://github.com/MoonshotAI/kimi-code/commit/ec51324230484f2ebaad1ab0aebf2e38f531d914) - Add a double-Esc shortcut to open the undo selector. Press Esc twice while idle to undo.
- [#1223](https://github.com/MoonshotAI/kimi-code/pull/1223) [`80e6888`](https://github.com/MoonshotAI/kimi-code/commit/80e6888e34e4362247c0eac5b77340df014ba286) - Fix @ file mentions not opening when typed inside a slash command argument.
- [#1233](https://github.com/MoonshotAI/kimi-code/pull/1233) [`020992c`](https://github.com/MoonshotAI/kimi-code/commit/020992c286f0f6bff6a038a7c7bd7e9db639e3c9) - Force-exit headless runs (`kimi -p`) so a stray ref'd handle left over from the run can't keep a completed run alive until an external timeout, and bound prompt cleanup so a wedged shutdown step can't hang shutdown.
- [#1225](https://github.com/MoonshotAI/kimi-code/pull/1225) [`659062d`](https://github.com/MoonshotAI/kimi-code/commit/659062d11cc272fe631fc6d4faf64d0e0b1a0142) - Show file path completions when typing `/` in shell mode (`!`).
- [#1236](https://github.com/MoonshotAI/kimi-code/pull/1236) [`bfe8e6a`](https://github.com/MoonshotAI/kimi-code/commit/bfe8e6ace3cda76b1991bf29c25b9444611d5512) - Fix adding a workspace by path in the web UI failing silently when the daemon rejects the path; it now shows an error instead of a broken workspace.
- [#1221](https://github.com/MoonshotAI/kimi-code/pull/1221) [`a3f9cec`](https://github.com/MoonshotAI/kimi-code/commit/a3f9cec8a975f11e37e992e42f954789ed394207) - Fix duplicate workspaces showing in the web sidebar when the same folder is registered more than once.
- [#1241](https://github.com/MoonshotAI/kimi-code/pull/1241) [`8ac337a`](https://github.com/MoonshotAI/kimi-code/commit/8ac337a2b2ac800aa79a373459308abb6c9e63bb) - Stop a malformed message history from permanently bricking a session on strict providers (Anthropic). The request is repaired before sending — orphaned tool calls are closed and empty/whitespace-only text blocks dropped — and if the provider still rejects its structure, it is resent once with a wire-compliant rebuild.
- [#1228](https://github.com/MoonshotAI/kimi-code/pull/1228) [`42e37eb`](https://github.com/MoonshotAI/kimi-code/commit/42e37eb898b722829d2ec83e909525ff18e336a5) - Split LLM streaming timing in the session log and `KIMI_CODE_DEBUG=1` output into client vs. API-server portions, so slow turns can be attributed without parsing the wire log. Time-to-first-token splits into the API-server portion (network + server) and the client portion (in-process request building); the decode window splits into time awaiting tokens from the server and time the client spends processing each streamed chunk.
- [#1234](https://github.com/MoonshotAI/kimi-code/pull/1234) [`882cf35`](https://github.com/MoonshotAI/kimi-code/commit/882cf355a9cb45bb5b3424a27b953bde8e106bb0) - Hide the provider management dialog in the web UI until the server supports it.
- [#1226](https://github.com/MoonshotAI/kimi-code/pull/1226) [`7f05f58`](https://github.com/MoonshotAI/kimi-code/commit/7f05f589e7bc77a2f26463a41317ff7087e3c3a0) - Add Mermaid diagram rendering to the web chat. Fenced `mermaid` blocks in assistant responses now render as diagrams. KaTeX math and Mermaid diagram parsing also run in Web Workers to keep the UI responsive during live streaming.
- [#1232](https://github.com/MoonshotAI/kimi-code/pull/1232) [`aa6b0d0`](https://github.com/MoonshotAI/kimi-code/commit/aa6b0d065ee888056c3812781483ddb74739897f) - Always show the usage-data opt-out toggle in the web settings with a clearer label and description.
- [#1234](https://github.com/MoonshotAI/kimi-code/pull/1234) [`882cf35`](https://github.com/MoonshotAI/kimi-code/commit/882cf355a9cb45bb5b3424a27b953bde8e106bb0) - Fix the web workspace rename not persisting after a page refresh.
## 0.20.3
### Patch Changes

View file

@ -1,6 +1,6 @@
{
"name": "@moonshot-ai/kimi-code",
"version": "0.20.3",
"version": "0.22.1",
"description": "The Starting Point for Next-Gen Agents",
"license": "MIT",
"author": "Moonshot AI",
@ -28,6 +28,7 @@
"files": [
"dist",
"dist-web",
"native",
"scripts/postinstall.mjs",
"scripts/postinstall",
"README.md"
@ -48,7 +49,7 @@
"provenance": true
},
"scripts": {
"build": "pnpm -C ../kimi-web run build && tsdown && node scripts/copy-web-assets.mjs",
"build": "pnpm -C ../kimi-web run build && tsdown && node scripts/copy-native-assets.mjs && node scripts/copy-web-assets.mjs",
"prebuild": "node scripts/build-vis-asset.mjs",
"catalog:update": "node scripts/update-catalog.mjs --out dist/built-in-catalog.json",
"smoke": "node scripts/smoke.mjs",
@ -75,17 +76,16 @@
},
"optionalDependencies": {
"@mariozechner/clipboard": "^0.3.9",
"koffi": "^2.16.0",
"node-pty": "^1.1.0"
},
"devDependencies": {
"@earendil-works/pi-tui": "^0.74.0",
"@moonshot-ai/acp-adapter": "workspace:^",
"@moonshot-ai/kimi-code-oauth": "workspace:^",
"@moonshot-ai/kimi-code-sdk": "workspace:^",
"@moonshot-ai/kimi-telemetry": "workspace:^",
"@moonshot-ai/kimi-web": "workspace:^",
"@moonshot-ai/migration-legacy": "workspace:^",
"@moonshot-ai/pi-tui": "workspace:^",
"@moonshot-ai/server": "workspace:^",
"@moonshot-ai/vis-server": "workspace:^",
"@moonshot-ai/vis-web": "workspace:*",
@ -94,6 +94,7 @@
"chalk": "^5.4.1",
"cli-highlight": "^2.1.11",
"commander": "^13.1.0",
"jimp": "^1.6.1",
"pathe": "^2.0.3",
"postject": "1.0.0-alpha.6",
"semver": "^7.7.4",

View file

@ -0,0 +1,38 @@
import { cp, mkdir, rm, stat } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const repoRoot = resolve(appRoot, '../..');
const source = resolve(repoRoot, 'packages/pi-tui/native');
const target = resolve(appRoot, 'native');
// pi-tui ships platform-specific native helpers only for darwin/win32;
// Linux has no native helper, so there is nothing to copy for it.
const PLATFORMS = ['darwin', 'win32'];
async function assertPrebuilds(platform) {
const dir = resolve(source, platform, 'prebuilds');
try {
const info = await stat(dir);
if (!info.isDirectory()) {
throw new Error('not a directory');
}
} catch {
throw new Error(
`pi-tui native prebuilds were not found at ${dir}. Build or restore packages/pi-tui first.`,
);
}
return dir;
}
await rm(target, { recursive: true, force: true });
await mkdir(target, { recursive: true });
for (const platform of PLATFORMS) {
const srcPrebuilds = await assertPrebuilds(platform);
const dstPrebuilds = resolve(target, platform, 'prebuilds');
await cp(srcPrebuilds, dstPrebuilds, { recursive: true });
}
console.log(`Copied pi-tui native prebuilds to ${target}`);

View file

@ -17,9 +17,7 @@ export const NATIVE_TARGETS = Object.freeze(
SUPPORTED_TARGETS.map((t) => {
const deps = resolveTargetDeps(t);
const clipboardTarget = deps.find((d) => d.id === 'clipboard-target')?.resolvedName;
const koffiNativeFile = deps.find((d) => d.id === 'koffi')?.nativeFileRelatives?.[0];
const koffiTriplet = koffiNativeFile?.match(/koffi\/([^/]+)\/koffi\.node$/)?.[1] ?? null;
return [t, { clipboardPackage: clipboardTarget, koffiTriplet }];
return [t, { clipboardPackage: clipboardTarget }];
}),
),
);
@ -161,16 +159,19 @@ async function collectPackageFiles({
packageName,
packageRoot,
includeNativeFiles,
includeEntryJs = true,
nativeFileRelatives = [],
}) {
const packageJsonPath = join(packageRoot, 'package.json');
const packageJson = await readJson(packageJsonPath);
const selected = new Set([packageJsonPath]);
const entry = resolvePackageEntry(packageRoot, packageJson);
if (entry !== null) {
selected.add(entry);
await addRuntimeDependencyFiles(packageRoot, entry, selected);
if (includeEntryJs) {
const entry = resolvePackageEntry(packageRoot, packageJson);
if (entry !== null) {
selected.add(entry);
await addRuntimeDependencyFiles(packageRoot, entry, selected);
}
}
for (const nativeFileRelative of nativeFileRelatives) {
@ -250,6 +251,7 @@ export async function collectNativeAssets({ appRoot, target }) {
packageName: dep.resolvedName,
packageRoot,
includeNativeFiles: dep.collect === 'native-files',
includeEntryJs: dep.collect !== 'native-file-only',
nativeFileRelatives: dep.nativeFileRelatives,
});
const result = await packageManifestEntries({

View file

@ -23,7 +23,7 @@ const optionalRuntimeRequires = new Set([
'utf-8-validate',
]);
const optionalRelativeRuntimeRequires = new Set(['./crypto/build/Release/sshcrypto.node']);
const handledNativeRuntimeRequires = new Set(['koffi']);
const handledNativeRuntimeRequires = new Set();
function isAllowedSpecifier(specifier) {
if (builtins.has(specifier) || specifier.startsWith('node:')) return true;

View file

@ -27,13 +27,16 @@ const clipboardSubpackageByTarget = Object.freeze({
'win32-x64': '@mariozechner/clipboard-win32-x64-msvc',
});
const koffiTripletByTarget = Object.freeze({
'darwin-arm64': 'darwin_arm64',
'darwin-x64': 'darwin_x64',
'linux-arm64': 'linux_arm64',
'linux-x64': 'linux_x64',
'win32-arm64': 'win32_arm64',
'win32-x64': 'win32_x64',
// pi-tui ships platform-specific native helpers (no Linux build):
// - darwin: Shift-modifier detection for Terminal.app Shift+Enter
// - win32: enable ENABLE_VIRTUAL_TERMINAL_INPUT so Shift+Tab is distinguishable
const piTuiNativeFileByTarget = Object.freeze({
'darwin-arm64': ['native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node'],
'darwin-x64': ['native/darwin/prebuilds/darwin-x64/darwin-modifiers.node'],
'linux-arm64': [],
'linux-x64': [],
'win32-arm64': ['native/win32/prebuilds/win32-arm64/win32-console-mode.node'],
'win32-x64': ['native/win32/prebuilds/win32-x64/win32-console-mode.node'],
});
export function isSupportedTarget(target) {
@ -45,13 +48,15 @@ export function isSupportedTarget(target) {
* @property {string} id stable internal id used for parent refs
* @property {(target: string) => string} name
* npm package name (may depend on target)
* @property {'js-only'|'native-files'|'js-and-native-file'|'virtual'} collect
* @property {'js-only'|'native-files'|'js-and-native-file'|'native-file-only'|'virtual'} collect
* @property {string|null} parent
* id of another registered dep this nests under (for pnpm),
* or null for top-level (resolvable from app root)
* @property {(target: string) => string[]} [nativeFileRelatives]
* explicit list of .node files relative to package root
* (used by 'js-and-native-file'; native-files mode auto-scans *.node)
* (used by 'js-and-native-file' and 'native-file-only';
* native-files mode auto-scans *.node). 'native-file-only' collects
* package.json + these .node files but skips the package entry JS.
*/
/** @type {readonly NativeDepDescriptor[]} */
@ -70,18 +75,14 @@ export const nativeDeps = Object.freeze([
},
{
id: 'pi-tui',
name: () => '@earendil-works/pi-tui',
// pi-tui is bundled into main.cjs at build time — we don't collect it as
// a native dep, only register it so koffi can declare it as parent.
collect: 'virtual',
name: () => '@moonshot-ai/pi-tui',
// pi-tui's JS is bundled into main.cjs, so only the platform-specific
// native helper (.node under native/) ships alongside the binary — its
// dist/ JS is intentionally NOT collected (it stays in the bundle). This
// keeps the SEA native-asset payload small. Linux has no native helper.
collect: 'native-file-only',
parent: null,
},
{
id: 'koffi',
name: () => 'koffi',
collect: 'js-and-native-file',
parent: 'pi-tui',
nativeFileRelatives: (target) => [`build/koffi/${koffiTripletByTarget[target]}/koffi.node`],
nativeFileRelatives: (target) => piTuiNativeFileByTarget[target] ?? [],
},
]);

View file

@ -0,0 +1,96 @@
import type { Writable } from 'node:stream';
import { HEADLESS_FORCE_EXIT_GRACE_MS, HEADLESS_STDIO_DRAIN_TIMEOUT_MS } from '#/constant/app';
/** Minimal process surface needed to force a headless run to terminate. */
export interface ExitableProcess {
exit(code?: number): void;
}
/**
* Schedule a best-effort force-exit for a completed headless (`kimi -p`) run.
*
* Print mode does not call `process.exit()`; it relies on the Node event loop
* draining once the run is done. If a stray ref'd handle survives shutdown a
* lingering socket (e.g. a connection blackholed by a restrictive firewall, or
* an HTTP/2 session kept alive by PING), an un-cleared timer, or a child whose
* pipes stay open the loop never empties and the process hangs until an
* external timeout kills it.
*
* This arms an **unref'd** fallback timer: a healthy run drains and exits
* naturally before it fires (so behaviour is unchanged), and the timer itself
* never keeps the loop alive. It only force-exits a run whose loop is already
* wedged. The exit code is read lazily at fire time so callers may set
* `process.exitCode` after scheduling (e.g. a goal turn mapping its terminal
* status to a non-zero code).
*
* Returns the timer handle so callers/tests can `clearTimeout` it.
*/
export function scheduleHeadlessForceExit(
proc: ExitableProcess,
getExitCode: () => number,
graceMs: number = HEADLESS_FORCE_EXIT_GRACE_MS,
): NodeJS.Timeout {
const timer = setTimeout(() => {
proc.exit(getExitCode());
}, graceMs);
timer.unref?.();
return timer;
}
/** Resolve once a stream's currently-buffered writes have flushed to its sink. */
function flushStream(stream: Writable): Promise<void> {
return new Promise<void>((resolve) => {
try {
// An empty write's callback fires after all previously-queued writes have
// been flushed (writes are ordered), which is the documented way to know a
// stream's buffer has drained.
stream.write('', () => resolve());
} catch {
resolve();
}
});
}
/**
* Wait for buffered output on the given streams to flush, bounded by `timeoutMs`.
*
* A slow or piped consumer that hasn't read all of stdout/stderr yet leaves the
* pipe as a legitimate ref'd handle keeping the loop alive. Flushing before any
* force-exit prevents truncating output from an otherwise-successful run. The
* wait is bounded so a permanently-stuck consumer can't re-introduce the hang.
*/
export async function drainStdio(
streams: readonly Writable[],
timeoutMs: number = HEADLESS_STDIO_DRAIN_TIMEOUT_MS,
): Promise<void> {
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<void>((resolve) => {
timer = setTimeout(resolve, timeoutMs);
timer.unref?.();
});
try {
await Promise.race([Promise.all(streams.map(flushStream)).then(() => undefined), timeout]);
} finally {
if (timer !== undefined) clearTimeout(timer);
}
}
/**
* Finalize a completed headless run: flush stdio, then arm the force-exit
* backstop.
*
* Draining first means in-flight legitimate output is fully written before the
* backstop can fire, and since drained stdio no longer holds the loop only a
* genuinely leaked handle can keep it alive afterwards, which is exactly what
* the backstop is for.
*/
export async function finalizeHeadlessRun(
proc: ExitableProcess,
streams: readonly Writable[],
getExitCode: () => number,
options: { drainTimeoutMs?: number; graceMs?: number } = {},
): Promise<void> {
await drainStdio(streams, options.drainTimeoutMs ?? HEADLESS_STDIO_DRAIN_TIMEOUT_MS);
scheduleHeadlessForceExit(proc, getExitCode, options.graceMs);
}

View file

@ -19,7 +19,7 @@ import {
} from '@moonshot-ai/kimi-code-sdk';
import { resolve } from 'pathe';
import { CLI_SHUTDOWN_TIMEOUT_MS } from '#/constant/app';
import { CLI_SHUTDOWN_TIMEOUT_MS, PROMPT_CLEANUP_TIMEOUT_MS } from '#/constant/app';
import type { CLIOptions, PromptOutputFormat } from './options';
import {
@ -32,6 +32,44 @@ import {
import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry';
import { createKimiCodeHostIdentity } from './version';
/**
* Await `promise`, but stop waiting after `timeoutMs`.
*
* The timeout only bounds how long we WAIT it does not change the outcome:
* - if `promise` settles first, its result is propagated (a rejection throws),
* so a cleanup step that actually fails in time still surfaces;
* - if the timeout wins, we resolve (give up waiting) and swallow the abandoned
* promise's eventual late rejection so it can't surface as an unhandled
* rejection.
*
* Used to bound shutdown so a wedged cleanup step can't keep a completed
* headless run alive, without silently swallowing a cleanup that fails fast. The
* timer is unref'd so it never keeps the loop alive on its own.
*/
async function raceWithTimeout(promise: Promise<void>, timeoutMs: number): Promise<void> {
let timedOut = false;
let timer: ReturnType<typeof setTimeout> | undefined;
// Attach the catch eagerly (synchronously) so `promise` is always consumed and
// a late rejection can never become an unhandled rejection. Before the timeout
// wins, the handler rethrows so a real cleanup failure still propagates.
const guarded = promise.catch((error: unknown) => {
if (timedOut) return;
throw error;
});
const timedOutSignal = new Promise<void>((resolve) => {
timer = setTimeout(() => {
timedOut = true;
resolve();
}, timeoutMs);
timer.unref?.();
});
try {
await Promise.race([guarded, timedOutSignal]);
} finally {
if (timer !== undefined) clearTimeout(timer);
}
}
interface PromptOutput {
readonly columns?: number | undefined;
write(chunk: string): boolean;
@ -96,7 +134,7 @@ export async function runPrompt(
let removeTerminationCleanup: (() => void) | undefined;
let cleanupPromise: Promise<void> | undefined;
const cleanupPromptRun = async (): Promise<void> => {
cleanupPromise ??= (async () => {
const pending = (cleanupPromise ??= (async () => {
removeTerminationCleanup?.();
setCrashPhase('shutdown');
try {
@ -105,8 +143,13 @@ export async function runPrompt(
await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS });
await harness.close();
}
})();
await cleanupPromise;
})());
// Bound cleanup so a wedged shutdown step (e.g. a SessionEnd hook, MCP
// shutdown, or a connection blackholed by a restrictive firewall) cannot
// keep a completed headless run alive forever. The cleanup keeps running in
// the background if it overruns; the caller (`kimi -p`) force-exits shortly
// after, so any straggling work is torn down with the process.
await raceWithTimeout(pending, PROMPT_CLEANUP_TIMEOUT_MS);
};
removeTerminationCleanup = installPromptTerminationCleanup(promptProcess, cleanupPromptRun);

View file

@ -1,4 +1,4 @@
import { execSync } from 'node:child_process';
import { execSync, spawnSync } from 'node:child_process';
import { homedir } from 'node:os';
import { join } from 'node:path';
@ -25,6 +25,7 @@ import { KimiTUI } from '#/tui/index';
import { currentTheme, getColorPalette } from '#/tui/theme';
import { combineStartupNotice } from '#/tui/utils/startup';
import { toTerminalHyperlink } from '#/utils/terminal-hyperlink';
import { restoreTerminalModes } from '#/utils/terminal-restore';
import type { CLIOptions } from './options';
import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry';
@ -133,6 +134,59 @@ export async function runShell(
trackLifecycleForSession(tui.getCurrentSessionId(), event, properties);
};
let savedStty: string | undefined;
try {
// stty operates on the terminal behind stdin, so stdin must be the TTY —
// piping /dev/null (ignore) makes stty fail with "not a tty".
const saved = execSync('stty -g', {
encoding: 'utf8',
stdio: ['inherit', 'pipe', 'ignore'],
});
savedStty = typeof saved === 'string' ? saved.trim() : undefined;
execSync('stty -ixon', { stdio: ['inherit', 'ignore', 'ignore'] });
} catch {
/* ignore */
}
const restoreStty = (): void => {
if (savedStty === undefined) return;
const args = savedStty.split(/\s+/).filter((arg) => arg.length > 0);
if (args.length === 0) return;
spawnSync('stty', args, { stdio: ['inherit', 'ignore', 'ignore'] });
};
// If we crash without going through KimiTUI.stop(), the terminal is left in
// raw mode with a hidden cursor and XON/XOFF flow control disabled. Restore
// both before exiting so the user's shell is usable afterwards.
const emergencyExit = (exitCode: number): void => {
restoreTerminalModes();
restoreStty();
process.exit(exitCode);
};
const onUncaughtException = (error: unknown): void => {
try {
log.error('uncaughtException, restoring terminal and exiting', { error: String(error) });
} catch {
/* ignore */
}
emergencyExit(1);
};
const onUnhandledRejection = (reason: unknown): void => {
try {
log.error('unhandledRejection, restoring terminal and exiting', { reason: String(reason) });
} catch {
/* ignore */
}
emergencyExit(1);
};
process.on('uncaughtException', onUncaughtException);
process.on('unhandledRejection', onUnhandledRejection);
// Remove the crash handlers once the TUI exits cleanly so repeated runShell()
// calls in the same process (e.g. tests) don't accumulate process listeners.
const removeCrashHandlers = (): void => {
process.off('uncaughtException', onUncaughtException);
process.off('unhandledRejection', onUnhandledRejection);
};
tui.onExit = async (exitCode = 0) => {
const sessionId = tui.getCurrentSessionId();
const hasContent = tui.hasSessionContent();
@ -151,13 +205,10 @@ export async function runShell(
if (hints.length > 0) {
process.stderr.write(`\n${hints.join('\n')}\n`);
}
removeCrashHandlers();
restoreStty();
process.exit(exitCode);
};
try {
execSync('stty -ixon', { stdio: 'ignore' });
} catch {
/* ignore */
}
try {
const initStartedAt = Date.now();
await tui.start();
@ -171,6 +222,7 @@ export async function runShell(
mcp_ms: mcpMs,
});
} catch (error) {
removeCrashHandlers();
setCrashPhase('shutdown');
trackLifecycle('exit', { duration_ms: Date.now() - startedAt });
await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS });

View file

@ -340,7 +340,7 @@ export async function handleCatalogAdd(
// already-configured provider would lose the user's previously-set default
// even when `--default-model` is not supplied.
const previousDefaultModel = config.defaultModel;
const previousDefaultThinking = config.defaultThinking;
const previousThinking = config.thinking;
if (config.providers[providerId] !== undefined) {
config = await harness.removeProvider(providerId);
@ -348,7 +348,7 @@ export async function handleCatalogAdd(
const baseUrl = catalogBaseUrl(entry, wire);
// `applyCatalogProvider` always overwrites both `defaultModel` and
// `defaultThinking`. The values we pass here are temporary; we restore
// `[thinking]`. The values we pass here are temporary; we restore
// a consistent state in the post-apply block below.
applyCatalogProvider(config, {
providerId,
@ -373,18 +373,18 @@ export async function handleCatalogAdd(
config.defaultModel = stillResolves ? previousDefaultModel : undefined;
}
// Always restore `defaultThinking` from what was there before — including
// `undefined`. Persisting `false` when the user never set it would make
// `resolveThinkingLevel` (agent-core/src/agent/config/thinking.ts) treat
// it as an explicit "off" request and silently disable thinking, even
// for thinking-capable models.
config.defaultThinking = previousDefaultThinking;
// Always restore `[thinking]` from what was there before — including
// `undefined`. Persisting `enabled: false` when the user never set it would
// make `resolveThinkingEffort` (agent-core/src/agent/config/thinking.ts) treat
// it as an explicit "off" request and silently disable thinking, even for
// thinking-capable models.
config.thinking = previousThinking;
await harness.setConfig({
providers: config.providers,
models: config.models,
defaultModel: config.defaultModel,
defaultThinking: config.defaultThinking,
thinking: config.thinking,
});
const displayName = entry.name ?? providerId;

View file

@ -488,7 +488,14 @@ export async function installUpdate(
): Promise<void> {
const { cmd, args } = spawnForSource(source, version, platform);
await new Promise<void>((resolve, reject) => {
const child = spawn(cmd, [...args], { stdio: 'inherit' });
// Windows package managers (npm/pnpm/yarn) are .cmd shims. Since the
// CVE-2024-27980 fix, Node throws EINVAL when spawning a .cmd/.bat without
// a shell, so run through the shell on win32. The version is a validated
// semver and the package name is a constant, so args are shell-safe.
const child = spawn(cmd, [...args], {
stdio: 'inherit',
shell: platform === 'win32' ? true : undefined,
});
child.once('error', reject);
child.once('exit', (code, signal) => {
if (code === 0) {
@ -596,7 +603,15 @@ async function startBackgroundInstall(
});
};
const child = spawn(cmd, [...args], { detached: true, stdio: 'ignore' });
const child = spawn(cmd, [...args], {
detached: true,
stdio: 'ignore',
shell: platform === 'win32' ? true : undefined,
// On Windows a detached child gets its own console window; with shell:true
// that window would flash during a passive background update. Hide it so
// the silent updater stays silent.
windowsHide: platform === 'win32' ? true : undefined,
});
child.once('error', () => { finish(false); });
child.once('exit', (code) => { finish(code === 0); });
child.unref();

View file

@ -14,6 +14,26 @@ export const WEB_UI_MODE = 'web';
// Give telemetry a short flush window without making CLI exit feel stuck.
export const CLI_SHUTDOWN_TIMEOUT_MS = 3000;
// Upper bound on headless (`kimi -p`) shutdown. A wedged cleanup step (e.g. a
// SessionEnd hook, an MCP shutdown, or a connection blackholed by a restrictive
// firewall) must not keep a completed run alive indefinitely — once this elapses
// we stop waiting on cleanup and let the run return.
export const PROMPT_CLEANUP_TIMEOUT_MS = 8000;
// Grace after a headless run has fully completed (turn done, cleanup attempted)
// before force-exiting. `kimi -p` otherwise relies on the event loop draining to
// exit; a stray ref'd handle (socket/timer/child) left over from the run would
// wedge it. The guard timer is unref'd, so a healthy run still exits naturally
// well before this fires.
export const HEADLESS_FORCE_EXIT_GRACE_MS = 2000;
// Max time to wait for buffered stdout/stderr to flush before arming the
// force-exit fallback. A slow/piped consumer's still-draining stdio is a
// legitimate ref'd handle — flushing first prevents the fallback from
// truncating completed output. Bounded so a permanently-stuck consumer can't
// re-introduce the hang.
export const HEADLESS_STDIO_DRAIN_TIMEOUT_MS = 10000;
// Published npm package name; this can differ from the executable command.
export const NPM_PACKAGE_NAME = '@moonshot-ai/kimi-code';

View file

@ -23,6 +23,7 @@ import {
} from '@moonshot-ai/kimi-telemetry';
import { createProgram } from './cli/commands';
import { finalizeHeadlessRun } from './cli/headless-exit';
import type { CLIOptions } from './cli/options';
import { OptionConflictError, validateOptions } from './cli/options';
import { runPrompt } from './cli/run-prompt';
@ -38,7 +39,22 @@ import { cleanupStaleNativeCacheForCurrent } from './native/native-assets';
import { installNativeModuleHook } from './native/module-hook';
import { runNativeAssetSmokeIfRequested } from './native/smoke';
export async function handleMainCommand(opts: CLIOptions, version: string): Promise<void> {
/**
* Outcome of a CLI command run, reported back to the process entrypoint.
*
* `handleMainCommand` is a reusable, unit-tested handler it must not terminate
* the process itself. It reports here whether a headless (`kimi -p`) run
* completed so the entrypoint (the only place that owns the process) can arm the
* force-exit fallback.
*/
export interface MainCommandOutcome {
readonly headlessCompleted: boolean;
}
export async function handleMainCommand(
opts: CLIOptions,
version: string,
): Promise<MainCommandOutcome> {
let validated: ReturnType<typeof validateOptions>;
try {
validated = validateOptions(opts);
@ -60,10 +76,11 @@ export async function handleMainCommand(opts: CLIOptions, version: string): Prom
if (validated.uiMode === 'print') {
await runPrompt(validated.options, version);
return;
return { headlessCompleted: true };
}
await runShell(validated.options, version);
return { headlessCompleted: false };
}
/** `kimi migrate`: launch the migration screen only, then exit. */
@ -139,17 +156,32 @@ export function main(): void {
const program = createProgram(
version,
(opts) => {
void handleMainCommand(opts, version).catch(async (error: unknown) => {
const operation = opts.prompt !== undefined ? 'run prompt' : 'start shell';
await logStartupFailure(operation, error);
process.stderr.write(
formatStartupError(error, {
operation,
}),
);
process.stderr.write(`See log: ${resolveGlobalLogPath(resolveKimiHome())}\n`);
process.exit(1);
});
void handleMainCommand(opts, version)
.then(async (outcome) => {
// Only the process entrypoint disposes of the process. Print mode
// relies on the event loop draining to exit; flush any buffered output
// and then arm an unref'd fallback so a stray ref'd handle left over
// from the run can't wedge a completed `kimi -p` until an external
// timeout. A healthy run drains and exits before the fallback fires.
if (outcome.headlessCompleted) {
await finalizeHeadlessRun(
process,
[process.stdout, process.stderr],
() => Number(process.exitCode) || 0,
);
}
})
.catch(async (error: unknown) => {
const operation = opts.prompt !== undefined ? 'run prompt' : 'start shell';
await logStartupFailure(operation, error);
process.stderr.write(
formatStartupError(error, {
operation,
}),
);
process.stderr.write(`See log: ${resolveGlobalLogPath(resolveKimiHome())}\n`);
process.exit(1);
});
},
() => {
void handleMigrateCommand(version).catch(async (error: unknown) => {

View file

@ -11,7 +11,7 @@
* This file implements the ask, progress, and result phases. `beginMigration`
* drives the real runMigration flow (injectable for tests).
*/
import { Container, matchesKey, Key, truncateToWidth, type Focusable } from '@earendil-works/pi-tui';
import { Container, matchesKey, Key, truncateToWidth, type Focusable } from '@moonshot-ai/pi-tui';
import chalk from 'chalk';
import type { ColorPalette } from '#/tui/theme/colors';

View file

@ -1,6 +1,8 @@
import { existsSync } from 'node:fs';
import { createRequire } from 'node:module';
import { join } from 'node:path';
import { loadNativePackage } from './native-require';
import { getNativePackageRoot } from './native-assets';
type ModuleLoad = (request: string, parent: unknown, isMain: boolean) => unknown;
@ -10,7 +12,16 @@ interface ModuleWithLoad {
const nodeRequire = createRequire(import.meta.url);
let installed = false;
let loadingNativePackage = false;
// pi-tui loads its platform-specific native helpers via an absolute-path
// require() computed from import.meta.url / process.execPath
// (see pi-tui dist/terminal.js and dist/native-modifiers.js). In a SEA binary
// those .node files live in the native-asset cache, so redirect any absolute
// require of a pi-tui native helper to the cached copy.
//
// Path shape: native/<darwin|win32>/prebuilds/<arch>/<file>.node — note the
// two path segments after "prebuilds", so ".+" (not "[^/]+") is required.
const PI_TUI_NATIVE_PATTERN = /native[\\/](?:win32|darwin)[\\/]prebuilds[\\/].+\.node$/;
export function installNativeModuleHook(): void {
if (installed) return;
@ -26,13 +37,18 @@ export function installNativeModuleHook(): void {
parent: unknown,
isMain: boolean,
): unknown {
if (request === 'koffi' && !loadingNativePackage) {
loadingNativePackage = true;
try {
const pkg = loadNativePackage<unknown>('koffi');
if (pkg !== null) return pkg;
} finally {
loadingNativePackage = false;
if (
typeof request === 'string' &&
PI_TUI_NATIVE_PATTERN.test(request) &&
!existsSync(request)
) {
const pkgRoot = getNativePackageRoot('@moonshot-ai/pi-tui');
if (pkgRoot !== null) {
const match = request.match(PI_TUI_NATIVE_PATTERN);
if (match !== null) {
const redirected = join(pkgRoot, match[0]);
return originalLoad.call(this, redirected, parent, isMain);
}
}
}
return originalLoad.call(this, request, parent, isMain);

View file

@ -1,6 +1,38 @@
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { getEmbeddedNativeAssetManifest, getNativePackageRoot } from './native-assets';
const smokePackages = ['@mariozechner/clipboard', 'koffi'];
const smokePackages = ['@mariozechner/clipboard', '@moonshot-ai/pi-tui'];
// Verify pi-tui's native helper can actually be loaded through the module hook.
// pi-tui computes native helper paths from process.execPath and require()s them;
// those paths do not exist next to the SEA binary, so this only succeeds when
// installNativeModuleHook() redirects the require into the native-asset cache.
function smokePiTuiNativeLoad(): void {
const platform = process.platform;
const arch = process.arch;
let rel: string | undefined;
if (platform === 'darwin' && (arch === 'x64' || arch === 'arm64')) {
rel = join('native', 'darwin', 'prebuilds', `darwin-${arch}`, 'darwin-modifiers.node');
} else if (platform === 'win32' && (arch === 'x64' || arch === 'arm64')) {
rel = join('native', 'win32', 'prebuilds', `win32-${arch}`, 'win32-console-mode.node');
}
if (rel === undefined) return; // Linux: no native helper, nothing to load.
const req = createRequire(import.meta.url);
const bogusPath = join(dirname(process.execPath), rel);
const helper = req(bogusPath) as {
isModifierPressed?: unknown;
enableVirtualTerminalInput?: unknown;
};
const ok =
typeof helper.isModifierPressed === 'function' ||
typeof helper.enableVirtualTerminalInput === 'function';
if (!ok) {
throw new Error(`pi-tui native helper loaded but exports are unexpected: ${rel}`);
}
}
export function runNativeAssetSmokeIfRequested(): boolean {
if (process.env['KIMI_CODE_NATIVE_ASSET_SMOKE'] !== '1') return false;
@ -16,6 +48,7 @@ export function runNativeAssetSmokeIfRequested(): boolean {
throw new Error(`Native package is not available: ${packageName}`);
}
}
smokePiTuiNativeLoad();
process.stdout.write(`Native asset smoke passed: ${manifest.target}\n`);
process.exit(0);
} catch (error) {

View file

@ -159,7 +159,11 @@ async function handleOpenPlatformLogin(
platform,
models,
selectedModel: selection.model,
thinking: selection.thinking,
thinking: selection.thinking !== 'off',
effort:
selection.thinking !== 'off' && selection.thinking !== 'on'
? selection.thinking
: undefined,
apiKey,
});
@ -167,7 +171,7 @@ async function handleOpenPlatformLogin(
providers: config.providers,
models: config.models,
defaultModel: config.defaultModel,
defaultThinking: config.defaultThinking,
thinking: config.thinking,
});
await host.authFlow.refreshConfigAfterLogin();

View file

@ -1,4 +1,4 @@
import type { AutocompleteItem } from '@earendil-works/pi-tui';
import type { AutocompleteItem } from '@moonshot-ai/pi-tui';
/**
* A completable token (subcommand or flag) for a slash command's argument

View file

@ -1,25 +1,30 @@
import type {
ExperimentalFeatureState,
FlagId,
PermissionMode,
Session,
import {
effectiveModelAlias,
type ExperimentalFeatureState,
type ModelAlias,
type PermissionMode,
type Session,
type ThinkingEffort,
} from '@moonshot-ai/kimi-code-sdk';
import { EditorSelectorComponent } from '../components/dialogs/editor-selector';
import { EffortSelectorComponent } from '../components/dialogs/effort-selector';
import {
ExperimentsSelectorComponent,
type ExperimentalFeatureDraftChange,
} from '../components/dialogs/experiments-selector';
import { modelDisplayName, segmentsFor } from '../components/dialogs/model-selector';
import { TabbedModelSelectorComponent } from '../components/dialogs/tabbed-model-selector';
import { PermissionSelectorComponent } from '../components/dialogs/permission-selector';
import { SettingsSelectorComponent, type SettingsSelection } from '../components/dialogs/settings-selector';
import { ThemeSelectorComponent } from '../components/dialogs/theme-selector';
import { UpdatePreferenceSelectorComponent } from '../components/dialogs/update-preference-selector';
import { saveTuiConfig } from '../config';
import { DEFAULT_TUI_CONFIG, saveTuiConfig, type TuiConfig } from '../config';
import type { ThemeName } from '#/tui/theme';
import { currentTheme, isBuiltInTheme, lightColors, loadCustomThemeMerged } from '#/tui/theme';
import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui';
import { formatErrorMessage } from '../utils/event-payload';
import { thinkingEffortToConfig } from '../utils/thinking-config';
import { showUsage } from './info';
import { setExperimentalFeatures } from './experimental-flags';
import type { SlashCommandHost } from './dispatch';
@ -30,6 +35,16 @@ import type { SlashCommandHost } from './dispatch';
const MODEL_PICKER_REFRESH_TIMEOUT_MS = 2_000;
function currentTuiConfig(host: SlashCommandHost): TuiConfig {
return {
theme: host.state.appState.theme,
editorCommand: host.state.appState.editorCommand,
disablePasteBurst: host.state.appState.disablePasteBurst ?? DEFAULT_TUI_CONFIG.disablePasteBurst,
notifications: host.state.appState.notifications,
upgrade: host.state.appState.upgrade,
};
}
export async function handlePlanCommand(host: SlashCommandHost, args: string): Promise<void> {
const session = host.session;
if (session === undefined) {
@ -212,6 +227,56 @@ export async function handleModelCommand(host: SlashCommandHost, args: string):
showModelPicker(host, alias);
}
export async function handleEffortCommand(host: SlashCommandHost, args: string): Promise<void> {
const alias = host.state.appState.model;
const model = host.state.appState.availableModels[alias];
if (model === undefined) {
host.showError('No model selected. Run /model to select one first.');
return;
}
const effective = effectiveModelAlias(model);
const segments = segmentsFor(effective);
const arg = args.trim().toLowerCase();
if (arg.length === 0) {
showEffortPicker(host, effective, segments);
return;
}
if (!segments.includes(arg)) {
host.showError(
`Unsupported thinking effort "${arg}" for ${alias}. Available: ${segments.join(', ')}`,
);
return;
}
await performModelSwitch(host, alias, arg, true);
}
function showEffortPicker(
host: SlashCommandHost,
model: ModelAlias,
segments: readonly string[],
): void {
const liveEffort = host.state.appState.thinkingEffort;
const currentValue = segments.includes(liveEffort) ? liveEffort : (segments[0] ?? 'off');
const alias = host.state.appState.model;
host.mountEditorReplacement(
new EffortSelectorComponent({
efforts: segments,
currentValue,
onSelect: (effort) => {
host.restoreEditor();
void performModelSwitch(host, alias, effort, true);
},
onSessionOnlySelect: (effort) => {
host.restoreEditor();
void performModelSwitch(host, alias, effort, false);
},
onCancel: () => {
host.restoreEditor();
},
}),
);
}
// ---------------------------------------------------------------------------
// Pickers & config apply
// ---------------------------------------------------------------------------
@ -273,10 +338,8 @@ async function applyEditorChoice(host: SlashCommandHost, value: string): Promise
const editorCommand = value.length > 0 ? value : null;
try {
await saveTuiConfig({
theme: host.state.appState.theme,
...currentTuiConfig(host),
editorCommand,
notifications: host.state.appState.notifications,
upgrade: host.state.appState.upgrade,
});
} catch (error) {
host.showStatus(
@ -308,7 +371,7 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string =
models: host.state.appState.availableModels,
currentValue: host.state.appState.model,
selectedValue,
currentThinking: host.state.appState.thinking,
currentThinkingEffort: host.state.appState.thinkingEffort,
onSelect: ({ alias, thinking }) => {
host.restoreEditor();
void performModelSwitch(host, alias, thinking, true);
@ -327,7 +390,7 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string =
async function performModelSwitch(
host: SlashCommandHost,
alias: string,
thinking: boolean,
effort: ThinkingEffort,
persist: boolean,
): Promise<void> {
if (host.state.appState.streamingPhase !== 'idle') {
@ -335,21 +398,23 @@ async function performModelSwitch(
return;
}
const level = thinking ? 'on' : 'off';
const prevModel = host.state.appState.model;
const prevThinking = host.state.appState.thinking;
const runtimeChanged = alias !== prevModel || thinking !== prevThinking;
const prevEffort = host.state.appState.thinkingEffort;
const modelChanged = alias !== prevModel;
const effortChanged = effort !== prevEffort;
const runtimeChanged = modelChanged || effortChanged;
const displayName = modelDisplayName(alias, host.state.appState.availableModels[alias]);
const session = host.session;
try {
if (session === undefined && runtimeChanged) {
await host.authFlow.activateModelAfterLogin(alias, thinking);
await host.authFlow.activateModelAfterLogin(alias, effort);
} else if (session !== undefined) {
if (alias !== prevModel) {
await session.setModel(alias);
}
if (thinking !== prevThinking) {
await session.setThinking(level);
if (effort !== prevEffort) {
await session.setThinking(effort);
}
}
} catch (error) {
@ -358,48 +423,65 @@ async function performModelSwitch(
return;
}
host.setAppState({ model: alias, thinking });
host.setAppState({ model: alias, thinkingEffort: effort });
if (session === undefined && runtimeChanged) {
if (alias !== prevModel) {
host.track('model_switch', { model: alias });
}
if (thinking !== prevThinking) {
host.track('thinking_toggle', { enabled: thinking });
if (effort !== prevEffort) {
host.track('thinking_toggle', {
enabled: effort !== 'off',
effort,
from: prevEffort,
});
}
}
let persisted = false;
if (persist) {
try {
persisted = await persistModelSelection(host, alias, thinking);
persisted = await persistModelSelection(host, alias, effort);
} catch (error) {
const msg = formatErrorMessage(error);
host.showError(`Switched to ${alias}, but failed to save default: ${msg}`);
host.showError(`Switched to ${displayName}, but failed to save default: ${msg}`);
return;
}
}
let status: string;
if (runtimeChanged) {
if (modelChanged) {
status = persist
? `Switched to ${alias} with thinking ${level}.`
: `Switched to ${alias} with thinking ${level} for this session only.`;
? `Switched to ${displayName} with thinking ${effort}.`
: `Switched to ${displayName} with thinking ${effort} for this session only.`;
} else if (effortChanged) {
status = persist
? `Thinking set to ${effort}.`
: `Thinking set to ${effort} for this session only.`;
} else if (persist && persisted) {
status = `Saved ${alias} with thinking ${level} as default.`;
status = `Saved ${displayName} with thinking ${effort} as default.`;
} else {
status = `Already using ${alias} with thinking ${level}.`;
status = `Already using ${displayName} with thinking ${effort}.`;
}
host.showStatus(status, 'success');
}
async function persistModelSelection(host: SlashCommandHost, alias: string, thinking: boolean): Promise<boolean> {
async function persistModelSelection(
host: SlashCommandHost,
alias: string,
effort: ThinkingEffort,
): Promise<boolean> {
const config = await host.harness.getConfig({ reload: true });
if (config.defaultModel === alias && config.defaultThinking === thinking) {
const patch = thinkingEffortToConfig(effort);
if (
config.defaultModel === alias &&
config.thinking?.enabled === patch.enabled &&
config.thinking?.effort === patch.effort
) {
return false;
}
await host.harness.setConfig({
defaultModel: alias,
defaultThinking: thinking,
thinking: patch,
});
return true;
}
@ -439,10 +521,8 @@ async function applyThemeChoice(host: SlashCommandHost, theme: ThemeName): Promi
try {
await saveTuiConfig({
...currentTuiConfig(host),
theme,
editorCommand: host.state.appState.editorCommand,
notifications: host.state.appState.notifications,
upgrade: host.state.appState.upgrade,
});
} catch (error) {
host.showStatus(
@ -515,7 +595,7 @@ export async function applyExperimentalFeatureChanges(
return;
}
const experimental: Partial<Record<FlagId, boolean>> = {};
const experimental: Record<string, boolean> = {};
for (const change of changes) {
experimental[change.id] = change.enabled;
}
@ -582,9 +662,7 @@ export async function applyUpdatePreferenceChoice(
const upgrade = { autoInstall };
try {
await saveTuiConfig({
theme: host.state.appState.theme,
editorCommand: host.state.appState.editorCommand,
notifications: host.state.appState.notifications,
...currentTuiConfig(host as unknown as SlashCommandHost),
upgrade,
});
} catch (error) {

View file

@ -1,4 +1,4 @@
import type { Component, Focusable } from '@earendil-works/pi-tui';
import type { Component, Focusable } from '@moonshot-ai/pi-tui';
import type { DeviceAuthorization } from '@moonshot-ai/kimi-code-oauth';
import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk';
@ -25,6 +25,7 @@ import {
handleAutoCommand,
handleCompactCommand,
handleEditorCommand,
handleEffortCommand,
handleModelCommand,
handlePlanCommand,
handleThemeCommand,
@ -65,6 +66,7 @@ export {
handleAutoCommand,
handleCompactCommand,
handleEditorCommand,
handleEffortCommand,
handleModelCommand,
handlePlanCommand,
handleThemeCommand,
@ -292,6 +294,9 @@ async function handleBuiltInSlashCommand(
case 'model':
await handleModelCommand(host, args);
return;
case 'effort':
await handleEffortCommand(host, args);
return;
case 'provider':
await handleProviderCommand(host);
return;

View file

@ -9,6 +9,7 @@ import {
FEEDBACK_ISSUE_URL,
FEEDBACK_STATUS_CANCELLED,
FEEDBACK_STATUS_FALLBACK,
FEEDBACK_STATUS_NETWORK_ERROR,
FEEDBACK_STATUS_NOT_SIGNED_IN,
FEEDBACK_STATUS_SUBMITTING,
FEEDBACK_STATUS_SUCCESS,
@ -58,29 +59,43 @@ export async function handleFeedbackCommand(host: SlashCommandHost): Promise<voi
const version = withFeedbackVersionPrefix(host.state.appState.version);
const spinner = host.showLoginProgressSpinner(FEEDBACK_STATUS_SUBMITTING);
const res = await host.harness.auth.submitFeedback({
content: input.value,
sessionId: host.state.appState.sessionId,
version,
os: `${osType()} ${osRelease()}`,
model: host.state.appState.model.length > 0 ? host.state.appState.model : null,
});
// Guarantee the spinner's underlying setInterval is always cleared, even when
// submitFeedback or submitFeedbackWithAttachments throws — otherwise the
// interval (and its per-frame requestRender) leaks for the rest of the session.
let stopped = false;
const stopSpinner = (opts: { ok: boolean; label: string }): void => {
if (stopped) return;
stopped = true;
spinner.stop(opts);
};
try {
const res = await host.harness.auth.submitFeedback({
content: input.value,
sessionId: host.state.appState.sessionId,
version,
os: `${osType()} ${osRelease()}`,
model: host.state.appState.model.length > 0 ? host.state.appState.model : null,
});
if (res.kind !== 'ok') {
spinner.stop({ ok: false, label: res.message });
fallback(FEEDBACK_STATUS_FALLBACK);
return;
}
if (res.kind !== 'ok') {
stopSpinner({ ok: false, label: res.message });
fallback(FEEDBACK_STATUS_FALLBACK);
return;
}
// Stage 3: prepare and upload each requested attachment independently.
const attachmentFailed = await submitFeedbackWithAttachments(host, res.feedbackId, level);
// Stage 3: prepare and upload each requested attachment independently.
const attachmentFailed = await submitFeedbackWithAttachments(host, res.feedbackId, level);
spinner.stop({ ok: true, label: FEEDBACK_STATUS_SUCCESS });
host.showStatus(feedbackSessionLine(host.state.appState.sessionId));
host.showStatus(feedbackIdLine(res.feedbackId));
host.track(FEEDBACK_TELEMETRY_EVENT);
if (attachmentFailed) {
host.showStatus(FEEDBACK_STATUS_UPLOAD_FAILED);
stopSpinner({ ok: true, label: FEEDBACK_STATUS_SUCCESS });
host.showStatus(feedbackSessionLine(host.state.appState.sessionId));
host.showStatus(feedbackIdLine(res.feedbackId));
host.track(FEEDBACK_TELEMETRY_EVENT);
if (attachmentFailed) {
host.showStatus(FEEDBACK_STATUS_UPLOAD_FAILED);
}
} catch (error) {
stopSpinner({ ok: false, label: FEEDBACK_STATUS_NETWORK_ERROR });
throw error;
}
}
@ -132,7 +147,7 @@ export async function showStatusReport(host: SlashCommandHost): Promise<void> {
workDir: appState.workDir,
sessionId: appState.sessionId,
sessionTitle: appState.sessionTitle,
thinking: appState.thinking,
thinkingEffort: appState.thinkingEffort,
permissionMode: appState.permissionMode,
planMode: appState.planMode,
contextUsage: appState.contextUsage,

View file

@ -4,6 +4,7 @@ import {
type Catalog,
type CatalogModel,
type ModelAlias,
type ThinkingEffort,
} from '@moonshot-ai/kimi-code-sdk';
import { capabilitiesForModel } from '@moonshot-ai/kimi-code-oauth';
import type {
@ -166,7 +167,7 @@ export async function promptModelSelectionForOpenPlatform(
host: SlashCommandHost,
models: ManagedKimiCodeModelInfo[],
platform: OpenPlatformDefinition,
): Promise<{ model: ManagedKimiCodeModelInfo; thinking: boolean } | undefined> {
): Promise<{ model: ManagedKimiCodeModelInfo; thinking: ThinkingEffort } | undefined> {
const modelDict: Record<string, ModelAlias> = {};
for (const m of models) {
modelDict[`${platform.id}/${m.id}`] = {
@ -187,7 +188,7 @@ export async function promptModelSelectionForCatalog(
host: SlashCommandHost,
providerId: string,
models: CatalogModel[],
): Promise<{ model: CatalogModel; thinking: boolean } | undefined> {
): Promise<{ model: CatalogModel; thinking: ThinkingEffort } | undefined> {
const modelDict: Record<string, ModelAlias> = {};
for (const m of models) {
modelDict[`${providerId}/${m.id}`] = catalogModelToAlias(providerId, m);
@ -201,7 +202,7 @@ export async function promptModelSelectionForCatalog(
export function runModelSelector(
host: SlashCommandHost,
modelDict: Record<string, ModelAlias>,
): Promise<{ alias: string; thinking: boolean } | undefined> {
): Promise<{ alias: string; thinking: ThinkingEffort } | undefined> {
return new Promise((resolve) => {
const firstAlias = Object.keys(modelDict)[0] ?? '';
const caps = modelDict[firstAlias]?.capabilities ?? [];
@ -209,7 +210,7 @@ export function runModelSelector(
const selector = new ModelSelectorComponent({
models: modelDict,
currentValue: firstAlias,
currentThinking: initialThinking,
currentThinkingEffort: initialThinking ? 'on' : 'off',
searchable: true,
onSelect: ({ alias, thinking }) => {
host.restoreEditor();

View file

@ -13,6 +13,7 @@ import {
fetchCatalog,
inferWireType,
type Catalog,
type ThinkingEffort,
} from '@moonshot-ai/kimi-code-sdk';
import { ChoicePickerComponent } from '../components/dialogs/choice-picker';
@ -27,6 +28,7 @@ import {
import { TabbedModelSelectorComponent } from '../components/dialogs/tabbed-model-selector';
import { DEFAULT_OAUTH_PROVIDER_NAME } from '../constant/kimi-tui';
import { formatErrorMessage } from '../utils/event-payload';
import { thinkingEffortToConfig } from '../utils/thinking-config';
import {
promptApiKey,
promptCatalogProviderSelection,
@ -233,7 +235,7 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise<void> {
models: mergedModels,
currentValue: host.state.appState.model,
selectedValue: Object.keys(mergedModels).find((a) => a.startsWith(`${providerId}/`)),
currentThinking: host.state.appState.thinking,
currentThinkingEffort: host.state.appState.thinkingEffort,
initialTabId: providerId,
onSelect: ({ alias, thinking }) => {
host.restoreEditor();
@ -251,15 +253,15 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise<void> {
async function setDefaultModel(
host: SlashCommandHost,
alias: string,
thinking: boolean,
effort: ThinkingEffort,
): Promise<void> {
await host.harness.setConfig({
defaultModel: alias,
defaultThinking: thinking,
thinking: thinkingEffortToConfig(effort),
});
await host.authFlow.refreshConfigAfterLogin();
host.track('model_switch', { model: alias });
host.showStatus(`Default model set to ${alias} with thinking ${thinking ? 'on' : 'off'}.`);
host.showStatus(`Default model set to ${alias} with thinking ${effort}.`);
}
async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise<boolean> {
@ -323,7 +325,7 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise
models: stateModels,
currentValue: host.state.appState.model,
selectedValue: firstNewAlias,
currentThinking: host.state.appState.thinking,
currentThinkingEffort: host.state.appState.thinkingEffort,
initialTabId: firstNewProvider,
onSelect: ({ alias, thinking }) => {
host.restoreEditor();

View file

@ -2,7 +2,7 @@ import { readdirSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import { basename, dirname, join, relative, resolve } from 'pathe';
import type { AutocompleteItem } from '@earendil-works/pi-tui';
import type { AutocompleteItem } from '@moonshot-ai/pi-tui';
import { completeLeadingArg, type ArgCompletionSpec } from './complete-args';
import type { KimiSlashCommand, SlashCommandAvailability } from './types';
@ -184,6 +184,13 @@ export const BUILTIN_SLASH_COMMANDS = [
priority: 100,
availability: 'always',
},
{
name: 'effort',
aliases: ['thinking'],
description: 'Switch thinking effort',
priority: 95,
availability: 'always',
},
{
name: 'provider',
aliases: ['providers'],

View file

@ -45,9 +45,11 @@ export async function applyReloadedTuiConfig(
host.refreshTerminalThemeTracking();
host.setAppState({
editorCommand: config.editorCommand,
disablePasteBurst: config.disablePasteBurst,
notifications: config.notifications,
upgrade: config.upgrade,
});
host.state.editor.setDisablePasteBurst(config.disablePasteBurst);
}
function applyRuntimeConfig(host: SlashCommandHost, config: KimiConfig): void {

View file

@ -1,4 +1,4 @@
import type { AutocompleteItem, SlashCommand } from '@earendil-works/pi-tui';
import type { AutocompleteItem, SlashCommand } from '@moonshot-ai/pi-tui';
import type { FlagId } from '@moonshot-ai/kimi-code-sdk';
export type SlashCommandAvailability = 'always' | 'idle-only';

View file

@ -1,4 +1,4 @@
import type { Component } from '@earendil-works/pi-tui';
import type { Component } from '@moonshot-ai/pi-tui';
import type { ContextMessage } from '@moonshot-ai/kimi-code-sdk';
import { isKimiError } from '@moonshot-ai/kimi-code-sdk';

View file

@ -1,5 +1,5 @@
import type { Component } from '@earendil-works/pi-tui';
import { visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui';
import type { Component } from '@moonshot-ai/pi-tui';
import { visibleWidth, wrapTextWithAnsi } from '@moonshot-ai/pi-tui';
import { currentTheme } from '#/tui/theme';
import type { BannerState } from '#/tui/types';

View file

@ -6,8 +6,8 @@
* active palette so theme switches take effect on the next render.
*/
import type { Component } from '@earendil-works/pi-tui';
import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
import type { Component } from '@moonshot-ai/pi-tui';
import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui';
import { currentTheme } from '#/tui/theme';

View file

@ -6,9 +6,10 @@
* Line 2: context: XX.X% (tokens/max)
*/
import type { Component } from '@earendil-works/pi-tui';
import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
import type { Component } from '@moonshot-ai/pi-tui';
import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui';
import chalk from 'chalk';
import { effectiveModelAlias } from '@moonshot-ai/kimi-code-sdk';
import { ALL_TIPS, type ToolbarTip } from '#/tui/constant/tips';
import { isRainbowDancing, renderDanceFooterModel } from '#/tui/easter-eggs/dance';
@ -132,7 +133,8 @@ function formatBadgeElapsed(ms: number): string {
function modelDisplayName(state: AppState): string {
const model = state.availableModels[state.model];
return model?.displayName ?? model?.model ?? state.model;
const effective = model === undefined ? undefined : effectiveModelAlias(model);
return effective?.displayName ?? effective?.model ?? state.model;
}
function shortenCwd(path: string): string {
@ -262,7 +264,18 @@ export class FooterComponent implements Component {
const model = modelDisplayName(state);
if (model) {
const thinkingLabel = state.thinking ? ' thinking' : '';
const effort = state.thinkingEffort;
const rawCurrentModel = state.availableModels[state.model];
const currentModel = rawCurrentModel === undefined ? undefined : effectiveModelAlias(rawCurrentModel);
// Only effort-capable models (those declaring support_efforts) show the
// concrete effort; legacy boolean models keep the plain "thinking" suffix.
const hasEfforts = (currentModel?.supportEfforts?.length ?? 0) > 0;
const thinkingLabel =
effort !== 'off'
? hasEfforts && effort !== 'on'
? ` thinking: ${effort}`
: ' thinking'
: '';
const modelLabel = `${model}${thinkingLabel}`;
let renderedModelLabel = chalk.hex(colors.text)(modelLabel);
if (isRainbowDancing()) {
@ -370,6 +383,13 @@ export class FooterComponent implements Component {
}
}
dispose(): void {
if (this.goalTimer !== null) {
clearInterval(this.goalTimer);
this.goalTimer = null;
}
}
private goalWallClockMs(goal: AppState['goal']): number | undefined {
if (goal === null || goal === undefined) return undefined;
if (goal.status !== 'active') return goal.wallClockMs;

View file

@ -9,8 +9,8 @@
* the edge and adding them would just churn the diff renderer.
*/
import { Container } from '@earendil-works/pi-tui';
import type { Component } from '@earendil-works/pi-tui';
import { Container } from '@moonshot-ai/pi-tui';
import type { Component } from '@moonshot-ai/pi-tui';
import { isRenderCacheEnabled } from '#/tui/utils/render-cache';

View file

@ -1,5 +1,5 @@
import { Text, visibleWidth } from '@earendil-works/pi-tui';
import type { TUI } from '@earendil-works/pi-tui';
import { Text, visibleWidth } from '@moonshot-ai/pi-tui';
import type { TUI } from '@moonshot-ai/pi-tui';
import {
BRAILLE_SPINNER_FRAMES,
@ -59,6 +59,10 @@ export class MoonLoader extends Text {
}
}
dispose(): void {
this.stop();
}
setLabel(label: string): void {
this.label = label;
this.updateDisplay();

View file

@ -9,8 +9,8 @@
* is issued.
*/
import type { Component } from '@earendil-works/pi-tui';
import { truncateToWidth } from '@earendil-works/pi-tui';
import type { Component } from '@moonshot-ai/pi-tui';
import { truncateToWidth } from '@moonshot-ai/pi-tui';
import chalk from 'chalk';
import { currentTheme } from '#/tui/theme';

View file

@ -3,10 +3,12 @@
* Renders a round-bordered box with the logo, session, model, and version.
*/
import type { Component } from '@earendil-works/pi-tui';
import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
import type { Component } from '@moonshot-ai/pi-tui';
import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui';
import chalk from 'chalk';
import { effectiveModelAlias } from '@moonshot-ai/kimi-code-sdk';
import { isRainbowDancing, renderDanceWelcomeHeader } from '#/tui/easter-eggs/dance';
import type { AppState } from '#/tui/types';
import { currentTheme } from '#/tui/theme';
@ -25,6 +27,7 @@ export class WelcomeComponent implements Component {
const primary = (s: string): string => chalk.hex(currentTheme.palette.primary)(s);
const isLoggedOut = !this.state.model;
const activeModel = this.state.availableModels[this.state.model];
const effectiveActiveModel = activeModel === undefined ? undefined : effectiveModelAlias(activeModel);
if (safeWidth < 24) {
const title = chalk.bold.hex(currentTheme.palette.primary)('Welcome to Kimi Code!');
@ -33,7 +36,7 @@ export class WelcomeComponent implements Component {
: chalk.hex(currentTheme.palette.textDim)('Send /help for help information.');
const model = isLoggedOut
? chalk.hex(currentTheme.palette.warning)('not set, run /login or /provider')
: (activeModel?.displayName ?? activeModel?.model ?? this.state.model);
: (effectiveActiveModel?.displayName ?? effectiveActiveModel?.model ?? this.state.model);
return ['', title, prompt, `Model: ${model}`].map((line) =>
truncateToWidth(line, safeWidth, '…'),
);
@ -71,7 +74,7 @@ export class WelcomeComponent implements Component {
const modelValue = isLoggedOut
? chalk.hex(currentTheme.palette.warning)('not set, run /login or /provider')
: (activeModel?.displayName ?? activeModel?.model ?? this.state.model);
: (effectiveActiveModel?.displayName ?? effectiveActiveModel?.model ?? this.state.model);
const infoLines = [
labelStyle('Directory: ') + this.state.workDir,

View file

@ -6,7 +6,7 @@ import {
truncateToWidth,
visibleWidth,
type Focusable,
} from '@earendil-works/pi-tui';
} from '@moonshot-ai/pi-tui';
import { currentTheme } from '#/tui/theme';

View file

@ -14,7 +14,7 @@ import {
truncateToWidth,
visibleWidth,
wrapTextWithAnsi,
} from '@earendil-works/pi-tui';
} from '@moonshot-ai/pi-tui';
import { currentTheme } from '#/tui/theme';
import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight';
import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview';

View file

@ -24,7 +24,7 @@ import {
truncateToWidth,
visibleWidth,
type Focusable,
} from '@earendil-works/pi-tui';
} from '@moonshot-ai/pi-tui';
import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight';
import { renderDiffLines } from '#/tui/components/media/diff-preview';

View file

@ -15,7 +15,7 @@ import {
truncateToWidth,
visibleWidth,
type Focusable,
} from '@earendil-works/pi-tui';
} from '@moonshot-ai/pi-tui';
import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols';
import { currentTheme, type ColorToken } from '#/tui/theme';
import { printableChar } from '#/tui/utils/printable-key';
@ -49,6 +49,9 @@ export interface ChoicePickerOptions {
/** Items per page. Lists longer than this paginate. */
readonly pageSize?: number;
readonly onSelect: (value: string) => void;
/** When provided, Alt+S invokes this with the selected value instead of
* onSelect used to apply the choice to the current session only. */
readonly onSessionOnlySelect?: (value: string) => void;
readonly onCancel: () => void;
}
@ -99,6 +102,11 @@ export class ChoicePickerComponent extends Container implements Focusable {
this.opts.onCancel();
return;
}
if (matchesKey(data, Key.alt('s')) && this.opts.onSessionOnlySelect !== undefined) {
const chosen = this.list.selected();
if (chosen !== undefined) this.opts.onSessionOnlySelect(chosen.value);
return;
}
// Left/Right page through the list (this picker has no horizontal control).
if (matchesKey(data, Key.left)) {
this.list.pageUp();

View file

@ -13,8 +13,8 @@
* reads the same "work in progress" signal across the UI.
*/
import { Container, Text, Spacer } from '@earendil-works/pi-tui';
import type { TUI } from '@earendil-works/pi-tui';
import { Container, Text, Spacer } from '@moonshot-ai/pi-tui';
import type { TUI } from '@moonshot-ai/pi-tui';
import { STATUS_BULLET } from '#/tui/constant/symbols';
import { currentTheme } from '#/tui/theme';

View file

@ -17,7 +17,7 @@ import {
truncateToWidth,
visibleWidth,
type Focusable,
} from '@earendil-works/pi-tui';
} from '@moonshot-ai/pi-tui';
import { currentTheme } from '#/tui/theme';

View file

@ -0,0 +1,94 @@
import {
Container,
Key,
matchesKey,
truncateToWidth,
type Focusable,
} from '@moonshot-ai/pi-tui';
import type { ThinkingEffort } from '@moonshot-ai/kimi-code-sdk';
import { currentTheme } from '#/tui/theme';
import { effortLabel } from './model-selector';
export interface EffortSelectorOptions {
readonly title?: string;
/** Selectable thinking efforts for the current model (e.g. ["off","low","high","max"]). */
readonly efforts: readonly ThinkingEffort[];
/** Currently active effort (highlighted). */
readonly currentValue: ThinkingEffort;
readonly onSelect: (effort: ThinkingEffort) => void;
/** When provided, Alt+S applies the choice to the current session only. */
readonly onSessionOnlySelect?: (effort: ThinkingEffort) => void;
readonly onCancel: () => void;
}
/**
* Horizontal segmented picker for the `/effort` command.
*
* Mirrors the thinking control rendered under `/model` (see
* `renderThinkingControl` in model-selector.ts): a single row of segments,
* the active one wrapped in `[ ]`. / step the active segment, Enter
* commits, and Alt+S (when provided) applies session-only.
*/
export class EffortSelectorComponent extends Container implements Focusable {
focused = false;
private readonly opts: EffortSelectorOptions;
private activeIndex: number;
constructor(opts: EffortSelectorOptions) {
super();
this.opts = opts;
const idx = opts.efforts.indexOf(opts.currentValue);
this.activeIndex = Math.max(idx, 0);
}
handleInput(data: string): void {
if (matchesKey(data, Key.escape)) {
this.opts.onCancel();
return;
}
if (matchesKey(data, Key.left)) {
this.activeIndex = Math.max(0, this.activeIndex - 1);
return;
}
if (matchesKey(data, Key.right)) {
this.activeIndex = Math.min(this.opts.efforts.length - 1, this.activeIndex + 1);
return;
}
if (matchesKey(data, Key.alt('s')) && this.opts.onSessionOnlySelect !== undefined) {
this.opts.onSessionOnlySelect(this.opts.efforts[this.activeIndex]!);
return;
}
if (matchesKey(data, Key.enter)) {
this.opts.onSelect(this.opts.efforts[this.activeIndex]!);
return;
}
}
override render(width: number): string[] {
const hintParts = ['←→ switch', 'Enter select'];
if (this.opts.onSessionOnlySelect !== undefined) hintParts.push('Alt+S session-only');
hintParts.push('Esc cancel');
const lines: string[] = [
currentTheme.fg('primary', '─'.repeat(width)),
currentTheme.boldFg('primary', ` ${this.opts.title ?? 'Select thinking effort'}`),
currentTheme.fg('textMuted', ` ${hintParts.join(' · ')}`),
'',
];
const segments = this.opts.efforts.map((effort, index) => {
const label = effortLabel(effort);
return index === this.activeIndex
? currentTheme.boldFg('primary', `[ ${label} ]`)
: currentTheme.fg('text', ` ${label} `);
});
lines.push(` ${segments.join(' ')}`);
lines.push('');
lines.push(currentTheme.fg('primary', '─'.repeat(width)));
return lines.map((line) => truncateToWidth(line, width));
}
}

View file

@ -5,7 +5,7 @@ import {
truncateToWidth,
visibleWidth,
type Focusable,
} from '@earendil-works/pi-tui';
} from '@moonshot-ai/pi-tui';
import type { ExperimentalFeatureState } from '@moonshot-ai/kimi-code-sdk';
import { SELECT_POINTER } from '#/tui/constant/symbols';

View file

@ -19,7 +19,7 @@ import {
truncateToWidth,
visibleWidth,
type Focusable,
} from '@earendil-works/pi-tui';
} from '@moonshot-ai/pi-tui';
import { currentTheme } from '#/tui/theme';
export type FeedbackInputDialogResult =

View file

@ -6,7 +6,7 @@ import {
truncateToWidth,
visibleWidth,
type Focusable,
} from '@earendil-works/pi-tui';
} from '@moonshot-ai/pi-tui';
import chalk from 'chalk';
import { SELECT_POINTER } from '#/tui/constant/symbols';

View file

@ -15,7 +15,7 @@ import {
decodeKittyPrintable,
type Focusable,
truncateToWidth,
} from '@earendil-works/pi-tui';
} from '@moonshot-ai/pi-tui';
import { currentTheme } from '#/tui/theme';
export interface KeyboardShortcut {

View file

@ -1,4 +1,4 @@
import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk';
import { effectiveModelAlias, type ModelAlias, type ThinkingEffort } from '@moonshot-ai/kimi-code-sdk';
import {
Container,
Key,
@ -6,7 +6,7 @@ import {
truncateToWidth,
visibleWidth,
type Focusable,
} from '@earendil-works/pi-tui';
} from '@moonshot-ai/pi-tui';
import { DEFAULT_OAUTH_PROVIDER_NAME, PRODUCT_NAME } from '#/constant/app';
import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols';
@ -30,11 +30,15 @@ interface ModelChoice {
export interface ModelSelection {
readonly alias: string;
readonly thinking: boolean;
/** Chosen thinking effort: 'off', or a concrete effort such as 'low' /
* 'high' / 'max'. Boolean 'on' is normalized to the model's default effort
* before the selection is committed (see commitEffort). */
readonly thinking: ThinkingEffort;
}
export function modelDisplayName(alias: string, model: ModelAlias | undefined): string {
return model?.displayName ?? model?.model ?? alias;
const effective = model === undefined ? undefined : effectiveModelAlias(model);
return effective?.displayName ?? effective?.model ?? alias;
}
export function providerDisplayName(provider: string): string {
@ -46,17 +50,22 @@ export function providerDisplayName(provider: string): string {
export function createModelChoiceOptions(
models: Record<string, ModelAlias>,
): readonly ChoiceOption[] {
return Object.entries(models).map(([alias, cfg]) => ({
value: alias,
label: `${modelDisplayName(alias, cfg)} (${providerDisplayName(cfg.provider)})`,
}));
return Object.entries(models).map(([alias, cfg]) => {
const effective = effectiveModelAlias(cfg);
return {
value: alias,
label: `${modelDisplayName(alias, effective)} (${providerDisplayName(effective.provider)})`,
};
});
}
export interface ModelSelectorOptions {
readonly models: Record<string, ModelAlias>;
readonly currentValue: string;
readonly selectedValue?: string;
readonly currentThinking: boolean;
/** Live thinking effort of the currently active model (e.g. 'off', 'on',
* 'high'). Used to highlight the active segment for the current model. */
readonly currentThinkingEffort: ThinkingEffort;
/** When true, typed characters filter the list (fuzzy) and a search line is shown. */
readonly searchable?: boolean;
/** Items per page. Lists longer than this paginate (PgUp/PgDn). */
@ -73,24 +82,69 @@ export interface ModelSelectorOptions {
function createModelChoices(models: Record<string, ModelAlias>): readonly ModelChoice[] {
return Object.entries(models).map(([alias, cfg]) => {
const name = modelDisplayName(alias, cfg);
const provider = providerDisplayName(cfg.provider);
return { alias, model: cfg, name, provider, label: `${name} (${provider})` };
const effective = effectiveModelAlias(cfg);
const name = modelDisplayName(alias, effective);
const provider = providerDisplayName(effective.provider);
return { alias, model: effective, name, provider, label: `${name} (${provider})` };
});
}
function thinkingAvailability(model: ModelAlias): ThinkingAvailability {
export function thinkingAvailability(model: ModelAlias): ThinkingAvailability {
const caps = model.capabilities ?? [];
if (caps.includes('always_thinking')) return 'always-on';
if (caps.includes('thinking') || model.adaptiveThinking === true) return 'toggle';
return 'unsupported';
}
function effectiveThinking(model: ModelAlias, thinkingDraft: boolean): boolean {
export function effortsOf(model: ModelAlias): readonly string[] {
return model.supportEfforts ?? [];
}
/**
* Ordered list of selectable thinking efforts for a model. Effort-capable models
* expose their declared efforts (with an 'off' entry when the model is not
* always-on); legacy boolean models expose 'on'/'off'; single-segment lists
* mean the control is effectively locked.
*/
export function segmentsFor(model: ModelAlias): readonly string[] {
const efforts = effortsOf(model);
const availability = thinkingAvailability(model);
if (availability === 'always-on') return true;
if (availability === 'unsupported') return false;
return thinkingDraft;
if (efforts.length > 0) {
return availability === 'always-on' ? efforts : ['off', ...efforts];
}
if (availability === 'always-on') return ['on'];
if (availability === 'unsupported') return ['off'];
return ['on', 'off'];
}
export function effortLabel(effort: string): string {
if (effort.length === 0) return effort;
return effort.charAt(0).toUpperCase() + effort.slice(1);
}
/**
* Default thinking effort for a model: declared `default_effort`, else the
* middle `support_efforts` entry, else `'on'` for boolean models, `'off'` when
* thinking is unsupported.
*/
function defaultThinkingEffortFor(model: ModelAlias): ThinkingEffort {
if (thinkingAvailability(model) === 'unsupported') return 'off';
const efforts = effortsOf(model);
if (efforts.length > 0) {
return model.defaultEffort ?? efforts[Math.floor(efforts.length / 2)]!;
}
return 'on';
}
/**
* Normalize a draft effort before committing a selection. A boolean `'on'`
* never leaks past the UI boundary it becomes the model's default effort
* (a concrete effort for effort-capable models, `'on'` only for genuine
* boolean models).
*/
function commitEffort(choice: ModelChoice, draft: ThinkingEffort): ThinkingEffort {
if (draft === 'on') return defaultThinkingEffortFor(choice.model);
return draft;
}
/**
@ -105,8 +159,8 @@ export class ModelSelectorComponent extends Container implements Focusable {
focused = false;
private readonly opts: ModelSelectorOptions;
private readonly list: SearchableList<ModelChoice>;
/** Per-model thinking override set by ←/→; absent → the capability default. */
private readonly thinkingOverrides = new Map<string, boolean>();
/** Per-model thinking-effort override set by ←/→; absent → the default. */
private readonly thinkingOverrides = new Map<string, string>();
constructor(opts: ModelSelectorOptions) {
super();
@ -124,15 +178,31 @@ export class ModelSelectorComponent extends Container implements Focusable {
}
/**
* Thinking draft for a model: an explicit / override when set, otherwise
* the live thinking state for the active model, otherwise On for any other
* thinking-capable model (a capable model should default to thinking on).
* Thinking effort for a model: an explicit / override when set, otherwise
* the live effort for the active model, otherwise the model's default effort
* (effort-capable) or 'on' (other thinking-capable models).
*/
private draftFor(choice: ModelChoice): boolean {
private draftFor(choice: ModelChoice): string {
const override = this.thinkingOverrides.get(choice.alias);
if (override !== undefined) return override;
if (choice.alias === this.opts.currentValue) return this.opts.currentThinking;
return thinkingAvailability(choice.model) !== 'unsupported';
if (choice.alias === this.opts.currentValue) return this.opts.currentThinkingEffort;
const efforts = effortsOf(choice.model);
if (efforts.length > 0) {
// A model with support_efforts but no default_effort defaults to the
// middle entry of its supported efforts.
const def = choice.model.defaultEffort ?? efforts[Math.floor(efforts.length / 2)];
if (def !== undefined && efforts.includes(def)) return def;
return efforts[0]!;
}
return thinkingAvailability(choice.model) !== 'unsupported' ? 'on' : 'off';
}
/** Draft coerced onto the model's segment list so rendering/selection never
* reference a effort the model cannot actually select. */
private effectiveEffort(choice: ModelChoice): string {
const draft = this.draftFor(choice);
const segments = segmentsFor(choice.model);
return segments.includes(draft) ? draft : segments[0]!;
}
handleInput(data: string): void {
@ -147,11 +217,27 @@ export class ModelSelectorComponent extends Container implements Focusable {
return;
}
// Left/Right toggle the thinking draft for models that support it.
// Left/Right move the active thinking effort within the model's segments.
if (matchesKey(data, Key.left) || matchesKey(data, Key.right)) {
const selected = this.selectedChoice();
if (selected !== undefined && thinkingAvailability(selected.model) === 'toggle') {
this.thinkingOverrides.set(selected.alias, !this.draftFor(selected));
if (selected !== undefined) {
const segments = segmentsFor(selected.model);
if (segments.length > 1) {
const current = this.effectiveEffort(selected);
const idx = segments.indexOf(current);
// The two-segment case is the legacy boolean On/Off control: both
// arrows flip it. With more segments (efforts), ←/→ step.
let next: number;
if (segments.length === 2) {
next = idx === 0 ? 1 : 0;
} else {
const delta = matchesKey(data, Key.left) ? -1 : 1;
next = Math.max(0, Math.min(segments.length - 1, idx + delta));
}
if (next !== idx) {
this.thinkingOverrides.set(selected.alias, segments[next]!);
}
}
}
return;
}
@ -161,7 +247,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
if (selected === undefined) return;
this.opts.onSelect({
alias: selected.alias,
thinking: effectiveThinking(selected.model, this.draftFor(selected)),
thinking: commitEffort(selected, this.effectiveEffort(selected)),
});
return;
}
@ -171,7 +257,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
if (selected === undefined) return;
this.opts.onSessionOnlySelect({
alias: selected.alias,
thinking: effectiveThinking(selected.model, this.draftFor(selected)),
thinking: commitEffort(selected, this.effectiveEffort(selected)),
});
}
}
@ -255,8 +341,8 @@ export class ModelSelectorComponent extends Container implements Focusable {
lines.push('');
const selected = this.selectedChoice();
if (selected !== undefined) {
const availability = thinkingAvailability(selected.model);
const thinkingHeader = availability === 'toggle' ? ' Thinking (←→ to switch)' : ' Thinking';
const canSwitch = segmentsFor(selected.model).length > 1;
const thinkingHeader = canSwitch ? ' Thinking (←→ to switch)' : ' Thinking';
lines.push(currentTheme.fg('textMuted', thinkingHeader));
lines.push(this.renderThinkingControl(selected));
}
@ -279,16 +365,20 @@ export class ModelSelectorComponent extends Container implements Focusable {
const unavailable = (label: string): string =>
currentTheme.fg('textMuted', ` ${label} (Unsupported) `);
// On stays left and Off right in all three states so the control never
// shifts while the cursor moves across models.
// Non-effort always-on / unsupported models keep the original On/Off layout
// so the control never shifts while moving across legacy models.
const efforts = effortsOf(choice.model);
const availability = thinkingAvailability(choice.model);
if (availability === 'always-on') {
if (efforts.length === 0 && availability === 'always-on') {
return ` ${segment('On', true)} ${unavailable('Off')}`;
}
if (availability === 'unsupported') {
if (efforts.length === 0 && availability === 'unsupported') {
return ` ${unavailable('On')} ${segment('Off', true)}`;
}
const draft = this.draftFor(choice);
return ` ${segment('On', draft)} ${segment('Off', !draft)}`;
const segments = segmentsFor(choice.model);
const active = this.effectiveEffort(choice);
const rendered = segments.map((effort) => segment(effortLabel(effort), effort === active));
return ` ${rendered.join(' ')}`;
}
}

View file

@ -6,7 +6,7 @@ import {
truncateToWidth,
visibleWidth,
type Focusable,
} from '@earendil-works/pi-tui';
} from '@moonshot-ai/pi-tui';
import type { PluginInfo, PluginMcpServerInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk';
import chalk from 'chalk';

View file

@ -42,7 +42,7 @@ import {
truncateToWidth,
visibleWidth,
type Focusable,
} from '@earendil-works/pi-tui';
} from '@moonshot-ai/pi-tui';
import { DEFAULT_OAUTH_PROVIDER_NAME } from '#/constant/app';
import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols';

View file

@ -15,7 +15,7 @@ import {
truncateToWidth,
visibleWidth,
wrapTextWithAnsi,
} from '@earendil-works/pi-tui';
} from '@moonshot-ai/pi-tui';
import { currentTheme } from '#/tui/theme';
import type {

View file

@ -9,7 +9,7 @@ import {
truncateToWidth,
visibleWidth,
type Focusable,
} from '@earendil-works/pi-tui';
} from '@moonshot-ai/pi-tui';
import { formatSessionLabel } from '#/migration/index';
import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols';
import { currentTheme } from '#/tui/theme';

View file

@ -5,7 +5,7 @@ import {
visibleWidth,
type Component,
type Focusable,
} from '@earendil-works/pi-tui';
} from '@moonshot-ai/pi-tui';
import { SELECT_POINTER } from '#/tui/constant/symbols';
import { currentTheme } from '#/tui/theme';

View file

@ -20,7 +20,7 @@ import {
matchesKey,
truncateToWidth,
type Focusable,
} from '@earendil-works/pi-tui';
} from '@moonshot-ai/pi-tui';
import { currentTheme } from '#/tui/theme';
import { renderTabStrip } from '#/tui/utils/tab-strip';
@ -39,7 +39,7 @@ export interface TabbedModelSelectorOptions {
readonly models: Record<string, ModelAlias>;
readonly currentValue: string;
readonly selectedValue?: string;
readonly currentThinking: boolean;
readonly currentThinkingEffort: string;
/** When set, the tab for this provider id is initially active instead of the
* tab derived from `currentValue`. */
readonly initialTabId?: string;
@ -179,7 +179,7 @@ function makeSelector(
models: subset,
currentValue: opts.currentValue,
...(selectedValue !== undefined ? { selectedValue } : {}),
currentThinking: opts.currentThinking,
currentThinkingEffort: opts.currentThinkingEffort,
searchable: true,
providerSwitchHint: true,
onSelect: opts.onSelect,

View file

@ -17,7 +17,7 @@ import {
truncateToWidth,
visibleWidth,
type Focusable,
} from '@earendil-works/pi-tui';
} from '@moonshot-ai/pi-tui';
import type { BackgroundTaskInfo, BackgroundTaskStatus } from '@moonshot-ai/kimi-code-sdk';
import { currentTheme } from '#/tui/theme';

View file

@ -21,7 +21,7 @@ import {
truncateToWidth,
visibleWidth,
type Focusable,
} from '@earendil-works/pi-tui';
} from '@moonshot-ai/pi-tui';
import type { BackgroundTaskInfo, BackgroundTaskStatus } from '@moonshot-ai/kimi-code-sdk';
import { SELECT_POINTER } from '@/tui/constant/symbols';

View file

@ -5,7 +5,7 @@ import {
truncateToWidth,
visibleWidth,
type Focusable,
} from '@earendil-works/pi-tui';
} from '@moonshot-ai/pi-tui';
import { SELECT_POINTER } from '#/tui/constant/symbols';
import { currentTheme } from '#/tui/theme';

View file

@ -11,7 +11,7 @@ import {
visibleWidth,
type SelectItem,
type TUI,
} from '@earendil-works/pi-tui';
} from '@moonshot-ai/pi-tui';
import { currentTheme } from '#/tui/theme';
import { createEditorTheme } from '#/tui/theme/pi-tui-theme';
@ -114,10 +114,8 @@ function stripSgr(s: string): string {
return s.replace(ANSI_SGR, '');
}
function getNewlineInput(data: string): string | undefined {
if (data === '\n' || data === '\u001B\r' || data === '\u001B[13;2~') return data;
if (matchesKey(data, Key.ctrl('j'))) return '\n';
return undefined;
interface CustomEditorOptions {
disablePasteBurst?: boolean;
}
export class CustomEditor extends Editor {
@ -137,7 +135,6 @@ export class CustomEditor extends Editor {
/** Return `true` to consume Ctrl+T (the todo list had overflow to toggle); return `false`/`undefined` to fall through to the editor default. */
public onToggleTodoExpand?: () => boolean;
public onUndo?: () => void;
public onInsertNewline?: () => void;
public onTextPaste?: () => void;
/**
* Called when is pressed in an empty editor. Return `true` to consume
@ -171,14 +168,14 @@ export class CustomEditor extends Editor {
this.argumentHints = hints;
}
constructor(tui: TUI) {
constructor(tui: TUI, options: CustomEditorOptions = {}) {
// paddingX: 4 reserves column 0 for the left vertical border (│),
// column 1 as a single space between border and prompt, column 2 for
// the `>` prompt token, and column 3 as the space between prompt and
// content. The right side mirrors with 3 padding columns and the right
// border at the last column.
const theme = createEditorTheme();
super(tui, theme, { paddingX: 4 });
super(tui, theme, { paddingX: 4, disablePasteBurst: options.disablePasteBurst });
// pi-tui keeps `createAutocompleteList` private; shadow it with an
// instance property so slash command menus render descriptions wrapped
@ -211,6 +208,16 @@ export class CustomEditor extends Editor {
};
}
override setDisablePasteBurst(disabled: boolean): void {
super.setDisablePasteBurst(disabled);
}
public setInputMode(mode: 'prompt' | 'bash'): void {
if (this.inputMode === mode) return;
this.inputMode = mode;
this.onInputModeChange?.(mode);
}
private expandPasteMarkerAtCursor(): boolean {
const { line, col } = this.getCursor();
const lines = this.getLines();
@ -430,13 +437,6 @@ export class CustomEditor extends Editor {
return;
}
const newlineInput = getNewlineInput(normalized);
if (newlineInput !== undefined) {
this.onInsertNewline?.();
super.handleInput(newlineInput);
return;
}
if (matchesKey(normalized, Key.up)) {
if (this.getText().length === 0 && this.onUpArrowEmpty) {
if (this.onUpArrowEmpty()) return;

View file

@ -8,7 +8,7 @@ import {
type AutocompleteProvider,
type AutocompleteSuggestions,
type SlashCommand,
} from '@earendil-works/pi-tui';
} from '@moonshot-ai/pi-tui';
const PATH_DELIMITERS = new Set([' ', '\t', '"', "'", '=']);
const MAX_FALLBACK_SCAN = 2000;

View file

@ -6,7 +6,7 @@ import {
type SelectItem,
type SelectListLayoutOptions,
type SelectListTheme,
} from '@earendil-works/pi-tui';
} from '@moonshot-ai/pi-tui';
// Mirror pi-tui's private select-list layout constants
// (dist/components/select-list.js); keep in sync when bumping pi-tui.

View file

@ -12,7 +12,7 @@
* the viewport; pi-tui handles proportional scaling internally.
*/
import { Container, Image, Text, type ImageTheme, getCapabilities } from '@earendil-works/pi-tui';
import { Container, Image, Text, type ImageTheme, getCapabilities } from '@moonshot-ai/pi-tui';
import { currentTheme } from '#/tui/theme';
import type { ImageAttachment } from '#/tui/utils/image-attachment-store';

View file

@ -15,8 +15,8 @@
* - Ungrouping is not implemented. Once formed, a group stays grouped.
*/
import type { TUI } from '@earendil-works/pi-tui';
import { Container, Spacer, Text } from '@earendil-works/pi-tui';
import type { TUI } from '@moonshot-ai/pi-tui';
import { Container, Spacer, Text } from '@moonshot-ai/pi-tui';
import { STATUS_BULLET } from '#/tui/constant/symbols';
import { currentTheme } from '#/tui/theme';

View file

@ -1,4 +1,4 @@
import { truncateToWidth, visibleWidth, type Component } from '@earendil-works/pi-tui';
import { truncateToWidth, visibleWidth, type Component } from '@moonshot-ai/pi-tui';
import chalk from 'chalk';
import {

View file

@ -5,7 +5,7 @@
* to align after the bullet.
*/
import { Container, Markdown, truncateToWidth, visibleWidth, type Component } from '@earendil-works/pi-tui';
import { Container, Markdown, truncateToWidth, visibleWidth, type Component } from '@moonshot-ai/pi-tui';
import { MESSAGE_INDENT } from '#/tui/constant/rendering';
import { STATUS_BULLET } from '#/tui/constant/symbols';

View file

@ -1,4 +1,4 @@
import { Text, truncateToWidth, type Component } from '@earendil-works/pi-tui';
import { Text, truncateToWidth, type Component } from '@moonshot-ai/pi-tui';
import { MESSAGE_INDENT } from '#/tui/constant/rendering';
import { FAILURE_MARK, STATUS_BULLET } from '#/tui/constant/symbols';

View file

@ -1,5 +1,5 @@
import type { Component } from '@earendil-works/pi-tui';
import { Spacer, Text, visibleWidth } from '@earendil-works/pi-tui';
import type { Component } from '@moonshot-ai/pi-tui';
import { Spacer, Text, visibleWidth } from '@moonshot-ai/pi-tui';
import { STATUS_BULLET } from '#/tui/constant/symbols';
import { currentTheme } from '#/tui/theme';

View file

@ -7,7 +7,7 @@
* the richer completion card (the `/goal` box), not this marker.
*/
import { truncateToWidth, type Component } from '@earendil-works/pi-tui';
import { truncateToWidth, type Component } from '@moonshot-ai/pi-tui';
import type { GoalChange } from '@moonshot-ai/kimi-code-sdk';
import { STATUS_BULLET } from '#/tui/constant/symbols';

View file

@ -19,7 +19,7 @@ import {
visibleWidth,
wrapTextWithAnsi,
type Component,
} from '@earendil-works/pi-tui';
} from '@moonshot-ai/pi-tui';
import type { GoalSnapshot, GoalStatus } from '@moonshot-ai/kimi-code-sdk';
import { MESSAGE_INDENT } from '#/tui/constant/rendering';

View file

@ -7,7 +7,7 @@
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { Markdown, truncateToWidth, visibleWidth, type Component, type MarkdownTheme } from '@earendil-works/pi-tui';
import { Markdown, truncateToWidth, visibleWidth, type Component, type MarkdownTheme } from '@moonshot-ai/pi-tui';
import chalk from 'chalk';
import { toTerminalHyperlink } from '#/utils/terminal-hyperlink';

View file

@ -11,7 +11,7 @@
* context; the TUI only consumes the `plugin_command.activated` event.
*/
import { Container, Text, Spacer } from '@earendil-works/pi-tui';
import { Container, Text, Spacer } from '@moonshot-ai/pi-tui';
import { currentTheme } from '#/tui/theme';

View file

@ -20,8 +20,8 @@
* src/missing.ts · failed
*/
import type { TUI } from '@earendil-works/pi-tui';
import { Container, Spacer, Text } from '@earendil-works/pi-tui';
import type { TUI } from '@moonshot-ai/pi-tui';
import { Container, Spacer, Text } from '@moonshot-ai/pi-tui';
import { STATUS_BULLET } from '#/tui/constant/symbols';
import { currentTheme } from '#/tui/theme';

View file

@ -1,5 +1,5 @@
import type { Component } from '@earendil-works/pi-tui';
import { Container, Text } from '@earendil-works/pi-tui';
import type { Component } from '@moonshot-ai/pi-tui';
import { Container, Text } from '@moonshot-ai/pi-tui';
import { currentTheme } from '#/tui/theme';
import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types';

View file

@ -1,4 +1,4 @@
import { Container, Text } from '@earendil-works/pi-tui';
import { Container, Text } from '@moonshot-ai/pi-tui';
import { currentTheme } from '#/tui/theme';

View file

@ -12,7 +12,7 @@
* metadata.
*/
import { Container, Text, Spacer } from '@earendil-works/pi-tui';
import { Container, Text, Spacer } from '@moonshot-ai/pi-tui';
import { currentTheme } from '#/tui/theme';
import type { SkillActivationTrigger } from '#/tui/types';

View file

@ -1,4 +1,4 @@
import { Container, Spacer, Text } from '@earendil-works/pi-tui';
import { Container, Spacer, Text } from '@moonshot-ai/pi-tui';
import { currentTheme } from '#/tui/theme';
import type { ColorToken } from '#/tui/theme';

View file

@ -5,7 +5,13 @@
* separate from the TUI orchestration layer.
*/
import type { ModelAlias, PermissionMode, SessionStatus } from '@moonshot-ai/kimi-code-sdk';
import {
effectiveModelAlias,
type ModelAlias,
type PermissionMode,
type SessionStatus,
type ThinkingEffort,
} from '@moonshot-ai/kimi-code-sdk';
import { PRODUCT_NAME } from '#/constant/app';
import { currentTheme } from '#/tui/theme';
@ -30,7 +36,7 @@ export interface StatusReportOptions {
readonly workDir: string;
readonly sessionId: string;
readonly sessionTitle: string | null;
readonly thinking: boolean;
readonly thinkingEffort: ThinkingEffort;
readonly permissionMode: PermissionMode;
readonly planMode: boolean;
readonly contextUsage: number;
@ -47,17 +53,16 @@ type Colorize = (text: string) => string;
function displayModelName(alias: string, models: Record<string, ModelAlias>): string {
const model = models[alias];
return model?.displayName ?? model?.model ?? alias;
const effective = model === undefined ? undefined : effectiveModelAlias(model);
return effective?.displayName ?? effective?.model ?? alias;
}
function formatModelStatus(options: StatusReportOptions): string {
const model = options.status?.model ?? options.model;
if (model.trim().length === 0) return 'not set';
const thinking = (options.status?.thinkingLevel ?? (options.thinking ? 'on' : 'off')) === 'off'
? 'off'
: 'on';
return `${displayModelName(model, options.availableModels)} (thinking ${thinking})`;
const effort = options.status?.thinkingEffort ?? options.thinkingEffort;
return `${displayModelName(model, options.availableModels)} (thinking ${effort})`;
}
function addFieldRows(

View file

@ -1,4 +1,4 @@
import type { Component } from '@earendil-works/pi-tui';
import type { Component } from '@moonshot-ai/pi-tui';
import { currentTheme } from '#/tui/theme';

View file

@ -1,4 +1,4 @@
import { truncateToWidth, type Component } from '@earendil-works/pi-tui';
import { truncateToWidth, type Component } from '@moonshot-ai/pi-tui';
import { STATUS_BULLET } from '#/tui/constant/symbols';
import { currentTheme } from '#/tui/theme';

Some files were not shown because too many files have changed in this diff Show more