mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-24 08:34:23 +00:00
Merge remote-tracking branch 'origin/main' into feat/kimi-desktop
# Conflicts: # pnpm-lock.yaml
This commit is contained in:
commit
dc97d96add
800 changed files with 50647 additions and 18567 deletions
|
|
@ -44,10 +44,12 @@ Format:
|
|||
|
||||
| Level | When to use |
|
||||
|---|---|
|
||||
| `patch` | Bug fixes; build/package fixes; internal refactors that do not change behavior; wording tweaks; small dependency upgrades |
|
||||
| `minor` | New backwards-compatible features or capabilities |
|
||||
| `patch` | Bug fixes; build/package fixes; internal refactors that do not change behavior; wording tweaks; small dependency upgrades; small improvements to existing features with limited user-facing impact (e.g. a new keyboard shortcut, a flag alias, a minor UX tweak) |
|
||||
| `minor` | A substantial new user-facing feature, such as a new slash command, a new built-in tool, or a new mode |
|
||||
| `major` | Breaking changes: incompatible config changes, renamed or removed commands/arguments, behavior semantics changes, and similar |
|
||||
|
||||
When in doubt between `patch` and `minor`: if the change improves an existing feature and the user-facing impact is small, choose `patch` even when the change is technically "new". Reserve `minor` for a substantial new capability that introduces something users could not do before.
|
||||
|
||||
### Major Rule
|
||||
|
||||
Never write `major` on your own.
|
||||
|
|
@ -57,13 +59,26 @@ If you believe a change qualifies as major, stop first, explain why, and ask the
|
|||
## Wording Rules
|
||||
|
||||
- Changelog entries **must be written in English**.
|
||||
- **Keep it short — ideally a single sentence that states what was done.** Do not write a paragraph, do not pile on technical detail, and do not enumerate every sub-change.
|
||||
- **Keep the whole entry concise.** Aim for one short sentence that states what was done; at most a short sentence plus a one-line usage hint. Do not write a paragraph, do not pile on technical detail, and do not enumerate every sub-change.
|
||||
- **For new user-facing features, append a brief usage hint** so users know how to try it. Keep it to a single short line — a command name, a subcommand, a flag, or a one-line "how to use". Do not explain design rationale or list edge cases. Skip the hint for bug fixes, internal changes, and refactors.
|
||||
- Slash command: `Add the /foo slash command to list active sessions. Run /foo to see them.`
|
||||
- CLI subcommand: `Add the kimi web subcommand to open the web UI. Run kimi web to launch it.`
|
||||
- Flag: `Add a --bar flag to skip confirmation prompts. Pass --bar to skip.`
|
||||
- Too long: `Add the /foo command to list active sessions. It accepts an optional --all flag to include background sessions, supports filtering by name with /foo <name>, and writes the result to the transcript...`
|
||||
- User-facing CLI wording should only be used when CLI users can perceive the change.
|
||||
- Internal changes that do not affect CLI users can still share a changeset with the CLI, but the wording must describe the real change honestly and must not present it as a user-facing feature.
|
||||
- Do not mention file names, class names, function names, PR numbers, or commit hashes.
|
||||
- Do not include real internal endpoints, key names, account names, or service names. If an example is needed, use neutral placeholders such as `example.com`, `example.test`, or `YOUR_API_KEY`.
|
||||
- Avoid vague words such as `refactor`, `optimize`, and `improve`. Describe the actual change, or use more specific wording.
|
||||
|
||||
## When You Are Unsure About a Change
|
||||
|
||||
Generate the changeset from what the diff clearly shows. If part of a change is unclear and you cannot confidently describe what it does for users, do not guess or pad the entry with vague wording.
|
||||
|
||||
1. Finish the changeset for the parts that are clear.
|
||||
2. Then ask the user once, in a short list: name the specific change(s) you do not understand, and ask whether you may dig into the repository (read related source, tests, or call sites) to describe it more accurately.
|
||||
3. Only read more code after the user agrees. If the user says no or does not reply, keep the concise wording you already have and do not invent detail.
|
||||
|
||||
## Common Examples
|
||||
|
||||
An internal package fixes a bug visible to CLI users:
|
||||
|
|
@ -76,6 +91,36 @@ An internal package fixes a bug visible to CLI users:
|
|||
Fix occasional loss of tool call results in long conversations.
|
||||
```
|
||||
|
||||
A new user-facing slash command (note the short usage hint):
|
||||
|
||||
```markdown
|
||||
---
|
||||
"@moonshot-ai/kimi-code": minor
|
||||
---
|
||||
|
||||
Add the /foo slash command to list active sessions. Run /foo to see them.
|
||||
```
|
||||
|
||||
A new CLI subcommand:
|
||||
|
||||
```markdown
|
||||
---
|
||||
"@moonshot-ai/kimi-code": minor
|
||||
---
|
||||
|
||||
Add the kimi web subcommand to open the web UI. Run kimi web to launch it.
|
||||
```
|
||||
|
||||
A new flag on an existing command:
|
||||
|
||||
```markdown
|
||||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Add a --bar flag to skip confirmation prompts. Pass --bar to skip.
|
||||
```
|
||||
|
||||
An internal package has an internal-only change, but it enters the CLI bundle:
|
||||
|
||||
```markdown
|
||||
|
|
@ -134,6 +179,8 @@ Add the server REST and WebSocket APIs that power the web UI.
|
|||
## Red Flags
|
||||
|
||||
- You are about to write `major` without asking the user.
|
||||
- A new user-facing feature entry has no usage hint, or the hint runs to multiple lines and explains design rationale.
|
||||
- You guessed wording for a change you do not understand instead of asking the user whether you may dig into the repo.
|
||||
- Internal package source enters the CLI bundle, but `@moonshot-ai/kimi-code` is missing.
|
||||
- A changeset frontmatter mixes ignored internal packages with non-ignored packages.
|
||||
- `packages/node-sdk` was not changed, but `@moonshot-ai/kimi-code-sdk` was listed for "internal package sync".
|
||||
|
|
|
|||
66
.agents/skills/pre-changelog/SKILL.md
Normal file
66
.agents/skills/pre-changelog/SKILL.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
---
|
||||
name: pre-changelog
|
||||
description: Use before merging a kimi-code release PR to preview the user-facing CLI changelog in Chinese. Reads the changelog that changesets pre-generated in the release PR, then reuses sync-changelog's strip / classify / translate logic to render a Chinese preview. Writes no files.
|
||||
---
|
||||
|
||||
# Pre-Changelog
|
||||
|
||||
Preview the user-facing **Chinese** changelog of an open `kimi-code` release PR **before** it is merged. Read-only: this skill writes no files and commits nothing.
|
||||
|
||||
This skill reuses `sync-changelog`'s strip / classify / translate rules. Read `sync-changelog` first; only the data source (release PR diff instead of a published `CHANGELOG.md`) and the output (preview instead of docs files) differ.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Locate the release PR
|
||||
|
||||
```bash
|
||||
gh pr list --state open --search "ci: release packages in:title" \
|
||||
--json number,title,url,headRefName,baseRefName
|
||||
```
|
||||
|
||||
Pick the one with `headRefName: changeset-release/main`; record `number`, `url` as `<RELEASE>`. If none is open, nothing to preview — stop.
|
||||
|
||||
### 2. Read the pre-generated CLI changelog block
|
||||
|
||||
changesets already pre-generates `apps/kimi-code/CHANGELOG.md` inside the release PR. Extract the new version block from the diff:
|
||||
|
||||
```bash
|
||||
gh api repos/MoonshotAI/kimi-code/pulls/<RELEASE>/files \
|
||||
--jq '.[] | select(.filename=="apps/kimi-code/CHANGELOG.md") | .patch'
|
||||
```
|
||||
|
||||
Take the added lines (`+`) from the top `## <version>` down to (but not including) the next `## `. That is the version block to preview.
|
||||
|
||||
If the CLI changelog is not in the diff (for example an SDK-only release), stop and tell the user — there is no user-facing CLI changelog to preview.
|
||||
|
||||
### 3. Render the Chinese preview (reuse `sync-changelog`)
|
||||
|
||||
Process the version block exactly as `sync-changelog` does for the docs site, but only in memory:
|
||||
|
||||
- **Strip** (`sync-changelog` step 3): drop the H1, the `### Patch Changes` / `### Minor Changes` / `### Major Changes` subheadings, PR links, and commit-hash links; keep only each entry's body text.
|
||||
- **Classify** (`sync-changelog` step 4): bucket into Features / Bug Fixes / Polish / Refactors / Other; order within each section by reader value.
|
||||
- **Translate** (`sync-changelog` step 6): translate entry bodies to Chinese; section headings become 新功能 / 修复 / 优化 / 重构 / 其他.
|
||||
|
||||
If an upstream entry is not in English, flag it and stop (changeset entries must be English).
|
||||
|
||||
### 4. Output
|
||||
|
||||
Print the preview directly. Use `<version>(预览)` as the heading because the version is not released yet. Write `无` for empty sections. Do not write any file.
|
||||
|
||||
```
|
||||
发版 PR: <url>
|
||||
|
||||
## <version>(预览)
|
||||
|
||||
### 新功能
|
||||
- ...
|
||||
|
||||
### 修复
|
||||
- ...
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- Read-only. Never write `CHANGELOG.md`, docs files, or commit anything.
|
||||
- Classification, ordering, and translation follow `sync-changelog` exactly — do not reword or reclassify beyond what it specifies.
|
||||
- If the release PR has no CLI changelog diff, report it and stop.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
name: sync-changelog
|
||||
description: Use after a release succeeds, when maintainers need to sync apps/kimi-code/CHANGELOG.md into docs/en/release-notes/changelog.md and docs/zh/release-notes/changelog.md.
|
||||
description: Use after a release succeeds, when maintainers need to sync apps/kimi-code/CHANGELOG.md into docs/en/release-notes/changelog.md and docs/zh/release-notes/changelog.md, then open a PR on a dedicated branch.
|
||||
---
|
||||
|
||||
# Sync Changelog
|
||||
|
|
@ -15,7 +15,7 @@ apps/kimi-code/CHANGELOG.md
|
|||
|
||||
This file is the **only upstream source** for the documentation-site changelog. Internal package changelogs such as `packages/*/CHANGELOG.md` do not go into the documentation site.
|
||||
|
||||
After the release flow finishes (Release PR merged → `Version Packages` completed → npm publish succeeded), maintainers manually run this skill to copy the new CLI changelog entries into the docs site and translate the English increment into Chinese.
|
||||
After the release flow finishes (Release PR merged → `Version Packages` completed → npm publish succeeded), maintainers manually run this skill to copy the new CLI changelog entries into the docs site, translate the English increment into Chinese, wait for an optional human review, then commit on a dedicated branch and open a PR.
|
||||
|
||||
## When To Use
|
||||
|
||||
|
|
@ -41,28 +41,54 @@ Before editing, confirm:
|
|||
|
||||
- The released version exists on npm (`npm view @moonshot-ai/kimi-code versions --json`) or has a matching GitHub Release tag.
|
||||
- The top of `apps/kimi-code/CHANGELOG.md` is that new version.
|
||||
- The current branch is clean, or you are on a dedicated docs-sync branch.
|
||||
|
||||
If any condition is not true, stop and confirm with the user.
|
||||
|
||||
Do **not** edit or commit directly on `main`. All sync work happens on a dedicated branch created in step 1.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Find The Version Range
|
||||
### 1. Prepare Branch
|
||||
|
||||
Start from an up-to-date default branch:
|
||||
|
||||
```bash
|
||||
# Upstream versions
|
||||
rg '^## ' apps/kimi-code/CHANGELOG.md | head -20
|
||||
git fetch origin
|
||||
git checkout main
|
||||
git pull --ff-only origin main
|
||||
```
|
||||
|
||||
# Latest version already synced into the English docs page
|
||||
Before creating the branch, peek at the version range so the branch name matches the newest version being synced:
|
||||
|
||||
```bash
|
||||
rg '^## ' apps/kimi-code/CHANGELOG.md | head -5
|
||||
rg '^## ' docs/en/release-notes/changelog.md | head -5
|
||||
```
|
||||
|
||||
Name the branch after the newest upstream version that is not yet in the English docs page:
|
||||
|
||||
```text
|
||||
docs/changelog-sync-<newest-version>
|
||||
```
|
||||
|
||||
Example: syncing `0.2.1` only → `docs/changelog-sync-0.2.1`.
|
||||
|
||||
```bash
|
||||
git checkout -b docs/changelog-sync-<newest-version>
|
||||
```
|
||||
|
||||
If the branch already exists locally or on the remote, stop and confirm with the user instead of reusing it.
|
||||
|
||||
### 2. Find The Version Range
|
||||
|
||||
Use the same version lists from step 1. Confirm:
|
||||
|
||||
- First sync: copy all upstream version blocks into the English page.
|
||||
- Incremental sync: copy every upstream version block above the latest version already present in the English page.
|
||||
|
||||
Use upstream order: newest version first.
|
||||
|
||||
### 2. Strip Decorations And Extract Entry Text
|
||||
### 3. Strip Decorations And Extract Entry Text
|
||||
|
||||
Upstream entries look like this:
|
||||
|
||||
|
|
@ -92,7 +118,7 @@ Upstream language rule: `gen-changesets` requires changelog entries to be Englis
|
|||
|
||||
Public-text rule: do not copy real internal endpoints, key names, account names, or service names into docs changelogs. Replace examples with neutral placeholders such as `example.com`, `example.test`, or `YOUR_API_KEY` while preserving the user-visible meaning.
|
||||
|
||||
### 3. Classify Entries
|
||||
### 4. Classify Entries
|
||||
|
||||
The docs changelog uses five section types:
|
||||
|
||||
|
|
@ -136,7 +162,7 @@ Omit empty sections. Within each section, order entries by reader value, not ups
|
|||
|
||||
Do not reword or exaggerate entries just to make them look more important; only reorder existing entries.
|
||||
|
||||
### 4. Write The English Page
|
||||
### 5. Write The English Page
|
||||
|
||||
Never change the English page header:
|
||||
|
||||
|
|
@ -177,7 +203,7 @@ Example:
|
|||
- Update the native release workflow to use current GitHub artifact actions.
|
||||
```
|
||||
|
||||
### 5. Translate The Increment Into Chinese
|
||||
### 6. Translate The Increment Into Chinese
|
||||
|
||||
After updating the English page, translate only the newly added English content into `docs/zh/release-notes/changelog.md`.
|
||||
|
||||
|
|
@ -205,7 +231,52 @@ Chinese page requirements:
|
|||
- Translate only entry body text. Do not add entries that are not present in English.
|
||||
- Follow `docs/AGENTS.md` for Chinese typography: full-width punctuation, spaces between Chinese and English, and the glossary.
|
||||
|
||||
### 6. Verify
|
||||
#### Chinese wording style
|
||||
|
||||
Structural fidelity does not mean literal translation. The Chinese entries should read like a concise, idiomatic Chinese changelog. Keep the same facts as the English entry, but rephrase for natural Chinese prose.
|
||||
|
||||
Guidelines:
|
||||
|
||||
- **One entry, one sentence.** Avoid chaining multiple effects with commas or semicolons. If the English entry is long, split it into shorter sentences or keep only the most important effect.
|
||||
- **Prefer common changelog verbs**: 新增、支持、修复、优化、改进、调整.
|
||||
- **Avoid indirect "through... make..." structures**. Do not write "通过 X,使 Y"; prefer direct cause-effect or just state the result.
|
||||
- Bad: `通过缓存已渲染消息行,使终端在长篇对话中保持响应。`
|
||||
- Better: `缓存已渲染消息行,提升长对话下终端的响应速度。`
|
||||
- **Be specific, not vague**. Prefer concrete actions over abstract quality words.
|
||||
- Bad: `加固默认系统提示词和内置工具描述。`
|
||||
- Better: `优化默认系统提示词与内置工具描述,避免 Agent 阻塞后台任务。`
|
||||
- **Name concrete files or config keys when it helps clarity**.
|
||||
- Bad: `插件现在可以在其清单中声明 hooks。`
|
||||
- Better: `插件现支持在 kimi.plugin.json 中声明生命周期 hooks。`
|
||||
- **Include required argument placeholders in CLI options**.
|
||||
- Bad: `--allowed-host`
|
||||
- Better: `--allowed-host <host>`
|
||||
- **Keep usage hints to one short clause**.
|
||||
- Bad: `传入 --allowed-host 以允许额外的 host。例如 ... (多句展开)`
|
||||
- Better: `例如 kimi web --allowed-host example.com。`
|
||||
- **Do not translate technical identifiers**: keep command names, flag names, file names, env vars, and config keys as-is.
|
||||
|
||||
Example — translating a feature entry:
|
||||
|
||||
English source:
|
||||
|
||||
```markdown
|
||||
- Add a --allowed-host flag to kimi web that lets extra Host header values pass the DNS-rebinding check, and include allow guidance in the 403 error message. Pass --allowed-host <host> to allow an extra host.
|
||||
```
|
||||
|
||||
Before (literal, wordy):
|
||||
|
||||
```markdown
|
||||
- 为 `kimi web` 新增 `--allowed-host` 标志,允许额外的 Host 请求头值通过 DNS 重绑定检查,并在 403 错误消息中包含允许指引。传入 `--allowed-host <host>` 以允许额外的 host。例如 `kimi web --allowed-host example.com`。
|
||||
```
|
||||
|
||||
After (concise, idiomatic):
|
||||
|
||||
```markdown
|
||||
- `kimi web` 新增 `--allowed-host <host>` 选项,可将指定 Host 加入 DNS 重绑定白名单;403 错误会提示如何通过 `--allowed-host` 或 `KIMI_CODE_ALLOWED_HOSTS` 放行,例如 `kimi web --allowed-host example.com`。
|
||||
```
|
||||
|
||||
### 7. Verify
|
||||
|
||||
Review:
|
||||
|
||||
|
|
@ -231,7 +302,36 @@ Then run the docs build:
|
|||
pnpm --filter docs run build
|
||||
```
|
||||
|
||||
### 7. Commit
|
||||
### 8. Human Review Checkpoint
|
||||
|
||||
After verification passes, **before committing**, ask the user whether they want to review the sync result. Use `AskQuestion` with options such as:
|
||||
|
||||
- **Review first** — show the diff and wait for the user to finish checking.
|
||||
- **Skip review, commit and open PR** — proceed directly to steps 9 and 10.
|
||||
|
||||
If the user chooses review:
|
||||
|
||||
1. Show the uncommitted diff:
|
||||
|
||||
```bash
|
||||
git diff docs/en/release-notes/changelog.md docs/zh/release-notes/changelog.md
|
||||
```
|
||||
|
||||
2. Summarize synced versions, section counts, and anything that needed manual classification.
|
||||
3. Tell the user to reply when they are done reviewing, or to ask for edits.
|
||||
4. Do **not** commit, push, or open a PR until the user explicitly says review is complete, or asks to proceed.
|
||||
|
||||
If the user requests edits during review, make the changes, re-run verification from step 7, and return to this checkpoint.
|
||||
|
||||
### 9. Commit
|
||||
|
||||
Only run this step when the user skipped review or confirmed review is complete.
|
||||
|
||||
Stage only the changelog docs files:
|
||||
|
||||
```bash
|
||||
git add docs/en/release-notes/changelog.md docs/zh/release-notes/changelog.md
|
||||
```
|
||||
|
||||
Use a neutral docs-sync commit message:
|
||||
|
||||
|
|
@ -241,12 +341,65 @@ docs(changelog): sync <version range> from apps/kimi-code/CHANGELOG.md
|
|||
|
||||
Do **not** create a changeset for changelog docs sync. Docs sync does not enter the bundle.
|
||||
|
||||
### 10. Push And Open PR
|
||||
|
||||
Run immediately after step 9.
|
||||
|
||||
Push the branch:
|
||||
|
||||
```bash
|
||||
git push -u origin HEAD
|
||||
```
|
||||
|
||||
Create the PR with `gh pr create`. Title follows Conventional Commits:
|
||||
|
||||
```text
|
||||
docs(changelog): sync <version range> from apps/kimi-code/CHANGELOG.md
|
||||
```
|
||||
|
||||
Fill in `.github/pull_request_template.md`. For changelog sync PRs:
|
||||
|
||||
- **Related Issue**: write `N/A — post-release docs maintenance` (no issue required).
|
||||
- **Problem**: the docs-site changelog is behind the published CLI release(s).
|
||||
- **What changed**: list synced version(s), note English source + Chinese translation, and mention verification (`pnpm --filter docs run build`).
|
||||
- **Checklist**: check CONTRIBUTING; explain no issue, no tests, no changeset, and that `gen-docs` is not needed because this is the dedicated changelog sync flow.
|
||||
|
||||
Example body:
|
||||
|
||||
```markdown
|
||||
## Related Issue
|
||||
|
||||
N/A — post-release docs maintenance
|
||||
|
||||
## Problem
|
||||
|
||||
The docs-site changelog has not yet been synced for `<version range>` after the npm release.
|
||||
|
||||
## What changed
|
||||
|
||||
- Synced `<version range>` from `apps/kimi-code/CHANGELOG.md` into `docs/en/release-notes/changelog.md`
|
||||
- Translated the new English increment into `docs/zh/release-notes/changelog.md`
|
||||
- Verified with `pnpm --filter docs run build`
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] I have read the CONTRIBUTING document.
|
||||
- [x] I have linked a related issue, or explained the problem above.
|
||||
- [ ] I have added tests that prove my feature works. (N/A — docs-only sync)
|
||||
- [x] Ran `gen-changesets` skill, or this PR needs no changeset. (No changeset — docs sync is out of bundle)
|
||||
- [x] Ran `gen-docs` skill, or this PR needs no doc update. (This PR is the dedicated changelog sync)
|
||||
```
|
||||
|
||||
Return the PR URL to the user when done.
|
||||
|
||||
## Rules
|
||||
|
||||
- The English docs changelog is the source of truth.
|
||||
- Never edit upstream `apps/kimi-code/CHANGELOG.md`.
|
||||
- Do not backfill unreleased `.changeset/*.md` drafts into the docs site.
|
||||
- If upstream wording is wrong, leave upstream alone and fix it in a future changeset.
|
||||
- Always sync on a `docs/changelog-sync-*` branch and open a PR; never push changelog docs sync directly to `main`.
|
||||
- Wait for the human review checkpoint before committing, pushing, or opening a PR.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
|
|
@ -268,6 +421,8 @@ Do **not** create a changeset for changelog docs sync. Docs sync does not enter
|
|||
| Putting everything under Other for convenience | Classify what can be classified first |
|
||||
| Translating tool names, command names, or config keys | Keep them as written |
|
||||
| Creating a changeset for docs sync | Do not create one |
|
||||
| Committing or pushing directly on `main` | Create `docs/changelog-sync-<version>`, commit there, then open a PR |
|
||||
| Committing or opening a PR before the user skips review or confirms review is done | Wait at the human review checkpoint |
|
||||
| Using curly quotes or half-width Chinese punctuation | Follow `docs/AGENTS.md` |
|
||||
| Omitting the release date from a version heading, or guessing it | Add ` (YYYY-MM-DD)` (full-width `()` in Chinese) taken from the published tag |
|
||||
|
||||
|
|
@ -279,3 +434,5 @@ Do **not** create a changeset for changelog docs sync. Docs sync does not enter
|
|||
- English and Chinese versions, entry counts, or section sets do not match.
|
||||
- A section is empty.
|
||||
- A Chinese term is uncertain and `docs/AGENTS.md` does not answer it.
|
||||
- A `docs/changelog-sync-*` branch already exists for the same version and you cannot confirm whether it is stale.
|
||||
- The user asked to review but has not yet confirmed review is complete.
|
||||
|
|
|
|||
|
|
@ -7,16 +7,6 @@
|
|||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": [
|
||||
"@moonshot-ai/acp-adapter",
|
||||
"@moonshot-ai/agent-core",
|
||||
"@moonshot-ai/kaos",
|
||||
"@moonshot-ai/kimi-code-oauth",
|
||||
"@moonshot-ai/kimi-telemetry",
|
||||
"@moonshot-ai/kimi-web",
|
||||
"@moonshot-ai/kosong",
|
||||
"@moonshot-ai/migration-legacy",
|
||||
"@moonshot-ai/protocol",
|
||||
"@moonshot-ai/server",
|
||||
"@moonshot-ai/server-e2e",
|
||||
"@moonshot-ai/vis",
|
||||
"@moonshot-ai/vis-server",
|
||||
|
|
|
|||
10
.gitattributes
vendored
Normal file
10
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Enforce LF line endings in the working tree on every platform so that
|
||||
# raw-imported text (e.g. `*.md?raw` templates) is byte-identical on Windows
|
||||
# and POSIX. Without this, Git for Windows' default `core.autocrlf=true`
|
||||
# checks text files out as CRLF, which shifts token-count snapshots.
|
||||
* text=auto eol=lf
|
||||
|
||||
# Binary assets — never normalize line endings.
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.png binary
|
||||
21
.github/workflows/ci.yml
vendored
21
.github/workflows/ci.yml
vendored
|
|
@ -44,6 +44,27 @@ jobs:
|
|||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm run test
|
||||
|
||||
test-windows:
|
||||
runs-on: windows-latest
|
||||
# Temporarily disabled while Windows tests are being stabilized.
|
||||
if: false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
# Windows runners are slower and run the whole suite (including
|
||||
# in-process e2e tests) under more contention, so the default 5s test
|
||||
# timeout causes flaky failures. Give it more headroom.
|
||||
- run: pnpm run test -- --testTimeout=30000
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -13,8 +13,9 @@ coverage/
|
|||
.conductor
|
||||
.kimi-stash-dir
|
||||
plugins/cdn/
|
||||
superpowers
|
||||
.worktrees/
|
||||
.kimi-code/local.toml
|
||||
.kimi-sandbox/
|
||||
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
|
|
|
|||
|
|
@ -150,7 +150,6 @@
|
|||
"node_modules/",
|
||||
"apps/*/scripts/",
|
||||
"docs/smoke-archive/",
|
||||
"plugins/curated/superpowers/",
|
||||
"*.generated.ts"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,331 @@
|
|||
# @moonshot-ai/kimi-code
|
||||
|
||||
## 0.20.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#1207](https://github.com/MoonshotAI/kimi-code/pull/1207) [`14d9e98`](https://github.com/MoonshotAI/kimi-code/commit/14d9e98903f30f83199e30b5fa20b3c61ab28781) - Refresh provider model lists automatically in the background instead of only at startup, so newly available models appear without restarting.
|
||||
|
||||
- [#1191](https://github.com/MoonshotAI/kimi-code/pull/1191) [`0df1812`](https://github.com/MoonshotAI/kimi-code/commit/0df18125022103dabb149b4f26f90959b669187b) - Fix provider error messages rendering as blank lines in the TUI when the server returns an HTML error page.
|
||||
|
||||
- [#1212](https://github.com/MoonshotAI/kimi-code/pull/1212) [`636ccc4`](https://github.com/MoonshotAI/kimi-code/commit/636ccc40f19f259bdd6653b2ca563a75b3548e23) - Fix the web composer being hidden behind the mobile Safari toolbar and the page auto-zooming when the composer is focused.
|
||||
|
||||
- [#1068](https://github.com/MoonshotAI/kimi-code/pull/1068) [`c82dcf9`](https://github.com/MoonshotAI/kimi-code/commit/c82dcf9cd8276eddf6acbf1030d1712b83a38083) - Glob now uses ripgrep, so it respects .gitignore by default, supports brace patterns, returns only files, and keeps partial results with a warning when some directories are unreadable.
|
||||
|
||||
- [#1209](https://github.com/MoonshotAI/kimi-code/pull/1209) [`0635387`](https://github.com/MoonshotAI/kimi-code/commit/063538744f64a1bd3da6f37ebd0643d10bfc068f) - Align malformed tool call argument handling with schema validation fallback.
|
||||
|
||||
## 0.20.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#1166](https://github.com/MoonshotAI/kimi-code/pull/1166) [`dfcfdfd`](https://github.com/MoonshotAI/kimi-code/commit/dfcfdfd9ddbe14fb6e358694394e1ddcc21b8911) - Add an optional exclude_empty parameter to the session list API to omit sessions that have no messages.
|
||||
|
||||
- [#1156](https://github.com/MoonshotAI/kimi-code/pull/1156) [`794db55`](https://github.com/MoonshotAI/kimi-code/commit/794db55538e01b4bf0c008c493de5d8b8bf67c5d) - Cap compaction output at 128k tokens by default to avoid provider max_tokens errors.
|
||||
|
||||
- [#1129](https://github.com/MoonshotAI/kimi-code/pull/1129) [`d02b5c4`](https://github.com/MoonshotAI/kimi-code/commit/d02b5c49844d65e005632fafcb1c172a7d32bfbe) - Fix compaction ignoring the configured max output size.
|
||||
|
||||
- [#1188](https://github.com/MoonshotAI/kimi-code/pull/1188) [`db5fbc5`](https://github.com/MoonshotAI/kimi-code/commit/db5fbc53c00c9945fc1fa98c69c4e5c7efb8077e) - Fix unnecessary full-screen redraws when typing in the input box or toggling the slash panel.
|
||||
|
||||
- [#1187](https://github.com/MoonshotAI/kimi-code/pull/1187) [`97f9263`](https://github.com/MoonshotAI/kimi-code/commit/97f9263c6f13ead5edc051f96993f8d1d7d5ec6f) - Fix debug timing output lingering after undoing a turn.
|
||||
|
||||
- [#1163](https://github.com/MoonshotAI/kimi-code/pull/1163) [`ff6e8bb`](https://github.com/MoonshotAI/kimi-code/commit/ff6e8bbd7c328dcc6575902cfd0cb3e522f20948) - Fix the web composer occasionally keeping typed text after sending the first message of a new session.
|
||||
|
||||
- [#1189](https://github.com/MoonshotAI/kimi-code/pull/1189) [`04b3492`](https://github.com/MoonshotAI/kimi-code/commit/04b3492e740dad5fca2af9f66eca98da3e14058a) - Fix working tips getting squeezed against the agent swarm progress bar.
|
||||
|
||||
- [#1159](https://github.com/MoonshotAI/kimi-code/pull/1159) [`23a553b`](https://github.com/MoonshotAI/kimi-code/commit/23a553bb91e9ee794aaf769f78f5acec739aec85) - In the bundled web UI, `/new` and `/clear` are now aliases that open the session onboarding composer and focus the input. iOS auto-zoom is prevented by keeping text inputs at 16px instead of disabling viewport scaling.
|
||||
|
||||
- [#1186](https://github.com/MoonshotAI/kimi-code/pull/1186) [`821847c`](https://github.com/MoonshotAI/kimi-code/commit/821847cb4b88d9128014609aad307ab8d9e9a5f3) - Add `KIMI_CODE_CUSTOM_HEADERS` for custom outbound LLM request headers and send the `User-Agent` header to non-Kimi providers. Set `KIMI_CODE_CUSTOM_HEADERS` to newline-separated `Name: Value` lines.
|
||||
|
||||
- [#1186](https://github.com/MoonshotAI/kimi-code/pull/1186) [`821847c`](https://github.com/MoonshotAI/kimi-code/commit/821847cb4b88d9128014609aad307ab8d9e9a5f3) - Route managed Kimi Code models on the Anthropic-compatible protocol through the beta Messages API.
|
||||
|
||||
- [#1170](https://github.com/MoonshotAI/kimi-code/pull/1170) [`cf558cd`](https://github.com/MoonshotAI/kimi-code/commit/cf558cd74267393d6497ddedf25e192eaac4f94b) - Recover from provider 413 context overflows by compacting before retrying.
|
||||
|
||||
- [#1170](https://github.com/MoonshotAI/kimi-code/pull/1170) [`cf558cd`](https://github.com/MoonshotAI/kimi-code/commit/cf558cd74267393d6497ddedf25e192eaac4f94b) - Support the Anthropic-compatible protocol for managed Kimi Code, including video input.
|
||||
|
||||
- [#1186](https://github.com/MoonshotAI/kimi-code/pull/1186) [`821847c`](https://github.com/MoonshotAI/kimi-code/commit/821847cb4b88d9128014609aad307ab8d9e9a5f3) - Add provider type and protocol attributes to turn and API error telemetry.
|
||||
|
||||
- [#1155](https://github.com/MoonshotAI/kimi-code/pull/1155) [`54baf5d`](https://github.com/MoonshotAI/kimi-code/commit/54baf5d07fe718b70b8840e509a905ac48b1ccac) - Upgrade web markdown renderer dependencies (katex, markstream-vue, shiki) for bug fixes and performance improvements.
|
||||
|
||||
- [#1162](https://github.com/MoonshotAI/kimi-code/pull/1162) [`b070846`](https://github.com/MoonshotAI/kimi-code/commit/b0708464f4160f7b73f25a520e493bf87e92149f) - Rework the web ask-user-question card into a step-by-step wizard so multi-question navigation and the final Submit action are easier to see.
|
||||
|
||||
- [#1179](https://github.com/MoonshotAI/kimi-code/pull/1179) [`fc3d69d`](https://github.com/MoonshotAI/kimi-code/commit/fc3d69dbdc965e525b5486a6b91e4ec44194ca97) - Add a completion sound and question notifications to the web UI, with separate Settings toggles for completion notifications, question notifications, and sound. Question notifications default off so question text only reaches your desktop after you opt in.
|
||||
|
||||
- [#1165](https://github.com/MoonshotAI/kimi-code/pull/1165) [`f3b1532`](https://github.com/MoonshotAI/kimi-code/commit/f3b15322da518b0e3d0560d19651435793c790d9) - Replace the web composer attach button's plus icon with an image icon.
|
||||
|
||||
- [#1167](https://github.com/MoonshotAI/kimi-code/pull/1167) [`c63edd5`](https://github.com/MoonshotAI/kimi-code/commit/c63edd5bf6d764c3ab771cb697a334ac100a0944) - In the bundled web UI, a new session is now created only when the first message is sent, so + New without a workspace opens the composer instead of making an empty session.
|
||||
|
||||
- [#1166](https://github.com/MoonshotAI/kimi-code/pull/1166) [`dfcfdfd`](https://github.com/MoonshotAI/kimi-code/commit/dfcfdfd9ddbe14fb6e358694394e1ddcc21b8911) - Hide unused "New Session" entries from the web session list by default.
|
||||
|
||||
- [#1181](https://github.com/MoonshotAI/kimi-code/pull/1181) [`1dab2c2`](https://github.com/MoonshotAI/kimi-code/commit/1dab2c2268af6f74464b6573981c1e1bb4bda703) - Restore each session's scroll position when switching back to it in the web UI.
|
||||
|
||||
- [#1181](https://github.com/MoonshotAI/kimi-code/pull/1181) [`1dab2c2`](https://github.com/MoonshotAI/kimi-code/commit/1dab2c2268af6f74464b6573981c1e1bb4bda703) - Keep the open side panel when switching between sessions in the web UI.
|
||||
|
||||
- [#1166](https://github.com/MoonshotAI/kimi-code/pull/1166) [`dfcfdfd`](https://github.com/MoonshotAI/kimi-code/commit/dfcfdfd9ddbe14fb6e358694394e1ddcc21b8911) - Remove the /sessions slash command from the web UI; the sidebar already covers session browsing.
|
||||
|
||||
- [#1181](https://github.com/MoonshotAI/kimi-code/pull/1181) [`1dab2c2`](https://github.com/MoonshotAI/kimi-code/commit/1dab2c2268af6f74464b6573981c1e1bb4bda703) - Keep unsent composer attachments scoped to their session in the web UI, so switching sessions no longer leaks them into another session's next message.
|
||||
|
||||
- [#1161](https://github.com/MoonshotAI/kimi-code/pull/1161) [`d968642`](https://github.com/MoonshotAI/kimi-code/commit/d968642384f672295756394ee07a536dbfdb4dfd) - Show the first five sessions per workspace in the web sidebar instead of ten.
|
||||
|
||||
- [#1181](https://github.com/MoonshotAI/kimi-code/pull/1181) [`1dab2c2`](https://github.com/MoonshotAI/kimi-code/commit/1dab2c2268af6f74464b6573981c1e1bb4bda703) - Scope the web composer's up/down input history to the current session instead of sharing it across all sessions.
|
||||
|
||||
## 0.20.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#1125](https://github.com/MoonshotAI/kimi-code/pull/1125) [`e9a3b7c`](https://github.com/MoonshotAI/kimi-code/commit/e9a3b7c83a623c7323da509ba885567c465093fc) - Add an `update` alias for the `kimi upgrade` command. Run `kimi update` to upgrade to the latest version.
|
||||
|
||||
- [#1122](https://github.com/MoonshotAI/kimi-code/pull/1122) [`820d77a`](https://github.com/MoonshotAI/kimi-code/commit/820d77ab4cfad7752358a4692fd3d7def49f005d) - Show the done / in progress / pending breakdown of hidden todos in the collapsed todo panel.
|
||||
|
||||
- [#1131](https://github.com/MoonshotAI/kimi-code/pull/1131) [`76c643b`](https://github.com/MoonshotAI/kimi-code/commit/76c643bcb6da447c8c47728b4f58512a7a11cfa6) - Cap completion tokens to the remaining context window for chat-completions providers, avoiding context-overflow and invalid max_tokens errors.
|
||||
|
||||
- [#1120](https://github.com/MoonshotAI/kimi-code/pull/1120) [`e736349`](https://github.com/MoonshotAI/kimi-code/commit/e736349a7c8ff55b73e05cc0192dfaf0114745fa) - Add optional feedback attachments for diagnostic logs and codebase context.
|
||||
|
||||
- [#1135](https://github.com/MoonshotAI/kimi-code/pull/1135) [`bf51fb7`](https://github.com/MoonshotAI/kimi-code/commit/bf51fb7a105b2f34a59ed4e83d2588e790cfb086) - Fix the local server failing to start on Windows after the first run because the persistent token file's synthesized mode was rejected as too permissive.
|
||||
|
||||
- [#1102](https://github.com/MoonshotAI/kimi-code/pull/1102) [`9c97161`](https://github.com/MoonshotAI/kimi-code/commit/9c9716125e104b217540d0591229d03c6d676ead) - Harden the default system prompt and built-in tool descriptions: stop the agent from blocking on background tasks it should let run, keep its guidance matched to the tools each profile actually provides, and surface tool-result details (fetched-page mode, Grep match totals) it previously missed.
|
||||
|
||||
- [#1127](https://github.com/MoonshotAI/kimi-code/pull/1127) [`184acf5`](https://github.com/MoonshotAI/kimi-code/commit/184acf5db521a964a8af9dfdb1502121a9be76dc) - Plugins can now declare hooks in their manifest to run scripts on lifecycle events.
|
||||
|
||||
- [#1128](https://github.com/MoonshotAI/kimi-code/pull/1128) [`0886bff`](https://github.com/MoonshotAI/kimi-code/commit/0886bff2bcd3aed954990c948201d84787c0f3f3) - Add a --allowed-host flag to kimi server run that lets extra Host header values pass the DNS-rebinding check, and include allow guidance in the 403 error message. Pass --allowed-host <host> to allow an extra host.
|
||||
|
||||
- [#1119](https://github.com/MoonshotAI/kimi-code/pull/1119) [`b0b2aee`](https://github.com/MoonshotAI/kimi-code/commit/b0b2aee8c5a496c2b679fc9dbbc05e3d1934d5d9) - Keep the terminal responsive in long conversations by caching rendered message lines.
|
||||
|
||||
- [#1119](https://github.com/MoonshotAI/kimi-code/pull/1119) [`b0b2aee`](https://github.com/MoonshotAI/kimi-code/commit/b0b2aee8c5a496c2b679fc9dbbc05e3d1934d5d9) - Keep long sessions responsive by retaining only recent turns in the transcript and collapsing older steps within each turn.
|
||||
|
||||
- [#1121](https://github.com/MoonshotAI/kimi-code/pull/1121) [`81ba48f`](https://github.com/MoonshotAI/kimi-code/commit/81ba48f45534e133947c4e5e78907c2ad0db0b90) - Make the web chat input grow with its content and add an expandable editor for longer messages.
|
||||
|
||||
- [#1133](https://github.com/MoonshotAI/kimi-code/pull/1133) [`f1c8175`](https://github.com/MoonshotAI/kimi-code/commit/f1c8175f9c5766f6a928fd07fb680e3159c564b0) - Fix the /web slash command not carrying the server token, so the opened web UI signs in automatically and the token is shown before the terminal exits.
|
||||
|
||||
## 0.20.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#1079](https://github.com/MoonshotAI/kimi-code/pull/1079) [`2db5fc2`](https://github.com/MoonshotAI/kimi-code/commit/2db5fc20ecdf3212afd47e7c26195e428f8eddd5) - Add shell mode for running shell commands.
|
||||
Type `!` in the input box to enable it.
|
||||
The command output is visible to the AI.
|
||||
For long-running commands, press Ctrl+B to move them to the background.
|
||||
For example, you can run `!gh auth login` to sign in to the GitHub CLI without opening a new terminal, so Kimi can use `gh`.
|
||||
|
||||
- [#1088](https://github.com/MoonshotAI/kimi-code/pull/1088) [`0030f76`](https://github.com/MoonshotAI/kimi-code/commit/0030f76c5cc6465c5a6646c166375127d83696d3) - Add a confirmation prompt before installing third-party plugins.
|
||||
|
||||
- [#1066](https://github.com/MoonshotAI/kimi-code/pull/1066) [`3554f7e`](https://github.com/MoonshotAI/kimi-code/commit/3554f7e7d6e472413aa7a9873d7a2eef5f2b819c) - Show update badges on the /plugins Installed tab, where Enter now installs the available update and I opens plugin details.
|
||||
|
||||
- [#1025](https://github.com/MoonshotAI/kimi-code/pull/1025) [`5ef66dd`](https://github.com/MoonshotAI/kimi-code/commit/5ef66ddfeda2f23c40fc0cf53225cdaf3cc1147d) - Redesign `/plugins` as a single tabbed panel: **Installed** (manage installed
|
||||
plugins — toggle, remove, MCP, details, reload), **Official** (Kimi-maintained
|
||||
marketplace plugins), **Third-party** (marketplace plugins from other
|
||||
publishers), and **Custom** (install straight from a GitHub URL, zip URL, or
|
||||
local path). `Tab` / `Shift-Tab` switch tabs. The Official and Third-party
|
||||
catalogs load lazily, so `/plugins` opens instantly and keeps working offline —
|
||||
a marketplace fetch failure is shown inline instead of closing the panel. The
|
||||
tab strip is shared with the `/model` provider tabs via the new `renderTabStrip`
|
||||
helper.
|
||||
|
||||
- [#1006](https://github.com/MoonshotAI/kimi-code/pull/1006) [`60dfb68`](https://github.com/MoonshotAI/kimi-code/commit/60dfb68a2d4c342cfbad5f48d4d269fb6cdd43c0) - Add server authentication and safe `--host` exposure. The local server now
|
||||
requires a per-start bearer token on all API and WebSocket calls (the CLI reads
|
||||
it automatically), enforces Host/Origin checks, and gains `--host` with a
|
||||
public-binding hardening tier: mandatory `KIMI_CODE_PASSWORD`, TLS (or
|
||||
`--insecure-no-tls`), auth-failure rate limiting, disabled remote
|
||||
shutdown/terminals, and security response headers. See `packages/server/SECURITY.md`.
|
||||
|
||||
- [#1040](https://github.com/MoonshotAI/kimi-code/pull/1040) [`6664038`](https://github.com/MoonshotAI/kimi-code/commit/66640380ebf60141994986beadf5347617f82814) - Replace silent AGENTS.md truncation with a visible warning in the TUI status bar and web UI.
|
||||
|
||||
- [#1101](https://github.com/MoonshotAI/kimi-code/pull/1101) [`3ea6ac2`](https://github.com/MoonshotAI/kimi-code/commit/3ea6ac278d2e57bb859ab423704bbd0fb2033c72) - Show the plan body and approach choices in the plan review card when exiting plan mode in the web UI.
|
||||
|
||||
- [#1103](https://github.com/MoonshotAI/kimi-code/pull/1103) [`18f7c34`](https://github.com/MoonshotAI/kimi-code/commit/18f7c34a0739dab454af1f09d951a1bbf278cccb) - Show a line-by-line diff when the agent edits or writes a file in the web chat.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#1072](https://github.com/MoonshotAI/kimi-code/pull/1072) [`a86bb97`](https://github.com/MoonshotAI/kimi-code/commit/a86bb9757d99f32983e82a6a82fd3ccaab691b1a) - Improve the image paste hint.
|
||||
|
||||
- [#1076](https://github.com/MoonshotAI/kimi-code/pull/1076) [`500677a`](https://github.com/MoonshotAI/kimi-code/commit/500677ab8baf9081b73a35df5fbbcfc49cb2f9b7) - Fix Ctrl-C during compaction so it clears a pending editor draft first instead of cancelling immediately.
|
||||
|
||||
- [#1067](https://github.com/MoonshotAI/kimi-code/pull/1067) [`0e227ba`](https://github.com/MoonshotAI/kimi-code/commit/0e227ba18aec793aa4c233be7c578068ae91e604) - Fix explore subagents silently losing git context when git commands time out or the directory is not a repository.
|
||||
|
||||
- [#1075](https://github.com/MoonshotAI/kimi-code/pull/1075) [`3aaf1e5`](https://github.com/MoonshotAI/kimi-code/commit/3aaf1e58037c4045aaa3b9fbabaffa158c60d2ca) - Fix a startup crash on Linux caused by an unhandled native clipboard error.
|
||||
|
||||
- [#1094](https://github.com/MoonshotAI/kimi-code/pull/1094) [`8ee5c0f`](https://github.com/MoonshotAI/kimi-code/commit/8ee5c0ff813d361733226a1606e7c724e5e38f2e) - Fix the terminal window repeatedly losing focus on Linux Wayland, which broke IME input.
|
||||
|
||||
- [#1057](https://github.com/MoonshotAI/kimi-code/pull/1057) [`ee69e16`](https://github.com/MoonshotAI/kimi-code/commit/ee69e16dc8fb18153d7ddff04bef1f4fc593688a) - Fix MCP server working directories when sessions are hosted by the web server.
|
||||
|
||||
- [#1064](https://github.com/MoonshotAI/kimi-code/pull/1064) [`a752a53`](https://github.com/MoonshotAI/kimi-code/commit/a752a5309b3c456f7da0e6141bcd435b497d127a) - Fix truncated skill descriptions missing an ellipsis in the model's skill listing.
|
||||
|
||||
- [#903](https://github.com/MoonshotAI/kimi-code/pull/903) [`bbd8a1a`](https://github.com/MoonshotAI/kimi-code/commit/bbd8a1a947ba26c0e59f98819cab9e20898ff0b7) - Fix `kimi web` and `/web` failing to start the background server daemon on Windows with `spawn EFTYPE` when the CLI is installed via npm/pnpm or run from source. The official single-binary install script was not affected.
|
||||
|
||||
- [#1070](https://github.com/MoonshotAI/kimi-code/pull/1070) [`ff17715`](https://github.com/MoonshotAI/kimi-code/commit/ff177155ca630248bcd692421faab21e7b5be069) - Stop auto-dismissing questions in the web UI after 60 seconds so they wait for the user's answer.
|
||||
|
||||
- [#1097](https://github.com/MoonshotAI/kimi-code/pull/1097) [`27ef516`](https://github.com/MoonshotAI/kimi-code/commit/27ef5166955b5deaecc367a4b3393909b0ccc9f9) - Add a hint to the per-turn step limit error pointing users to the loop_control.max_steps_per_turn config option.
|
||||
|
||||
- [#1062](https://github.com/MoonshotAI/kimi-code/pull/1062) [`ea6a4bf`](https://github.com/MoonshotAI/kimi-code/commit/ea6a4bfe6ef8914f67f254f24b0c5c543c48a341) - Preserve full tool output logs when previews are truncated and link background task completion notifications to saved output.
|
||||
|
||||
- [#1086](https://github.com/MoonshotAI/kimi-code/pull/1086) [`fe667d7`](https://github.com/MoonshotAI/kimi-code/commit/fe667d7c2ef113aef8a9546148f980d9adf560a3) - `/reload` now refreshes the assistant's view of plugin skills, so plugin changes take effect in the current session instead of requiring a new one.
|
||||
|
||||
- [#1081](https://github.com/MoonshotAI/kimi-code/pull/1081) [`8fc6aa5`](https://github.com/MoonshotAI/kimi-code/commit/8fc6aa5f6842aa78acf8f23912342b721efcf7a9) - Sync session title changes across all connected clients in server mode.
|
||||
|
||||
- [#1078](https://github.com/MoonshotAI/kimi-code/pull/1078) [`75ca3b2`](https://github.com/MoonshotAI/kimi-code/commit/75ca3b21609d7197bb2c9b4389901595840ac7e3) - Add Ctrl+U and Ctrl+D as page up and page down shortcuts in the task output viewer.
|
||||
|
||||
- [#1069](https://github.com/MoonshotAI/kimi-code/pull/1069) [`d18aa16`](https://github.com/MoonshotAI/kimi-code/commit/d18aa1666a09b038d5a107e9a37fff1031b2e847) - Reduce streaming redraw cost for long assistant messages with code blocks.
|
||||
|
||||
- [#1112](https://github.com/MoonshotAI/kimi-code/pull/1112) [`6a97d0b`](https://github.com/MoonshotAI/kimi-code/commit/6a97d0bf431bc7038ce801da21164a67e07422d8) - Add a copy button to user messages in the web chat.
|
||||
|
||||
- [#1035](https://github.com/MoonshotAI/kimi-code/pull/1035) [`ea03f30`](https://github.com/MoonshotAI/kimi-code/commit/ea03f30e5174825049ed4dfedebf8e43fbe751a4) - Render LaTeX display math (`$$…$$`) in the web chat via KaTeX. Single `$` is intentionally left as literal text, so prices, env vars, and shell paths (e.g. `$PATH`, `$5/$10`, `$HOME/bin`) are never swallowed as a formula.
|
||||
|
||||
- [#1084](https://github.com/MoonshotAI/kimi-code/pull/1084) [`d6e5246`](https://github.com/MoonshotAI/kimi-code/commit/d6e524682d9fb95460fceb86e17632ed858f7fcb) - Page the web session list per workspace so the first screen no longer fetches every session up front.
|
||||
|
||||
- [#1113](https://github.com/MoonshotAI/kimi-code/pull/1113) [`6194d3f`](https://github.com/MoonshotAI/kimi-code/commit/6194d3fad3b53e6c2b80c422fe98043145494655) - Keep the web session sidebar from re-rendering on every streaming token. The
|
||||
event reducer now reuses the `sessions` array reference for events that do not
|
||||
change sessions, so the sidebar computeds (`sessionsForView` / `workspaceGroups`
|
||||
/ `mergedWorkspaces`) are no longer dirtied by unrelated high-frequency events.
|
||||
|
||||
- [#1087](https://github.com/MoonshotAI/kimi-code/pull/1087) [`884b65a`](https://github.com/MoonshotAI/kimi-code/commit/884b65a04014be8d68ffd406f89fc2d26af6e62c) - Fix duplicate session snapshot reloads in the bundled web UI during resync.
|
||||
|
||||
- [#1109](https://github.com/MoonshotAI/kimi-code/pull/1109) [`d554f9a`](https://github.com/MoonshotAI/kimi-code/commit/d554f9ac8771be09b5c9a56943167dd45108dc4f) - Show the full accumulated progress of a subagent in its detail panel, with concise tool-call summaries instead of raw JSON.
|
||||
|
||||
- [#1065](https://github.com/MoonshotAI/kimi-code/pull/1065) [`4b837d6`](https://github.com/MoonshotAI/kimi-code/commit/4b837d6bfbf3850807b5f88ccdd10f31e69b019c) - Create missing parent directories automatically when writing a file.
|
||||
|
||||
## 0.19.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#999](https://github.com/MoonshotAI/kimi-code/pull/999) [`6b68aa8`](https://github.com/MoonshotAI/kimi-code/commit/6b68aa85e2a58cfdaacba5580f66a6a74550ccf6) - Add `-c` as a shorthand for `--continue`.
|
||||
|
||||
- [#1028](https://github.com/MoonshotAI/kimi-code/pull/1028) [`be77d5d`](https://github.com/MoonshotAI/kimi-code/commit/be77d5da03b96ebc24169ef563be1dc1c545590f) - Show a transient footer hint when an image is detected in the clipboard, displaying the platform-appropriate paste shortcut.
|
||||
|
||||
- [#1004](https://github.com/MoonshotAI/kimi-code/pull/1004) [`d70c3a8`](https://github.com/MoonshotAI/kimi-code/commit/d70c3a8c0121f55e5f29f9a2ad01b17df449467a) - Show the command in running Bash tool cards and allow expanding it with Ctrl+O before the result arrives.
|
||||
|
||||
- [#1009](https://github.com/MoonshotAI/kimi-code/pull/1009) [`e47de61`](https://github.com/MoonshotAI/kimi-code/commit/e47de610e4de9b11ccd182c0c16387f9d3fb0de4) - Add a Ctrl+T shortcut to expand and collapse a truncated todo list.
|
||||
|
||||
- [#1028](https://github.com/MoonshotAI/kimi-code/pull/1028) [`be77d5d`](https://github.com/MoonshotAI/kimi-code/commit/be77d5da03b96ebc24169ef563be1dc1c545590f) - Fix stale rows occasionally leaving duplicate input boxes after tall content shrinks.
|
||||
|
||||
- [#1028](https://github.com/MoonshotAI/kimi-code/pull/1028) [`be77d5d`](https://github.com/MoonshotAI/kimi-code/commit/be77d5da03b96ebc24169ef563be1dc1c545590f) - Fix inline images being rendered as broken escape sequences in the transcript.
|
||||
|
||||
- [#1027](https://github.com/MoonshotAI/kimi-code/pull/1027) [`c240bfa`](https://github.com/MoonshotAI/kimi-code/commit/c240bfab7d2b00d41b993f681be612f2db45baa7) - Fix resume not realigning a tool call that was interrupted mid-history. The synthetic interrupted result is now closed in place at the next step boundary, so later turns and deferred messages keep their recorded order instead of only the trailing exchange being repaired. The `/messages` wire transcript reducer mirrors the same closure so its folded length stays aligned with live history, preventing the later turn from being duplicated/reordered. Replay also drops a tool result whose call is no longer awaiting one, so a stale interrupted result left at the log tail by an older resume of a damaged session is not re-applied as a duplicate.
|
||||
|
||||
- [#1012](https://github.com/MoonshotAI/kimi-code/pull/1012) [`fd16ffb`](https://github.com/MoonshotAI/kimi-code/commit/fd16ffb80a90fda8a611a27a158b9b7a33e13303) - Show subcommand suggestions after Tab-completing a slash command name.
|
||||
|
||||
- [#1012](https://github.com/MoonshotAI/kimi-code/pull/1012) [`fd16ffb`](https://github.com/MoonshotAI/kimi-code/commit/fd16ffb80a90fda8a611a27a158b9b7a33e13303) - Fix the Tab key unexpectedly opening the file completion list.
|
||||
|
||||
- [#1044](https://github.com/MoonshotAI/kimi-code/pull/1044) [`9d197e0`](https://github.com/MoonshotAI/kimi-code/commit/9d197e0f67c879306b9d7659d66e9295e63faa5a) - Fix clipboard copy actions in the web UI when served over plain HTTP.
|
||||
|
||||
- [#1032](https://github.com/MoonshotAI/kimi-code/pull/1032) [`a753b05`](https://github.com/MoonshotAI/kimi-code/commit/a753b0535e44f624289715bd560cf1346149e786) - Fix code blocks nested inside list items rendering blank in the web chat after a turn finishes generating.
|
||||
|
||||
- [#1015](https://github.com/MoonshotAI/kimi-code/pull/1015) [`83384ee`](https://github.com/MoonshotAI/kimi-code/commit/83384ee6d46b37c00b7b8f160a7c48aebbd6921e) - Fix the composer's ↑/↓ input-history recall doing nothing right after the first message of a new session. The history is now persisted to localStorage and re-read on mount, so the docked composer no longer starts empty when it takes over from the empty-session composer. Slash commands are now recorded too — both typed-and-submitted and ones picked from the slash menu — so they can be recalled like plain messages.
|
||||
|
||||
- [#1003](https://github.com/MoonshotAI/kimi-code/pull/1003) [`e15edfd`](https://github.com/MoonshotAI/kimi-code/commit/e15edfd017506fde396b8b0dcf68008b61b39752) - Fix the web question prompt missing the free-text Other option.
|
||||
|
||||
- [#1056](https://github.com/MoonshotAI/kimi-code/pull/1056) [`b93e936`](https://github.com/MoonshotAI/kimi-code/commit/b93e9365b68d53f8f1a148e7349a5865b1b669a8) - Fix yolo mode in the web app auto-approving plan reviews and sensitive file access.
|
||||
|
||||
- [#971](https://github.com/MoonshotAI/kimi-code/pull/971) [`b84704b`](https://github.com/MoonshotAI/kimi-code/commit/b84704bff39ae5cb382d2a8dc0911db286e84ead) - Read large text files in bounded memory and read tail lines without scanning whole files.
|
||||
|
||||
- [#1020](https://github.com/MoonshotAI/kimi-code/pull/1020) [`9c553e4`](https://github.com/MoonshotAI/kimi-code/commit/9c553e4bf7d0a2c09030212fe06577343ea76a60) - Add an Alt+S shortcut in the model picker to switch the model for the current session only, without saving it as the default.
|
||||
|
||||
- [#1043](https://github.com/MoonshotAI/kimi-code/pull/1043) [`27df39c`](https://github.com/MoonshotAI/kimi-code/commit/27df39c7ed2b012815c380a33fe56bd37c7fc7c1) - Fix web chat stop actions so stale prompt ids fall back to cancelling the active session.
|
||||
|
||||
- [#1036](https://github.com/MoonshotAI/kimi-code/pull/1036) [`866b91c`](https://github.com/MoonshotAI/kimi-code/commit/866b91c8f5dc98dfc18e5c658beaa11afea5032e) - Reorganize the web app's components into area subdirectories (chat/settings/dialogs/mobile) and refresh the component path comments.
|
||||
|
||||
- [#1042](https://github.com/MoonshotAI/kimi-code/pull/1042) [`dc6b9ef`](https://github.com/MoonshotAI/kimi-code/commit/dc6b9ef02bf7583d166c8c5b001a960329c225f8) - Add a development-mode indicator to the web sidebar for local development.
|
||||
|
||||
- [#1047](https://github.com/MoonshotAI/kimi-code/pull/1047) [`98d3e5b`](https://github.com/MoonshotAI/kimi-code/commit/98d3e5b71d5760475f7a5a23b2b794584d12b89b) - Keep the web sidebar's workspace order stable and let workspaces be reordered by drag-and-drop, persisted locally instead of following recent activity; sessions now also float to the top of their group as soon as a new message arrives.
|
||||
|
||||
- [#1034](https://github.com/MoonshotAI/kimi-code/pull/1034) [`603a767`](https://github.com/MoonshotAI/kimi-code/commit/603a7679de91e221802a7f7b0ab7df23c7e5526c) - Extract the composer's image/video attachment handling into a reusable composable.
|
||||
|
||||
- [#1031](https://github.com/MoonshotAI/kimi-code/pull/1031) [`2bfd686`](https://github.com/MoonshotAI/kimi-code/commit/2bfd6860e487f902be53fd5f52f03e66d1839ae2) - Extract the composer's text state and per-session draft persistence into a reusable composable.
|
||||
|
||||
- [#1011](https://github.com/MoonshotAI/kimi-code/pull/1011) [`fb780fc`](https://github.com/MoonshotAI/kimi-code/commit/fb780fce9665e2119cee6d0bc7f85895c6970865) - Extract the composer's shell-style input-history recall into a reusable composable.
|
||||
|
||||
- [#1030](https://github.com/MoonshotAI/kimi-code/pull/1030) [`661c1fb`](https://github.com/MoonshotAI/kimi-code/commit/661c1fbe5b026ec32d80696290a18313b24eafef) - Extract the composer's @-mention menu logic into a reusable composable.
|
||||
|
||||
- [#1026](https://github.com/MoonshotAI/kimi-code/pull/1026) [`318c964`](https://github.com/MoonshotAI/kimi-code/commit/318c964f074123ad228cbddcf7809fa4baaa7fb2) - Extract the composer's slash-command menu logic into a reusable composable.
|
||||
|
||||
- [#1045](https://github.com/MoonshotAI/kimi-code/pull/1045) [`ac1882f`](https://github.com/MoonshotAI/kimi-code/commit/ac1882fe28c906904ffaacd8434bb20e689d6677) - Persist the collapsed state of workspace groups in the web sidebar across page reloads.
|
||||
|
||||
- [#1001](https://github.com/MoonshotAI/kimi-code/pull/1001) [`ea1b33b`](https://github.com/MoonshotAI/kimi-code/commit/ea1b33b6743b822aa5083dbeb2d5e84a78b0ab3d) - Extract pure turn-rendering helpers out of the chat pane into their own module.
|
||||
|
||||
- [#1010](https://github.com/MoonshotAI/kimi-code/pull/1010) [`a2650f8`](https://github.com/MoonshotAI/kimi-code/commit/a2650f85d467707e7c85d22cff590f68852d33f3) - Extract the beta conversation outline (table of contents) into its own component.
|
||||
|
||||
- [#998](https://github.com/MoonshotAI/kimi-code/pull/998) [`3e4793d`](https://github.com/MoonshotAI/kimi-code/commit/3e4793d6111059cbfb97159f682ed4bd7a33441d) - Extract the workspace group rendering out of the sidebar into its own component.
|
||||
|
||||
- [#985](https://github.com/MoonshotAI/kimi-code/pull/985) [`92c2cf0`](https://github.com/MoonshotAI/kimi-code/commit/92c2cf0ef57f00928d337bcfeb1d7eff9b0d0f7f) - Allow the web sidebar and detail panel to be resized up to the available viewport width, keeping their resize handles reachable on narrow windows.
|
||||
|
||||
- [#1033](https://github.com/MoonshotAI/kimi-code/pull/1033) [`b1e6b64`](https://github.com/MoonshotAI/kimi-code/commit/b1e6b6431903fde002fdddbdfcabfab39f3ef5c5) - Optimize the loading tips display.
|
||||
|
||||
## 0.19.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#992](https://github.com/MoonshotAI/kimi-code/pull/992) [`7341fb4`](https://github.com/MoonshotAI/kimi-code/commit/7341fb4979523d4429ccf9177b5e3907f544d8c0) - Fix ACP editors such as Zed failing to start a new thread.
|
||||
|
||||
- [#984](https://github.com/MoonshotAI/kimi-code/pull/984) [`da81858`](https://github.com/MoonshotAI/kimi-code/commit/da81858802127cb8bb8ed2deaa1989793b356adf) - Clear all per-session state when a session is archived or removed, so archived sessions no longer leave orphaned data behind.
|
||||
|
||||
- [#978](https://github.com/MoonshotAI/kimi-code/pull/978) [`d4ae02d`](https://github.com/MoonshotAI/kimi-code/commit/d4ae02d82e9da0d163ea4235a54d6535c591172e) - Fix the web sidebar's unread dots getting out of sync across browser tabs.
|
||||
|
||||
- [#979](https://github.com/MoonshotAI/kimi-code/pull/979) [`8c6cade`](https://github.com/MoonshotAI/kimi-code/commit/8c6cade69efa42fdcc280f51a283ea6f717d62fc) - Consolidate web client localStorage access and split the root state store and app shell into focused composables.
|
||||
|
||||
## 0.19.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#812](https://github.com/MoonshotAI/kimi-code/pull/812) [`c0eeca2`](https://github.com/MoonshotAI/kimi-code/commit/c0eeca24692edd736eecd3c2541d7566bac9f80f) - Added the ability to add extra workspace directories:
|
||||
|
||||
- Use the `/add-dir <path>` command to add extra working directories to the current session, or remember them for the project.
|
||||
- Use `kimi --add-dir <path>` to add them on startup.
|
||||
- Project-level local config is now managed in `.kimi-code/local.toml`; we recommend adding it to your `.gitignore`.
|
||||
|
||||
- [#975](https://github.com/MoonshotAI/kimi-code/pull/975) [`c5c1834`](https://github.com/MoonshotAI/kimi-code/commit/c5c18347251221fab74e4f452ac4910116c4224d) - Speed up session snapshot loading with a direct disk reader and a request timeout safeguard, keeping the previous path as a legacy fallback.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#910](https://github.com/MoonshotAI/kimi-code/pull/910) [`7644f10`](https://github.com/MoonshotAI/kimi-code/commit/7644f1036ca1079e4527c0b1c825ec5384d6d8da) - Fix provider requests failing when restored conversation history contains empty text content blocks.
|
||||
|
||||
- [#963](https://github.com/MoonshotAI/kimi-code/pull/963) [`4292ae9`](https://github.com/MoonshotAI/kimi-code/commit/4292ae9f9bc49e9edaaaeae50dbddabbd4b9bb25) - Surface provider safety-policy blocks instead of silently treating them as completed turns, and prevent the context token count from dropping to zero after a filtered response.
|
||||
|
||||
- [#970](https://github.com/MoonshotAI/kimi-code/pull/970) [`2730079`](https://github.com/MoonshotAI/kimi-code/commit/27300797f2149900219b05dda49dce65e71fa85a) - Detect the real image format from file contents when reading media, so a mismatched filename extension no longer produces a data URL the model API rejects.
|
||||
|
||||
- [#977](https://github.com/MoonshotAI/kimi-code/pull/977) [`d521932`](https://github.com/MoonshotAI/kimi-code/commit/d521932c3e99a0c5fa1d5d658cf1cd64f0306a75) - Stop showing unread dots on cancelled or failed sessions in the web sidebar.
|
||||
|
||||
- [#957](https://github.com/MoonshotAI/kimi-code/pull/957) [`b57fc90`](https://github.com/MoonshotAI/kimi-code/commit/b57fc905fe480aac07839dd0213768dbeb2a8002) - Fix commands flashing an empty console window on Windows.
|
||||
|
||||
- [#821](https://github.com/MoonshotAI/kimi-code/pull/821) [`ba64072`](https://github.com/MoonshotAI/kimi-code/commit/ba64072559c1e9bb3447ede39991ac2e8bdb7645) - Allow long-running foreground commands and subagents to be moved into background tasks with Ctrl+B, and inspect them via the `/tasks` panel.
|
||||
|
||||
- [#812](https://github.com/MoonshotAI/kimi-code/pull/812) [`c0eeca2`](https://github.com/MoonshotAI/kimi-code/commit/c0eeca24692edd736eecd3c2541d7566bac9f80f) - Polish file mention UX.
|
||||
|
||||
- [#974](https://github.com/MoonshotAI/kimi-code/pull/974) [`d434d8f`](https://github.com/MoonshotAI/kimi-code/commit/d434d8f0d809599f4ae7de77b58e337bfd4ebcc9) - Unify image format detection when sniffing fails.
|
||||
|
||||
- [#958](https://github.com/MoonshotAI/kimi-code/pull/958) [`98905eb`](https://github.com/MoonshotAI/kimi-code/commit/98905eb409ec643fd916a13beecec85212f834bd) - Show longer branch names in the web chat header and expose the full name on hover.
|
||||
|
||||
- [#964](https://github.com/MoonshotAI/kimi-code/pull/964) [`4223739`](https://github.com/MoonshotAI/kimi-code/commit/42237392ddc3a0816c045da23e77c4875cc692e5) - Keep the web page title fixed instead of changing with the session or workspace name.
|
||||
|
||||
- [#973](https://github.com/MoonshotAI/kimi-code/pull/973) [`3b9938b`](https://github.com/MoonshotAI/kimi-code/commit/3b9938b4c3a386394ed4d35c7b89b48878476977) - Consolidate web client localStorage access and decouple appearance/notification state into dedicated modules.
|
||||
|
||||
## 0.18.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#888](https://github.com/MoonshotAI/kimi-code/pull/888) [`58898de`](https://github.com/MoonshotAI/kimi-code/commit/58898de0200d6626ca634e344fe85b860abcfd1b) - Add an environment variable to cap AgentSwarm concurrency during the initial ramp, so large swarms do not trip provider rate limits as easily.
|
||||
|
||||
- [#895](https://github.com/MoonshotAI/kimi-code/pull/895) [`495fe8c`](https://github.com/MoonshotAI/kimi-code/commit/495fe8c674d654cdf87217ca4ada775507f861f6) - Add instant session search to the web sidebar, filtering by title and the last user prompt.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#896](https://github.com/MoonshotAI/kimi-code/pull/896) [`de610de`](https://github.com/MoonshotAI/kimi-code/commit/de610deb5f760606b82cc595e59c5176cc66ce82) - Fix the web workspace session count so it drops to 0 after archiving the last session instead of staying at 1.
|
||||
|
||||
- [#876](https://github.com/MoonshotAI/kimi-code/pull/876) [`49183d8`](https://github.com/MoonshotAI/kimi-code/commit/49183d8729e3e7d361a253dc5c68f409e6382ba9) - Suggest `/reload` alongside `/new` in plugin-change hints.
|
||||
|
||||
- [#867](https://github.com/MoonshotAI/kimi-code/pull/867) [`d1dc2a3`](https://github.com/MoonshotAI/kimi-code/commit/d1dc2a3e77ec1422d60cb008c5520a44a2ed7c00) - Redesign the web OAuth login dialog: lead with a single "Authorize in browser" button that opens the verification link with the device code already embedded, demote manual code entry to a clearly secondary fallback, and drop the duplicate open-browser and cancel controls so the order of steps is unambiguous.
|
||||
|
||||
- [#867](https://github.com/MoonshotAI/kimi-code/pull/867) [`d1dc2a3`](https://github.com/MoonshotAI/kimi-code/commit/d1dc2a3e77ec1422d60cb008c5520a44a2ed7c00) - Fix the web login slash command description to match the browser authorization flow.
|
||||
|
||||
- [#893](https://github.com/MoonshotAI/kimi-code/pull/893) [`d7ec056`](https://github.com/MoonshotAI/kimi-code/commit/d7ec05686a09580f9ffd99f6ef26385aed8eb02c) - Add scroll-up lazy loading for older messages in the web chat session view, and fix the "new messages" pill overlapping the composer dock.
|
||||
|
||||
- [#882](https://github.com/MoonshotAI/kimi-code/pull/882) [`8ab9e96`](https://github.com/MoonshotAI/kimi-code/commit/8ab9e969637ffee18b09a0b265ffa860c5a2e11c) - Fix the web app only loading the 20 most recent sessions; it now follows pagination so older sessions are reachable.
|
||||
|
||||
- [#889](https://github.com/MoonshotAI/kimi-code/pull/889) [`23277a5`](https://github.com/MoonshotAI/kimi-code/commit/23277a574c7e0782c04f62e10370494247be3a66) - Show the connected server version in the web settings General tab.
|
||||
|
||||
- [#881](https://github.com/MoonshotAI/kimi-code/pull/881) [`7bc3d99`](https://github.com/MoonshotAI/kimi-code/commit/7bc3d99933b0bbc3f9188a2b02bcc90e81623f72) - Keep the highlighted web slash command visible while navigating a long slash menu.
|
||||
|
||||
- [#878](https://github.com/MoonshotAI/kimi-code/pull/878) [`a74a6b7`](https://github.com/MoonshotAI/kimi-code/commit/a74a6b7f6b1d13d24eae356a2208c012128b180d) - Allow long web slash command names and descriptions to wrap without overflowing the slash menu.
|
||||
|
||||
- [#878](https://github.com/MoonshotAI/kimi-code/pull/878) [`a74a6b7`](https://github.com/MoonshotAI/kimi-code/commit/a74a6b7f6b1d13d24eae356a2208c012128b180d) - Fix web slash skill selection sending immediately and allow slash search to match skill names by substring.
|
||||
|
||||
## 0.17.1
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@moonshot-ai/kimi-code",
|
||||
"version": "0.17.1",
|
||||
"version": "0.20.3",
|
||||
"description": "The Starting Point for Next-Gen Agents",
|
||||
"license": "MIT",
|
||||
"author": "Moonshot AI",
|
||||
|
|
@ -74,17 +74,9 @@
|
|||
"postinstall": "node scripts/postinstall.mjs"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@mariozechner/clipboard": "^0.3.2",
|
||||
"chalk": "^5.4.1",
|
||||
"cli-highlight": "^2.1.11",
|
||||
"commander": "^13.1.0",
|
||||
"@mariozechner/clipboard": "^0.3.9",
|
||||
"koffi": "^2.16.0",
|
||||
"node-pty": "^1.1.0",
|
||||
"pathe": "^2.0.3",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"semver": "^7.7.4",
|
||||
"smol-toml": "^1.6.1",
|
||||
"zod": "^4.3.6"
|
||||
"node-pty": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@earendil-works/pi-tui": "^0.74.0",
|
||||
|
|
|
|||
|
|
@ -44,7 +44,8 @@ export function createProgram(
|
|||
.hideHelp()
|
||||
.argParser((val: string | boolean) => (val === true ? '' : (val as string))),
|
||||
)
|
||||
.option('-C, --continue', 'Continue the previous session for the working directory.', false)
|
||||
.option('-c, --continue', 'Continue the previous session for the working directory.', false)
|
||||
.addOption(new Option('-C').hideHelp().default(false))
|
||||
.option('-y, --yolo', 'Automatically approve all actions.', false)
|
||||
.option('--auto', 'Start in auto permission mode.', false)
|
||||
.addOption(
|
||||
|
|
@ -73,6 +74,14 @@ export function createProgram(
|
|||
.argParser((value: string, previous: string[] | undefined) => [...(previous ?? []), value])
|
||||
.default([]),
|
||||
)
|
||||
.addOption(
|
||||
new Option(
|
||||
'--add-dir <dir>',
|
||||
'Add an additional workspace directory for this session. Can be repeated.',
|
||||
)
|
||||
.argParser((value: string, previous: string[] | undefined) => [...(previous ?? []), value])
|
||||
.default([]),
|
||||
)
|
||||
.addOption(new Option('--yes').hideHelp().default(false))
|
||||
.addOption(new Option('--auto-approve').hideHelp().default(false))
|
||||
.option('--plan', 'Start in plan mode.', false);
|
||||
|
|
@ -87,6 +96,7 @@ export function createProgram(
|
|||
registerMigrateCommand(program, onMigrate);
|
||||
program
|
||||
.command('upgrade')
|
||||
.alias('update')
|
||||
.description('Upgrade Kimi Code to the latest version.')
|
||||
.action(async () => {
|
||||
await onUpgrade();
|
||||
|
|
@ -115,7 +125,7 @@ export function createProgram(
|
|||
|
||||
const opts: CLIOptions = {
|
||||
session: sessionValue,
|
||||
continue: raw['continue'] as boolean,
|
||||
continue: raw['continue'] === true || raw['C'] === true,
|
||||
yolo: yoloValue,
|
||||
auto: autoValue,
|
||||
plan: raw['plan'] as boolean,
|
||||
|
|
@ -123,6 +133,7 @@ export function createProgram(
|
|||
outputFormat: raw['outputFormat'] as CLIOptions['outputFormat'],
|
||||
prompt: raw['prompt'] as string | undefined,
|
||||
skillsDirs: raw['skillsDir'] as string[],
|
||||
addDirs: raw['addDir'] as string[],
|
||||
};
|
||||
|
||||
onMain(opts);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export interface CLIOptions {
|
|||
outputFormat: PromptOutputFormat | undefined;
|
||||
prompt: string | undefined;
|
||||
skillsDirs: string[];
|
||||
addDirs?: string[];
|
||||
}
|
||||
|
||||
export interface ValidatedOptions {
|
||||
|
|
|
|||
|
|
@ -78,11 +78,12 @@ export async function runPrompt(
|
|||
telemetry: telemetryClient,
|
||||
onOAuthRefresh: (outcome) => {
|
||||
if (outcome.success) {
|
||||
track('oauth_refresh', { success: true });
|
||||
track('oauth_refresh', { outcome: 'success' });
|
||||
return;
|
||||
}
|
||||
track('oauth_refresh', { success: false, reason: outcome.reason });
|
||||
track('oauth_refresh', { outcome: 'error', reason: outcome.reason });
|
||||
},
|
||||
sessionStartedProperties: { yolo: false, plan: false, afk: true },
|
||||
});
|
||||
log.info('kimi-code starting', {
|
||||
version,
|
||||
|
|
@ -115,7 +116,7 @@ export async function runPrompt(
|
|||
for (const warning of (await harness.getConfigDiagnostics()).warnings) {
|
||||
stderr.write(`Warning: ${warning}\n`);
|
||||
}
|
||||
const { session, resumed, restorePermission, telemetryModel, goalModel } =
|
||||
const { session, restorePermission, telemetryModel, goalModel } =
|
||||
await resolvePromptSession(
|
||||
harness,
|
||||
opts,
|
||||
|
|
@ -138,13 +139,6 @@ export async function runPrompt(
|
|||
});
|
||||
setCrashPhase('runtime');
|
||||
|
||||
withTelemetryContext({ sessionId: session.id }).track('started', {
|
||||
resumed,
|
||||
yolo: false,
|
||||
plan: false,
|
||||
afk: true,
|
||||
});
|
||||
|
||||
const outputFormat = opts.outputFormat ?? 'text';
|
||||
// Headless goal mode: `kimi -p "/goal <objective>"`. The goal driver keeps
|
||||
// the turn-run alive across continuation turns, so the normal prompt-turn
|
||||
|
|
@ -159,7 +153,7 @@ export async function runPrompt(
|
|||
writeResumeHint(session.id, outputFormat, stdout, stderr);
|
||||
|
||||
withTelemetryContext({ sessionId: session.id }).track('exit', {
|
||||
duration_s: (Date.now() - startedAt) / 1000,
|
||||
duration_ms: Date.now() - startedAt,
|
||||
});
|
||||
} finally {
|
||||
await cleanupPromptRun();
|
||||
|
|
@ -243,7 +237,10 @@ async function resolvePromptSession(
|
|||
`Session "${opts.session}" was created under a different directory.`,
|
||||
);
|
||||
}
|
||||
const session = await harness.resumeSession({ id: opts.session });
|
||||
const session = await harness.resumeSession({
|
||||
id: opts.session,
|
||||
additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined,
|
||||
});
|
||||
const status = await session.getStatus();
|
||||
const restorePermission = await forcePromptPermission(
|
||||
session,
|
||||
|
|
@ -267,7 +264,10 @@ async function resolvePromptSession(
|
|||
const sessions = await harness.listSessions({ workDir });
|
||||
const previous = sessions[0];
|
||||
if (previous !== undefined) {
|
||||
const session = await harness.resumeSession({ id: previous.id });
|
||||
const session = await harness.resumeSession({
|
||||
id: previous.id,
|
||||
additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined,
|
||||
});
|
||||
const status = await session.getStatus();
|
||||
const restorePermission = await forcePromptPermission(
|
||||
session,
|
||||
|
|
@ -290,7 +290,12 @@ async function resolvePromptSession(
|
|||
}
|
||||
|
||||
const model = requireConfiguredModel(opts.model, defaultModel);
|
||||
const session = await harness.createSession({ workDir, model, permission: 'auto' });
|
||||
const session = await harness.createSession({
|
||||
workDir,
|
||||
model,
|
||||
permission: 'auto',
|
||||
additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined,
|
||||
});
|
||||
installHeadlessHandlers(session);
|
||||
return {
|
||||
session,
|
||||
|
|
@ -801,5 +806,8 @@ function hasTurnId(event: Event): event is Event & { readonly turnId: number } {
|
|||
|
||||
function formatTurnEndedFailure(event: Extract<Event, { type: 'turn.ended' }>): string {
|
||||
if (event.error !== undefined) return `${event.error.code}: ${event.error.message}`;
|
||||
if (event.reason === 'filtered') {
|
||||
return 'Provider safety policy blocked the response.';
|
||||
}
|
||||
return `Prompt turn ended with reason: ${event.reason}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,14 +64,15 @@ export async function runShell(
|
|||
telemetry: telemetryClient,
|
||||
onOAuthRefresh: (outcome) => {
|
||||
if (outcome.success) {
|
||||
track('oauth_refresh', { success: true });
|
||||
track('oauth_refresh', { outcome: 'success' });
|
||||
return;
|
||||
}
|
||||
track('oauth_refresh', {
|
||||
success: false,
|
||||
outcome: 'error',
|
||||
reason: outcome.reason,
|
||||
});
|
||||
},
|
||||
sessionStartedProperties: { yolo: opts.yolo, auto: opts.auto, plan: opts.plan, afk: false },
|
||||
});
|
||||
log.info('kimi-code starting', {
|
||||
version,
|
||||
|
|
@ -99,6 +100,7 @@ export async function runShell(
|
|||
const configMs = Date.now() - configStartedAt;
|
||||
const tui = new KimiTUI(harness, {
|
||||
cliOptions: opts,
|
||||
additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined,
|
||||
tuiConfig,
|
||||
version,
|
||||
workDir,
|
||||
|
|
@ -116,7 +118,6 @@ export async function runShell(
|
|||
});
|
||||
setCrashPhase('runtime');
|
||||
|
||||
const resumed = opts.continue || opts.session !== undefined;
|
||||
const trackLifecycleForSession = (
|
||||
sessionId: string,
|
||||
event: string,
|
||||
|
|
@ -136,7 +137,7 @@ export async function runShell(
|
|||
const sessionId = tui.getCurrentSessionId();
|
||||
const hasContent = tui.hasSessionContent();
|
||||
setCrashPhase('shutdown');
|
||||
trackLifecycle('exit', { duration_s: (Date.now() - startedAt) / 1000 });
|
||||
trackLifecycle('exit', { duration_ms: Date.now() - startedAt });
|
||||
await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS });
|
||||
const gutter = ' '.repeat(CHROME_GUTTER);
|
||||
process.stdout.write(`${gutter}Bye!\n`);
|
||||
|
|
@ -161,13 +162,6 @@ export async function runShell(
|
|||
const initStartedAt = Date.now();
|
||||
await tui.start();
|
||||
const initMs = Date.now() - initStartedAt;
|
||||
trackLifecycle('started', {
|
||||
resumed,
|
||||
yolo: opts.yolo,
|
||||
auto: opts.auto,
|
||||
plan: opts.plan,
|
||||
afk: false,
|
||||
});
|
||||
const startupSessionId = tui.getCurrentSessionId();
|
||||
const mcpMs = await tui.getStartupMcpMs();
|
||||
trackLifecycleForSession(startupSessionId, 'startup_perf', {
|
||||
|
|
@ -178,7 +172,7 @@ export async function runShell(
|
|||
});
|
||||
} catch (error) {
|
||||
setCrashPhase('shutdown');
|
||||
trackLifecycle('exit', { duration_s: (Date.now() - startedAt) / 1000 });
|
||||
trackLifecycle('exit', { duration_ms: Date.now() - startedAt });
|
||||
await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS });
|
||||
await harness.close();
|
||||
throw error;
|
||||
|
|
|
|||
85
apps/kimi-code/src/cli/sub/server/access-urls.ts
Normal file
85
apps/kimi-code/src/cli/sub/server/access-urls.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* Build the clickable/copyable access URLs for the running server.
|
||||
*
|
||||
* Shared by the `server run` ready banner and `server rotate-token` so both
|
||||
* show the same Local/Network links. When a token is known it rides in the
|
||||
* `#token=` fragment (never sent to the server, so never logged), letting a
|
||||
* user open the link on another device and be authenticated automatically.
|
||||
*/
|
||||
|
||||
import { formatHostForUrl, listNetworkAddresses, type NetworkAddress } from './networks';
|
||||
|
||||
/**
|
||||
* Build a directly-openable server URL. When the token is known it is appended
|
||||
* as `#token=<token>`; otherwise the bare origin (with a trailing slash) is
|
||||
* returned.
|
||||
*/
|
||||
export function buildOpenableUrl(bareOrigin: string, token: string | undefined): string {
|
||||
const base = bareOrigin.endsWith('/') ? bareOrigin.slice(0, -1) : bareOrigin;
|
||||
return token === undefined ? `${base}/` : `${base}/#token=${token}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a full URL into the part before `#token=` and the `#token=…` fragment
|
||||
* itself, so callers can render the fragment in a de-emphasized color. Returns
|
||||
* `[fullUrl, '']` when there is no token fragment.
|
||||
*/
|
||||
export function splitTokenFragment(fullUrl: string): [string, string] {
|
||||
const marker = '#token=';
|
||||
const idx = fullUrl.indexOf(marker);
|
||||
return idx < 0 ? [fullUrl, ''] : [fullUrl.slice(0, idx), fullUrl.slice(idx)];
|
||||
}
|
||||
|
||||
export interface AccessUrlLine {
|
||||
/** Fixed-width label including trailing padding, e.g. `"Local: "`. */
|
||||
label: string;
|
||||
/** Full URL, carrying `#token=` when a token is known. */
|
||||
url: string;
|
||||
}
|
||||
|
||||
function isWildcard(host: string): boolean {
|
||||
return host === '' || host === '0.0.0.0' || host === '::';
|
||||
}
|
||||
|
||||
/** True when `host` is a loopback address (this host only). */
|
||||
export function isLoopbackHost(host: string): boolean {
|
||||
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
||||
}
|
||||
|
||||
function hostOrigin(host: string, port: number): string {
|
||||
const family = host.includes(':') ? 'IPv6' : 'IPv4';
|
||||
return `http://${formatHostForUrl(host, family)}:${port}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the access-URL lines for a bind host/port.
|
||||
*
|
||||
* - wildcard (`0.0.0.0` / `::` / empty): a `Local:` line (localhost) plus one
|
||||
* `Network:` line per non-loopback interface.
|
||||
* - loopback: a single `Local:` line.
|
||||
* - specific host: a single `URL:` line.
|
||||
*/
|
||||
export function accessUrlLines(
|
||||
host: string,
|
||||
port: number,
|
||||
token: string | undefined,
|
||||
networkAddresses?: NetworkAddress[],
|
||||
): AccessUrlLine[] {
|
||||
if (isWildcard(host)) {
|
||||
const lines: AccessUrlLine[] = [
|
||||
{ label: 'Local: ', url: buildOpenableUrl(`http://localhost:${port}`, token) },
|
||||
];
|
||||
const addrs = networkAddresses ?? listNetworkAddresses();
|
||||
for (const addr of addrs) {
|
||||
lines.push({
|
||||
label: 'Network: ',
|
||||
url: buildOpenableUrl(`http://${formatHostForUrl(addr.address, addr.family)}:${port}`, token),
|
||||
});
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
if (isLoopbackHost(host)) {
|
||||
return [{ label: 'Local: ', url: buildOpenableUrl(hostOrigin(host, port), token) }];
|
||||
}
|
||||
return [{ label: 'URL: ', url: buildOpenableUrl(hostOrigin(host, port), token) }];
|
||||
}
|
||||
|
|
@ -16,8 +16,8 @@
|
|||
* foreground runner so it can share the same bootstrap helpers.
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { appendFileSync, closeSync, mkdirSync, openSync } from 'node:fs';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { createServer } from 'node:net';
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
||||
|
|
@ -27,6 +27,7 @@ import { DEFAULT_LOCK_DIR, getLiveLock, type LockContents } from '@moonshot-ai/s
|
|||
import {
|
||||
DEFAULT_SERVER_HOST,
|
||||
DEFAULT_SERVER_PORT,
|
||||
LOCAL_SERVER_HOST,
|
||||
isServerHealthy,
|
||||
serverOrigin,
|
||||
waitForServerHealthy,
|
||||
|
|
@ -44,18 +45,34 @@ const POLL_INTERVAL_MS = 200;
|
|||
const DEFAULT_DAEMON_LOG_LEVEL = 'info';
|
||||
|
||||
export interface EnsureDaemonOptions {
|
||||
/** Bind host for the spawned daemon (default `127.0.0.1`). */
|
||||
host?: string;
|
||||
/** Preferred port; on conflict a free port is chosen automatically. */
|
||||
port?: number;
|
||||
/** Pino log level for the spawned daemon (defaults to `info`). */
|
||||
logLevel?: string;
|
||||
/** Mount `/api/v1/debug/*` routes on the spawned daemon. */
|
||||
debugEndpoints?: boolean;
|
||||
/** Allow a non-loopback bind without a TLS-terminating reverse proxy. */
|
||||
insecureNoTls?: boolean;
|
||||
/** Keep `POST /api/v1/shutdown` enabled on a non-loopback bind. */
|
||||
allowRemoteShutdown?: boolean;
|
||||
/** Keep the PTY `/api/v1/terminals/*` routes enabled on a non-loopback bind. */
|
||||
allowRemoteTerminals?: boolean;
|
||||
/** Extra `Host` header values to allow through the DNS-rebinding check. */
|
||||
allowedHosts?: readonly string[];
|
||||
/** Idle-shutdown grace in ms for the spawned daemon (daemon mode only). */
|
||||
idleGraceMs?: number;
|
||||
}
|
||||
|
||||
export interface EnsureDaemonResult {
|
||||
readonly origin: string;
|
||||
/** True when an already-running daemon was reused (no new server started). */
|
||||
readonly reused: boolean;
|
||||
/** Bind host the running daemon is actually listening on (from the lock). */
|
||||
readonly host: string;
|
||||
/** Port the running daemon is actually listening on (from the lock). */
|
||||
readonly port: number;
|
||||
}
|
||||
|
||||
/** Path of the daemon log file (shared with the OS-service log location). */
|
||||
|
|
@ -64,8 +81,8 @@ export function daemonLogPath(): string {
|
|||
}
|
||||
|
||||
export function lockConnectHost(lock: LockContents): string {
|
||||
const host = lock.host ?? DEFAULT_SERVER_HOST;
|
||||
return host === '0.0.0.0' ? DEFAULT_SERVER_HOST : host;
|
||||
const host = lock.host ?? LOCAL_SERVER_HOST;
|
||||
return host === '0.0.0.0' ? LOCAL_SERVER_HOST : host;
|
||||
}
|
||||
|
||||
/** True when `host:port` is currently free to bind (nothing listening). */
|
||||
|
|
@ -178,13 +195,18 @@ export function resolveDaemonProgram(
|
|||
}
|
||||
|
||||
interface SpawnDaemonChildOptions {
|
||||
host?: string;
|
||||
port: number;
|
||||
logLevel: string;
|
||||
debugEndpoints?: boolean;
|
||||
insecureNoTls?: boolean;
|
||||
allowRemoteShutdown?: boolean;
|
||||
allowRemoteTerminals?: boolean;
|
||||
allowedHosts?: readonly string[];
|
||||
idleGraceMs?: number;
|
||||
}
|
||||
|
||||
export function spawnDaemonChild(options: SpawnDaemonChildOptions): void {
|
||||
export function spawnDaemonChild(options: SpawnDaemonChildOptions): ChildProcess {
|
||||
const program = resolveDaemonProgram();
|
||||
const logPath = daemonLogPath();
|
||||
const logDir = dirname(logPath);
|
||||
|
|
@ -198,15 +220,37 @@ export function spawnDaemonChild(options: SpawnDaemonChildOptions): void {
|
|||
'--log-level',
|
||||
options.logLevel,
|
||||
];
|
||||
if (options.host !== undefined) {
|
||||
args.push('--host', options.host);
|
||||
}
|
||||
if (options.debugEndpoints === true) {
|
||||
args.push('--debug-endpoints');
|
||||
}
|
||||
if (options.insecureNoTls === true) {
|
||||
args.push('--insecure-no-tls');
|
||||
}
|
||||
if (options.allowRemoteShutdown === true) {
|
||||
args.push('--allow-remote-shutdown');
|
||||
}
|
||||
if (options.allowRemoteTerminals === true) {
|
||||
args.push('--allow-remote-terminals');
|
||||
}
|
||||
if (options.idleGraceMs !== undefined) {
|
||||
args.push('--idle-grace-ms', String(options.idleGraceMs));
|
||||
}
|
||||
if (options.allowedHosts !== undefined && options.allowedHosts.length > 0) {
|
||||
args.push('--allowed-host', ...options.allowedHosts);
|
||||
}
|
||||
// On Windows `.mjs` files are not executable PE binaries, so we must run
|
||||
// the script through the Node binary rather than spawning it directly. In
|
||||
// SEA mode or when re-spawning from an already-running daemon, `program` is
|
||||
// `process.execPath` itself, so no script argument is needed.
|
||||
const execPath = process.execPath;
|
||||
const spawnArgs = program === execPath ? args : [program, ...args];
|
||||
|
||||
const logFd = openSync(logPath, 'a');
|
||||
try {
|
||||
const child = spawn(program, args, {
|
||||
const child = spawn(execPath, spawnArgs, {
|
||||
detached: true,
|
||||
// Run from the server log directory instead of inheriting the caller's
|
||||
// cwd, so the long-lived daemon does not pin the directory it was
|
||||
|
|
@ -226,6 +270,7 @@ export function spawnDaemonChild(options: SpawnDaemonChildOptions): void {
|
|||
}
|
||||
});
|
||||
child.unref();
|
||||
return child;
|
||||
} finally {
|
||||
// `spawn` dups the fd into the child; the parent must not keep it open.
|
||||
closeSync(logFd);
|
||||
|
|
@ -244,6 +289,7 @@ function sleep(ms: number): Promise<void> {
|
|||
* detached process after this returns.
|
||||
*/
|
||||
export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise<EnsureDaemonResult> {
|
||||
const host = options.host ?? DEFAULT_SERVER_HOST;
|
||||
const preferred = options.port ?? DEFAULT_SERVER_PORT;
|
||||
const logLevel = options.logLevel ?? DEFAULT_DAEMON_LOG_LEVEL;
|
||||
|
||||
|
|
@ -252,7 +298,12 @@ export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise<E
|
|||
if (existing) {
|
||||
const origin = serverOrigin(lockConnectHost(existing), existing.port);
|
||||
if (await waitForServerHealthy(origin, REUSE_HEALTH_TIMEOUT_MS)) {
|
||||
return { origin };
|
||||
return {
|
||||
origin,
|
||||
reused: true,
|
||||
host: existing.host ?? DEFAULT_SERVER_HOST,
|
||||
port: existing.port,
|
||||
};
|
||||
}
|
||||
// Live pid but not responding (wedged or mid-boot failure). Fall through
|
||||
// and spawn: if it is truly wedged our child loses the lock race and we
|
||||
|
|
@ -260,14 +311,34 @@ export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise<E
|
|||
}
|
||||
|
||||
// 2. No reusable daemon — pick a free port and spawn one detached.
|
||||
const port = await resolveDaemonPort(DEFAULT_SERVER_HOST, preferred);
|
||||
spawnDaemonChild({
|
||||
const port = await resolveDaemonPort(host, preferred);
|
||||
const child = spawnDaemonChild({
|
||||
host,
|
||||
port,
|
||||
logLevel,
|
||||
debugEndpoints: options.debugEndpoints,
|
||||
insecureNoTls: options.insecureNoTls,
|
||||
allowRemoteShutdown: options.allowRemoteShutdown,
|
||||
allowRemoteTerminals: options.allowRemoteTerminals,
|
||||
allowedHosts: options.allowedHosts,
|
||||
idleGraceMs: options.idleGraceMs,
|
||||
});
|
||||
|
||||
// Watch for an early exit so a boot failure (e.g. the non-loopback TLS gate,
|
||||
// a config error, or a lost lock race with no other daemon to fall back to)
|
||||
// surfaces the real error immediately instead of waiting out the full spawn
|
||||
// timeout. The exit code/signal plus a tail of the daemon log is what tells
|
||||
// the operator *why* it failed.
|
||||
let childExit: { code: number | null; signal: NodeJS.Signals | null } | undefined;
|
||||
child.once('exit', (code, signal) => {
|
||||
childExit = { code, signal };
|
||||
});
|
||||
child.once('error', () => {
|
||||
// Spawn failure (ENOENT etc.) is already recorded in the log by
|
||||
// spawnDaemonChild; treat it as an early exit here.
|
||||
childExit = { code: -1, signal: null };
|
||||
});
|
||||
|
||||
// 3. Wait until some live daemon (ours, or a racer that won the lock) is up.
|
||||
const deadline = Date.now() + SPAWN_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
|
|
@ -275,14 +346,53 @@ export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise<E
|
|||
if (live) {
|
||||
const origin = serverOrigin(lockConnectHost(live), live.port);
|
||||
if (await isServerHealthy(origin, 500)) {
|
||||
return { origin };
|
||||
return {
|
||||
origin,
|
||||
reused: false,
|
||||
host: live.host ?? DEFAULT_SERVER_HOST,
|
||||
port: live.port,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (childExit !== undefined && !live) {
|
||||
// Our child exited and no other live daemon holds the lock to fall back
|
||||
// to — this is a real boot failure, not a lost race.
|
||||
throw new Error(formatDaemonBootFailure(childExit, daemonLogPath()));
|
||||
}
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Kimi server daemon failed to start within ${String(SPAWN_TIMEOUT_MS)}ms. ` +
|
||||
`Check the log for details: ${daemonLogPath()}`,
|
||||
`Kimi server daemon failed to start within ${String(SPAWN_TIMEOUT_MS)}ms.\n\n` +
|
||||
formatLogTail(daemonLogPath()),
|
||||
);
|
||||
}
|
||||
|
||||
function formatDaemonBootFailure(
|
||||
exit: { code: number | null; signal: NodeJS.Signals | null },
|
||||
logPath: string,
|
||||
): string {
|
||||
const reason =
|
||||
exit.signal === null
|
||||
? `exited with code ${String(exit.code)}`
|
||||
: `was terminated by signal ${exit.signal}`;
|
||||
return `Kimi server daemon ${reason} during startup.\n\n${formatLogTail(logPath)}`;
|
||||
}
|
||||
|
||||
function formatLogTail(logPath: string): string {
|
||||
const tail = tailFile(logPath, 30);
|
||||
if (tail.length === 0) {
|
||||
return `Check the log for details: ${logPath}`;
|
||||
}
|
||||
return `Last log lines (${logPath}):\n${tail}`;
|
||||
}
|
||||
|
||||
function tailFile(filePath: string, maxLines: number): string {
|
||||
try {
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
const lines = content.split('\n').filter((line) => line.length > 0);
|
||||
return lines.slice(-maxLines).join('\n');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import type { Command } from 'commander';
|
|||
import { registerPsCommand } from './ps';
|
||||
import { registerKillCommand } from './kill';
|
||||
import { buildRunCommand } from './run';
|
||||
import { registerRotateTokenCommand } from './rotate-token';
|
||||
import { registerWebAliasCommand } from './web-alias';
|
||||
|
||||
export function registerServerCommand(program: Command): void {
|
||||
|
|
@ -33,6 +34,8 @@ export function registerServerCommand(program: Command): void {
|
|||
|
||||
registerKillCommand(server);
|
||||
|
||||
registerRotateTokenCommand(server);
|
||||
|
||||
// OS service-manager commands (`install/uninstall/start/stop/restart/status`)
|
||||
// are temporarily hidden — the product now favors the on-demand background
|
||||
// daemon (`kimi web`) over service-ization. The implementation still lives in
|
||||
|
|
|
|||
|
|
@ -18,8 +18,10 @@ import type { Command } from 'commander';
|
|||
|
||||
import { getLiveLock, type LockContents } from '@moonshot-ai/server';
|
||||
|
||||
import { getDataDir } from '#/utils/paths';
|
||||
|
||||
import { lockConnectHost } from './daemon';
|
||||
import { serverOrigin } from './shared';
|
||||
import { authHeaders, serverOrigin, tryResolveServerToken } from './shared';
|
||||
|
||||
/** How long to wait for the graceful API shutdown request. */
|
||||
const API_TIMEOUT_MS = 2000;
|
||||
|
|
@ -32,7 +34,9 @@ const POLL_INTERVAL_MS = 100;
|
|||
|
||||
export interface KillCommandDeps {
|
||||
getLiveLock(): LockContents | undefined;
|
||||
requestShutdown(origin: string): Promise<void>;
|
||||
requestShutdown(origin: string, token: string | undefined): Promise<void>;
|
||||
/** Best-effort read of the persistent bearer token; undefined on miss. */
|
||||
resolveToken(): string | undefined;
|
||||
signalPid(pid: number, signal: NodeJS.Signals): boolean;
|
||||
pidAlive(pid: number): boolean;
|
||||
sleep(ms: number): Promise<void>;
|
||||
|
|
@ -66,8 +70,11 @@ export async function handleKillCommand(deps: KillCommandDeps): Promise<void> {
|
|||
|
||||
// 1. API path — best-effort graceful shutdown. Ignore every outcome: the
|
||||
// server may be an older build without the route, already wedged, or may
|
||||
// drop the connection as it exits.
|
||||
await deps.requestShutdown(origin).catch(() => {});
|
||||
// drop the connection as it exits. The bearer token (M5.1) is best-effort
|
||||
// too: if it can't be read the API call 401s and the PID path below still
|
||||
// guarantees the kill.
|
||||
const token = deps.resolveToken();
|
||||
await deps.requestShutdown(origin, token).catch(() => {});
|
||||
|
||||
// 2. PID path — SIGTERM, wait, then SIGKILL.
|
||||
deps.signalPid(pid, 'SIGTERM');
|
||||
|
|
@ -126,7 +133,10 @@ export function signalPid(pid: number, signal: NodeJS.Signals): boolean {
|
|||
}
|
||||
|
||||
/** POST the shutdown endpoint; resolves once the request completes or times out. */
|
||||
export async function requestShutdownViaApi(origin: string): Promise<void> {
|
||||
export async function requestShutdownViaApi(
|
||||
origin: string,
|
||||
token: string | undefined,
|
||||
): Promise<void> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => {
|
||||
controller.abort();
|
||||
|
|
@ -134,6 +144,7 @@ export async function requestShutdownViaApi(origin: string): Promise<void> {
|
|||
try {
|
||||
await fetch(`${origin}/api/v1/shutdown`, {
|
||||
method: 'POST',
|
||||
headers: token !== undefined ? authHeaders(token) : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -144,6 +155,7 @@ export async function requestShutdownViaApi(origin: string): Promise<void> {
|
|||
const DEFAULT_KILL_DEPS: KillCommandDeps = {
|
||||
getLiveLock,
|
||||
requestShutdown: requestShutdownViaApi,
|
||||
resolveToken: () => tryResolveServerToken(getDataDir()),
|
||||
signalPid,
|
||||
pidAlive,
|
||||
sleep: (ms) =>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
DEFAULT_LOG_LEVEL,
|
||||
DEFAULT_SERVER_HOST,
|
||||
DEFAULT_SERVER_PORT,
|
||||
LOCAL_SERVER_HOST,
|
||||
parseLogLevel,
|
||||
parsePort,
|
||||
serverOrigin,
|
||||
|
|
@ -249,5 +250,5 @@ function withStatusDetails(
|
|||
}
|
||||
|
||||
function formatServiceUrl(host: string, port: number): string {
|
||||
return serverOrigin(host === '0.0.0.0' ? DEFAULT_SERVER_HOST : host, port);
|
||||
return serverOrigin(host === '0.0.0.0' ? LOCAL_SERVER_HOST : host, port);
|
||||
}
|
||||
|
|
|
|||
84
apps/kimi-code/src/cli/sub/server/networks.ts
Normal file
84
apps/kimi-code/src/cli/sub/server/networks.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* Enumerate this machine's non-loopback network interface addresses, used to
|
||||
* print `Network: http://<addr>:<port>/` hints (à la Vite) when the server
|
||||
* binds a wildcard host (`0.0.0.0` / `::`).
|
||||
*/
|
||||
|
||||
import { networkInterfaces } from 'node:os';
|
||||
|
||||
export interface NetworkAddress {
|
||||
/** Raw IP address (IPv4 or IPv6); IPv6 is NOT bracket-wrapped here. */
|
||||
address: string;
|
||||
family: 'IPv4' | 'IPv6';
|
||||
}
|
||||
|
||||
/**
|
||||
* List non-internal interface addresses, IPv4 first then IPv6, preserving
|
||||
* interface order within each family.
|
||||
*
|
||||
* Like Vite, this lists the machine's own interface addresses — LAN
|
||||
* (192.168/10/172.16) plus any directly-assigned public address. It does not
|
||||
* (and cannot, without an external service) discover a NAT-translated WAN IP,
|
||||
* and we deliberately avoid any network call for a startup hint.
|
||||
*/
|
||||
export function listNetworkAddresses(): NetworkAddress[] {
|
||||
const raw: NetworkAddress[] = [];
|
||||
for (const entries of Object.values(networkInterfaces())) {
|
||||
for (const info of entries ?? []) {
|
||||
if (info.internal) {
|
||||
continue;
|
||||
}
|
||||
if (info.family === 'IPv4') {
|
||||
raw.push({ address: info.address, family: 'IPv4' });
|
||||
} else if (info.family === 'IPv6') {
|
||||
raw.push({ address: info.address, family: 'IPv6' });
|
||||
}
|
||||
}
|
||||
}
|
||||
return filterDisplayAddresses(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop addresses that are not useful as a connect target and de-duplicate.
|
||||
*
|
||||
* IPv6 link-local (`fe80::/10`) is filtered out: it is only reachable with a
|
||||
* zone id (e.g. `fe80::1%en0`), which our bare URL cannot carry, so showing it
|
||||
* is pure noise — and it is the bulk of what `os.networkInterfaces()` reports
|
||||
* on a typical machine. Duplicates (the same address reported on more than one
|
||||
* interface) are collapsed. The result is IPv4 first, then IPv6, preserving
|
||||
* order within each family.
|
||||
*/
|
||||
export function filterDisplayAddresses(
|
||||
addrs: readonly NetworkAddress[],
|
||||
): NetworkAddress[] {
|
||||
const seen = new Set<string>();
|
||||
const kept: NetworkAddress[] = [];
|
||||
for (const addr of addrs) {
|
||||
if (addr.family === 'IPv6' && isLinkLocalV6(addr.address)) {
|
||||
continue;
|
||||
}
|
||||
if (seen.has(addr.address)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(addr.address);
|
||||
kept.push(addr);
|
||||
}
|
||||
return [
|
||||
...kept.filter((a) => a.family === 'IPv4'),
|
||||
...kept.filter((a) => a.family === 'IPv6'),
|
||||
];
|
||||
}
|
||||
|
||||
/** True for IPv6 link-local addresses (`fe80::/10`, i.e. `fe80::`–`febf::`). */
|
||||
function isLinkLocalV6(address: string): boolean {
|
||||
const first = Number.parseInt(address.split(':')[0] ?? '', 16);
|
||||
return first >= 0xfe80 && first <= 0xfebf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an address for use as a URL host: bracket-wrap IPv6 per RFC 3986,
|
||||
* return IPv4 as-is.
|
||||
*/
|
||||
export function formatHostForUrl(address: string, family: NetworkAddress['family']): string {
|
||||
return family === 'IPv6' ? `[${address}]` : address;
|
||||
}
|
||||
|
|
@ -11,8 +11,10 @@ import type { Command } from 'commander';
|
|||
|
||||
import { getLiveLock } from '@moonshot-ai/server';
|
||||
|
||||
import { getDataDir } from '#/utils/paths';
|
||||
|
||||
import { lockConnectHost } from './daemon';
|
||||
import { isServerHealthy, serverOrigin } from './shared';
|
||||
import { authHeaders, isServerHealthy, resolveServerToken, serverOrigin } from './shared';
|
||||
|
||||
/** Wire shape of a single connection returned by `GET /api/v1/connections`. */
|
||||
interface ConnectionInfo {
|
||||
|
|
@ -62,7 +64,11 @@ async function handlePsCommand(opts: { json?: boolean }): Promise<void> {
|
|||
throw new Error(`Kimi server at ${origin} is not responding.`);
|
||||
}
|
||||
|
||||
const connections = await fetchConnections(origin);
|
||||
// The `/api/v1/connections` route is gated by bearer auth (M5.1). Read the
|
||||
// persistent token; a clear error here means the server has never been
|
||||
// started (no token file yet) or the token file was removed.
|
||||
const token = resolveServerToken(getDataDir());
|
||||
const connections = await fetchConnections(origin, token);
|
||||
|
||||
if (opts.json) {
|
||||
process.stdout.write(`${JSON.stringify(connections, null, 2)}\n`);
|
||||
|
|
@ -71,13 +77,14 @@ async function handlePsCommand(opts: { json?: boolean }): Promise<void> {
|
|||
process.stdout.write(formatTable(connections));
|
||||
}
|
||||
|
||||
async function fetchConnections(origin: string): Promise<ConnectionInfo[]> {
|
||||
async function fetchConnections(origin: string, token: string): Promise<ConnectionInfo[]> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(`${origin}/api/v1/connections`, {
|
||||
headers: authHeaders(token),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
|
|
|
|||
57
apps/kimi-code/src/cli/sub/server/rotate-token.ts
Normal file
57
apps/kimi-code/src/cli/sub/server/rotate-token.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* `kimi server rotate-token` — generate a new persistent server token.
|
||||
*
|
||||
* Rewrites `<KIMI_CODE_HOME>/server.token` (0600, atomic). The previous token
|
||||
* stops working immediately: a running server re-reads the file on its next
|
||||
* auth check, so rotation takes effect without a restart.
|
||||
*/
|
||||
|
||||
import { getLiveLock, rotateServerToken } from '@moonshot-ai/server';
|
||||
import chalk from 'chalk';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { darkColors } from '#/tui/theme/colors';
|
||||
import { getDataDir } from '#/utils/paths';
|
||||
|
||||
import { accessUrlLines, splitTokenFragment } from './access-urls';
|
||||
import { DEFAULT_SERVER_HOST } from './shared';
|
||||
|
||||
export function registerRotateTokenCommand(server: Command): void {
|
||||
server
|
||||
.command('rotate-token')
|
||||
.description(
|
||||
'Generate a new persistent server token; the previous token stops working immediately.',
|
||||
)
|
||||
.action(async () => {
|
||||
try {
|
||||
const token = await rotateServerToken(getDataDir());
|
||||
process.stdout.write(
|
||||
'The previous token is now invalid. A running server picks up the new token automatically.\n',
|
||||
);
|
||||
|
||||
// Token in the middle: indented and set off by blank lines (no color
|
||||
// highlight), so it is easy to spot without dominating the output.
|
||||
process.stdout.write(`\n ${chalk.bold('New server token:')} ${token}\n\n`);
|
||||
|
||||
// Re-print the access links with the new token so the user can
|
||||
// reconnect immediately. When a server is running its bind host/port
|
||||
// come from the lock; otherwise there is nothing to connect to yet.
|
||||
const lock = getLiveLock();
|
||||
if (lock !== undefined) {
|
||||
const host = lock.host ?? DEFAULT_SERVER_HOST;
|
||||
for (const { label, url: href } of accessUrlLines(host, lock.port, token)) {
|
||||
// De-emphasize the `#token=…` fragment so the host/port stands out.
|
||||
const [base, frag] = splitTokenFragment(href);
|
||||
const rendered =
|
||||
frag === ''
|
||||
? chalk.hex(darkColors.accent)(base)
|
||||
: chalk.hex(darkColors.accent)(base) + chalk.hex(darkColors.textDim)(frag);
|
||||
process.stdout.write(` ${chalk.dim(label)}${rendered}\n`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -13,31 +13,40 @@
|
|||
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
|
||||
import { shutdownTelemetry, track } from '@moonshot-ai/kimi-telemetry';
|
||||
import { startServer, type RunningServer } from '@moonshot-ai/server';
|
||||
import chalk from 'chalk';
|
||||
import { Option, type Command } from 'commander';
|
||||
|
||||
import { CLI_SHUTDOWN_TIMEOUT_MS, WEB_UI_MODE } from '#/constant/app';
|
||||
import { CLI_SHUTDOWN_TIMEOUT_MS } from '#/constant/app';
|
||||
import { getNativeWebAssetsDir } from '#/native/web-assets';
|
||||
import { darkColors } from '#/tui/theme/colors';
|
||||
import { openUrl as defaultOpenUrl } from '#/utils/open-url';
|
||||
import { getDataDir } from '#/utils/paths';
|
||||
|
||||
import { initializeServerTelemetry } from '../../telemetry';
|
||||
import { createKimiCodeHostIdentity, getHostPackageRoot, getVersion } from '../../version';
|
||||
import { ensureDaemon } from './daemon';
|
||||
import {
|
||||
accessUrlLines,
|
||||
buildOpenableUrl,
|
||||
isLoopbackHost,
|
||||
splitTokenFragment,
|
||||
} from './access-urls';
|
||||
import { ensureDaemon, type EnsureDaemonResult } from './daemon';
|
||||
import { type NetworkAddress } from './networks';
|
||||
import {
|
||||
DEFAULT_FOREGROUND_LOG_LEVEL,
|
||||
DEFAULT_LAN_HOST,
|
||||
DEFAULT_SERVER_HOST,
|
||||
DEFAULT_SERVER_PORT,
|
||||
parseServerOptions,
|
||||
tryResolveServerToken,
|
||||
VALID_LOG_LEVELS,
|
||||
type ParsedServerOptions,
|
||||
type ServerCliOptions,
|
||||
} from './shared';
|
||||
|
||||
const WEB_ASSETS_DIR = 'dist-web';
|
||||
const READY_PANEL_WIDTH = 72;
|
||||
|
||||
export interface RunCliOptions extends ServerCliOptions {
|
||||
open?: boolean;
|
||||
|
|
@ -51,17 +60,49 @@ export interface StartForegroundHooks {
|
|||
}
|
||||
|
||||
export interface RunCommandDeps {
|
||||
startServerBackground(options: ParsedServerOptions): Promise<{ origin: string }>;
|
||||
startServerBackground(options: ParsedServerOptions): Promise<{
|
||||
origin: string;
|
||||
/** True when an already-running daemon was reused (no new server started). */
|
||||
reused?: boolean;
|
||||
/** Bind host the running daemon is actually listening on (from the lock). */
|
||||
host?: string;
|
||||
/** Port the running daemon is actually listening on (from the lock). */
|
||||
port?: number;
|
||||
}>;
|
||||
/** Foreground runner; defaults to the real in-process runner when omitted. */
|
||||
startServerForeground?: (
|
||||
options: ParsedServerOptions,
|
||||
hooks?: StartForegroundHooks,
|
||||
) => Promise<never>;
|
||||
openUrl(url: string): void;
|
||||
/**
|
||||
* Best-effort read of the server's persistent bearer token. When it returns
|
||||
* a token, the ready banner prints it and the opened Web UI URL carries it in
|
||||
* the `#token=` fragment (M5.5). Optional so callers/tests that don't supply
|
||||
* it simply print/open the plain origin.
|
||||
*/
|
||||
resolveToken?: () => string | undefined;
|
||||
/**
|
||||
* Non-loopback interface addresses to display for a wildcard bind. Defaults
|
||||
* to the machine's own interfaces (`listNetworkAddresses()`); inject a fixed
|
||||
* list in tests for deterministic output.
|
||||
*/
|
||||
networkAddresses?: NetworkAddress[];
|
||||
stdout: Pick<NodeJS.WriteStream, 'write'>;
|
||||
stderr: Pick<NodeJS.WriteStream, 'write'>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Web UI URL, carrying the bearer token in the URL fragment.
|
||||
*
|
||||
* The token rides in `#token=<token>` — a client-side fragment that is never
|
||||
* sent to the server (so it never appears in server access logs) and is not
|
||||
* logged by proxies. The Web UI reads it from `location.hash` after load.
|
||||
*/
|
||||
export function buildWebUrl(origin: string, token: string): string {
|
||||
return buildOpenableUrl(origin, token);
|
||||
}
|
||||
|
||||
/** Build the `run` subcommand, mounted under a parent (`server` or top-level). */
|
||||
export function buildRunCommand(cmd: Command, options: { defaultOpen: boolean }): Command {
|
||||
return cmd
|
||||
|
|
@ -70,6 +111,29 @@ export function buildRunCommand(cmd: Command, options: { defaultOpen: boolean })
|
|||
`Bind port (default ${DEFAULT_SERVER_PORT})`,
|
||||
String(DEFAULT_SERVER_PORT),
|
||||
)
|
||||
.option(
|
||||
'--host [host]',
|
||||
`Bind host. Omit to bind ${DEFAULT_SERVER_HOST} (this machine only); pass --host to bind ${DEFAULT_LAN_HOST} (all interfaces), or --host <host> for a specific host. The bearer token is printed at startup.`,
|
||||
)
|
||||
.option(
|
||||
'--allowed-host <host...>',
|
||||
'Extra Host header value to allow through the DNS-rebinding check. Repeat or comma-separate; a leading dot matches a domain suffix (e.g. .example.com).',
|
||||
)
|
||||
.option(
|
||||
'--insecure-no-tls',
|
||||
'Allow a non-loopback bind without a TLS-terminating reverse proxy. Defaults to true; only relevant for non-loopback binds.',
|
||||
true,
|
||||
)
|
||||
.option(
|
||||
'--allow-remote-shutdown',
|
||||
'On a non-loopback bind, keep POST /api/v1/shutdown enabled (default: route is disabled → 404).',
|
||||
false,
|
||||
)
|
||||
.option(
|
||||
'--allow-remote-terminals',
|
||||
'On a non-loopback bind, keep the PTY /api/v1/terminals/* routes enabled (default: disabled → 404). Remote shell is high risk.',
|
||||
false,
|
||||
)
|
||||
.option(
|
||||
'--log-level <level>',
|
||||
`Server log level: ${VALID_LOG_LEVELS.join('|')}. Omit to keep logs off.`,
|
||||
|
|
@ -119,34 +183,55 @@ export async function handleRunCommand(
|
|||
await startServerDaemon(parsed);
|
||||
return;
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
// Resolve the persistent token once: it is printed in the ready banner and
|
||||
// rides in the opened Web UI URL's `#token=` fragment (M5.5). Falls back to
|
||||
// the plain origin / no token line when unavailable.
|
||||
const writeReady = (result: { origin: string; reused?: boolean; host?: string }): void => {
|
||||
const { origin } = result;
|
||||
const host = result.host ?? parsed.host;
|
||||
const token = deps.resolveToken?.();
|
||||
let output = '';
|
||||
if (result.reused === true) {
|
||||
// A daemon was already running, so this command's --host/--port/etc. did
|
||||
// not start a new one. Say so loudly, then print the actual running
|
||||
// server's URLs (using its real bind host, not the requested one).
|
||||
output += formatReuseNotice(origin);
|
||||
}
|
||||
output +=
|
||||
parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL
|
||||
? formatReadyBanner(origin, host, {
|
||||
token,
|
||||
networkAddresses: deps.networkAddresses,
|
||||
})
|
||||
: formatReadyLine(origin, token);
|
||||
deps.stdout.write(output);
|
||||
if (opts.open === true) {
|
||||
deps.openUrl(token !== undefined ? buildWebUrl(origin, token) : origin);
|
||||
}
|
||||
};
|
||||
if (opts.foreground === true) {
|
||||
const run = deps.startServerForeground ?? startServerForeground;
|
||||
await run(parsed, {
|
||||
onReady: (origin) => {
|
||||
const readyMs = Date.now() - startedAt;
|
||||
deps.stdout.write(
|
||||
parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL
|
||||
? formatReadyBanner(origin, readyMs)
|
||||
: `Kimi server: ${origin}\n`,
|
||||
);
|
||||
if (opts.open === true) {
|
||||
deps.openUrl(origin);
|
||||
}
|
||||
writeReady({ origin, reused: false, host: parsed.host });
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { origin } = await deps.startServerBackground(parsed);
|
||||
const readyMs = Date.now() - startedAt;
|
||||
deps.stdout.write(
|
||||
parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL
|
||||
? formatReadyBanner(origin, readyMs)
|
||||
: `Kimi server: ${origin}\n`,
|
||||
const result = await deps.startServerBackground(parsed);
|
||||
writeReady(result);
|
||||
}
|
||||
|
||||
function formatReuseNotice(origin: string): string {
|
||||
return (
|
||||
`${chalk.hex(darkColors.warning)('A server is already running')} at ${origin} — ` +
|
||||
`the options from this command were not applied. ` +
|
||||
`Run ${chalk.bold('kimi server kill')} first to bind a new host/port.\n`
|
||||
);
|
||||
if (opts.open === true) {
|
||||
deps.openUrl(origin);
|
||||
}
|
||||
}
|
||||
|
||||
function formatReadyLine(origin: string, token: string | undefined): string {
|
||||
return `Kimi server: ${buildOpenableUrl(origin, token)}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -157,14 +242,18 @@ export async function handleRunCommand(
|
|||
*/
|
||||
export async function startServerBackground(
|
||||
options: ParsedServerOptions,
|
||||
): Promise<{ origin: string }> {
|
||||
const { origin } = await ensureDaemon({
|
||||
): Promise<EnsureDaemonResult> {
|
||||
return ensureDaemon({
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
logLevel: options.logLevel,
|
||||
debugEndpoints: options.debugEndpoints,
|
||||
insecureNoTls: options.insecureNoTls,
|
||||
allowRemoteShutdown: options.allowRemoteShutdown,
|
||||
allowRemoteTerminals: options.allowRemoteTerminals,
|
||||
allowedHosts: options.allowedHosts,
|
||||
idleGraceMs: options.idleGraceMs,
|
||||
});
|
||||
return { origin };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -238,6 +327,10 @@ async function runServerInProcess(
|
|||
port: options.port,
|
||||
logLevel: options.logLevel,
|
||||
debugEndpoints: options.debugEndpoints,
|
||||
insecureNoTls: options.insecureNoTls,
|
||||
allowRemoteShutdown: options.allowRemoteShutdown,
|
||||
allowRemoteTerminals: options.allowRemoteTerminals,
|
||||
allowedHosts: options.allowedHosts,
|
||||
webAssetsDir: serverWebAssetsDir(),
|
||||
coreProcessOptions: {
|
||||
identity: createKimiCodeHostIdentity(version),
|
||||
|
|
@ -253,7 +346,7 @@ async function runServerInProcess(
|
|||
},
|
||||
});
|
||||
|
||||
track('server_started', { ui_mode: WEB_UI_MODE, daemon: mode.daemon });
|
||||
track('server_started', { daemon: mode.daemon });
|
||||
|
||||
process.once('SIGINT', () => {
|
||||
void shutdown('SIGINT');
|
||||
|
|
@ -323,65 +416,80 @@ export function resolveServerWebAssetsDir(
|
|||
return nativeWebAssetsDir ?? join(getHostPackageRoot(), WEB_ASSETS_DIR);
|
||||
}
|
||||
|
||||
function formatReadyBanner(origin: string, readyMs: number): string {
|
||||
interface FormatReadyBannerOptions {
|
||||
/** Persistent bearer token to print; omitted when unresolvable. */
|
||||
token?: string;
|
||||
/** Non-loopback interface addresses to list for a wildcard bind. */
|
||||
networkAddresses?: NetworkAddress[];
|
||||
}
|
||||
|
||||
function formatReadyBanner(
|
||||
origin: string,
|
||||
host: string,
|
||||
opts: FormatReadyBannerOptions = {},
|
||||
): string {
|
||||
const primary = (text: string): string => chalk.hex(darkColors.primary)(text);
|
||||
const title = (text: string): string => chalk.bold.hex(darkColors.primary)(text);
|
||||
const dim = (text: string): string => chalk.hex(darkColors.textDim)(text);
|
||||
const muted = (text: string): string => chalk.hex(darkColors.textMuted)(text);
|
||||
const label = (text: string): string => chalk.bold.hex(darkColors.textDim)(text);
|
||||
const url = chalk.hex(darkColors.accent)(displayOrigin(origin));
|
||||
const width = READY_PANEL_WIDTH;
|
||||
const innerWidth = width - 4;
|
||||
const pad = ' ';
|
||||
const url = (text: string): string => chalk.hex(darkColors.accent)(text);
|
||||
// Render the `#token=…` fragment in a de-emphasized gray so the host/port
|
||||
// stands out while the full URL stays selectable for copying.
|
||||
const urlWithDimToken = (href: string): string => {
|
||||
const [base, frag] = splitTokenFragment(href);
|
||||
return frag === '' ? url(base) : url(base) + dim(frag);
|
||||
};
|
||||
|
||||
const port = Number(new URL(origin).port);
|
||||
// Borderless header: the Kimi sprite (the little mascot with eyes) sits next
|
||||
// to the title, keeping the brand without the enclosing box.
|
||||
const logo = ['▐█▛█▛█▌', '▐█████▌'] as const;
|
||||
const logoWidth = Math.max(...logo.map((row) => visibleWidth(row)));
|
||||
const gap = ' ';
|
||||
const textWidth = innerWidth - logoWidth - gap.length;
|
||||
const headerLines = [
|
||||
primary(logo[0].padEnd(logoWidth)) +
|
||||
gap +
|
||||
truncateToWidth(title('Kimi server ready'), textWidth, '…'),
|
||||
primary(logo[1].padEnd(logoWidth)) +
|
||||
gap +
|
||||
truncateToWidth(dim('Local web UI is available from this machine.'), textWidth, '…'),
|
||||
];
|
||||
const infoLines = [
|
||||
label('URL: ') + url,
|
||||
label('Network: ') + muted('local only'),
|
||||
label('Logs: ') + muted('off') + dim(' use --log-level info to enable'),
|
||||
label('Stop: ') + muted('kimi server kill'),
|
||||
label('Ready: ') + muted(`${String(Math.max(0, readyMs))} ms`),
|
||||
label('Version: ') + muted(getVersion()),
|
||||
];
|
||||
const contentLines = [...headerLines, '', ...infoLines];
|
||||
|
||||
const lines = [
|
||||
const lines: string[] = [
|
||||
'',
|
||||
` ${primary(logo[0])} ${title('Kimi server ready')} ${dim(getVersion())}`,
|
||||
` ${primary(logo[1])} ${dim('Local web UI is available from this machine.')}`,
|
||||
'',
|
||||
primary('╭' + '─'.repeat(width - 2) + '╮'),
|
||||
primary('│') + ' '.repeat(width - 2) + primary('│'),
|
||||
];
|
||||
|
||||
for (const content of contentLines) {
|
||||
const truncated = truncateToWidth(content, innerWidth, '…');
|
||||
const rightPad = Math.max(0, innerWidth - visibleWidth(truncated));
|
||||
lines.push(primary('│') + pad + truncated + ' '.repeat(rightPad) + primary('│'));
|
||||
// Access links.
|
||||
for (const { label: text, url: href } of accessUrlLines(
|
||||
host,
|
||||
port,
|
||||
opts.token,
|
||||
opts.networkAddresses,
|
||||
)) {
|
||||
lines.push(` ${label(text)}${urlWithDimToken(href)}`);
|
||||
}
|
||||
// On a loopback bind there is no network URL; show how to enable one.
|
||||
if (isLoopbackHost(host)) {
|
||||
lines.push(` ${label('Network: ')}${muted('off')}${dim(' use --host to enable')}`);
|
||||
}
|
||||
if (opts.token !== undefined) {
|
||||
// Set the token off with surrounding whitespace rather than color, so it is
|
||||
// easy to spot without being highlighted.
|
||||
lines.push('');
|
||||
lines.push(` ${label('Token: ')}${opts.token}`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push(primary('│') + ' '.repeat(width - 2) + primary('│'));
|
||||
lines.push(primary('╰' + '─'.repeat(width - 2) + '╯'));
|
||||
// Auxiliary controls last.
|
||||
lines.push(` ${label('Logs: ')}${muted('off')}${dim(' use --log-level info to enable')}`);
|
||||
lines.push(` ${label('Stop: ')}${muted('kimi server kill')}`);
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function displayOrigin(origin: string): string {
|
||||
return origin.endsWith('/') ? origin : `${origin}/`;
|
||||
}
|
||||
|
||||
const DEFAULT_RUN_COMMAND_DEPS: RunCommandDeps = {
|
||||
startServerBackground,
|
||||
startServerForeground,
|
||||
openUrl: defaultOpenUrl,
|
||||
resolveToken: () => {
|
||||
// Read the persistent `<homeDir>/server.token` written on first boot
|
||||
// (M5.1). Best-effort: a missing/older server yields undefined and the
|
||||
// caller opens the plain origin.
|
||||
return tryResolveServerToken(getDataDir());
|
||||
},
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,12 +5,20 @@
|
|||
* `run`, `web`, and `status` all use.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import type { ServerLogLevel } from '@moonshot-ai/server';
|
||||
|
||||
export const DEFAULT_SERVER_HOST = '127.0.0.1';
|
||||
export const LOCAL_SERVER_HOST = '127.0.0.1';
|
||||
export const DEFAULT_LAN_HOST = '0.0.0.0';
|
||||
export const DEFAULT_SERVER_HOST = LOCAL_SERVER_HOST;
|
||||
export const DEFAULT_SERVER_PORT = 58627;
|
||||
export const DEFAULT_SERVER_ORIGIN = serverOrigin(DEFAULT_SERVER_HOST, DEFAULT_SERVER_PORT);
|
||||
|
||||
/** Filename (under KIMI_CODE_HOME) of the persistent server bearer token. */
|
||||
export const SERVER_TOKEN_FILE = 'server.token';
|
||||
|
||||
export const DEFAULT_LOG_LEVEL: ServerLogLevel = 'info';
|
||||
export const DEFAULT_FOREGROUND_LOG_LEVEL: ServerLogLevel = 'silent';
|
||||
|
||||
|
|
@ -36,6 +44,14 @@ export interface ParsedServerOptions {
|
|||
port: number;
|
||||
logLevel: ServerLogLevel;
|
||||
debugEndpoints: boolean;
|
||||
/** Allow a non-loopback bind without a TLS-terminating reverse proxy. */
|
||||
insecureNoTls: boolean;
|
||||
/** Allow `POST /api/v1/shutdown` on a non-loopback bind. */
|
||||
allowRemoteShutdown: boolean;
|
||||
/** Allow PTY `/api/v1/terminals/*` routes on a non-loopback bind. */
|
||||
allowRemoteTerminals: boolean;
|
||||
/** Extra `Host` header values to allow through the DNS-rebinding check. */
|
||||
allowedHosts: readonly string[];
|
||||
/** Internal: run as an idle-exiting background daemon instead of foreground. */
|
||||
daemon: boolean;
|
||||
/** Internal: idle-shutdown grace in ms (daemon mode only). */
|
||||
|
|
@ -43,10 +59,18 @@ export interface ParsedServerOptions {
|
|||
}
|
||||
|
||||
export interface ServerCliOptions {
|
||||
host?: string;
|
||||
host?: string | boolean;
|
||||
port?: string;
|
||||
logLevel?: string;
|
||||
debugEndpoints?: boolean;
|
||||
/** Allow a non-loopback bind without TLS (`--insecure-no-tls`). */
|
||||
insecureNoTls?: boolean;
|
||||
/** Allow remote shutdown on a non-loopback bind (`--allow-remote-shutdown`). */
|
||||
allowRemoteShutdown?: boolean;
|
||||
/** Allow remote terminals on a non-loopback bind (`--allow-remote-terminals`). */
|
||||
allowRemoteTerminals?: boolean;
|
||||
/** Extra `Host` header values to allow (`--allowed-host`). */
|
||||
allowedHost?: string[];
|
||||
/** Internal flag set by the daemon spawner (`kimi web`). */
|
||||
daemon?: boolean;
|
||||
/** Internal flag set by the daemon spawner / tests. */
|
||||
|
|
@ -55,15 +79,33 @@ export interface ServerCliOptions {
|
|||
|
||||
export function parseServerOptions(opts: ServerCliOptions): ParsedServerOptions {
|
||||
return {
|
||||
host: opts.host ?? DEFAULT_SERVER_HOST,
|
||||
host: parseHost(opts.host),
|
||||
port: parsePort(opts.port, '--port', DEFAULT_SERVER_PORT),
|
||||
logLevel: parseLogLevel(opts.logLevel ?? DEFAULT_FOREGROUND_LOG_LEVEL),
|
||||
debugEndpoints: opts.debugEndpoints === true,
|
||||
insecureNoTls: opts.insecureNoTls !== false,
|
||||
allowRemoteShutdown: opts.allowRemoteShutdown === true,
|
||||
allowRemoteTerminals: opts.allowRemoteTerminals === true,
|
||||
allowedHosts: parseAllowedHostArgs(opts.allowedHost),
|
||||
daemon: opts.daemon === true,
|
||||
idleGraceMs: parseIdleGraceMs(opts.idleGraceMs),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseAllowedHostArgs(raw: readonly string[] | undefined): string[] {
|
||||
if (raw === undefined) return [];
|
||||
return raw
|
||||
.flatMap((entry) => entry.split(','))
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
}
|
||||
|
||||
function parseHost(raw: string | boolean | undefined): string {
|
||||
if (raw === undefined || raw === false) return DEFAULT_SERVER_HOST;
|
||||
if (raw === true || raw === '') return DEFAULT_LAN_HOST;
|
||||
return raw;
|
||||
}
|
||||
|
||||
function parseIdleGraceMs(raw: string | undefined): number {
|
||||
if (raw === undefined) return DEFAULT_IDLE_GRACE_MS;
|
||||
const n = Number.parseInt(raw, 10);
|
||||
|
|
@ -174,3 +216,41 @@ export async function ensureServerWebReady(origin: string): Promise<void> {
|
|||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the persistent bearer token for the server.
|
||||
*
|
||||
* The server writes `<homeDir>/server.token` (0600) on first boot and reuses
|
||||
* it across restarts (ROADMAP M5.1); CLI commands that hit a gated REST route
|
||||
* read it back here and send it as `Authorization: Bearer <token>`. `homeDir`
|
||||
* is the CLI's own KIMI_CODE_HOME resolution (`getDataDir()`).
|
||||
*
|
||||
* Throws a clear error when the file is missing/unreadable — the usual cause
|
||||
* is a server that has never been started (no token file yet), or an older
|
||||
* build that predates token auth.
|
||||
*/
|
||||
export function resolveServerToken(homeDir: string): string {
|
||||
const tokenPath = join(homeDir, SERVER_TOKEN_FILE);
|
||||
try {
|
||||
return readFileSync(tokenPath, 'utf8').trim();
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`unable to read server token at ${tokenPath}; has the server been started at least once?`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Best-effort token read: returns `undefined` instead of throwing. */
|
||||
export function tryResolveServerToken(homeDir: string): string | undefined {
|
||||
try {
|
||||
return resolveServerToken(homeDir);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** An `Authorization: Bearer <token>` header bag for `fetch`. */
|
||||
export function authHeaders(token: string): { Authorization: string } {
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -430,8 +430,6 @@ function trackUpdatePrompted(
|
|||
rolloutTelemetry: RolloutTelemetry,
|
||||
): void {
|
||||
trackUpdateEvent(track, 'update_prompted', {
|
||||
current: currentVersion,
|
||||
latest: target.version,
|
||||
current_version: currentVersion,
|
||||
target_version: target.version,
|
||||
source,
|
||||
|
|
|
|||
72
apps/kimi-code/src/feedback/archive.ts
Normal file
72
apps/kimi-code/src/feedback/archive.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { mkdir, mkdtemp, readdir, rm, stat } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
import { getCacheDir } from '../utils/paths';
|
||||
|
||||
const STALE_ARCHIVE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours.
|
||||
|
||||
/**
|
||||
* A file produced for a feedback attachment upload. Both the session log
|
||||
* archive and the codebase archive share this shape; the generic uploader
|
||||
* consumes it without caring how the file was produced.
|
||||
*/
|
||||
export interface FeedbackArchive {
|
||||
readonly path: string;
|
||||
readonly size: number;
|
||||
readonly sha256: string;
|
||||
readonly fingerprint: string;
|
||||
readonly fileCount: number;
|
||||
/** Directory created exclusively for this archive and safe to remove after upload. */
|
||||
readonly cleanupDir?: string;
|
||||
}
|
||||
|
||||
export async function createFeedbackArchivePath(filename: string): Promise<{
|
||||
readonly archivePath: string;
|
||||
readonly cleanupDir: string;
|
||||
}> {
|
||||
const archivePath = await createArchivePath(filename);
|
||||
return { archivePath, cleanupDir: archivePathCleanupDir(archivePath) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove feedback-upload archive directories older than 24 hours. Packaging
|
||||
* cleans up its own archive on success and on failure, but a killed process
|
||||
* or an empty parent dir can still leave leftovers behind; this is a
|
||||
* best-effort backstop so the cache dir does not grow without bound.
|
||||
*
|
||||
* `dir` is injectable for tests; production callers leave it as the default.
|
||||
*/
|
||||
export async function removeStaleFeedbackUploads(
|
||||
options: { readonly now?: number; readonly dir?: string } = {},
|
||||
): Promise<void> {
|
||||
const now = options.now ?? Date.now();
|
||||
const dir = options.dir ?? join(getCacheDir(), 'feedback-uploads');
|
||||
const entries = await readdir(dir, { withFileTypes: true }).catch((error: unknown) => {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||
throw error;
|
||||
});
|
||||
if (entries === null) return;
|
||||
|
||||
const cutoff = now - STALE_ARCHIVE_MAX_AGE_MS;
|
||||
await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
if (!entry.isDirectory() && !entry.isSymbolicLink()) return;
|
||||
const target = join(dir, entry.name);
|
||||
const targetStat = await stat(target).catch(() => null);
|
||||
if (targetStat === null || targetStat.mtimeMs >= cutoff) return;
|
||||
await rm(target, { recursive: true, force: true }).catch(() => {});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function createArchivePath(filename: string): Promise<string> {
|
||||
await removeStaleFeedbackUploads();
|
||||
const root = join(getCacheDir(), 'feedback-uploads');
|
||||
await mkdir(root, { recursive: true });
|
||||
const dir = await mkdtemp(join(root, 'upload-'));
|
||||
return join(dir, filename);
|
||||
}
|
||||
|
||||
function archivePathCleanupDir(archivePath: string): string {
|
||||
return dirname(archivePath);
|
||||
}
|
||||
92
apps/kimi-code/src/feedback/codebase/filter.ts
Normal file
92
apps/kimi-code/src/feedback/codebase/filter.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
export const DEFAULT_MAX_FILES = 50000;
|
||||
export const DEFAULT_MAX_FILE_SIZE = 50 * 1024 * 1024;
|
||||
// Upper bound for the compressed codebase archive, aligned with the backend's
|
||||
// per-upload limit. The scanner uses cumulative raw file size as a conservative
|
||||
// estimate so the resulting zip stays within this bound.
|
||||
export const DEFAULT_MAX_ARCHIVE_SIZE = 500 * 1024 * 1024;
|
||||
|
||||
const IGNORED_DIR_NAMES: ReadonlySet<string> = new Set([
|
||||
'.git',
|
||||
'.hg',
|
||||
'.svn',
|
||||
'node_modules',
|
||||
'dist',
|
||||
'build',
|
||||
'out',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.turbo',
|
||||
'.cache',
|
||||
'.parcel-cache',
|
||||
'coverage',
|
||||
'.nyc_output',
|
||||
'target',
|
||||
'__pycache__',
|
||||
'.pytest_cache',
|
||||
'.mypy_cache',
|
||||
'.venv',
|
||||
'venv',
|
||||
'env',
|
||||
'.idea',
|
||||
]);
|
||||
|
||||
const SENSITIVE_DIR_NAMES: ReadonlySet<string> = new Set([
|
||||
'.ssh',
|
||||
'.gnupg',
|
||||
'.aws',
|
||||
'.kube',
|
||||
'.docker',
|
||||
]);
|
||||
|
||||
const SENSITIVE_FILE_NAMES: ReadonlySet<string> = new Set([
|
||||
'.env',
|
||||
'id_rsa',
|
||||
'id_dsa',
|
||||
'id_ecdsa',
|
||||
'id_ed25519',
|
||||
'credentials.json',
|
||||
'service-account.json',
|
||||
'serviceAccount.json',
|
||||
'.netrc',
|
||||
'.htpasswd',
|
||||
'.pypirc',
|
||||
'.npmrc',
|
||||
'.envrc',
|
||||
'.yarnrc',
|
||||
'.yarnrc.yml',
|
||||
]);
|
||||
|
||||
const SENSITIVE_FILE_SUFFIXES: readonly string[] = [
|
||||
'.pem',
|
||||
'.key',
|
||||
'.p12',
|
||||
'.pfx',
|
||||
'.jks',
|
||||
'.keystore',
|
||||
];
|
||||
|
||||
const ENV_FILE_ALLOWED_SUFFIXES: ReadonlySet<string> = new Set(['.example', '.sample', '.template']);
|
||||
|
||||
export function isIgnoredDirName(name: string): boolean {
|
||||
return IGNORED_DIR_NAMES.has(name);
|
||||
}
|
||||
|
||||
export function isSensitivePath(relativePath: string): boolean {
|
||||
const segments = relativePath.split('/');
|
||||
for (let i = 0; i < segments.length - 1; i += 1) {
|
||||
const segment = segments[i];
|
||||
if (segment !== undefined && SENSITIVE_DIR_NAMES.has(segment)) return true;
|
||||
}
|
||||
|
||||
const base = segments.at(-1);
|
||||
if (base === undefined || base.length === 0) return false;
|
||||
if (SENSITIVE_FILE_NAMES.has(base)) return true;
|
||||
if (SENSITIVE_FILE_SUFFIXES.some((suffix) => base.endsWith(suffix))) return true;
|
||||
|
||||
if (base.startsWith('.env.')) {
|
||||
const suffix = base.slice('.env'.length);
|
||||
return !ENV_FILE_ALLOWED_SUFFIXES.has(suffix);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
3
apps/kimi-code/src/feedback/codebase/index.ts
Normal file
3
apps/kimi-code/src/feedback/codebase/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './packager';
|
||||
export * from './scanner';
|
||||
export * from './types';
|
||||
98
apps/kimi-code/src/feedback/codebase/packager.ts
Normal file
98
apps/kimi-code/src/feedback/codebase/packager.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import { mkdir, rm, stat } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
import { ZipFile } from 'yazl';
|
||||
|
||||
import type { FeedbackArchive } from '../archive';
|
||||
import type { FeedbackCodebaseScanResult } from './types';
|
||||
|
||||
interface PackageEntry {
|
||||
readonly absolutePath: string;
|
||||
readonly archivePath: string;
|
||||
readonly size: number;
|
||||
readonly mtimeMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pack the scanned codebase into a zip, with files placed at the zip root.
|
||||
*/
|
||||
export async function packageCodebase(
|
||||
scan: FeedbackCodebaseScanResult,
|
||||
archivePath: string,
|
||||
): Promise<FeedbackArchive> {
|
||||
const entries: PackageEntry[] = scan.files.map((file) => ({
|
||||
absolutePath: file.absolutePath,
|
||||
archivePath: file.path,
|
||||
size: file.size,
|
||||
mtimeMs: file.mtimeMs,
|
||||
}));
|
||||
return packageEntries(entries, archivePath);
|
||||
}
|
||||
|
||||
async function packageEntries(
|
||||
entries: readonly PackageEntry[],
|
||||
archivePath: string,
|
||||
): Promise<FeedbackArchive> {
|
||||
if (entries.length === 0) {
|
||||
throw new Error('Cannot package an empty feedback archive.');
|
||||
}
|
||||
await mkdir(dirname(archivePath), { recursive: true });
|
||||
|
||||
const zip = new ZipFile();
|
||||
const hash = createHash('sha256');
|
||||
const output = createWriteStream(archivePath);
|
||||
|
||||
try {
|
||||
const done = new Promise<void>((resolvePromise, rejectPromise) => {
|
||||
output.on('finish', resolvePromise);
|
||||
output.on('error', rejectPromise);
|
||||
zip.outputStream.on('error', rejectPromise);
|
||||
});
|
||||
|
||||
zip.outputStream.on('data', (chunk: Buffer) => {
|
||||
hash.update(chunk);
|
||||
});
|
||||
zip.outputStream.pipe(output);
|
||||
|
||||
for (const entry of entries) {
|
||||
zip.addFile(entry.absolutePath, entry.archivePath, {
|
||||
mtime: new Date(entry.mtimeMs),
|
||||
mode: 0o100644,
|
||||
});
|
||||
}
|
||||
zip.end();
|
||||
await done;
|
||||
|
||||
const archiveStat = await stat(archivePath);
|
||||
return {
|
||||
path: archivePath,
|
||||
size: archiveStat.size,
|
||||
sha256: hash.digest('hex'),
|
||||
fingerprint: fingerprintEntries(entries),
|
||||
fileCount: entries.length,
|
||||
};
|
||||
} catch (error) {
|
||||
// A failed zip (e.g. a source file vanished or became unreadable between
|
||||
// scan and packaging) would otherwise leave a partial archive behind in
|
||||
// the cache dir. Destroy the stream so the handle is released before we
|
||||
// remove the file, then best-effort delete it.
|
||||
output.destroy();
|
||||
await rm(archivePath, { force: true }).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function fingerprintEntries(entries: readonly PackageEntry[]): string {
|
||||
const hash = createHash('sha256');
|
||||
for (const entry of entries) {
|
||||
hash.update(entry.archivePath);
|
||||
hash.update('\0');
|
||||
hash.update(String(entry.size));
|
||||
hash.update('\0');
|
||||
hash.update(String(Math.trunc(entry.mtimeMs)));
|
||||
hash.update('\n');
|
||||
}
|
||||
return hash.digest('hex');
|
||||
}
|
||||
217
apps/kimi-code/src/feedback/codebase/scanner.ts
Normal file
217
apps/kimi-code/src/feedback/codebase/scanner.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import { execFile } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { lstat, readdir } from 'node:fs/promises';
|
||||
import { join, relative, resolve } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import {
|
||||
DEFAULT_MAX_ARCHIVE_SIZE,
|
||||
DEFAULT_MAX_FILES,
|
||||
DEFAULT_MAX_FILE_SIZE,
|
||||
isIgnoredDirName,
|
||||
isSensitivePath,
|
||||
} from './filter';
|
||||
import type {
|
||||
FeedbackCodebaseFile,
|
||||
FeedbackCodebaseLimitExceeded,
|
||||
FeedbackCodebaseScanResult,
|
||||
} from './types';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface ScanCodebaseLimits {
|
||||
readonly maxFiles: number;
|
||||
readonly maxFileSize: number;
|
||||
readonly maxArchiveSize: number;
|
||||
}
|
||||
|
||||
export interface ScanCodebaseOptions {
|
||||
readonly limits?: {
|
||||
readonly maxFiles?: number;
|
||||
readonly maxFileSize?: number;
|
||||
readonly maxArchiveSize?: number;
|
||||
};
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
interface CollectedFiles {
|
||||
readonly files: FeedbackCodebaseFile[];
|
||||
readonly exceedsLimit?: FeedbackCodebaseLimitExceeded;
|
||||
}
|
||||
|
||||
export async function scanCodebase(
|
||||
rootInput: string,
|
||||
options: ScanCodebaseOptions = {},
|
||||
): Promise<FeedbackCodebaseScanResult> {
|
||||
const root = resolve(rootInput);
|
||||
const limits = resolveLimits(options.limits);
|
||||
throwIfAborted(options.signal);
|
||||
const usedGitIgnore = await isInsideGitWorkTree(root);
|
||||
const collected = usedGitIgnore
|
||||
? await scanWithGit(root, limits, options.signal)
|
||||
: await scanWithoutFilter(root, limits, options.signal);
|
||||
const sortedFiles = collected.files.toSorted((a, b) => a.path.localeCompare(b.path));
|
||||
|
||||
return {
|
||||
root,
|
||||
files: sortedFiles,
|
||||
fingerprint: fingerprintFiles(sortedFiles),
|
||||
usedGitIgnore,
|
||||
exceedsLimit: collected.exceedsLimit,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveLimits(limits: ScanCodebaseOptions['limits']): ScanCodebaseLimits {
|
||||
return {
|
||||
maxFiles: limits?.maxFiles ?? DEFAULT_MAX_FILES,
|
||||
maxFileSize: limits?.maxFileSize ?? DEFAULT_MAX_FILE_SIZE,
|
||||
maxArchiveSize: limits?.maxArchiveSize ?? DEFAULT_MAX_ARCHIVE_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
async function isInsideGitWorkTree(root: string): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['-C', root, 'rev-parse', '--is-inside-work-tree']);
|
||||
return stdout.trim() === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function scanWithGit(
|
||||
root: string,
|
||||
limits: ScanCodebaseLimits,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CollectedFiles> {
|
||||
const { stdout } = await execFileAsync(
|
||||
'git',
|
||||
['-C', root, 'ls-files', '-co', '--exclude-standard', '-z'],
|
||||
{ encoding: 'buffer', maxBuffer: 1024 * 1024 * 64, signal },
|
||||
);
|
||||
|
||||
throwIfAborted(signal);
|
||||
const relativePaths = splitNull(stdout);
|
||||
const files: FeedbackCodebaseFile[] = [];
|
||||
let exceedsLimit: FeedbackCodebaseLimitExceeded | undefined;
|
||||
let totalSize = 0;
|
||||
|
||||
for (const relativePath of relativePaths) {
|
||||
throwIfAborted(signal);
|
||||
if (files.length >= limits.maxFiles) {
|
||||
exceedsLimit = { reason: 'file-count', limit: limits.maxFiles };
|
||||
break;
|
||||
}
|
||||
if (isSensitivePath(relativePath)) continue;
|
||||
const file = await statFile(root, relativePath);
|
||||
if (file) {
|
||||
if (file.size > limits.maxFileSize) continue;
|
||||
if (totalSize + file.size > limits.maxArchiveSize) {
|
||||
exceedsLimit = { reason: 'total-size', limit: limits.maxArchiveSize };
|
||||
break;
|
||||
}
|
||||
files.push(file);
|
||||
totalSize += file.size;
|
||||
}
|
||||
}
|
||||
|
||||
return { files, exceedsLimit };
|
||||
}
|
||||
|
||||
async function scanWithoutFilter(
|
||||
root: string,
|
||||
limits: ScanCodebaseLimits,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CollectedFiles> {
|
||||
const files: FeedbackCodebaseFile[] = [];
|
||||
let exceedsLimit: FeedbackCodebaseLimitExceeded | undefined;
|
||||
let stopped = false;
|
||||
let totalSize = 0;
|
||||
|
||||
async function walk(dir: string): Promise<void> {
|
||||
if (stopped) return;
|
||||
throwIfAborted(signal);
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (stopped) return;
|
||||
throwIfAborted(signal);
|
||||
if (files.length >= limits.maxFiles) {
|
||||
exceedsLimit = { reason: 'file-count', limit: limits.maxFiles };
|
||||
stopped = true;
|
||||
return;
|
||||
}
|
||||
if (entry.isSymbolicLink()) continue;
|
||||
const absolutePath = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (isIgnoredDirName(entry.name)) continue;
|
||||
await walk(absolutePath);
|
||||
if (stopped) return;
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
const relativePath = toPosixPath(relative(root, absolutePath));
|
||||
if (isSensitivePath(relativePath)) continue;
|
||||
const file = await statFile(root, relativePath);
|
||||
if (file) {
|
||||
if (file.size > limits.maxFileSize) continue;
|
||||
if (totalSize + file.size > limits.maxArchiveSize) {
|
||||
exceedsLimit = { reason: 'total-size', limit: limits.maxArchiveSize };
|
||||
stopped = true;
|
||||
return;
|
||||
}
|
||||
files.push(file);
|
||||
totalSize += file.size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(root);
|
||||
return { files, exceedsLimit };
|
||||
}
|
||||
|
||||
async function statFile(root: string, relativePath: string): Promise<FeedbackCodebaseFile | null> {
|
||||
const absolutePath = resolve(root, relativePath);
|
||||
// A tracked file can be deleted from the working tree but still listed by
|
||||
// `git ls-files`; lstat then throws ENOENT. Treat unreadable/vanished paths
|
||||
// like any other non-regular entry so one bad path does not abort the scan.
|
||||
const stat = await lstat(absolutePath).catch(() => null);
|
||||
if (stat === null || stat.isSymbolicLink() || !stat.isFile()) return null;
|
||||
|
||||
return {
|
||||
path: toPosixPath(relativePath),
|
||||
absolutePath,
|
||||
size: stat.size,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
};
|
||||
}
|
||||
|
||||
function throwIfAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) {
|
||||
const error = new Error('Codebase scan aborted.');
|
||||
error.name = 'AbortError';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function fingerprintFiles(files: readonly FeedbackCodebaseFile[]): string {
|
||||
const hash = createHash('sha256');
|
||||
for (const file of files) {
|
||||
hash.update(file.path);
|
||||
hash.update('\0');
|
||||
hash.update(String(file.size));
|
||||
hash.update('\0');
|
||||
hash.update(String(Math.trunc(file.mtimeMs)));
|
||||
hash.update('\n');
|
||||
}
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
function splitNull(buffer: Buffer): string[] {
|
||||
return buffer
|
||||
.toString('utf8')
|
||||
.split('\0')
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
function toPosixPath(value: string): string {
|
||||
return value.split('\\').join('/');
|
||||
}
|
||||
19
apps/kimi-code/src/feedback/codebase/types.ts
Normal file
19
apps/kimi-code/src/feedback/codebase/types.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export interface FeedbackCodebaseFile {
|
||||
readonly path: string;
|
||||
readonly absolutePath: string;
|
||||
readonly size: number;
|
||||
readonly mtimeMs: number;
|
||||
}
|
||||
|
||||
export interface FeedbackCodebaseLimitExceeded {
|
||||
readonly reason: 'file-count' | 'total-size';
|
||||
readonly limit: number;
|
||||
}
|
||||
|
||||
export interface FeedbackCodebaseScanResult {
|
||||
readonly root: string;
|
||||
readonly files: readonly FeedbackCodebaseFile[];
|
||||
readonly fingerprint: string;
|
||||
readonly usedGitIgnore: boolean;
|
||||
readonly exceedsLimit?: FeedbackCodebaseLimitExceeded;
|
||||
}
|
||||
183
apps/kimi-code/src/feedback/feedback-attachments.ts
Normal file
183
apps/kimi-code/src/feedback/feedback-attachments.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import { appendFile, mkdir, readFile, rm, stat } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { detectInstallSource } from '#/cli/update/source';
|
||||
import type { SlashCommandHost } from '#/tui/commands/dispatch';
|
||||
import type { FeedbackAttachmentLevel } from '#/tui/commands/prompts';
|
||||
import { getLogDir } from '#/utils/paths';
|
||||
import { detectShellEnvironment } from '#/utils/process/shell-env';
|
||||
|
||||
import { createFeedbackArchivePath, type FeedbackArchive } from './archive';
|
||||
import { packageCodebase, scanCodebase, type FeedbackCodebaseScanResult } from './codebase';
|
||||
import { uploadArchive, type FeedbackUploadUrlApi } from './upload';
|
||||
|
||||
export const CODEBASE_ARCHIVE_FILENAME = 'repo.zip';
|
||||
export const SESSION_ARCHIVE_FILENAME = 'session.zip';
|
||||
|
||||
const CODEBASE_SCAN_TIMEOUT_MS = 3000;
|
||||
|
||||
/**
|
||||
* Stage 3 of the `/feedback` flow: prepare and upload each requested attachment
|
||||
* independently. Attachment failures are non-fatal because the text feedback
|
||||
* already exists, but any requested artifact that cannot be prepared/uploaded
|
||||
* is reported as a partial attachment failure instead of silently downgrading
|
||||
* the request.
|
||||
*
|
||||
* Returns `true` when at least one requested attachment failed so the caller
|
||||
* can surface a partial-failure status.
|
||||
*/
|
||||
export async function submitFeedbackWithAttachments(
|
||||
host: SlashCommandHost,
|
||||
feedbackId: number,
|
||||
level: FeedbackAttachmentLevel,
|
||||
): Promise<boolean> {
|
||||
const api = createFeedbackUploadApi(host);
|
||||
|
||||
if (level === 'logs') {
|
||||
const uploaded = await prepareAndUploadSessionArchive(host, api, feedbackId);
|
||||
return !uploaded;
|
||||
}
|
||||
if (level === 'logs+codebase') {
|
||||
const [sessionDir, scan] = await Promise.all([
|
||||
resolveCurrentSessionDir(host),
|
||||
scanCodebaseForFeedback(host.state.appState.workDir),
|
||||
]);
|
||||
const [uploadedSession, uploadedCodebase] = await Promise.all([
|
||||
prepareAndUploadSessionArchive(host, api, feedbackId, sessionDir),
|
||||
prepareAndUploadCodebaseArchive(api, feedbackId, scan),
|
||||
]);
|
||||
return !uploadedSession || !uploadedCodebase;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function prepareAndUploadSessionArchive(
|
||||
host: SlashCommandHost,
|
||||
api: FeedbackUploadUrlApi,
|
||||
feedbackId: number,
|
||||
knownSessionDir?: string,
|
||||
): Promise<boolean> {
|
||||
const sessionDir = knownSessionDir ?? (await resolveCurrentSessionDir(host));
|
||||
if (sessionDir === undefined) {
|
||||
await logFeedbackUploadError(new Error('cannot locate the current session directory'));
|
||||
return false;
|
||||
}
|
||||
return uploadProducedArchive(api, feedbackId, SESSION_ARCHIVE_FILENAME, async (archivePath) => {
|
||||
const exported = await host.harness.exportSession({
|
||||
id: host.state.appState.sessionId,
|
||||
outputPath: archivePath,
|
||||
includeGlobalLog: true,
|
||||
version: host.state.appState.version,
|
||||
installSource: await detectInstallSource(),
|
||||
shellEnv: detectShellEnvironment(),
|
||||
});
|
||||
return archiveFromExportedSession(exported.zipPath);
|
||||
});
|
||||
}
|
||||
|
||||
async function prepareAndUploadCodebaseArchive(
|
||||
api: FeedbackUploadUrlApi,
|
||||
feedbackId: number,
|
||||
scan: FeedbackCodebaseScanResult | undefined,
|
||||
): Promise<boolean> {
|
||||
if (scan === undefined) return false;
|
||||
return uploadProducedArchive(api, feedbackId, CODEBASE_ARCHIVE_FILENAME, (archivePath) =>
|
||||
packageCodebase(scan, archivePath),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared lifecycle for a single attachment: create a temp archive path, let
|
||||
* `produce` write the archive to it, upload it, then always remove the temp
|
||||
* directory — even when `produce` or the upload throws. Both the session log
|
||||
* archive and the codebase archive flow through here so their cleanup and
|
||||
* error handling cannot drift apart.
|
||||
*/
|
||||
async function uploadProducedArchive(
|
||||
api: FeedbackUploadUrlApi,
|
||||
feedbackId: number,
|
||||
filename: string,
|
||||
produce: (archivePath: string) => Promise<FeedbackArchive>,
|
||||
): Promise<boolean> {
|
||||
const { archivePath, cleanupDir } = await createFeedbackArchivePath(filename);
|
||||
try {
|
||||
const archive = await produce(archivePath);
|
||||
await uploadArchive(api, { ...archive, cleanupDir }, feedbackId, { filename });
|
||||
return true;
|
||||
} catch (error) {
|
||||
await logFeedbackUploadError(error);
|
||||
return false;
|
||||
} finally {
|
||||
await rm(cleanupDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveFromExportedSession(zipPath: string): Promise<FeedbackArchive> {
|
||||
const data = await readFile(zipPath);
|
||||
const archiveStat = await stat(zipPath);
|
||||
return {
|
||||
path: zipPath,
|
||||
size: archiveStat.size,
|
||||
sha256: createHash('sha256').update(data).digest('hex'),
|
||||
fingerprint: createHash('sha256').update(data).digest('hex'),
|
||||
fileCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveCurrentSessionDir(host: SlashCommandHost): Promise<string | undefined> {
|
||||
try {
|
||||
const sessions = await host.harness.listSessions({ workDir: host.state.appState.workDir });
|
||||
return sessions.find((session) => session.id === host.state.appState.sessionId)?.sessionDir;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function scanCodebaseForFeedback(
|
||||
workDir: string,
|
||||
): Promise<FeedbackCodebaseScanResult | undefined> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, CODEBASE_SCAN_TIMEOUT_MS);
|
||||
try {
|
||||
return await scanCodebase(workDir, { signal: controller.signal });
|
||||
} catch (error) {
|
||||
await logFeedbackUploadError(error);
|
||||
return undefined;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function logFeedbackUploadError(error: unknown): Promise<void> {
|
||||
try {
|
||||
const logDir = getLogDir();
|
||||
await mkdir(logDir, { recursive: true });
|
||||
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
|
||||
await appendFile(join(logDir, 'feedback-upload.log'), `${new Date().toISOString()} ${message}\n`);
|
||||
} catch {
|
||||
// best-effort logging only
|
||||
}
|
||||
}
|
||||
|
||||
function createFeedbackUploadApi(host: SlashCommandHost): FeedbackUploadUrlApi {
|
||||
return {
|
||||
async createUploadUrl(input) {
|
||||
const res = await host.harness.auth.createFeedbackUploadUrl(input);
|
||||
if (res.kind !== 'ok') throw new Error(res.message);
|
||||
return {
|
||||
uploadId: res.uploadId,
|
||||
parts: res.parts,
|
||||
};
|
||||
},
|
||||
async completeUpload(input) {
|
||||
const res = await host.harness.auth.completeFeedbackUpload({
|
||||
uploadId: input.uploadId,
|
||||
parts: input.parts.map((part) => ({ partNumber: part.partNumber, etag: part.etag })),
|
||||
});
|
||||
if (res.kind !== 'ok') throw new Error(res.message);
|
||||
},
|
||||
};
|
||||
}
|
||||
208
apps/kimi-code/src/feedback/upload.ts
Normal file
208
apps/kimi-code/src/feedback/upload.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import { createReadStream } from 'node:fs';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import type { FeedbackArchive } from './archive';
|
||||
|
||||
const MAX_ARCHIVE_SIZE = 524_288_000; // 500 MiB, matches the backend limit.
|
||||
const DEFAULT_CONCURRENCY = 3;
|
||||
const DEFAULT_MAX_RETRIES = 3;
|
||||
const DEFAULT_PART_TIMEOUT_MS = 60_000;
|
||||
const RETRY_BASE_DELAY_MS = 1_000;
|
||||
|
||||
export interface FeedbackUploadPart {
|
||||
readonly partNumber: number;
|
||||
readonly url: string;
|
||||
readonly method: string;
|
||||
readonly size: number;
|
||||
}
|
||||
|
||||
export interface CreateFeedbackUploadUrlInput {
|
||||
readonly feedbackId: number;
|
||||
readonly filename: string;
|
||||
readonly size: number;
|
||||
readonly sha256: string;
|
||||
}
|
||||
|
||||
export interface CreateFeedbackUploadUrlResult {
|
||||
readonly uploadId: number;
|
||||
readonly parts: readonly FeedbackUploadPart[];
|
||||
}
|
||||
|
||||
export interface CompletedUploadPart {
|
||||
readonly partNumber: number;
|
||||
readonly etag: string;
|
||||
}
|
||||
|
||||
export interface CompleteFeedbackUploadUrlInput {
|
||||
readonly uploadId: number;
|
||||
readonly parts: readonly CompletedUploadPart[];
|
||||
}
|
||||
|
||||
export interface FeedbackUploadUrlApi {
|
||||
createUploadUrl(input: CreateFeedbackUploadUrlInput): Promise<CreateFeedbackUploadUrlResult>;
|
||||
completeUpload(input: CompleteFeedbackUploadUrlInput): Promise<void>;
|
||||
}
|
||||
|
||||
export interface UploadArchiveOptions {
|
||||
/** Zip entry name sent to the backend. */
|
||||
readonly filename: string;
|
||||
/** Abort a single part PUT if it does not complete within this many milliseconds. */
|
||||
readonly timeoutMs?: number;
|
||||
/** Number of parts to upload concurrently (defaults to 3). */
|
||||
readonly concurrency?: number;
|
||||
/** Per-part retry attempts after the first failure (defaults to 3). */
|
||||
readonly maxRetries?: number;
|
||||
/** Called after each part finishes with the cumulative uploaded bytes. */
|
||||
readonly onProgress?: (uploadedBytes: number, totalBytes: number) => void;
|
||||
}
|
||||
|
||||
export async function uploadArchive(
|
||||
api: FeedbackUploadUrlApi,
|
||||
archive: FeedbackArchive,
|
||||
feedbackId: number,
|
||||
options: UploadArchiveOptions,
|
||||
): Promise<void> {
|
||||
if (archive.size > MAX_ARCHIVE_SIZE) {
|
||||
throw new Error(
|
||||
`Failed to upload archive: size ${archive.size} exceeds maximum allowed size ${MAX_ARCHIVE_SIZE}.`,
|
||||
);
|
||||
}
|
||||
const created = await api.createUploadUrl({
|
||||
feedbackId,
|
||||
filename: options.filename,
|
||||
size: archive.size,
|
||||
sha256: archive.sha256,
|
||||
});
|
||||
const completed = await uploadParts(archive.path, created.parts, archive.size, options);
|
||||
await api.completeUpload({ uploadId: created.uploadId, parts: completed });
|
||||
}
|
||||
|
||||
interface PartLayout {
|
||||
readonly part: FeedbackUploadPart;
|
||||
readonly start: number;
|
||||
}
|
||||
|
||||
function layoutParts(parts: readonly FeedbackUploadPart[]): PartLayout[] {
|
||||
const sorted = parts.toSorted((a, b) => a.partNumber - b.partNumber);
|
||||
let offset = 0;
|
||||
return sorted.map((part) => {
|
||||
const start = offset;
|
||||
offset += part.size;
|
||||
return { part, start };
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadParts(
|
||||
filePath: string,
|
||||
parts: readonly FeedbackUploadPart[],
|
||||
totalBytes: number,
|
||||
options: UploadArchiveOptions,
|
||||
): Promise<CompletedUploadPart[]> {
|
||||
const layout = layoutParts(parts);
|
||||
const results: CompletedUploadPart[] = Array.from({ length: layout.length });
|
||||
const concurrency = Math.max(1, Math.min(options.concurrency ?? DEFAULT_CONCURRENCY, layout.length));
|
||||
let nextIndex = 0;
|
||||
let uploadedBytes = 0;
|
||||
|
||||
async function worker(): Promise<void> {
|
||||
while (true) {
|
||||
const index = nextIndex;
|
||||
nextIndex += 1;
|
||||
if (index >= layout.length) return;
|
||||
const entry = layout[index];
|
||||
if (entry === undefined) return;
|
||||
const completed = await uploadOnePartWithRetry(filePath, entry, options);
|
||||
results[index] = completed;
|
||||
uploadedBytes += entry.part.size;
|
||||
options.onProgress?.(uploadedBytes, totalBytes);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: concurrency }, () => worker()));
|
||||
return results;
|
||||
}
|
||||
|
||||
async function uploadOnePartWithRetry(
|
||||
filePath: string,
|
||||
layout: PartLayout,
|
||||
options: UploadArchiveOptions,
|
||||
): Promise<CompletedUploadPart> {
|
||||
const maxRetries = Math.max(0, options.maxRetries ?? DEFAULT_MAX_RETRIES);
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
|
||||
try {
|
||||
return await uploadOnePart(filePath, layout, options);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt === maxRetries || !isRetryable(error)) break;
|
||||
await sleep(RETRY_BASE_DELAY_MS * 2 ** attempt);
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async function uploadOnePart(
|
||||
filePath: string,
|
||||
layout: PartLayout,
|
||||
options: UploadArchiveOptions,
|
||||
): Promise<CompletedUploadPart> {
|
||||
const { part, start } = layout;
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_PART_TIMEOUT_MS;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, timeoutMs);
|
||||
const stream = createReadStream(filePath, { start, end: start + part.size - 1 });
|
||||
try {
|
||||
const res = await fetch(part.url, {
|
||||
method: part.method,
|
||||
body: Readable.toWeb(stream),
|
||||
headers: { 'Content-Length': String(part.size) },
|
||||
duplex: 'half',
|
||||
signal: controller.signal,
|
||||
} as RequestInit);
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new UploadPartHttpError(part.partNumber, res.status, text);
|
||||
}
|
||||
const etag = res.headers.get('etag');
|
||||
if (etag === null || etag.length === 0) {
|
||||
throw new Error(`Failed to upload part ${part.partNumber}: missing ETag in response.`);
|
||||
}
|
||||
return { partNumber: part.partNumber, etag };
|
||||
} catch (error) {
|
||||
stream.destroy();
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new Error(`Failed to upload part ${part.partNumber}: upload timed out.`, { cause: error });
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
class UploadPartHttpError extends Error {
|
||||
constructor(
|
||||
readonly partNumber: number,
|
||||
readonly status: number,
|
||||
readonly responseBody: string,
|
||||
) {
|
||||
super(
|
||||
`Failed to upload part ${partNumber}: HTTP ${String(status)}${responseBody.length > 0 ? ` ${responseBody}` : ''}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isRetryable(error: unknown): boolean {
|
||||
if (error instanceof UploadPartHttpError) {
|
||||
return error.status >= 500 || error.status === 408 || error.status === 429;
|
||||
}
|
||||
// Network errors and timeouts are retryable.
|
||||
return true;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
import { createRequire } from 'node:module';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join, win32 as pathWin32 } from 'node:path';
|
||||
import { join as joinPosix } from 'pathe';
|
||||
|
||||
import { KIMI_BUILD_INFO } from '#/cli/build-info';
|
||||
import { NATIVE_ASSET_MANIFEST_VERSION as MANIFEST_VERSION, buildManifestKey } from '../../scripts/native/manifest.mjs';
|
||||
|
|
@ -143,7 +144,7 @@ export function getNativeCacheBase(options: NativeAssetOptions = {}): string {
|
|||
const cacheDirEnv = optionalEnvValue(env, 'KIMI_CODE_CACHE_DIR');
|
||||
if (cacheDirEnv !== null) return cacheDirEnv;
|
||||
|
||||
if (platform === 'darwin') return join(home, 'Library', 'Caches', 'kimi-code');
|
||||
if (platform === 'darwin') return joinPosix(home, 'Library', 'Caches', 'kimi-code');
|
||||
if (platform === 'win32') {
|
||||
const localAppData = optionalEnvValue(env, 'LOCALAPPDATA');
|
||||
return localAppData !== null
|
||||
|
|
@ -151,7 +152,7 @@ export function getNativeCacheBase(options: NativeAssetOptions = {}): string {
|
|||
: pathWin32.join(home, 'AppData', 'Local', 'kimi-code', 'Cache');
|
||||
}
|
||||
|
||||
return join(optionalEnvValue(env, 'XDG_CACHE_HOME') ?? join(home, '.cache'), 'kimi-code');
|
||||
return joinPosix(optionalEnvValue(env, 'XDG_CACHE_HOME') ?? joinPosix(home, '.cache'), 'kimi-code');
|
||||
}
|
||||
|
||||
export function getNativeAssetCacheRoot(
|
||||
|
|
|
|||
91
apps/kimi-code/src/tui/commands/add-dir.ts
Normal file
91
apps/kimi-code/src/tui/commands/add-dir.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui';
|
||||
import { ChoicePickerComponent } from '../components/dialogs/choice-picker';
|
||||
import type { SlashCommandHost } from './dispatch';
|
||||
|
||||
type AddDirChoice = 'session' | 'remember' | 'cancel';
|
||||
|
||||
export async function handleAddDirCommand(host: SlashCommandHost, args: string): Promise<void> {
|
||||
const input = args.trim();
|
||||
const session = host.session;
|
||||
|
||||
if (input.length === 0 || input.toLowerCase() === 'list') {
|
||||
const additionalDirs = session?.summary?.additionalDirs ?? [];
|
||||
if (additionalDirs.length === 0) {
|
||||
host.showStatus('No additional directories configured.');
|
||||
return;
|
||||
}
|
||||
host.showStatus(formatAdditionalDirsStatus(additionalDirs));
|
||||
return;
|
||||
}
|
||||
|
||||
if (session === undefined) {
|
||||
host.showError(NO_ACTIVE_SESSION_MESSAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
host.mountEditorReplacement(
|
||||
new ChoicePickerComponent({
|
||||
title: `Add directory to workspace: ${input}`,
|
||||
hint: '↑↓ navigate · Enter confirm · Esc cancel',
|
||||
options: [
|
||||
{
|
||||
value: 'session',
|
||||
label: 'Yes, for this session',
|
||||
},
|
||||
{
|
||||
value: 'remember',
|
||||
label: 'Yes, and remember this directory',
|
||||
},
|
||||
{
|
||||
value: 'cancel',
|
||||
label: 'No',
|
||||
},
|
||||
],
|
||||
onSelect: (value) => {
|
||||
void handleAddDirChoice(host, session.id, input, value as AddDirChoice);
|
||||
},
|
||||
onCancel: () => {
|
||||
host.restoreEditor();
|
||||
host.showStatus(`Did not add ${input} as a working directory.`);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function formatAdditionalDirsStatus(additionalDirs: readonly string[]): string {
|
||||
return ['Additional directories:', ...additionalDirs.map((dir) => ` ${dir}`)].join('\n');
|
||||
}
|
||||
|
||||
async function handleAddDirChoice(
|
||||
host: SlashCommandHost,
|
||||
sessionId: string,
|
||||
path: string,
|
||||
choice: AddDirChoice,
|
||||
): Promise<void> {
|
||||
host.restoreEditor();
|
||||
|
||||
if (choice === 'cancel') {
|
||||
host.showStatus(`Did not add ${path} as a working directory.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const session = host.session;
|
||||
if (session === undefined || session.id !== sessionId) {
|
||||
host.showError(NO_ACTIVE_SESSION_MESSAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await session.addAdditionalDir(path, { persist: choice === 'remember' });
|
||||
host.setAppState({ additionalDirs: result.additionalDirs });
|
||||
host.refreshSlashCommandAutocomplete();
|
||||
host.showStatus(
|
||||
choice === 'remember'
|
||||
? `Added workspace directory:\n ${path}\n Saved to:\n ${result.configPath}`
|
||||
: `Added workspace directory:\n ${path}\n For this session only`,
|
||||
'success',
|
||||
);
|
||||
} catch (error) {
|
||||
host.showError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
|
@ -70,6 +70,7 @@ async function handleKimiCodeOAuthLogin(host: SlashCommandHost): Promise<void> {
|
|||
}
|
||||
host.track('login', {
|
||||
provider: DEFAULT_OAUTH_PROVIDER_NAME,
|
||||
method: 'oauth',
|
||||
already_logged_in: alreadyLoggedIn,
|
||||
});
|
||||
if (alreadyLoggedIn) {
|
||||
|
|
|
|||
|
|
@ -311,7 +311,11 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string =
|
|||
currentThinking: host.state.appState.thinking,
|
||||
onSelect: ({ alias, thinking }) => {
|
||||
host.restoreEditor();
|
||||
void performModelSwitch(host, alias, thinking);
|
||||
void performModelSwitch(host, alias, thinking, true);
|
||||
},
|
||||
onSessionOnlySelect: ({ alias, thinking }) => {
|
||||
host.restoreEditor();
|
||||
void performModelSwitch(host, alias, thinking, false);
|
||||
},
|
||||
onCancel: () => {
|
||||
host.restoreEditor();
|
||||
|
|
@ -320,7 +324,12 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string =
|
|||
);
|
||||
}
|
||||
|
||||
async function performModelSwitch(host: SlashCommandHost, alias: string, thinking: boolean): Promise<void> {
|
||||
async function performModelSwitch(
|
||||
host: SlashCommandHost,
|
||||
alias: string,
|
||||
thinking: boolean,
|
||||
persist: boolean,
|
||||
): Promise<void> {
|
||||
if (host.state.appState.streamingPhase !== 'idle') {
|
||||
host.showError('Cannot switch models while streaming — press Esc or Ctrl-C first.');
|
||||
return;
|
||||
|
|
@ -360,19 +369,26 @@ async function performModelSwitch(host: SlashCommandHost, alias: string, thinkin
|
|||
}
|
||||
|
||||
let persisted = false;
|
||||
try {
|
||||
persisted = await persistModelSelection(host, alias, thinking);
|
||||
} catch (error) {
|
||||
const msg = formatErrorMessage(error);
|
||||
host.showError(`Switched to ${alias}, but failed to save default: ${msg}`);
|
||||
return;
|
||||
if (persist) {
|
||||
try {
|
||||
persisted = await persistModelSelection(host, alias, thinking);
|
||||
} catch (error) {
|
||||
const msg = formatErrorMessage(error);
|
||||
host.showError(`Switched to ${alias}, but failed to save default: ${msg}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const status = runtimeChanged
|
||||
? `Switched to ${alias} with thinking ${level}.`
|
||||
: persisted
|
||||
? `Saved ${alias} with thinking ${level} as default.`
|
||||
: `Already using ${alias} with thinking ${level}.`;
|
||||
let status: string;
|
||||
if (runtimeChanged) {
|
||||
status = persist
|
||||
? `Switched to ${alias} with thinking ${level}.`
|
||||
: `Switched to ${alias} with thinking ${level} for this session only.`;
|
||||
} else if (persist && persisted) {
|
||||
status = `Saved ${alias} with thinking ${level} as default.`;
|
||||
} else {
|
||||
status = `Already using ${alias} with thinking ${level}.`;
|
||||
}
|
||||
host.showStatus(status, 'success');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
} from './config';
|
||||
import { handleGoalCommand } from './goal';
|
||||
import { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info';
|
||||
import { handleAddDirCommand } from './add-dir';
|
||||
import { parseSlashInput } from './parse';
|
||||
import { handlePluginsCommand } from './plugins';
|
||||
import { handleProviderCommand } from './provider';
|
||||
|
|
@ -59,6 +60,7 @@ import { handleWebCommand } from './web';
|
|||
|
||||
export { handleLoginCommand, handleLogoutCommand } from './auth';
|
||||
export { handleBtwCommand } from './btw';
|
||||
export { handleAddDirCommand } from './add-dir';
|
||||
export {
|
||||
handleAutoCommand,
|
||||
handleCompactCommand,
|
||||
|
|
@ -247,6 +249,9 @@ async function handleBuiltInSlashCommand(
|
|||
case 'plugins':
|
||||
void handlePluginsCommand(host, args);
|
||||
return;
|
||||
case 'add-dir':
|
||||
await handleAddDirCommand(host, args);
|
||||
return;
|
||||
case 'experiments':
|
||||
await showExperimentsPanel(host);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -412,7 +412,6 @@ async function startGoal(
|
|||
if (options.beforeSend !== undefined && !(await options.beforeSend())) {
|
||||
return false;
|
||||
}
|
||||
host.track('goal_create', { replace: parsed.replace });
|
||||
host.state.transcriptContainer.addChild(new GoalSetMessageComponent());
|
||||
host.state.ui.requestRender();
|
||||
if (options.sendInput !== undefined) {
|
||||
|
|
|
|||
|
|
@ -12,14 +12,17 @@ import {
|
|||
FEEDBACK_STATUS_NOT_SIGNED_IN,
|
||||
FEEDBACK_STATUS_SUBMITTING,
|
||||
FEEDBACK_STATUS_SUCCESS,
|
||||
FEEDBACK_STATUS_UPLOAD_FAILED,
|
||||
FEEDBACK_TELEMETRY_EVENT,
|
||||
feedbackIdLine,
|
||||
feedbackSessionLine,
|
||||
withFeedbackVersionPrefix,
|
||||
} from '../constant/feedback';
|
||||
import { isManagedUsageProvider } from '../constant/kimi-tui';
|
||||
import { submitFeedbackWithAttachments } from '../../feedback/feedback-attachments';
|
||||
import { formatErrorMessage } from '../utils/event-payload';
|
||||
import { openUrl } from '#/utils/open-url';
|
||||
import { promptFeedbackInput } from './prompts';
|
||||
import { promptFeedbackAttachment, promptFeedbackInput } from './prompts';
|
||||
import type { SlashCommandHost } from './dispatch';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -39,30 +42,46 @@ export async function handleFeedbackCommand(host: SlashCommandHost): Promise<voi
|
|||
return;
|
||||
}
|
||||
|
||||
const content = await promptFeedbackInput(host);
|
||||
if (content === undefined) {
|
||||
// Stage 1: collect the free-form feedback text.
|
||||
const input = await promptFeedbackInput(host);
|
||||
if (input === undefined) {
|
||||
host.showStatus(FEEDBACK_STATUS_CANCELLED);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stage 2: ask whether to attach diagnostics (logs / codebase).
|
||||
const level = await promptFeedbackAttachment(host);
|
||||
if (level === undefined) {
|
||||
host.showStatus(FEEDBACK_STATUS_CANCELLED);
|
||||
return;
|
||||
}
|
||||
|
||||
const version = withFeedbackVersionPrefix(host.state.appState.version);
|
||||
const spinner = host.showLoginProgressSpinner(FEEDBACK_STATUS_SUBMITTING);
|
||||
const res = await host.harness.auth.submitFeedback({
|
||||
content,
|
||||
content: input.value,
|
||||
sessionId: host.state.appState.sessionId,
|
||||
version: withFeedbackVersionPrefix(host.state.appState.version),
|
||||
version,
|
||||
os: `${osType()} ${osRelease()}`,
|
||||
model: host.state.appState.model.length > 0 ? host.state.appState.model : null,
|
||||
});
|
||||
|
||||
if (res.kind === 'ok') {
|
||||
spinner.stop({ ok: true, label: FEEDBACK_STATUS_SUCCESS });
|
||||
host.showStatus(feedbackSessionLine(host.state.appState.sessionId));
|
||||
host.track(FEEDBACK_TELEMETRY_EVENT);
|
||||
if (res.kind !== 'ok') {
|
||||
spinner.stop({ ok: false, label: res.message });
|
||||
fallback(FEEDBACK_STATUS_FALLBACK);
|
||||
return;
|
||||
}
|
||||
|
||||
spinner.stop({ ok: false, label: res.message });
|
||||
fallback(FEEDBACK_STATUS_FALLBACK);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -4,14 +4,15 @@ import { isAbsolute, join, resolve } from 'node:path';
|
|||
import type { PluginInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk';
|
||||
|
||||
import {
|
||||
PluginInstallTrustConfirmComponent,
|
||||
PluginMcpSelectorComponent,
|
||||
PluginMarketplaceSelectorComponent,
|
||||
PluginRemoveConfirmComponent,
|
||||
PluginsOverviewSelectorComponent,
|
||||
PluginsPanelComponent,
|
||||
type PluginInstallTrustConfirmResult,
|
||||
type PluginMcpSelection,
|
||||
type PluginMarketplaceSelection,
|
||||
type PluginRemoveConfirmResult,
|
||||
type PluginsOverviewSelection,
|
||||
type PluginsPanelSelection,
|
||||
type PluginsPanelTabId,
|
||||
} from '../components/dialogs/plugins-selector';
|
||||
import {
|
||||
buildPluginsInfoLines,
|
||||
|
|
@ -19,7 +20,7 @@ import {
|
|||
} from '../components/messages/plugins-status-panel';
|
||||
import { UsagePanelComponent } from '../components/messages/usage-panel';
|
||||
import { formatErrorMessage } from '../utils/event-payload';
|
||||
import { formatPluginSourceLabel } from '../utils/plugin-source-label';
|
||||
import { formatPluginSourceLabel, isOfficialPluginSource } from '../utils/plugin-source-label';
|
||||
import { loadPluginMarketplace } from '#/utils/plugin-marketplace';
|
||||
import type { SlashCommandHost } from './dispatch';
|
||||
|
||||
|
|
@ -29,6 +30,8 @@ interface ShowPluginsPickerOptions {
|
|||
readonly id: string;
|
||||
readonly text: string;
|
||||
};
|
||||
readonly initialTab?: PluginsPanelTabId;
|
||||
readonly marketplaceSource?: string;
|
||||
}
|
||||
|
||||
interface PluginMcpServerHint {
|
||||
|
|
@ -62,6 +65,10 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri
|
|||
host.showError('Usage: /plugins install <local-path-or-zip-url>');
|
||||
return;
|
||||
}
|
||||
if (!(await confirmInstallTrust(host, source, isOfficialPluginSource(source)))) {
|
||||
host.showStatus('Install cancelled.');
|
||||
return;
|
||||
}
|
||||
const spinner = host.showProgressSpinner(`Installing plugin from ${truncateForStatus(source)}…`);
|
||||
try {
|
||||
await installPluginFromSource(host, source);
|
||||
|
|
@ -73,7 +80,15 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri
|
|||
return;
|
||||
}
|
||||
if (sub === 'marketplace') {
|
||||
await showPluginMarketplacePicker(host, rest.join(' ').trim() || undefined);
|
||||
const marketplaceSource = rest.join(' ').trim() || undefined;
|
||||
await showPluginsPicker(host, {
|
||||
// Custom marketplaces often omit `tier`, so their entries land on the
|
||||
// Third-party tab (entry.tier !== 'official'). Open there when a custom
|
||||
// source is supplied; otherwise the default catalog's official entries
|
||||
// make Official the right landing tab.
|
||||
initialTab: marketplaceSource === undefined ? 'official' : 'third-party',
|
||||
marketplaceSource,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (sub === 'info') {
|
||||
|
|
@ -95,7 +110,7 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri
|
|||
}
|
||||
await session.setPluginMcpServerEnabled(id, server, action === 'enable');
|
||||
host.showStatus(
|
||||
`${action === 'enable' ? 'Enabled' : 'Disabled'} MCP server ${server} for ${id}. Run /new to apply.`,
|
||||
`${action === 'enable' ? 'Enabled' : 'Disabled'} MCP server ${server} for ${id}. Run /reload or /new to apply.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
@ -118,8 +133,7 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri
|
|||
host.showStatus(`Remove cancelled: ${id}.`);
|
||||
return;
|
||||
}
|
||||
await session.removePlugin(id);
|
||||
host.showStatus(`Removed ${id} (plugin files left in place).`);
|
||||
await removePlugin(host, id);
|
||||
return;
|
||||
}
|
||||
if (sub === 'reload') {
|
||||
|
|
@ -149,55 +163,58 @@ async function showPluginsPicker(
|
|||
return;
|
||||
}
|
||||
|
||||
host.mountEditorReplacement(
|
||||
new PluginsOverviewSelectorComponent({
|
||||
plugins,
|
||||
selectedId: options?.selectedId,
|
||||
pluginHint: options?.pluginHint,
|
||||
onSelect: (selection) => {
|
||||
// Each branch of the handler either mounts the next view or restores
|
||||
// the editor itself, so do not pre-restore here — that would flash the
|
||||
// editor for in-place actions like toggling a plugin.
|
||||
void handlePluginsOverviewSelection(host, selection).catch((error: unknown) => {
|
||||
host.showError(`/plugins failed: ${formatErrorMessage(error)}`);
|
||||
});
|
||||
},
|
||||
onCancel: () => {
|
||||
host.restoreEditor();
|
||||
},
|
||||
}),
|
||||
);
|
||||
const panel = new PluginsPanelComponent({
|
||||
installed: plugins,
|
||||
installedIds: new Set(plugins.map((plugin) => plugin.id)),
|
||||
initialTab: options?.initialTab,
|
||||
selectedId: options?.selectedId,
|
||||
pluginHint: options?.pluginHint,
|
||||
onSelect: (selection) => {
|
||||
// Each branch of the handler either mounts the next view or restores the
|
||||
// editor itself, so do not pre-restore here — that would flash the editor
|
||||
// for in-place actions like toggling a plugin.
|
||||
void handlePluginsPanelSelection(host, panel, selection).catch((error: unknown) => {
|
||||
host.showError(`/plugins failed: ${formatErrorMessage(error)}`);
|
||||
});
|
||||
},
|
||||
onCancel: () => {
|
||||
host.restoreEditor();
|
||||
},
|
||||
// Every tab except Custom needs the catalog: Official/Third-party list it,
|
||||
// and Installed uses it to show update badges. The Installed/Custom tabs
|
||||
// keep working even when the marketplace is unreachable (badges simply stay
|
||||
// hidden until data arrives).
|
||||
onRequestMarketplace: () => {
|
||||
void loadMarketplaceCatalog(host, panel, options?.marketplaceSource);
|
||||
},
|
||||
});
|
||||
host.mountEditorReplacement(panel);
|
||||
// Kick off the catalog fetch for any tab that needs it: Installed uses it for
|
||||
// update badges, Official/Third-party list it. Custom never reads the catalog,
|
||||
// so skip the fetch there. Done here (after `panel` is initialized) rather
|
||||
// than inside the component constructor, because the callback above closes
|
||||
// over `panel`.
|
||||
if (options?.initialTab !== 'custom') {
|
||||
panel.setMarketplaceLoading();
|
||||
void loadMarketplaceCatalog(host, panel, options?.marketplaceSource);
|
||||
}
|
||||
}
|
||||
|
||||
async function showPluginMarketplacePicker(host: SlashCommandHost, source?: string): Promise<void> {
|
||||
async function loadMarketplaceCatalog(
|
||||
host: SlashCommandHost,
|
||||
panel: PluginsPanelComponent,
|
||||
source?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const [marketplace, installed] = await Promise.all([
|
||||
loadPluginMarketplace({ workDir: host.state.appState.workDir, source }),
|
||||
host.requireSession().listPlugins(),
|
||||
]);
|
||||
host.mountEditorReplacement(
|
||||
new PluginMarketplaceSelectorComponent({
|
||||
entries: marketplace.plugins,
|
||||
installed: new Map(
|
||||
installed.map((plugin): [string, string | undefined] => [plugin.id, plugin.version]),
|
||||
),
|
||||
source: marketplace.source,
|
||||
onSelect: (selection) => {
|
||||
// Every marketplace action re-mounts a picker, so let the handler do
|
||||
// the mounting — pre-restoring the editor here would flash.
|
||||
void handlePluginMarketplaceSelection(host, selection).catch((error: unknown) => {
|
||||
host.showError(`/plugins marketplace failed: ${formatErrorMessage(error)}`);
|
||||
});
|
||||
},
|
||||
onCancel: () => {
|
||||
host.restoreEditor();
|
||||
void showPluginsPicker(host);
|
||||
},
|
||||
}),
|
||||
);
|
||||
const marketplace = await loadPluginMarketplace({
|
||||
workDir: host.state.appState.workDir,
|
||||
source,
|
||||
});
|
||||
panel.setMarketplace(marketplace.plugins, marketplace.source);
|
||||
} catch (error) {
|
||||
host.showError(`Failed to load plugin marketplace: ${formatErrorMessage(error)}`);
|
||||
panel.setMarketplaceError(formatErrorMessage(error));
|
||||
}
|
||||
host.state.ui.requestRender();
|
||||
}
|
||||
|
||||
async function showPluginMcpPicker(
|
||||
|
|
@ -255,6 +272,67 @@ async function confirmRemovePlugin(host: SlashCommandHost, id: string): Promise<
|
|||
});
|
||||
}
|
||||
|
||||
async function confirmInstallTrust(
|
||||
host: SlashCommandHost,
|
||||
label: string,
|
||||
official: boolean,
|
||||
): Promise<boolean> {
|
||||
// Kimi-built official plugins are trusted implicitly; anything else requires
|
||||
// the user to explicitly opt in via the trust prompt.
|
||||
if (official) return true;
|
||||
return new Promise((resolveConfirmed) => {
|
||||
host.mountEditorReplacement(
|
||||
new PluginInstallTrustConfirmComponent({
|
||||
label,
|
||||
onDone: (result: PluginInstallTrustConfirmResult) => {
|
||||
host.restoreEditor();
|
||||
resolveConfirmed(result.kind === 'confirm');
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function installFromPanel(
|
||||
host: SlashCommandHost,
|
||||
panel: PluginsPanelComponent,
|
||||
source: string,
|
||||
label: string,
|
||||
official: boolean,
|
||||
): Promise<void> {
|
||||
if (!(await confirmInstallTrust(host, label, official))) {
|
||||
host.showStatus(`Install cancelled: ${label}.`);
|
||||
host.restoreEditor();
|
||||
return;
|
||||
}
|
||||
// Official installs keep the panel mounted and show the inline installing
|
||||
// state; third-party installs pass through a trust prompt that replaces the
|
||||
// panel, so fall back to a transcript status for those.
|
||||
if (official) {
|
||||
panel.setInstalling(truncateForStatus(label));
|
||||
} else {
|
||||
host.showStatus(`Installing or updating ${label} from marketplace...`);
|
||||
}
|
||||
host.state.ui.requestRender();
|
||||
try {
|
||||
await installPluginFromSource(host, source);
|
||||
} catch (error) {
|
||||
if (official) {
|
||||
panel.clearInstalling();
|
||||
host.state.ui.requestRender();
|
||||
} else {
|
||||
// The trust prompt replaced the panel; re-mount it so the user can retry
|
||||
// instead of being dropped back at the editor.
|
||||
host.mountEditorReplacement(panel);
|
||||
}
|
||||
host.showError(`Failed to install ${label}: ${formatErrorMessage(error)}`);
|
||||
return;
|
||||
}
|
||||
// Close the panel after installing so the result status and the
|
||||
// "/reload or /new" tip are visible in the transcript.
|
||||
host.restoreEditor();
|
||||
}
|
||||
|
||||
async function applyPluginEnabled(
|
||||
host: SlashCommandHost,
|
||||
id: string,
|
||||
|
|
@ -274,54 +352,65 @@ async function applyPluginEnabled(
|
|||
? ` Some MCP servers are disabled; re-enable with /plugins mcp enable ${id} <server>.`
|
||||
: '';
|
||||
if (showStatus) {
|
||||
host.showStatus(`${enabled ? 'Enabled' : 'Disabled'} ${id}. Run /new to apply.${mcpHint}`);
|
||||
host.showStatus(`${enabled ? 'Enabled' : 'Disabled'} ${id}. Run /reload or /new to apply.${mcpHint}`);
|
||||
}
|
||||
const inlineMcpHint = mcpHint.length > 0 ? ' · MCP servers disabled' : '';
|
||||
return `${pluginInlineChangeHint()}${inlineMcpHint}`;
|
||||
}
|
||||
|
||||
async function handlePluginsOverviewSelection(
|
||||
async function handlePluginsPanelSelection(
|
||||
host: SlashCommandHost,
|
||||
selection: PluginsOverviewSelection,
|
||||
panel: PluginsPanelComponent,
|
||||
selection: PluginsPanelSelection,
|
||||
): Promise<void> {
|
||||
const session = host.requireSession();
|
||||
switch (selection.kind) {
|
||||
case 'marketplace':
|
||||
await showPluginMarketplacePicker(host);
|
||||
return;
|
||||
case 'reload':
|
||||
await reloadPlugins(host);
|
||||
await showPluginsPicker(host);
|
||||
return;
|
||||
case 'show-list':
|
||||
host.restoreEditor();
|
||||
await renderPluginsList(host);
|
||||
return;
|
||||
case 'toggle': {
|
||||
const hint = await applyPluginEnabled(host, selection.id, selection.enabled, false);
|
||||
await showPluginsPicker(host, {
|
||||
initialTab: 'installed',
|
||||
selectedId: selection.id,
|
||||
pluginHint: { id: selection.id, text: hint },
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'mcp':
|
||||
await showPluginMcpPicker(host, selection.id);
|
||||
return;
|
||||
case 'remove':
|
||||
if (!(await confirmRemovePlugin(host, selection.id))) {
|
||||
host.showStatus(`Remove cancelled: ${selection.id}.`);
|
||||
await showPluginsPicker(host, { selectedId: selection.id });
|
||||
await showPluginsPicker(host, { initialTab: 'installed', selectedId: selection.id });
|
||||
return;
|
||||
}
|
||||
await session.removePlugin(selection.id);
|
||||
host.showStatus(`Removed ${selection.id} (plugin files left in place).`);
|
||||
await showPluginsPicker(host);
|
||||
await removePlugin(host, selection.id);
|
||||
await showPluginsPicker(host, { initialTab: 'installed' });
|
||||
return;
|
||||
case 'info':
|
||||
case 'mcp':
|
||||
await showPluginMcpPicker(host, selection.id);
|
||||
return;
|
||||
case 'details':
|
||||
host.restoreEditor();
|
||||
await renderPluginInfo(host, selection.id);
|
||||
return;
|
||||
case 'reload':
|
||||
await reloadPlugins(host);
|
||||
await showPluginsPicker(host, { initialTab: 'installed' });
|
||||
return;
|
||||
case 'install':
|
||||
await installFromPanel(
|
||||
host,
|
||||
panel,
|
||||
selection.entry.source,
|
||||
selection.entry.displayName,
|
||||
isOfficialPluginSource(selection.entry.source),
|
||||
);
|
||||
return;
|
||||
case 'install-source':
|
||||
await installFromPanel(
|
||||
host,
|
||||
panel,
|
||||
selection.source,
|
||||
selection.source,
|
||||
isOfficialPluginSource(selection.source),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -350,22 +439,10 @@ async function handlePluginMcpSelection(
|
|||
}
|
||||
}
|
||||
|
||||
async function handlePluginMarketplaceSelection(
|
||||
host: SlashCommandHost,
|
||||
selection: PluginMarketplaceSelection,
|
||||
): Promise<void> {
|
||||
switch (selection.kind) {
|
||||
case 'install':
|
||||
host.showStatus(`Installing or updating ${selection.entry.displayName} from marketplace...`);
|
||||
await installPluginFromSource(host, selection.entry.source, {
|
||||
successNotice: 'marketplace',
|
||||
});
|
||||
await showPluginsPicker(host, { selectedId: selection.entry.id });
|
||||
return;
|
||||
case 'back':
|
||||
await showPluginsPicker(host);
|
||||
return;
|
||||
}
|
||||
async function removePlugin(host: SlashCommandHost, id: string): Promise<void> {
|
||||
await host.requireSession().removePlugin(id);
|
||||
host.showStatus(`Removed ${id}.`);
|
||||
host.showStatus(PLUGIN_RELOAD_HINT, 'warning');
|
||||
}
|
||||
|
||||
async function renderPluginsList(
|
||||
|
|
@ -397,25 +474,21 @@ async function renderPluginInfo(host: SlashCommandHost, id: string): Promise<voi
|
|||
async function installPluginFromSource(
|
||||
host: SlashCommandHost,
|
||||
source: string,
|
||||
options?: {
|
||||
readonly successNotice?: 'marketplace';
|
||||
},
|
||||
): Promise<void> {
|
||||
const session = host.requireSession();
|
||||
const beforeList = await session.listPlugins();
|
||||
const summary = await session.installPlugin(
|
||||
resolvePluginInstallSource(source, host.state.appState.workDir),
|
||||
);
|
||||
showPluginInstallResult(host, beforeList, summary, options);
|
||||
showPluginInstallResult(host, beforeList, summary);
|
||||
}
|
||||
|
||||
const PLUGIN_RELOAD_HINT = 'Run /new or /reload to apply plugin changes.';
|
||||
|
||||
function showPluginInstallResult(
|
||||
host: SlashCommandHost,
|
||||
beforeList: readonly PluginSummary[],
|
||||
summary: PluginSummary,
|
||||
options?: {
|
||||
readonly successNotice?: 'marketplace';
|
||||
},
|
||||
): void {
|
||||
const previous = beforeList.find((entry) => entry.id === summary.id);
|
||||
const serverWord = summary.mcpServerCount === 1 ? 'server' : 'servers';
|
||||
|
|
@ -424,15 +497,8 @@ function showPluginInstallResult(
|
|||
? ` Declares ${summary.mcpServerCount} MCP ${serverWord}; enabled by default and configurable from /plugins.`
|
||||
: '';
|
||||
const action = describeInstallAction(previous, summary);
|
||||
host.showStatus(
|
||||
`${action} (${summary.id}).${mcpHint} Run /new to apply plugin changes.`,
|
||||
);
|
||||
if (options?.successNotice === 'marketplace') {
|
||||
host.showNotice(
|
||||
`Installed or updated ${summary.displayName}`,
|
||||
`Marketplace install or update succeeded for ${summary.id}. Run /new to apply plugin changes.`,
|
||||
);
|
||||
}
|
||||
host.showStatus(`${action} (${summary.id}).${mcpHint}`);
|
||||
host.showStatus(PLUGIN_RELOAD_HINT, 'warning');
|
||||
}
|
||||
|
||||
function describeInstallAction(
|
||||
|
|
@ -445,13 +511,19 @@ function describeInstallAction(
|
|||
return ` ${prev} → ${cur ?? '-'}`;
|
||||
};
|
||||
if (previous === undefined) {
|
||||
return `Installed ${next.displayName}${versionFromTo(undefined, next.version)} from ${sourceLabel}`;
|
||||
return `Installed ${next.displayName}${versionFromTo(undefined, next.version)} ${sourcePhrase(sourceLabel)}`;
|
||||
}
|
||||
if (sourceIdentity(previous) !== sourceIdentity(next)) {
|
||||
const prevSourceLabel = formatPluginSourceLabel(previous);
|
||||
return `Migrated ${next.displayName}: ${prevSourceLabel} → ${sourceLabel}${versionFromTo(previous.version, next.version)}`;
|
||||
}
|
||||
return `Updated ${next.displayName}${versionFromTo(previous.version, next.version)} from ${sourceLabel}`;
|
||||
return `Updated ${next.displayName}${versionFromTo(previous.version, next.version)} ${sourcePhrase(sourceLabel)}`;
|
||||
}
|
||||
|
||||
// formatPluginSourceLabel already prefixes zip-url hosts with "via", so adding
|
||||
// "from" would read as "from via <host>". Only prepend "from" otherwise.
|
||||
function sourcePhrase(sourceLabel: string): string {
|
||||
return sourceLabel.startsWith('via ') ? sourceLabel : `from ${sourceLabel}`;
|
||||
}
|
||||
|
||||
function sourceIdentity(plugin: PluginSummary): string {
|
||||
|
|
@ -482,5 +554,5 @@ function resolvePluginInstallSource(source: string, workDir: string): string {
|
|||
}
|
||||
|
||||
function pluginInlineChangeHint(): string {
|
||||
return 'require run /new to apply';
|
||||
return 'run /reload or /new to apply';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,16 +57,58 @@ export function promptLogoutProviderSelection(
|
|||
});
|
||||
}
|
||||
|
||||
export function promptFeedbackInput(host: SlashCommandHost): Promise<string | undefined> {
|
||||
export interface FeedbackPromptResult {
|
||||
readonly value: string;
|
||||
}
|
||||
|
||||
export function promptFeedbackInput(host: SlashCommandHost): Promise<FeedbackPromptResult | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
const dialog = new FeedbackInputDialogComponent((result: FeedbackInputDialogResult) => {
|
||||
host.restoreEditor();
|
||||
resolve(result.kind === 'ok' ? result.value : undefined);
|
||||
resolve(result.kind === 'ok' ? { value: result.value } : undefined);
|
||||
});
|
||||
host.mountEditorReplacement(dialog);
|
||||
});
|
||||
}
|
||||
|
||||
export type FeedbackAttachmentLevel = 'none' | 'logs' | 'logs+codebase';
|
||||
|
||||
const FEEDBACK_ATTACHMENT_OPTIONS: readonly ChoiceOption[] = [
|
||||
{ value: 'none', label: 'No attachment', description: 'Text feedback only' },
|
||||
{
|
||||
value: 'logs',
|
||||
label: 'Logs only',
|
||||
description: 'Upload wire events and diagnostic logs from this session',
|
||||
},
|
||||
{
|
||||
value: 'logs+codebase',
|
||||
label: 'Logs + codebase',
|
||||
description:
|
||||
'Include your codebase for deeper diagnosis. Sensitive files are automatically excluded — e.g. .env, config files, secret keys. We use attachments only for diagnosis and never share them.',
|
||||
descriptionTone: 'warning',
|
||||
},
|
||||
];
|
||||
|
||||
export function promptFeedbackAttachment(
|
||||
host: SlashCommandHost,
|
||||
): Promise<FeedbackAttachmentLevel | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
const picker = new ChoicePickerComponent({
|
||||
title: 'Share diagnostic info to help us investigate?',
|
||||
options: FEEDBACK_ATTACHMENT_OPTIONS,
|
||||
onSelect: (value) => {
|
||||
host.restoreEditor();
|
||||
resolve(value as FeedbackAttachmentLevel);
|
||||
},
|
||||
onCancel: () => {
|
||||
host.restoreEditor();
|
||||
resolve(undefined);
|
||||
},
|
||||
});
|
||||
host.mountEditorReplacement(picker);
|
||||
});
|
||||
}
|
||||
|
||||
export function promptApiKey(
|
||||
host: SlashCommandHost,
|
||||
platformName: string,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,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 { completeLeadingArg, type ArgCompletionSpec } from './complete-args';
|
||||
|
|
@ -22,6 +26,10 @@ const SWARM_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [
|
|||
{ value: 'off', description: 'Turn swarm mode off' },
|
||||
];
|
||||
|
||||
const ADD_DIR_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [
|
||||
{ value: 'list', description: 'Show configured additional workspace directories' },
|
||||
];
|
||||
|
||||
/** Argument autocompletion for the `/goal` command (subcommands). */
|
||||
export function goalArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
|
||||
const nextMatch = argumentPrefix.match(/^next\s+(\S*)$/i);
|
||||
|
|
@ -41,6 +49,89 @@ export function swarmArgumentCompletions(argumentPrefix: string): AutocompleteIt
|
|||
return completeLeadingArg(SWARM_ARG_COMPLETIONS, argumentPrefix);
|
||||
}
|
||||
|
||||
/** Argument autocompletion for the `/add-dir` command. */
|
||||
export function addDirArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
|
||||
if (isPathLikeAddDirArgument(argumentPrefix)) {
|
||||
return completeAddDirPath(argumentPrefix);
|
||||
}
|
||||
return completeLeadingArg(ADD_DIR_ARG_COMPLETIONS, argumentPrefix);
|
||||
}
|
||||
|
||||
function isPathLikeAddDirArgument(argumentPrefix: string): boolean {
|
||||
return argumentPrefix === '.' || argumentPrefix === '..' || argumentPrefix.startsWith('./') || argumentPrefix.startsWith('../') || argumentPrefix.startsWith('/') || argumentPrefix.startsWith('~');
|
||||
}
|
||||
|
||||
function completeAddDirPath(argumentPrefix: string): AutocompleteItem[] | null {
|
||||
const normalizedPrefix = argumentPrefix === '~' ? '~/' : argumentPrefix;
|
||||
const expandedPrefix = expandHomePrefix(normalizedPrefix);
|
||||
const parentInput = getDirectoryCompletionParentInput(normalizedPrefix, expandedPrefix);
|
||||
const partialName = normalizedPrefix.endsWith('/') ? '' : basename(expandedPrefix);
|
||||
const parentDir = resolveDirectoryCompletionParent(parentInput);
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(parentDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const items: AutocompleteItem[] = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.name === '.' || entry.name === '..' || entry.name.startsWith('.')) continue;
|
||||
if (partialName.length > 0 && !entry.name.toLowerCase().startsWith(partialName.toLowerCase())) continue;
|
||||
const absolutePath = join(parentDir, entry.name);
|
||||
if (!isDirectoryPath(absolutePath, entry.isDirectory(), entry.isSymbolicLink())) continue;
|
||||
const value = formatDirectoryCompletionValue(normalizedPrefix, parentInput, entry.name);
|
||||
items.push({
|
||||
value,
|
||||
label: `${entry.name}/`,
|
||||
description: absolutePath,
|
||||
});
|
||||
}
|
||||
|
||||
return items.length > 0 ? items : null;
|
||||
}
|
||||
|
||||
function expandHomePrefix(argumentPrefix: string): string {
|
||||
if (argumentPrefix === '~') return homedir();
|
||||
if (argumentPrefix.startsWith('~/')) return join(homedir(), argumentPrefix.slice(2));
|
||||
return argumentPrefix;
|
||||
}
|
||||
|
||||
function getDirectoryCompletionParentInput(argumentPrefix: string, expandedPrefix: string): string {
|
||||
if (argumentPrefix === '/') return '/';
|
||||
if (argumentPrefix === '~/') return homedir();
|
||||
if (argumentPrefix.endsWith('/')) return expandedPrefix.slice(0, -1);
|
||||
return dirname(expandedPrefix);
|
||||
}
|
||||
|
||||
function resolveDirectoryCompletionParent(parentInput: string): string {
|
||||
if (parentInput === '~') return homedir();
|
||||
if (parentInput.startsWith('~/')) return join(homedir(), parentInput.slice(2));
|
||||
return resolve(parentInput);
|
||||
}
|
||||
|
||||
function isDirectoryPath(path: string, isDirectory: boolean, isSymlink: boolean): boolean {
|
||||
if (isDirectory) return true;
|
||||
if (!isSymlink) return false;
|
||||
try {
|
||||
return statSync(path).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDirectoryCompletionValue(argumentPrefix: string, parentInput: string, entryName: string): string {
|
||||
if (argumentPrefix.startsWith('~/')) {
|
||||
const home = homedir();
|
||||
const homeRelative = relative(home, parentInput);
|
||||
return `~${homeRelative.length > 0 ? `/${homeRelative}` : ''}/${entryName}/`;
|
||||
}
|
||||
if (argumentPrefix.startsWith('/')) {
|
||||
return `${join(parentInput, entryName)}/`;
|
||||
}
|
||||
return `${join(parentInput, entryName)}/`;
|
||||
}
|
||||
|
||||
export const BUILTIN_SLASH_COMMANDS = [
|
||||
{
|
||||
name: 'yolo',
|
||||
|
|
@ -82,6 +173,7 @@ export const BUILTIN_SLASH_COMMANDS = [
|
|||
aliases: [],
|
||||
description: 'Toggle swarm mode or run one task in swarm mode',
|
||||
priority: 100,
|
||||
argumentHint: '[on|off] | <task>',
|
||||
completeArgs: swarmArgumentCompletions,
|
||||
availability: 'idle-only',
|
||||
},
|
||||
|
|
@ -146,6 +238,15 @@ export const BUILTIN_SLASH_COMMANDS = [
|
|||
priority: 60,
|
||||
availability: 'always',
|
||||
},
|
||||
{
|
||||
name: 'add-dir',
|
||||
aliases: [],
|
||||
description: 'Add or list an additional workspace directory',
|
||||
priority: 60,
|
||||
availability: 'idle-only',
|
||||
argumentHint: '[list] | <path>',
|
||||
completeArgs: addDirArgumentCompletions,
|
||||
},
|
||||
{
|
||||
name: 'experiments',
|
||||
aliases: ['experimental'],
|
||||
|
|
@ -172,16 +273,14 @@ export const BUILTIN_SLASH_COMMANDS = [
|
|||
aliases: [],
|
||||
description: 'Compact the conversation context',
|
||||
priority: 80,
|
||||
argumentHint: '<instruction>',
|
||||
},
|
||||
{
|
||||
name: 'goal',
|
||||
aliases: [],
|
||||
description: 'Start or manage an autonomous goal',
|
||||
priority: 80,
|
||||
// No argumentHint: the menu description stays as short as every other
|
||||
// command's. The subcommands (status/pause/resume/cancel/replace) surface in
|
||||
// the argument autocomplete list once the user types `/goal ` (see
|
||||
// completeArgs), so they don't need to be spelled out inline.
|
||||
argumentHint: '[status|pause|resume|cancel|replace|next] | <objective>',
|
||||
completeArgs: goalArgumentCompletions,
|
||||
// status / pause / cancel are always available; creation, replacement, and
|
||||
// resume start (or restart) a turn and so are idle-only.
|
||||
|
|
@ -209,6 +308,7 @@ export const BUILTIN_SLASH_COMMANDS = [
|
|||
aliases: ['rename'],
|
||||
description: 'Set or show session title',
|
||||
priority: 60,
|
||||
argumentHint: '<title>',
|
||||
availability: 'always',
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export async function handleReloadCommand(host: SlashCommandHost): Promise<void>
|
|||
const session = host.session;
|
||||
|
||||
if (session !== undefined) {
|
||||
await session.reloadSession();
|
||||
await session.reloadSession({ forcePluginSessionStartReminder: true });
|
||||
await host.reloadCurrentSessionView(session, 'Session reloaded.');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { ensureDaemon } from '#/cli/sub/server/daemon';
|
||||
import { tryResolveServerToken } from '#/cli/sub/server/shared';
|
||||
import { openUrl } from '#/utils/open-url';
|
||||
import { getDataDir } from '#/utils/paths';
|
||||
|
||||
import { ChoicePickerComponent } from '../components/dialogs/choice-picker';
|
||||
import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui';
|
||||
|
|
@ -63,13 +65,28 @@ export async function handleWebCommand(host: SlashCommandHost): Promise<void> {
|
|||
return;
|
||||
}
|
||||
|
||||
const url = webSessionUrl(origin, sessionId);
|
||||
// Resolve the persistent token so the opened browser auto-authenticates via
|
||||
// the `#token=` fragment — matching the `kimi web` subcommand. Show the URL
|
||||
// and token in green under the status line so they can be copied before the
|
||||
// terminal exits. Best-effort: an older/never-started server has no token
|
||||
// file, so we fall back to the plain URL and skip the token line.
|
||||
const token = tryResolveServerToken(getDataDir());
|
||||
const url = webSessionUrl(origin, sessionId, token);
|
||||
host.showStatus(`open ${url}`, 'success');
|
||||
if (token !== undefined) {
|
||||
host.showStatus(`Token: ${token}`, 'success');
|
||||
}
|
||||
openUrl(url);
|
||||
host.setExitOpenUrl(url);
|
||||
await host.stop();
|
||||
}
|
||||
|
||||
/** Build the deep-link URL the web UI recognises for a session. */
|
||||
export function webSessionUrl(origin: string, sessionId: string): string {
|
||||
return `${origin.replace(/\/+$/, '')}/sessions/${encodeURIComponent(sessionId)}`;
|
||||
/**
|
||||
* Build the deep-link URL the web UI recognises for a session. When a token is
|
||||
* known it rides in the `#token=` fragment (never sent to the server, so never
|
||||
* logged), so the browser authenticates on load just like `kimi web`.
|
||||
*/
|
||||
export function webSessionUrl(origin: string, sessionId: string, token?: string): string {
|
||||
const base = `${origin.replace(/\/+$/, '')}/sessions/${encodeURIComponent(sessionId)}`;
|
||||
return token === undefined ? base : `${base}#token=${token}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type { Component } from '@earendil-works/pi-tui';
|
|||
import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { ALL_TIPS, type ToolbarTip } from '#/tui/constant/tips';
|
||||
import { isRainbowDancing, renderDanceFooterModel } from '#/tui/easter-eggs/dance';
|
||||
import { currentTheme } from '#/tui/theme';
|
||||
import type { ColorPalette } from '#/tui/theme/colors';
|
||||
|
|
@ -31,46 +32,9 @@ const GOAL_TIMER_INTERVAL_MS = 1_000;
|
|||
// important enough to take the whole slot on their own. A `priority` weight
|
||||
// makes a tip recur more often in the rotation (default 1). Width is always
|
||||
// the final arbiter (a pair that doesn't fit falls back to its first tip).
|
||||
//
|
||||
// This is deliberately code-level configuration: edit the interval and the
|
||||
// TOOLBAR_TIPS array below to change what the footer advertises.
|
||||
const TIP_ROTATE_INTERVAL_MS = 10_000;
|
||||
const TIP_SEPARATOR = ' | ';
|
||||
|
||||
export interface ToolbarTip {
|
||||
readonly text: string;
|
||||
/**
|
||||
* Long/important tips render on their own. They never pair with a
|
||||
* neighbour and never appear as the second half of someone else's pair.
|
||||
*/
|
||||
readonly solo?: boolean;
|
||||
/**
|
||||
* Rotation weight: a higher value makes the tip recur more often. Defaults
|
||||
* to 1. Used to give newer/important features more airtime.
|
||||
*/
|
||||
readonly priority?: number;
|
||||
}
|
||||
|
||||
const TOOLBAR_TIPS: readonly ToolbarTip[] = [
|
||||
{ text: 'shift+tab: plan mode' },
|
||||
{ text: '/model: switch model' },
|
||||
{ text: 'ctrl+s: steer mid-turn', priority: 2 },
|
||||
{ text: '/compact: compact context', priority: 2 },
|
||||
{ text: 'ctrl+o: expand tool output' },
|
||||
{ text: '/tasks: background tasks' },
|
||||
{ text: 'shift+enter: newline' },
|
||||
{ text: '/init: generate AGENTS.md', priority: 2 },
|
||||
{ text: '@: mention files' },
|
||||
{ text: 'ctrl+c: cancel' },
|
||||
{ text: '/theme: switch theme' },
|
||||
{ text: '/auto: auto permission mode' },
|
||||
{ text: '/yolo: toggle yolo' },
|
||||
{ text: '/help: show commands' },
|
||||
{ text: '/dance: rainbow mode, because why not' },
|
||||
{ text: '/plugins: manage plugins — try the "superpowers" plugin', solo: true, priority: 3 },
|
||||
{ text: 'ask Kimi to schedule tasks, e.g. "remind me at 5pm"', solo: true, priority: 3 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Expand tips into a rotation sequence using smooth weighted round-robin
|
||||
* (the nginx SWRR algorithm). Higher-`priority` tips appear more often while
|
||||
|
|
@ -98,7 +62,7 @@ export function buildWeightedTips(tips: readonly ToolbarTip[]): readonly Toolbar
|
|||
return seq;
|
||||
}
|
||||
|
||||
const ROTATION: readonly ToolbarTip[] = buildWeightedTips(TOOLBAR_TIPS);
|
||||
const ROTATION: readonly ToolbarTip[] = buildWeightedTips(ALL_TIPS);
|
||||
|
||||
function currentTipIndex(): number {
|
||||
return Math.floor(Date.now() / TIP_ROTATE_INTERVAL_MS);
|
||||
|
|
@ -264,6 +228,10 @@ export class FooterComponent implements Component {
|
|||
this.transientHint = hint;
|
||||
}
|
||||
|
||||
getTransientHint(): string | null {
|
||||
return this.transientHint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync both background-task badges with live counts. Each non-zero
|
||||
* count produces its own bracketed badge on line 1; zeros hide them
|
||||
|
|
|
|||
|
|
@ -10,8 +10,20 @@
|
|||
*/
|
||||
|
||||
import { Container } from '@earendil-works/pi-tui';
|
||||
import type { Component } from '@earendil-works/pi-tui';
|
||||
|
||||
import { isRenderCacheEnabled } from '#/tui/utils/render-cache';
|
||||
|
||||
interface TranscriptRenderCache {
|
||||
width: number;
|
||||
childRefs: Component[];
|
||||
childRenderRefs: string[][];
|
||||
prefixed: string[][];
|
||||
out: string[];
|
||||
}
|
||||
|
||||
export class GutterContainer extends Container {
|
||||
private renderCache: TranscriptRenderCache | undefined;
|
||||
constructor(
|
||||
private readonly leftPad: number,
|
||||
private readonly rightPad: number,
|
||||
|
|
@ -19,15 +31,56 @@ export class GutterContainer extends Container {
|
|||
super();
|
||||
}
|
||||
|
||||
override invalidate(): void {
|
||||
this.renderCache = undefined;
|
||||
super.invalidate();
|
||||
}
|
||||
|
||||
override render(width: number): string[] {
|
||||
const inner = Math.max(1, width - this.leftPad - this.rightPad);
|
||||
const lead = ' '.repeat(this.leftPad);
|
||||
const out: string[] = [];
|
||||
|
||||
const cache = this.renderCache;
|
||||
const cacheValid =
|
||||
isRenderCacheEnabled() &&
|
||||
cache !== undefined &&
|
||||
cache.width === width &&
|
||||
cache.childRefs.length === this.children.length;
|
||||
|
||||
const childRefs: Component[] = [];
|
||||
const childRenderRefs: string[][] = [];
|
||||
const prefixed: string[][] = [];
|
||||
let allReused = cacheValid;
|
||||
|
||||
let i = 0;
|
||||
for (const child of this.children) {
|
||||
for (const line of child.render(inner)) {
|
||||
out.push(lead + line);
|
||||
const lines = child.render(inner);
|
||||
childRefs.push(child);
|
||||
childRenderRefs.push(lines);
|
||||
const reused = cacheValid && cache.childRefs[i] === child && cache.childRenderRefs[i] === lines;
|
||||
if (reused) {
|
||||
prefixed.push(cache.prefixed[i]!);
|
||||
} else {
|
||||
allReused = false;
|
||||
prefixed.push(lines.map((line) => lead + line));
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
let out: string[];
|
||||
if (allReused) {
|
||||
out = cache!.out;
|
||||
} else {
|
||||
out = [];
|
||||
for (const lines of prefixed) {
|
||||
for (const line of lines) out.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (isRenderCacheEnabled()) {
|
||||
this.renderCache = { width, childRefs, childRenderRefs, prefixed, out };
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Text } from '@earendil-works/pi-tui';
|
||||
import { Text, visibleWidth } from '@earendil-works/pi-tui';
|
||||
import type { TUI } from '@earendil-works/pi-tui';
|
||||
|
||||
import {
|
||||
|
|
@ -7,6 +7,7 @@ import {
|
|||
MOON_SPINNER_FRAMES,
|
||||
MOON_SPINNER_INTERVAL_MS,
|
||||
} from '#/tui/constant/rendering';
|
||||
import { currentTheme } from '#/tui/theme';
|
||||
|
||||
export type SpinnerStyle = 'moon' | 'braille';
|
||||
|
||||
|
|
@ -19,6 +20,14 @@ export class MoonLoader extends Text {
|
|||
private colorFn?: (s: string) => string;
|
||||
private label: string;
|
||||
private displayText = '';
|
||||
// Inline text used when the spinner is embedded into another line (e.g. the
|
||||
// agent-swarm progress status line). It intentionally excludes the tip: the
|
||||
// tip is only rendered when the loader sits on its own row in the activity
|
||||
// pane, otherwise it would get squeezed against whatever follows the inline
|
||||
// spinner (like the swarm progress bar).
|
||||
private inlineText = '';
|
||||
private tip: string = '';
|
||||
private availableWidth = 0;
|
||||
|
||||
constructor(
|
||||
ui: TUI,
|
||||
|
|
@ -60,14 +69,34 @@ export class MoonLoader extends Text {
|
|||
this.updateDisplay();
|
||||
}
|
||||
|
||||
setTip(tip: string): void {
|
||||
this.tip = tip;
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
setAvailableWidth(width: number): void {
|
||||
if (this.availableWidth === width) return;
|
||||
this.availableWidth = width;
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
renderInline(): string {
|
||||
return this.displayText;
|
||||
return this.inlineText;
|
||||
}
|
||||
|
||||
private updateDisplay(): void {
|
||||
const frame = this.frames[this.currentFrame]!;
|
||||
const coloredFrame = this.colorFn ? this.colorFn(frame) : frame;
|
||||
this.displayText = this.label ? `${coloredFrame} ${this.label}` : coloredFrame;
|
||||
const baseText = this.label ? `${coloredFrame} ${this.label}` : coloredFrame;
|
||||
this.inlineText = baseText;
|
||||
let text = baseText;
|
||||
if (this.tip) {
|
||||
const withTip = baseText + currentTheme.fg('textDim', this.tip);
|
||||
if (this.availableWidth === 0 || visibleWidth(withTip) <= this.availableWidth) {
|
||||
text = withTip;
|
||||
}
|
||||
}
|
||||
this.displayText = text;
|
||||
this.setText(this.displayText);
|
||||
this.ui.requestRender();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ const MAX_VISIBLE = 5;
|
|||
export interface VisibleTodos {
|
||||
readonly rows: readonly TodoItem[];
|
||||
readonly hidden: number;
|
||||
readonly hiddenCounts: Record<TodoStatus, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -49,7 +50,11 @@ export interface VisibleTodos {
|
|||
*/
|
||||
export function selectVisibleTodos(todos: readonly TodoItem[]): VisibleTodos {
|
||||
if (todos.length <= MAX_VISIBLE) {
|
||||
return { rows: [...todos], hidden: 0 };
|
||||
return {
|
||||
rows: [...todos],
|
||||
hidden: 0,
|
||||
hiddenCounts: { done: 0, in_progress: 0, pending: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const inProgress: number[] = [];
|
||||
|
|
@ -91,14 +96,24 @@ export function selectVisibleTodos(todos: readonly TodoItem[]): VisibleTodos {
|
|||
}
|
||||
|
||||
const sortedIdx = [...picked].toSorted((a, b) => a - b);
|
||||
|
||||
const hiddenCounts: Record<TodoStatus, number> = { done: 0, in_progress: 0, pending: 0 };
|
||||
for (const [i, todo] of todos.entries()) {
|
||||
if (!picked.has(i)) {
|
||||
hiddenCounts[todo.status] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
rows: sortedIdx.map((i) => todos[i] as TodoItem),
|
||||
hidden: todos.length - sortedIdx.length,
|
||||
hiddenCounts,
|
||||
};
|
||||
}
|
||||
|
||||
export class TodoPanelComponent implements Component {
|
||||
private todos: readonly TodoItem[] = [];
|
||||
private expanded = false;
|
||||
|
||||
setTodos(todos: readonly TodoItem[]): void {
|
||||
this.todos = todos.map((t) => ({ title: t.title, status: t.status }));
|
||||
|
|
@ -110,27 +125,57 @@ export class TodoPanelComponent implements Component {
|
|||
|
||||
clear(): void {
|
||||
this.todos = [];
|
||||
this.expanded = false;
|
||||
}
|
||||
|
||||
isEmpty(): boolean {
|
||||
return this.todos.length === 0;
|
||||
}
|
||||
|
||||
/** True when the list exceeds the collapsed cap, i.e. there is something to expand. */
|
||||
hasOverflow(): boolean {
|
||||
return this.todos.length > MAX_VISIBLE;
|
||||
}
|
||||
|
||||
setExpanded(expanded: boolean): void {
|
||||
this.expanded = expanded;
|
||||
}
|
||||
|
||||
toggleExpanded(): void {
|
||||
this.expanded = !this.expanded;
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
if (this.todos.length === 0) return [];
|
||||
const c = currentTheme.palette;
|
||||
const { rows, hidden } = selectVisibleTodos(this.todos);
|
||||
const lines: string[] = [
|
||||
chalk.hex(c.border)('─'.repeat(width)),
|
||||
chalk.hex(c.primary).bold(' Todo'),
|
||||
];
|
||||
for (const todo of rows) {
|
||||
lines.push(renderRow(todo, c));
|
||||
}
|
||||
if (hidden > 0) {
|
||||
lines.push(chalk.hex(c.textDim)(` … +${hidden} more`));
|
||||
|
||||
if (this.expanded) {
|
||||
for (const todo of this.todos) {
|
||||
lines.push(renderRow(todo, c));
|
||||
}
|
||||
if (this.todos.length > MAX_VISIBLE) {
|
||||
lines.push(
|
||||
chalk.hex(c.textDim)(` all ${String(this.todos.length)} items · ctrl+t to collapse`),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const { rows, hidden, hiddenCounts } = selectVisibleTodos(this.todos);
|
||||
for (const todo of rows) {
|
||||
lines.push(renderRow(todo, c));
|
||||
}
|
||||
if (hidden > 0) {
|
||||
const distribution = formatHiddenCounts(hiddenCounts);
|
||||
const suffix = distribution.length > 0 ? ` (${distribution})` : '';
|
||||
lines.push(
|
||||
chalk.hex(c.textDim)(` … +${hidden} more${suffix} · ctrl+t to expand`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.map((line) => truncateToWidth(line, width));
|
||||
|
|
@ -164,3 +209,15 @@ function styleTitle(title: string, status: TodoStatus, colors: ColorPalette): st
|
|||
return chalk.hex(colors.text)(title);
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_LABELS: readonly { status: TodoStatus; label: string }[] = [
|
||||
{ status: 'done', label: 'done' },
|
||||
{ status: 'in_progress', label: 'in progress' },
|
||||
{ status: 'pending', label: 'pending' },
|
||||
];
|
||||
|
||||
export function formatHiddenCounts(counts: Record<TodoStatus, number>): string {
|
||||
return STATUS_LABELS.filter(({ status }) => counts[status] > 0)
|
||||
.map(({ status, label }) => `${counts[status]} ${label}`)
|
||||
.join(' · ');
|
||||
}
|
||||
|
|
|
|||
31
apps/kimi-code/src/tui/components/chrome/working-tips.ts
Normal file
31
apps/kimi-code/src/tui/components/chrome/working-tips.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { WORKING_TIPS, type ToolbarTip } from '#/tui/constant/tips';
|
||||
|
||||
import { buildWeightedTips } from './footer';
|
||||
|
||||
export { WORKING_TIPS };
|
||||
|
||||
const TIP_ROTATE_INTERVAL_MS = 10_000;
|
||||
|
||||
const WORKING_TIP_ROTATION = buildWeightedTips(WORKING_TIPS);
|
||||
|
||||
export function currentWorkingTip(now = Date.now()): ToolbarTip | undefined {
|
||||
if (WORKING_TIP_ROTATION.length === 0) return undefined;
|
||||
const index = Math.floor(now / TIP_ROTATE_INTERVAL_MS) % WORKING_TIP_ROTATION.length;
|
||||
return WORKING_TIP_ROTATION[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a random tip from the weighted working-tip rotation.
|
||||
* If `excludeText` is provided and there are other tips available, avoid
|
||||
* returning the same text twice in a row.
|
||||
*/
|
||||
export function pickRandomWorkingTip(excludeText?: string): ToolbarTip | undefined {
|
||||
if (WORKING_TIP_ROTATION.length === 0) return undefined;
|
||||
const candidates =
|
||||
excludeText === undefined || WORKING_TIP_ROTATION.length === 1
|
||||
? WORKING_TIP_ROTATION
|
||||
: WORKING_TIP_ROTATION.filter((t) => t.text !== excludeText);
|
||||
const pool = candidates.length > 0 ? candidates : WORKING_TIP_ROTATION;
|
||||
const index = Math.floor(Math.random() * pool.length);
|
||||
return pool[index];
|
||||
}
|
||||
|
|
@ -379,6 +379,18 @@ export class ApprovalPanelComponent extends Container implements Focusable {
|
|||
} else {
|
||||
lines.push(indent(strong(` ${labelWithNum}`)));
|
||||
}
|
||||
|
||||
// Optional helper text under the label, aligned past the pointer/number.
|
||||
// Choices without a description render exactly as before.
|
||||
if (
|
||||
option.description !== undefined &&
|
||||
option.description.length > 0 &&
|
||||
!(this.feedbackMode && option.requires_feedback === true && isSelected)
|
||||
) {
|
||||
for (const descLine of wrapTextWithAnsi(option.description, Math.max(20, width - 7))) {
|
||||
lines.push(indent(` ${dim(descLine)}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
type Focusable,
|
||||
} from '@earendil-works/pi-tui';
|
||||
import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols';
|
||||
import { currentTheme } from '#/tui/theme';
|
||||
import { currentTheme, type ColorToken } from '#/tui/theme';
|
||||
import { printableChar } from '#/tui/utils/printable-key';
|
||||
import { SearchableList } from '#/tui/utils/searchable-list';
|
||||
|
||||
|
|
@ -30,6 +30,9 @@ export interface ChoiceOption {
|
|||
readonly tone?: 'danger';
|
||||
/** Optional explanatory text shown below the label. */
|
||||
readonly description?: string | undefined;
|
||||
/** Color token applied to the description while this option is selected, drawing
|
||||
* attention to important details. Falls back to `textMuted` when unset or not selected. */
|
||||
readonly descriptionTone?: ColorToken;
|
||||
}
|
||||
|
||||
export interface ChoicePickerOptions {
|
||||
|
|
@ -37,6 +40,8 @@ export interface ChoicePickerOptions {
|
|||
readonly hint?: string;
|
||||
readonly formatHint?: (text: string) => string;
|
||||
readonly notice?: string;
|
||||
/** Color tone for the notice line. Defaults to 'success'. */
|
||||
readonly noticeTone?: 'success' | 'warning';
|
||||
readonly options: readonly ChoiceOption[];
|
||||
readonly currentValue?: string;
|
||||
/** When true, typed characters filter the list (fuzzy) and a search line is shown. */
|
||||
|
|
@ -129,15 +134,26 @@ export class ChoicePickerComponent extends Container implements Focusable {
|
|||
|
||||
const titleSuffix =
|
||||
searchable && view.query.length === 0 ? currentTheme.fg('textMuted', ' (type to search)') : '';
|
||||
const hintLines = hint.split(/\r?\n/);
|
||||
const lines: string[] = [
|
||||
currentTheme.fg('primary', '─'.repeat(width)),
|
||||
currentTheme.boldFg('primary', ` ${this.opts.title}`) + titleSuffix,
|
||||
this.opts.formatHint === undefined
|
||||
? currentTheme.fg('textMuted', ` ${hint}`)
|
||||
: this.opts.formatHint(` ${hint}`),
|
||||
];
|
||||
for (const hintLine of hintLines) {
|
||||
lines.push(
|
||||
this.opts.formatHint === undefined
|
||||
? currentTheme.fg('textMuted', ` ${hintLine}`)
|
||||
: this.opts.formatHint(` ${hintLine}`),
|
||||
);
|
||||
}
|
||||
if (this.opts.notice !== undefined) {
|
||||
lines.push(currentTheme.fg('success', ` ${this.opts.notice}`));
|
||||
const tone = this.opts.noticeTone ?? 'success';
|
||||
const noticeWidth = Math.max(1, width - 1);
|
||||
for (const noticeLine of this.opts.notice.split(/\r?\n/)) {
|
||||
for (const wrapped of wrapDescription(noticeLine, noticeWidth)) {
|
||||
lines.push(currentTheme.fg(tone, ` ${wrapped}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
if (searchable && view.query.length > 0) {
|
||||
|
|
@ -161,8 +177,10 @@ export class ChoicePickerComponent extends Container implements Focusable {
|
|||
lines.push(line);
|
||||
if (opt.description !== undefined && opt.description.length > 0) {
|
||||
const descriptionWidth = Math.max(1, width - 4);
|
||||
const descriptionColor =
|
||||
isSelected && opt.descriptionTone !== undefined ? opt.descriptionTone : 'textMuted';
|
||||
for (const descLine of wrapDescription(opt.description, descriptionWidth)) {
|
||||
lines.push(currentTheme.fg('textMuted', ` ${descLine}`));
|
||||
lines.push(currentTheme.fg(descriptionColor, ` ${descLine}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ export class CompactionComponent extends Container {
|
|||
private readonly ui: TUI | undefined;
|
||||
private readonly headerText: Text;
|
||||
private readonly instruction: string | undefined;
|
||||
private readonly tip: string | undefined;
|
||||
private blinkOn = true;
|
||||
private blinkTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private done = false;
|
||||
|
|
@ -32,10 +33,11 @@ export class CompactionComponent extends Container {
|
|||
private tokensBefore: number | undefined;
|
||||
private tokensAfter: number | undefined;
|
||||
|
||||
constructor(ui?: TUI, instruction?: string | undefined) {
|
||||
constructor(ui?: TUI, instruction?: string | undefined, tip?: string) {
|
||||
super();
|
||||
this.ui = ui;
|
||||
this.instruction = instruction;
|
||||
this.tip = tip;
|
||||
|
||||
// Top margin so the block isn't glued to the previous transcript
|
||||
// entry (status line, tool result, etc.).
|
||||
|
|
@ -107,7 +109,8 @@ export class CompactionComponent extends Container {
|
|||
}
|
||||
const bullet = this.blinkOn ? currentTheme.fg('text', STATUS_BULLET) : ' ';
|
||||
const label = currentTheme.boldFg('primary', 'Compacting context...');
|
||||
return `${bullet}${label}`;
|
||||
const tip = this.tip ? currentTheme.fg('textDim', ` · Tip: ${this.tip}`) : '';
|
||||
return `${bullet}${label}${tip}`;
|
||||
}
|
||||
|
||||
private startBlink(): void {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@
|
|||
* Geometry mirrors `DeviceCodeBox` so the chrome stays consistent with
|
||||
* the OAuth login flow. The box embeds a `pi-tui` Input for the actual
|
||||
* text entry; cursor visibility tracks the dialog's `focused` flag.
|
||||
*
|
||||
* This is stage 1 of the feedback flow: it collects the free-form text
|
||||
* only. Whether to attach diagnostic logs / codebase is decided in a
|
||||
* follow-up stage (see `promptFeedbackAttachment`).
|
||||
*/
|
||||
|
||||
import {
|
||||
|
|
@ -83,7 +87,15 @@ export class FeedbackInputDialogComponent extends Container implements Focusable
|
|||
const footerLine = truncateToWidth(footerStyled, innerWidth, '…');
|
||||
const inputLine = this.input.render(innerWidth)[0] ?? '> ';
|
||||
|
||||
const contentLines: string[] = [titleLine, '', subtitleLine, '', inputLine, '', footerLine];
|
||||
const contentLines: string[] = [
|
||||
titleLine,
|
||||
'',
|
||||
subtitleLine,
|
||||
'',
|
||||
inputLine,
|
||||
'',
|
||||
footerLine,
|
||||
];
|
||||
|
||||
if (safeWidth < 4) {
|
||||
return ['', ...contentLines.map((line) => truncateToWidth(line, safeWidth, '…'))];
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export interface GoalStartPermissionPromptOptions {
|
|||
readonly onCancel: () => void;
|
||||
}
|
||||
|
||||
const MANUAL_OPTIONS: readonly StartPermissionOption[] = [
|
||||
export const GOAL_START_MANUAL_OPTIONS: readonly StartPermissionOption[] = [
|
||||
{
|
||||
value: 'auto',
|
||||
label: 'Switch to Auto and start',
|
||||
|
|
@ -37,7 +37,7 @@ const MANUAL_OPTIONS: readonly StartPermissionOption[] = [
|
|||
},
|
||||
];
|
||||
|
||||
const YOLO_OPTIONS: readonly StartPermissionOption[] = [
|
||||
export const GOAL_START_YOLO_OPTIONS: readonly StartPermissionOption[] = [
|
||||
{
|
||||
value: 'auto',
|
||||
label: 'Switch to Auto and start',
|
||||
|
|
@ -57,6 +57,14 @@ const YOLO_OPTIONS: readonly StartPermissionOption[] = [
|
|||
},
|
||||
];
|
||||
|
||||
export function goalStartOptions(mode: 'manual' | 'yolo'): readonly StartPermissionOption[] {
|
||||
return mode === 'yolo' ? GOAL_START_YOLO_OPTIONS : GOAL_START_MANUAL_OPTIONS;
|
||||
}
|
||||
|
||||
const MANUAL_OPTIONS = GOAL_START_MANUAL_OPTIONS;
|
||||
|
||||
const YOLO_OPTIONS = GOAL_START_YOLO_OPTIONS;
|
||||
|
||||
const MANUAL_NOTICE_LINES = [
|
||||
'Manual mode asks you before Kimi Code runs commands, edits files, or takes other risky actions.',
|
||||
'Manual mode is not suitable for unattended goal work.',
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: readonly KeyboardShortcut[] = [
|
|||
{ keys: 'Shift-Tab', description: 'Toggle plan mode' },
|
||||
{ keys: 'Ctrl-G', description: 'Edit in external editor ($VISUAL / $EDITOR)' },
|
||||
{ keys: 'Ctrl-O', description: 'Toggle tool output expansion' },
|
||||
{ keys: 'Ctrl-T', description: 'Expand / collapse the todo list (when truncated)' },
|
||||
{ keys: 'Ctrl-S', description: 'Steer — inject a follow-up during streaming' },
|
||||
{ keys: 'Shift-Enter / Ctrl-J', description: 'Insert newline' },
|
||||
{ keys: 'Ctrl-C', description: 'Interrupt stream / clear input' },
|
||||
|
|
|
|||
|
|
@ -65,6 +65,9 @@ export interface ModelSelectorOptions {
|
|||
* TabbedModelSelectorComponent so the inner list advertises the tab keys. */
|
||||
readonly providerSwitchHint?: boolean;
|
||||
readonly onSelect: (selection: ModelSelection) => void;
|
||||
/** When provided, Alt+S invokes this instead of onSelect — used to apply the
|
||||
* choice to the current session only, without persisting it as the default. */
|
||||
readonly onSessionOnlySelect?: (selection: ModelSelection) => void;
|
||||
readonly onCancel: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -160,6 +163,16 @@ export class ModelSelectorComponent extends Container implements Focusable {
|
|||
alias: selected.alias,
|
||||
thinking: effectiveThinking(selected.model, this.draftFor(selected)),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(data, Key.alt('s')) && this.opts.onSessionOnlySelect !== undefined) {
|
||||
const selected = this.selectedChoice();
|
||||
if (selected === undefined) return;
|
||||
this.opts.onSessionOnlySelect({
|
||||
alias: selected.alias,
|
||||
thinking: effectiveThinking(selected.model, this.draftFor(selected)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -179,7 +192,9 @@ export class ModelSelectorComponent extends Container implements Focusable {
|
|||
if (this.opts.providerSwitchHint) hintParts.push('Tab toggle provider');
|
||||
hintParts.push('↑↓ navigate');
|
||||
if (searchable && view.query.length > 0) hintParts.push('Backspace clear');
|
||||
hintParts.push('Enter select', 'Esc cancel');
|
||||
hintParts.push('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)),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
Container,
|
||||
Input,
|
||||
Key,
|
||||
matchesKey,
|
||||
truncateToWidth,
|
||||
|
|
@ -7,23 +8,24 @@ import {
|
|||
type Focusable,
|
||||
} from '@earendil-works/pi-tui';
|
||||
import type { PluginInfo, PluginMcpServerInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { SELECT_POINTER } from '#/tui/constant/symbols';
|
||||
import { currentTheme } from '#/tui/theme';
|
||||
import type { ColorPalette } from '#/tui/theme/colors';
|
||||
import { formatPluginSourceLabel, pluginTrustLabel } from '#/tui/utils/plugin-source-label';
|
||||
import { printableChar } from '#/tui/utils/printable-key';
|
||||
import { renderTabStrip } from '#/tui/utils/tab-strip';
|
||||
import { computeUpdateStatus, type PluginMarketplaceEntry } from '#/utils/plugin-marketplace';
|
||||
|
||||
import { ChoicePickerComponent } from './choice-picker';
|
||||
|
||||
const OVERVIEW_MARKETPLACE = 'marketplace';
|
||||
const OVERVIEW_RELOAD = 'reload';
|
||||
const OVERVIEW_SHOW_LIST = 'show-list';
|
||||
const OVERVIEW_PLUGIN_PREFIX = 'plugin:';
|
||||
const MCP_SERVER_PREFIX = 'mcp:';
|
||||
|
||||
const REMOVE_CONFIRM_CANCEL = 'cancel';
|
||||
const REMOVE_CONFIRM_REMOVE = 'remove';
|
||||
const INSTALL_TRUST_EXIT = 'exit';
|
||||
const INSTALL_TRUST_TRUST = 'trust';
|
||||
const ELLIPSIS = '…';
|
||||
|
||||
interface PluginsOverviewItem {
|
||||
|
|
@ -34,252 +36,6 @@ interface PluginsOverviewItem {
|
|||
readonly description: string;
|
||||
}
|
||||
|
||||
export type PluginsOverviewSelection =
|
||||
| { readonly kind: 'marketplace' }
|
||||
| { readonly kind: 'reload' }
|
||||
| { readonly kind: 'show-list' }
|
||||
| { readonly kind: 'toggle'; readonly id: string; readonly enabled: boolean }
|
||||
| { readonly kind: 'mcp'; readonly id: string }
|
||||
| { readonly kind: 'remove'; readonly id: string }
|
||||
| { readonly kind: 'info'; readonly id: string };
|
||||
|
||||
export interface PluginsOverviewSelectorOptions {
|
||||
readonly plugins: readonly PluginSummary[];
|
||||
readonly selectedId?: string;
|
||||
readonly pluginHint?: {
|
||||
readonly id: string;
|
||||
readonly text: string;
|
||||
};
|
||||
readonly onSelect: (selection: PluginsOverviewSelection) => void;
|
||||
readonly onCancel: () => void;
|
||||
}
|
||||
|
||||
export class PluginsOverviewSelectorComponent extends Container implements Focusable {
|
||||
focused = false;
|
||||
|
||||
private readonly opts: PluginsOverviewSelectorOptions;
|
||||
private readonly items: readonly PluginsOverviewItem[];
|
||||
private selectedIndex = 0;
|
||||
|
||||
constructor(opts: PluginsOverviewSelectorOptions) {
|
||||
super();
|
||||
this.opts = opts;
|
||||
this.items = buildOverviewItems(opts.plugins);
|
||||
const selectedIndex = this.items.findIndex(
|
||||
(item) => item.value === `${OVERVIEW_PLUGIN_PREFIX}${opts.selectedId}`,
|
||||
);
|
||||
this.selectedIndex = Math.max(0, selectedIndex);
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (matchesKey(data, Key.escape)) {
|
||||
this.opts.onCancel();
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.up)) {
|
||||
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.down)) {
|
||||
this.selectedIndex = Math.min(this.items.length - 1, this.selectedIndex + 1);
|
||||
return;
|
||||
}
|
||||
const chosen = this.items[this.selectedIndex];
|
||||
if (chosen === undefined) return;
|
||||
const pluginId = overviewItemPluginId(chosen);
|
||||
const decoded = printableChar(data);
|
||||
if (matchesKey(data, Key.space) || decoded === ' ') {
|
||||
if (pluginId === undefined) return;
|
||||
const plugin = this.opts.plugins.find((item) => item.id === pluginId);
|
||||
if (plugin !== undefined) {
|
||||
this.opts.onSelect({ kind: 'toggle', id: pluginId, enabled: !plugin.enabled });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (decoded === 'd' || decoded === 'D') {
|
||||
if (pluginId !== undefined) this.opts.onSelect({ kind: 'remove', id: pluginId });
|
||||
return;
|
||||
}
|
||||
if (decoded === 'm' || decoded === 'M') {
|
||||
if (pluginId === undefined) return;
|
||||
const plugin = this.opts.plugins.find((item) => item.id === pluginId);
|
||||
if (plugin !== undefined && plugin.mcpServerCount > 0) {
|
||||
this.opts.onSelect({ kind: 'mcp', id: pluginId });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.enter)) {
|
||||
if (pluginId !== undefined) {
|
||||
this.opts.onSelect({ kind: 'info', id: pluginId });
|
||||
return;
|
||||
}
|
||||
const selection = parseOverviewSelection(chosen.value);
|
||||
if (selection !== undefined) this.opts.onSelect(selection);
|
||||
}
|
||||
}
|
||||
|
||||
override render(width: number): string[] {
|
||||
const { plugins } = this.opts;
|
||||
const hint =
|
||||
'↑↓ navigate · Space toggle · M MCP servers · D remove · Enter details · Esc cancel';
|
||||
const pluginItems = this.items.filter((item) => item.kind === 'plugin');
|
||||
const actionItems = this.items.filter((item) => item.kind === 'action');
|
||||
const lines: string[] = [
|
||||
currentTheme.fg('primary', '─'.repeat(width)),
|
||||
currentTheme.boldFg('primary', ' Plugins'),
|
||||
mutedHintLine(` ${hint}`),
|
||||
'',
|
||||
sectionLabel(`Installed plugins (${plugins.length})`),
|
||||
];
|
||||
|
||||
if (pluginItems.length === 0) {
|
||||
lines.push(currentTheme.fg('textMuted', ' No plugins installed.'));
|
||||
} else {
|
||||
let absoluteIndex = 0;
|
||||
for (const item of pluginItems) {
|
||||
lines.push(...this.renderItem(item, absoluteIndex, width));
|
||||
absoluteIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push(sectionLabel('Actions'));
|
||||
for (let i = 0; i < actionItems.length; i++) {
|
||||
lines.push(...this.renderItem(actionItems[i]!, pluginItems.length + i, width));
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push(currentTheme.fg('primary', '─'.repeat(width)));
|
||||
return lines.map((line) => truncateToWidth(line, width, ELLIPSIS));
|
||||
}
|
||||
|
||||
private renderItem(item: PluginsOverviewItem, index: number, width: number): string[] {
|
||||
const selected = index === this.selectedIndex;
|
||||
const pointer = selected ? SELECT_POINTER : ' ';
|
||||
const labelStyle = selected
|
||||
? (text: string) => currentTheme.boldFg('primary', text)
|
||||
: (text: string) => currentTheme.fg('text', text);
|
||||
const prefix = currentTheme.fg(selected ? 'primary' : 'textDim', ` ${pointer} `);
|
||||
let line = prefix + labelStyle(item.label);
|
||||
if (item.status !== undefined) {
|
||||
line += ' ' + statusStyle(item)(item.status);
|
||||
}
|
||||
const pluginId = overviewItemPluginId(item);
|
||||
if (pluginId !== undefined && this.opts.pluginHint?.id === pluginId) {
|
||||
line += ' ' + currentTheme.fg('warning', this.opts.pluginHint.text);
|
||||
}
|
||||
|
||||
const descriptionWidth = Math.max(1, width - 4);
|
||||
const lines = [line];
|
||||
for (const descLine of wrapOverviewDescription(item.description, descriptionWidth)) {
|
||||
lines.push(mutedHintLine(` ${descLine}`));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
||||
export type PluginMarketplaceSelection =
|
||||
| { readonly kind: 'install'; readonly entry: PluginMarketplaceEntry }
|
||||
| { readonly kind: 'back' };
|
||||
|
||||
export interface PluginMarketplaceSelectorOptions {
|
||||
readonly entries: readonly PluginMarketplaceEntry[];
|
||||
readonly installed: ReadonlyMap<string, string | undefined>;
|
||||
readonly source: string;
|
||||
readonly onSelect: (selection: PluginMarketplaceSelection) => void;
|
||||
readonly onCancel: () => void;
|
||||
}
|
||||
|
||||
export class PluginMarketplaceSelectorComponent extends Container implements Focusable {
|
||||
focused = false;
|
||||
|
||||
private readonly opts: PluginMarketplaceSelectorOptions;
|
||||
private readonly items: readonly PluginsOverviewItem[];
|
||||
private selectedIndex = 0;
|
||||
|
||||
constructor(opts: PluginMarketplaceSelectorOptions) {
|
||||
super();
|
||||
this.opts = opts;
|
||||
this.items = buildMarketplaceItems(opts.entries, opts.installed);
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (matchesKey(data, Key.escape)) {
|
||||
this.opts.onCancel();
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.up)) {
|
||||
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.down)) {
|
||||
this.selectedIndex = Math.min(this.items.length - 1, this.selectedIndex + 1);
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.enter)) {
|
||||
const chosen = this.items[this.selectedIndex];
|
||||
if (chosen === undefined) return;
|
||||
if (chosen.value === 'back') {
|
||||
this.opts.onSelect({ kind: 'back' });
|
||||
return;
|
||||
}
|
||||
const entry = this.opts.entries.find((item) => item.id === chosen.value);
|
||||
if (entry === undefined) return;
|
||||
this.opts.onSelect({ kind: 'install', entry });
|
||||
}
|
||||
}
|
||||
|
||||
override render(width: number): string[] {
|
||||
const entries = this.items.filter((item) => item.kind === 'plugin');
|
||||
const actions = this.items.filter((item) => item.kind === 'action');
|
||||
const lines: string[] = [
|
||||
currentTheme.fg('primary', '─'.repeat(width)),
|
||||
currentTheme.boldFg('primary', ' Official plugins'),
|
||||
mutedHintLine(' ↑↓ navigate · Enter install/update · Esc cancel'),
|
||||
currentTheme.fg('textMuted', ` Source: ${this.opts.source}`),
|
||||
'',
|
||||
sectionLabel(`Marketplace (${entries.length})`),
|
||||
];
|
||||
|
||||
if (entries.length === 0) {
|
||||
lines.push(currentTheme.fg('textMuted', ' No marketplace plugins found.'));
|
||||
} else {
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
lines.push(...this.renderItem(entries[i]!, i, width));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push(sectionLabel('Actions'));
|
||||
for (let i = 0; i < actions.length; i++) {
|
||||
lines.push(...this.renderItem(actions[i]!, entries.length + i, width));
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push(currentTheme.fg('primary', '─'.repeat(width)));
|
||||
return lines.map((line) => truncateToWidth(line, width, ELLIPSIS));
|
||||
}
|
||||
|
||||
private renderItem(item: PluginsOverviewItem, index: number, width: number): string[] {
|
||||
const selected = index === this.selectedIndex;
|
||||
const pointer = selected ? SELECT_POINTER : ' ';
|
||||
const labelStyle = selected
|
||||
? (text: string) => currentTheme.boldFg('primary', text)
|
||||
: (text: string) => currentTheme.fg('text', text);
|
||||
const prefix = currentTheme.fg(selected ? 'primary' : 'textDim', ` ${pointer} `);
|
||||
let line = prefix + labelStyle(item.label);
|
||||
if (item.status !== undefined) {
|
||||
line += ' ' + statusStyle(item)(item.status);
|
||||
}
|
||||
const descriptionWidth = Math.max(1, width - 4);
|
||||
const lines = [line];
|
||||
for (const descLine of wrapOverviewDescription(item.description, descriptionWidth)) {
|
||||
lines.push(mutedHintLine(` ${descLine}`));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
||||
export type PluginMcpSelection =
|
||||
| { readonly kind: 'toggle'; readonly pluginId: string; readonly server: string; readonly enabled: boolean }
|
||||
| { readonly kind: 'back'; readonly pluginId: string };
|
||||
|
|
@ -347,18 +103,19 @@ export class PluginMcpSelectorComponent extends Container implements Focusable {
|
|||
|
||||
override render(width: number): string[] {
|
||||
const { info } = this.opts;
|
||||
const colors = currentTheme.palette;
|
||||
const serverItems = this.items.filter((item) => item.kind === 'plugin');
|
||||
const actionItems = this.items.filter((item) => item.kind === 'action');
|
||||
const lines: string[] = [
|
||||
currentTheme.fg('primary', '─'.repeat(width)),
|
||||
currentTheme.boldFg('primary', ` MCP servers · ${info.displayName}`),
|
||||
mutedHintLine(' ↑↓ navigate · Enter/Space enable/disable · Esc cancel'),
|
||||
chalk.hex(colors.primary)('─'.repeat(width)),
|
||||
chalk.hex(colors.primary).bold(` MCP servers · ${info.displayName}`),
|
||||
mutedHintLine(' ↑↓ navigate · Enter/Space enable/disable · Esc cancel', colors),
|
||||
'',
|
||||
sectionLabel(`MCP servers (${info.enabledMcpServerCount}/${info.mcpServerCount} enabled)`),
|
||||
sectionLabel(`MCP servers (${info.enabledMcpServerCount}/${info.mcpServerCount} enabled)`, colors),
|
||||
];
|
||||
|
||||
if (serverItems.length === 0) {
|
||||
lines.push(currentTheme.fg('textMuted', ' No MCP servers declared.'));
|
||||
lines.push(chalk.hex(colors.textMuted)(' No MCP servers declared.'));
|
||||
} else {
|
||||
for (let i = 0; i < serverItems.length; i++) {
|
||||
lines.push(...this.renderItem(serverItems[i]!, i, width));
|
||||
|
|
@ -366,35 +123,34 @@ export class PluginMcpSelectorComponent extends Container implements Focusable {
|
|||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push(sectionLabel('Actions'));
|
||||
lines.push(sectionLabel('Actions', colors));
|
||||
for (let i = 0; i < actionItems.length; i++) {
|
||||
lines.push(...this.renderItem(actionItems[i]!, serverItems.length + i, width));
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push(currentTheme.fg('primary', '─'.repeat(width)));
|
||||
lines.push(chalk.hex(colors.primary)('─'.repeat(width)));
|
||||
return lines.map((line) => truncateToWidth(line, width, ELLIPSIS));
|
||||
}
|
||||
|
||||
private renderItem(item: PluginsOverviewItem, index: number, width: number): string[] {
|
||||
const colors = currentTheme.palette;
|
||||
const selected = index === this.selectedIndex;
|
||||
const pointer = selected ? SELECT_POINTER : ' ';
|
||||
const labelStyle = selected
|
||||
? (text: string) => currentTheme.boldFg('primary', text)
|
||||
: (text: string) => currentTheme.fg('text', text);
|
||||
const prefix = currentTheme.fg(selected ? 'primary' : 'textDim', ` ${pointer} `);
|
||||
const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text);
|
||||
const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `);
|
||||
let line = prefix + labelStyle(item.label);
|
||||
if (item.status !== undefined) {
|
||||
line += ' ' + statusStyle(item)(item.status);
|
||||
line += ' ' + statusStyle(item, colors)(item.status);
|
||||
}
|
||||
const serverName = mcpItemServerName(item);
|
||||
if (serverName !== undefined && this.opts.serverHint?.server === serverName) {
|
||||
line += ' ' + currentTheme.fg('warning', this.opts.serverHint.text);
|
||||
line += ' ' + chalk.hex(colors.warning)(this.opts.serverHint.text);
|
||||
}
|
||||
const descriptionWidth = Math.max(1, width - 4);
|
||||
const lines = [line];
|
||||
for (const descLine of wrapOverviewDescription(item.description, descriptionWidth)) {
|
||||
lines.push(mutedHintLine(` ${descLine}`));
|
||||
lines.push(mutedHintLine(` ${descLine}`, colors));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
|
@ -439,35 +195,53 @@ export class PluginRemoveConfirmComponent extends ChoicePickerComponent {
|
|||
}
|
||||
}
|
||||
|
||||
function buildOverviewItems(plugins: readonly PluginSummary[]): PluginsOverviewItem[] {
|
||||
const options: PluginsOverviewItem[] = plugins.map((plugin) => ({
|
||||
value: `${OVERVIEW_PLUGIN_PREFIX}${plugin.id}`,
|
||||
kind: 'plugin',
|
||||
label: plugin.displayName,
|
||||
status: pluginStatus(plugin),
|
||||
description: overviewPluginDescription(plugin),
|
||||
}));
|
||||
options.push(
|
||||
{
|
||||
value: OVERVIEW_MARKETPLACE,
|
||||
kind: 'action',
|
||||
label: 'Marketplace',
|
||||
description: 'Browse official plugins.',
|
||||
},
|
||||
{
|
||||
value: OVERVIEW_RELOAD,
|
||||
kind: 'action',
|
||||
label: 'Reload',
|
||||
description: 'Re-read installed plugins and manifests.',
|
||||
},
|
||||
{
|
||||
value: OVERVIEW_SHOW_LIST,
|
||||
kind: 'action',
|
||||
label: 'Summary',
|
||||
description: 'Append the current plugin summary to the transcript.',
|
||||
},
|
||||
);
|
||||
return options;
|
||||
export type PluginInstallTrustConfirmResult =
|
||||
| { readonly kind: 'confirm' }
|
||||
| { readonly kind: 'cancel' };
|
||||
|
||||
export interface PluginInstallTrustConfirmOptions {
|
||||
/** Plugin display name or source, shown in the title for identification. */
|
||||
readonly label: string;
|
||||
readonly onDone: (result: PluginInstallTrustConfirmResult) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmation shown before installing a third-party (unofficial) plugin.
|
||||
* Defaults to "Exit" so the user must explicitly switch to "Trust and install"
|
||||
* to proceed with a plugin that Kimi has not reviewed.
|
||||
*/
|
||||
export class PluginInstallTrustConfirmComponent extends ChoicePickerComponent {
|
||||
constructor(opts: PluginInstallTrustConfirmOptions) {
|
||||
super({
|
||||
title: `Install third-party plugin ${opts.label}?`,
|
||||
hint: '↑↓ navigate · Enter/Space select · ←/Esc cancel',
|
||||
formatHint: mutedHintLine,
|
||||
notice:
|
||||
'⚠️ This is a third-party plugin that Kimi has not reviewed. It can bundle MCP servers, ' +
|
||||
'skills, or files that run code and access your workspace. Install it only if you ' +
|
||||
'trust the source.',
|
||||
noticeTone: 'warning',
|
||||
options: [
|
||||
{
|
||||
value: INSTALL_TRUST_EXIT,
|
||||
label: 'Exit',
|
||||
description: 'Cancel the installation.',
|
||||
},
|
||||
{
|
||||
value: INSTALL_TRUST_TRUST,
|
||||
label: 'Trust and install',
|
||||
tone: 'danger',
|
||||
description: 'Install this third-party plugin anyway.',
|
||||
},
|
||||
],
|
||||
onSelect: (value) => {
|
||||
opts.onDone(value === INSTALL_TRUST_TRUST ? { kind: 'confirm' } : { kind: 'cancel' });
|
||||
},
|
||||
onCancel: () => {
|
||||
opts.onDone({ kind: 'cancel' });
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function overviewPluginDescription(plugin: PluginSummary): string {
|
||||
|
|
@ -483,41 +257,441 @@ function overviewPluginDescription(plugin: PluginSummary): string {
|
|||
return `id ${plugin.id} · ${skills}${mcp}${source}${trust}${state}${diagnostics}`;
|
||||
}
|
||||
|
||||
function pluginStatus(plugin: PluginSummary): string {
|
||||
function pluginStatus(plugin: PluginSummary): string | undefined {
|
||||
if (plugin.state !== 'ok') return plugin.state;
|
||||
return plugin.enabled ? 'enabled' : 'disabled';
|
||||
}
|
||||
|
||||
function parseOverviewSelection(value: string): PluginsOverviewSelection | undefined {
|
||||
if (value === OVERVIEW_MARKETPLACE) return { kind: 'marketplace' };
|
||||
if (value === OVERVIEW_RELOAD) return { kind: 'reload' };
|
||||
if (value === OVERVIEW_SHOW_LIST) return { kind: 'show-list' };
|
||||
return undefined;
|
||||
function marketplaceStatusStyle(status: string, colors: ColorPalette): (text: string) => string {
|
||||
// "update …" is a warning (actionable); "installed …" is success;
|
||||
// "install …" is the available action.
|
||||
if (status.startsWith('update')) return chalk.hex(colors.warning);
|
||||
if (status.startsWith('installed')) return chalk.hex(colors.success);
|
||||
return chalk.hex(colors.primary);
|
||||
}
|
||||
|
||||
function overviewItemPluginId(item: PluginsOverviewItem): string | undefined {
|
||||
if (!item.value.startsWith(OVERVIEW_PLUGIN_PREFIX)) return undefined;
|
||||
return item.value.slice(OVERVIEW_PLUGIN_PREFIX.length);
|
||||
/** Rounded single-line URL input box (DESIGN §9), shared by the marketplace
|
||||
* Custom tab and the unified plugins panel. */
|
||||
function renderUrlInputBox(
|
||||
input: Input,
|
||||
focused: boolean,
|
||||
width: number,
|
||||
colors: ColorPalette,
|
||||
): string[] {
|
||||
input.focused = focused;
|
||||
const border = (s: string): string => chalk.hex(colors.primary)(s);
|
||||
const boxWidth = Math.max(24, width - 2);
|
||||
const innerWidth = Math.max(10, boxWidth - 4);
|
||||
const inputLine = input.render(innerWidth)[0] ?? '';
|
||||
const rightPad = Math.max(0, innerWidth - visibleWidth(inputLine));
|
||||
return [
|
||||
' ' + border('╭' + '─'.repeat(boxWidth - 2) + '╮'),
|
||||
' ' + border('│') + ' ' + inputLine + ' '.repeat(rightPad) + border('│'),
|
||||
' ' + border('╰' + '─'.repeat(boxWidth - 2) + '╯'),
|
||||
];
|
||||
}
|
||||
|
||||
function buildMarketplaceItems(
|
||||
entries: readonly PluginMarketplaceEntry[],
|
||||
installed: ReadonlyMap<string, string | undefined>,
|
||||
): PluginsOverviewItem[] {
|
||||
const items: PluginsOverviewItem[] = entries.map((entry) => ({
|
||||
value: entry.id,
|
||||
kind: 'plugin',
|
||||
label: entry.displayName,
|
||||
status: marketplaceItemStatus(entry, installed),
|
||||
description: marketplaceEntryDescription(entry),
|
||||
}));
|
||||
items.push({
|
||||
value: 'back',
|
||||
kind: 'action',
|
||||
label: 'Back to installed plugins',
|
||||
description: 'Return to the local plugin manager.',
|
||||
});
|
||||
return items;
|
||||
// ===========================================================================
|
||||
// Unified /plugins panel: Installed / Official / Third-party / Custom tabs.
|
||||
// ===========================================================================
|
||||
|
||||
export type PluginsPanelTabId = 'installed' | 'official' | 'third-party' | 'custom';
|
||||
|
||||
export type PluginsPanelSelection =
|
||||
| { readonly kind: 'toggle'; readonly id: string; readonly enabled: boolean }
|
||||
| { readonly kind: 'remove'; readonly id: string }
|
||||
| { readonly kind: 'mcp'; readonly id: string }
|
||||
| { readonly kind: 'details'; readonly id: string }
|
||||
| { readonly kind: 'reload' }
|
||||
| { readonly kind: 'install'; readonly entry: PluginMarketplaceEntry }
|
||||
| { readonly kind: 'install-source'; readonly source: string };
|
||||
|
||||
export interface PluginsPanelOptions {
|
||||
readonly installed: readonly PluginSummary[];
|
||||
readonly installedIds: ReadonlySet<string>;
|
||||
readonly initialTab?: PluginsPanelTabId;
|
||||
readonly selectedId?: string;
|
||||
readonly pluginHint?: { readonly id: string; readonly text: string };
|
||||
readonly onSelect: (selection: PluginsPanelSelection) => void;
|
||||
readonly onCancel: () => void;
|
||||
/** Called the first time the Official or Third-party tab needs its catalog.
|
||||
* The host fetches the marketplace and calls setMarketplace / setMarketplaceError. */
|
||||
readonly onRequestMarketplace?: () => void;
|
||||
}
|
||||
|
||||
type MarketState =
|
||||
| { readonly status: 'idle' }
|
||||
| { readonly status: 'loading' }
|
||||
| { readonly status: 'error'; readonly message: string }
|
||||
| { readonly status: 'loaded'; readonly entries: readonly PluginMarketplaceEntry[]; readonly source: string };
|
||||
|
||||
const PLUGINS_PANEL_TABS: readonly { id: PluginsPanelTabId; label: string }[] = [
|
||||
{ id: 'installed', label: 'Installed' },
|
||||
{ id: 'official', label: 'Official' },
|
||||
{ id: 'third-party', label: 'Third-party' },
|
||||
{ id: 'custom', label: 'Custom' },
|
||||
];
|
||||
|
||||
export class PluginsPanelComponent extends Container implements Focusable {
|
||||
focused = false;
|
||||
|
||||
private readonly opts: PluginsPanelOptions;
|
||||
private readonly customInput = new Input();
|
||||
private activeTabIndex: number;
|
||||
private selectedIndex = 0;
|
||||
private market: MarketState = { status: 'idle' };
|
||||
private installing: string | undefined;
|
||||
|
||||
constructor(opts: PluginsPanelOptions) {
|
||||
super();
|
||||
this.opts = opts;
|
||||
this.activeTabIndex = Math.max(
|
||||
0,
|
||||
PLUGINS_PANEL_TABS.findIndex((tab) => tab.id === (opts.initialTab ?? 'installed')),
|
||||
);
|
||||
if (opts.selectedId !== undefined && this.activeTab.id === 'installed') {
|
||||
const idx = opts.installed.findIndex((p) => p.id === opts.selectedId);
|
||||
if (idx >= 0) this.selectedIndex = idx;
|
||||
}
|
||||
this.customInput.onSubmit = (value) => {
|
||||
const source = value.trim();
|
||||
if (source.length > 0) this.opts.onSelect({ kind: 'install-source', source });
|
||||
};
|
||||
}
|
||||
|
||||
marketplaceStatus(): MarketState['status'] {
|
||||
return this.market.status;
|
||||
}
|
||||
|
||||
setMarketplaceLoading(): void {
|
||||
this.market = { status: 'loading' };
|
||||
}
|
||||
|
||||
setMarketplace(entries: readonly PluginMarketplaceEntry[], source: string): void {
|
||||
this.market = { status: 'loaded', entries, source };
|
||||
}
|
||||
|
||||
setMarketplaceError(message: string): void {
|
||||
this.market = { status: 'error', message };
|
||||
}
|
||||
|
||||
setInstalling(label: string): void {
|
||||
this.installing = label;
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
clearInstalling(): void {
|
||||
this.installing = undefined;
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
private get activeTab(): (typeof PLUGINS_PANEL_TABS)[number] {
|
||||
return PLUGINS_PANEL_TABS[this.activeTabIndex]!;
|
||||
}
|
||||
|
||||
private get marketplaceEntries(): readonly PluginMarketplaceEntry[] {
|
||||
if (this.market.status !== 'loaded') return [];
|
||||
const { installedIds } = this.opts;
|
||||
return this.market.entries.toSorted(
|
||||
(a, b) => Number(installedIds.has(b.id)) - Number(installedIds.has(a.id)),
|
||||
);
|
||||
}
|
||||
|
||||
private get installedVersions(): ReadonlyMap<string, string | undefined> {
|
||||
return new Map(this.opts.installed.map((plugin) => [plugin.id, plugin.version]));
|
||||
}
|
||||
|
||||
private get officialEntries(): readonly PluginMarketplaceEntry[] {
|
||||
return this.marketplaceEntries.filter((entry) => entry.tier === 'official');
|
||||
}
|
||||
|
||||
private get thirdPartyEntries(): readonly PluginMarketplaceEntry[] {
|
||||
// Anything not explicitly marked official lands here: `curated` entries plus
|
||||
// entries that omit `tier` (custom marketplaces often do). Without this,
|
||||
// untiered entries would be invisible in both marketplace tabs.
|
||||
return this.marketplaceEntries.filter((entry) => entry.tier !== 'official');
|
||||
}
|
||||
|
||||
private requestMarketplaceIfNeeded(): void {
|
||||
// The Installed tab also needs the catalog to render update badges; only the
|
||||
// Custom tab (manual URL entry) can skip the fetch entirely.
|
||||
if (this.market.status === 'idle' && this.activeTab.id !== 'custom') {
|
||||
this.market = { status: 'loading' };
|
||||
this.opts.onRequestMarketplace?.();
|
||||
}
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (matchesKey(data, Key.escape)) {
|
||||
this.opts.onCancel();
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.tab)) {
|
||||
this.activeTabIndex = (this.activeTabIndex + 1) % PLUGINS_PANEL_TABS.length;
|
||||
this.selectedIndex = 0;
|
||||
this.requestMarketplaceIfNeeded();
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.shift('tab'))) {
|
||||
this.activeTabIndex =
|
||||
(this.activeTabIndex - 1 + PLUGINS_PANEL_TABS.length) % PLUGINS_PANEL_TABS.length;
|
||||
this.selectedIndex = 0;
|
||||
this.requestMarketplaceIfNeeded();
|
||||
return;
|
||||
}
|
||||
switch (this.activeTab.id) {
|
||||
case 'installed':
|
||||
this.handleInstalledInput(data);
|
||||
return;
|
||||
case 'official':
|
||||
case 'third-party':
|
||||
this.handleMarketplaceInput(data);
|
||||
return;
|
||||
case 'custom':
|
||||
this.customInput.handleInput(data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private handleInstalledInput(data: string): void {
|
||||
const plugins = this.opts.installed;
|
||||
if (matchesKey(data, Key.up)) {
|
||||
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.down)) {
|
||||
this.selectedIndex = Math.min(plugins.length - 1, this.selectedIndex + 1);
|
||||
return;
|
||||
}
|
||||
const plugin = plugins[this.selectedIndex];
|
||||
const ch = printableChar(data);
|
||||
// Decode Space for terminals that send printable keys via Kitty/CSI-u
|
||||
// sequences (e.g. VS Code's integrated terminal); `matchesKey(Key.space)`
|
||||
// alone misses those and the toggle silently stops working.
|
||||
if (matchesKey(data, Key.space) || ch === ' ') {
|
||||
if (plugin !== undefined) {
|
||||
this.opts.onSelect({ kind: 'toggle', id: plugin.id, enabled: !plugin.enabled });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (ch === 'd' || ch === 'D') {
|
||||
if (plugin !== undefined) this.opts.onSelect({ kind: 'remove', id: plugin.id });
|
||||
return;
|
||||
}
|
||||
if (ch === 'm' || ch === 'M') {
|
||||
if (plugin !== undefined) this.opts.onSelect({ kind: 'mcp', id: plugin.id });
|
||||
return;
|
||||
}
|
||||
if (ch === 'r' || ch === 'R') {
|
||||
this.opts.onSelect({ kind: 'reload' });
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.enter)) {
|
||||
if (plugin === undefined) return;
|
||||
const update = this.installedUpdateStatus(plugin);
|
||||
if (update !== undefined) {
|
||||
this.opts.onSelect({ kind: 'install', entry: update.entry });
|
||||
} else {
|
||||
this.opts.onSelect({ kind: 'details', id: plugin.id });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (ch === 'i' || ch === 'I') {
|
||||
if (plugin !== undefined) this.opts.onSelect({ kind: 'details', id: plugin.id });
|
||||
}
|
||||
}
|
||||
|
||||
private handleMarketplaceInput(data: string): void {
|
||||
const entries = this.activeTab.id === 'official' ? this.officialEntries : this.thirdPartyEntries;
|
||||
if (matchesKey(data, Key.up)) {
|
||||
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.down)) {
|
||||
// Clamp to 0 while the catalog is still loading (entries empty); otherwise
|
||||
// `entries.length - 1` is -1 and a later Enter reads `entries[-1]`.
|
||||
this.selectedIndex = entries.length === 0 ? 0 : Math.min(entries.length - 1, this.selectedIndex + 1);
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.enter)) {
|
||||
const entry = entries[this.selectedIndex];
|
||||
if (entry === undefined) return;
|
||||
this.opts.onSelect({ kind: 'install', entry });
|
||||
}
|
||||
}
|
||||
|
||||
override invalidate(): void {
|
||||
super.invalidate();
|
||||
this.customInput.invalidate();
|
||||
}
|
||||
|
||||
override render(width: number): string[] {
|
||||
if (this.installing !== undefined) {
|
||||
return this.renderInstalling(width);
|
||||
}
|
||||
const colors = currentTheme.palette;
|
||||
const tab = this.activeTab.id;
|
||||
const hint =
|
||||
tab === 'installed'
|
||||
? this.installedHint()
|
||||
: tab === 'custom'
|
||||
? ' Tab switch · Enter install · Esc cancel'
|
||||
: ' Tab switch · ↑↓ navigate · Enter open/install · Esc cancel';
|
||||
const lines: string[] = [
|
||||
chalk.hex(colors.primary)('─'.repeat(width)),
|
||||
chalk.hex(colors.primary).bold(' Plugins'),
|
||||
mutedHintLine(hint, colors),
|
||||
'',
|
||||
renderTabStrip({
|
||||
labels: PLUGINS_PANEL_TABS.map((t) => t.label),
|
||||
activeIndex: this.activeTabIndex,
|
||||
width,
|
||||
colors,
|
||||
}),
|
||||
'',
|
||||
];
|
||||
|
||||
if (tab === 'installed') this.renderInstalled(lines, width);
|
||||
else if (tab === 'official') this.renderOfficial(lines, width);
|
||||
else if (tab === 'third-party') this.renderThirdParty(lines, width);
|
||||
else this.renderCustom(lines, width);
|
||||
|
||||
lines.push(chalk.hex(colors.primary)('─'.repeat(width)));
|
||||
return lines.map((line) => truncateToWidth(line, width, ELLIPSIS));
|
||||
}
|
||||
|
||||
private renderInstalled(lines: string[], width: number): void {
|
||||
const { installed } = this.opts;
|
||||
const colors = currentTheme.palette;
|
||||
if (installed.length === 0) {
|
||||
lines.push(chalk.hex(colors.textMuted)(' No plugins installed.'));
|
||||
} else {
|
||||
for (let i = 0; i < installed.length; i++) {
|
||||
lines.push(...this.renderInstalledRow(installed[i]!, i, width));
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
lines.push(mutedHintLine(` ${installed.length} installed`, colors));
|
||||
}
|
||||
|
||||
private installedHint(): string {
|
||||
const plugin = this.opts.installed[this.selectedIndex];
|
||||
const hasUpdate = plugin !== undefined && this.installedUpdateStatus(plugin) !== undefined;
|
||||
const enter = hasUpdate ? 'Enter update' : 'Enter details';
|
||||
return ` Tab switch · Space toggle · D remove · M MCP · ${enter} · I details · R reload · Esc cancel`;
|
||||
}
|
||||
|
||||
private installedUpdateStatus(
|
||||
plugin: PluginSummary,
|
||||
): { entry: PluginMarketplaceEntry; local: string; latest: string } | undefined {
|
||||
if (this.market.status !== 'loaded') return undefined;
|
||||
const entry = this.market.entries.find((e) => e.id === plugin.id);
|
||||
if (entry === undefined) return undefined;
|
||||
const status = computeUpdateStatus(entry.version, plugin.version, true);
|
||||
return status.kind === 'update' ? { entry, local: status.local, latest: status.latest } : undefined;
|
||||
}
|
||||
|
||||
private renderInstalledRow(plugin: PluginSummary, index: number, width: number): string[] {
|
||||
const colors = currentTheme.palette;
|
||||
const selected = index === this.selectedIndex;
|
||||
const pointer = selected ? SELECT_POINTER : ' ';
|
||||
const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text);
|
||||
const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `);
|
||||
const status = pluginStatus(plugin);
|
||||
const update = this.installedUpdateStatus(plugin);
|
||||
let line = prefix + labelStyle(plugin.displayName);
|
||||
if (status !== undefined) {
|
||||
line += ' ' + statusStyle({ kind: 'plugin', value: '', label: '', description: '', status }, colors)(status);
|
||||
}
|
||||
if (update !== undefined) {
|
||||
const badge = `update ${update.local} → ${update.latest}`;
|
||||
line += ' ' + marketplaceStatusStyle(badge, colors)(badge);
|
||||
}
|
||||
if (this.opts.pluginHint?.id === plugin.id) {
|
||||
line += ' ' + chalk.hex(colors.warning)(this.opts.pluginHint.text);
|
||||
}
|
||||
const descWidth = Math.max(1, width - 4);
|
||||
const out = [line];
|
||||
for (const descLine of wrapOverviewDescription(overviewPluginDescription(plugin), descWidth)) {
|
||||
out.push(mutedHintLine(` ${descLine}`, colors));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private renderMarketplaceTab(
|
||||
lines: string[],
|
||||
width: number,
|
||||
entries: readonly PluginMarketplaceEntry[],
|
||||
): void {
|
||||
const colors = currentTheme.palette;
|
||||
if (this.market.status === 'loading' || this.market.status === 'idle') {
|
||||
lines.push(chalk.hex(colors.textMuted)(' Loading marketplace…'));
|
||||
return;
|
||||
}
|
||||
if (this.market.status === 'error') {
|
||||
lines.push(chalk.hex(colors.warning)(` Marketplace unavailable: ${this.market.message}`));
|
||||
lines.push(mutedHintLine(' Use the Custom tab to install from a URL.', colors));
|
||||
return;
|
||||
}
|
||||
if (entries.length === 0) {
|
||||
lines.push(chalk.hex(colors.textMuted)(' No plugins found.'));
|
||||
} else {
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
lines.push(...this.renderMarketplaceRow(entries[i]!, i, width));
|
||||
}
|
||||
}
|
||||
const installedCount = entries.filter((e) => this.opts.installedIds.has(e.id)).length;
|
||||
lines.push('');
|
||||
lines.push(
|
||||
mutedHintLine(` ${installedCount} installed · ${entries.length - installedCount} available`, colors),
|
||||
);
|
||||
lines.push(mutedHintLine(` Source: ${this.market.source}`, colors));
|
||||
}
|
||||
|
||||
private renderOfficial(lines: string[], width: number): void {
|
||||
this.renderMarketplaceTab(lines, width, this.officialEntries);
|
||||
}
|
||||
|
||||
private renderThirdParty(lines: string[], width: number): void {
|
||||
this.renderMarketplaceTab(lines, width, this.thirdPartyEntries);
|
||||
}
|
||||
|
||||
private renderMarketplaceRow(entry: PluginMarketplaceEntry, index: number, width: number): string[] {
|
||||
const colors = currentTheme.palette;
|
||||
const selected = index === this.selectedIndex;
|
||||
const pointer = selected ? SELECT_POINTER : ' ';
|
||||
const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text);
|
||||
const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `);
|
||||
const status = marketplaceEntryStatus(entry, this.installedVersions);
|
||||
const line =
|
||||
prefix + labelStyle(entry.displayName) + ' ' + marketplaceStatusStyle(status, colors)(status);
|
||||
const descWidth = Math.max(1, width - 4);
|
||||
const out = [line];
|
||||
for (const descLine of wrapOverviewDescription(marketplaceEntryDescription(entry), descWidth)) {
|
||||
out.push(mutedHintLine(` ${descLine}`, colors));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private renderCustom(lines: string[], width: number): void {
|
||||
const colors = currentTheme.palette;
|
||||
lines.push(mutedHintLine(' Install from a GitHub URL (or zip URL / local path):', colors));
|
||||
lines.push('');
|
||||
lines.push(...renderUrlInputBox(this.customInput, this.focused, width, colors));
|
||||
}
|
||||
|
||||
private renderInstalling(width: number): string[] {
|
||||
const colors = currentTheme.palette;
|
||||
const lines = [
|
||||
chalk.hex(colors.primary)('─'.repeat(width)),
|
||||
chalk.hex(colors.primary).bold(' Plugins'),
|
||||
'',
|
||||
chalk.hex(colors.textMuted)(` Installing ${this.installing} from marketplace…`),
|
||||
'',
|
||||
chalk.hex(colors.primary)('─'.repeat(width)),
|
||||
];
|
||||
return lines.map((line) => truncateToWidth(line, width, ELLIPSIS));
|
||||
}
|
||||
}
|
||||
|
||||
function buildMcpItems(info: PluginInfo): PluginsOverviewItem[] {
|
||||
|
|
@ -556,13 +730,13 @@ function mcpItemServerName(item: PluginsOverviewItem): string | undefined {
|
|||
function marketplaceEntryDescription(entry: PluginMarketplaceEntry): string {
|
||||
const tier = marketplaceTierLabel(entry.tier);
|
||||
const description = entry.description ?? tier;
|
||||
const version = entry.version !== undefined ? ` · v${entry.version}` : '';
|
||||
const keywords =
|
||||
entry.keywords !== undefined && entry.keywords.length > 0
|
||||
? ` · ${entry.keywords.join(', ')}`
|
||||
: '';
|
||||
const tierSuffix = entry.description !== undefined ? ` · ${tier}` : '';
|
||||
// The version now lives in the status badge, so it is omitted here to avoid duplication.
|
||||
return `${description} · id ${entry.id}${tierSuffix}${keywords}`;
|
||||
return `${description} · id ${entry.id}${version}${tierSuffix}${keywords}`;
|
||||
}
|
||||
|
||||
function marketplaceTierLabel(tier: PluginMarketplaceEntry['tier']): string {
|
||||
|
|
@ -571,7 +745,11 @@ function marketplaceTierLabel(tier: PluginMarketplaceEntry['tier']): string {
|
|||
return 'Plugin';
|
||||
}
|
||||
|
||||
function marketplaceItemStatus(
|
||||
function installStatus(entry: PluginMarketplaceEntry): string {
|
||||
return entry.version === undefined ? 'install' : `install v${entry.version}`;
|
||||
}
|
||||
|
||||
function marketplaceEntryStatus(
|
||||
entry: PluginMarketplaceEntry,
|
||||
installed: ReadonlyMap<string, string | undefined>,
|
||||
): string {
|
||||
|
|
@ -582,27 +760,30 @@ function marketplaceItemStatus(
|
|||
case 'up-to-date':
|
||||
return status.version === undefined ? 'installed' : `installed · v${status.version}`;
|
||||
case 'not-installed':
|
||||
return entry.version === undefined ? 'install' : `install v${entry.version}`;
|
||||
return installStatus(entry);
|
||||
}
|
||||
}
|
||||
|
||||
function sectionLabel(label: string): string {
|
||||
return currentTheme.boldFg('textDim', ` ${label}`);
|
||||
function sectionLabel(label: string, colors: ColorPalette): string {
|
||||
return chalk.hex(colors.textDim).bold(` ${label}`);
|
||||
}
|
||||
|
||||
function statusStyle(
|
||||
item: PluginsOverviewItem,
|
||||
colors: ColorPalette,
|
||||
): (text: string) => string {
|
||||
if (item.kind === 'action') return (text) => currentTheme.fg('textDim', text);
|
||||
if (item.status?.startsWith('update')) return (text) => currentTheme.fg('warning', text);
|
||||
if (item.status === 'enabled' || item.status?.startsWith('installed')) return (text) => currentTheme.fg('success', text);
|
||||
if (item.status?.startsWith('install')) return (text) => currentTheme.fg('primary', text);
|
||||
if (item.status === 'disabled') return (text) => currentTheme.fg('textDim', text);
|
||||
if (item.status !== undefined && /^\d/.test(item.status)) return (text) => currentTheme.fg('textDim', text);
|
||||
return (text) => currentTheme.fg('warning', text);
|
||||
if (item.kind === 'action') return chalk.hex(colors.textDim);
|
||||
if (item.status === 'enabled' || item.status === 'installed') return chalk.hex(colors.success);
|
||||
if (item.status?.startsWith('install')) return chalk.hex(colors.primary);
|
||||
if (item.status === 'disabled') return chalk.hex(colors.textDim);
|
||||
if (item.status !== undefined && /^\d/.test(item.status)) return chalk.hex(colors.textDim);
|
||||
return chalk.hex(colors.warning);
|
||||
}
|
||||
|
||||
function mutedHintLine(text: string): string {
|
||||
function mutedHintLine(text: string, colors?: ColorPalette): string {
|
||||
if (colors !== undefined) {
|
||||
return chalk.hex(colors.textMuted)(text);
|
||||
}
|
||||
return currentTheme.fg('textMuted', text);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,11 +19,11 @@ import {
|
|||
Key,
|
||||
matchesKey,
|
||||
truncateToWidth,
|
||||
visibleWidth,
|
||||
type Focusable,
|
||||
} from '@earendil-works/pi-tui';
|
||||
|
||||
import { currentTheme } from '#/tui/theme';
|
||||
import { renderTabStrip } from '#/tui/utils/tab-strip';
|
||||
|
||||
import {
|
||||
ModelSelectorComponent,
|
||||
|
|
@ -44,6 +44,9 @@ export interface TabbedModelSelectorOptions {
|
|||
* tab derived from `currentValue`. */
|
||||
readonly initialTabId?: string;
|
||||
readonly onSelect: (selection: ModelSelection) => void;
|
||||
/** Forwarded to each inner selector; when set, Alt+S applies the choice to
|
||||
* the current session only without persisting it as the default. */
|
||||
readonly onSessionOnlySelect?: (selection: ModelSelection) => void;
|
||||
readonly onCancel: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -100,7 +103,12 @@ export class TabbedModelSelectorComponent extends Container implements Focusable
|
|||
// Layout: divider, title, hint, blank, tab strip, blank, then the model
|
||||
// list. The inner selector's blank line (inner[3]) separates the hint from
|
||||
// the tab strip; an extra blank separates the tabs from their list.
|
||||
const stripLine = this.renderTabStrip(width);
|
||||
const stripLine = renderTabStrip({
|
||||
labels: this.tabs.map((tab) => tab.label),
|
||||
activeIndex: this.activeIndex,
|
||||
width,
|
||||
colors: currentTheme.palette,
|
||||
});
|
||||
const out: string[] = [
|
||||
inner[0] ?? '',
|
||||
inner[1] ?? '',
|
||||
|
|
@ -126,81 +134,6 @@ export class TabbedModelSelectorComponent extends Container implements Focusable
|
|||
tab.selector.focused = this.focused && i === this.activeIndex;
|
||||
}
|
||||
}
|
||||
|
||||
/** Style a tab segment. The active tab is filled with the brand background
|
||||
* (matching the AskUserQuestion dialog); inactive tabs are muted. Both have
|
||||
* the same visible width so switching never shifts the layout. */
|
||||
private styleTab(label: string, isActive: boolean): string {
|
||||
const cell = ` ${label} `;
|
||||
return isActive
|
||||
? currentTheme.bg('primary', currentTheme.boldFg('text', cell))
|
||||
: currentTheme.fg('textMuted', cell);
|
||||
}
|
||||
|
||||
private renderTabStrip(width: number): string {
|
||||
const segments: string[] = [];
|
||||
for (let i = 0; i < this.tabs.length; i++) {
|
||||
const tab = this.tabs[i]!;
|
||||
segments.push(this.styleTab(tab.label, i === this.activeIndex));
|
||||
}
|
||||
|
||||
// If everything fits with a leading space, show the whole strip. The
|
||||
// provider-switch hint lives in the inner selector's hint line, not here.
|
||||
const totalSegmentWidth = segments.reduce((sum, s) => sum + visibleWidth(s), 0);
|
||||
if (1 + totalSegmentWidth <= width) {
|
||||
return ' ' + segments.join(' ');
|
||||
}
|
||||
|
||||
// Scrolling needed. Find the widest window that contains activeIndex.
|
||||
const segmentWidths = segments.map((s) => visibleWidth(s));
|
||||
let start = this.activeIndex;
|
||||
let end = this.activeIndex + 1;
|
||||
let contentWidth = segmentWidths[this.activeIndex]!;
|
||||
|
||||
const fits = (s: number, e: number, cw: number): boolean => {
|
||||
const needLeft = s > 0;
|
||||
const needRight = e < segments.length;
|
||||
const frameWidth = (needLeft ? 2 : 1) + (needRight ? 2 : 0);
|
||||
return cw + frameWidth <= width;
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const leftW = start > 0 ? segmentWidths[start - 1]! : Infinity;
|
||||
const rightW = end < segments.length ? segmentWidths[end]! : Infinity;
|
||||
if (leftW === Infinity && rightW === Infinity) break;
|
||||
|
||||
if (leftW <= rightW) {
|
||||
if (fits(start - 1, end, contentWidth + leftW)) {
|
||||
contentWidth += leftW;
|
||||
start--;
|
||||
} else if (fits(start, end + 1, contentWidth + rightW)) {
|
||||
contentWidth += rightW;
|
||||
end++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
if (fits(start, end + 1, contentWidth + rightW)) {
|
||||
contentWidth += rightW;
|
||||
end++;
|
||||
} else if (fits(start - 1, end, contentWidth + leftW)) {
|
||||
contentWidth += leftW;
|
||||
start--;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasLeft = start > 0;
|
||||
const hasRight = end < segments.length;
|
||||
let strip = hasLeft ? currentTheme.fg('textMuted', '< ') : ' ';
|
||||
strip += segments.slice(start, end).join(' ');
|
||||
if (hasRight) {
|
||||
strip += currentTheme.fg('textMuted', ' >');
|
||||
}
|
||||
return strip;
|
||||
}
|
||||
}
|
||||
|
||||
function buildTabs(opts: TabbedModelSelectorOptions): readonly ModelTab[] {
|
||||
|
|
@ -250,6 +183,7 @@ function makeSelector(
|
|||
searchable: true,
|
||||
providerSwitchHint: true,
|
||||
onSelect: opts.onSelect,
|
||||
onSessionOnlySelect: opts.onSessionOnlySelect,
|
||||
onCancel: opts.onCancel,
|
||||
};
|
||||
return new ModelSelectorComponent(inner);
|
||||
|
|
|
|||
|
|
@ -125,11 +125,20 @@ export class TaskOutputViewer extends Container implements Focusable {
|
|||
this.scrollBy(1);
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.pageUp) || k === ' ' || data === '\u0002' /* C-b */) {
|
||||
if (
|
||||
matchesKey(data, Key.pageUp) ||
|
||||
matchesKey(data, Key.ctrl('u')) ||
|
||||
k === ' ' ||
|
||||
data === '\u0002' /* C-b */
|
||||
) {
|
||||
this.scrollBy(-Math.max(1, visible - 1));
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.pageDown) || data === '\u0006' /* C-f */) {
|
||||
if (
|
||||
matchesKey(data, Key.pageDown) ||
|
||||
matchesKey(data, Key.ctrl('d')) ||
|
||||
data === '\u0006' /* C-f */
|
||||
) {
|
||||
this.scrollBy(Math.max(1, visible - 1));
|
||||
return;
|
||||
}
|
||||
|
|
@ -240,7 +249,7 @@ export class TaskOutputViewer extends Container implements Focusable {
|
|||
);
|
||||
const keys =
|
||||
`${key('↑↓')} ${dim('line')} ` +
|
||||
`${key('PgUp/PgDn')} ${dim('page')} ` +
|
||||
`${key('PgUp/PgDn/Ctrl+U/D')} ${dim('page')} ` +
|
||||
`${key('g/G')} ${dim('top/bot')} ` +
|
||||
`${key('Q/Esc')} ${dim('cancel')}`;
|
||||
const left = ` ${keys}`;
|
||||
|
|
|
|||
|
|
@ -130,8 +130,13 @@ function visibleTasks(
|
|||
tasks: readonly BackgroundTaskInfo[],
|
||||
filter: TasksFilter,
|
||||
): BackgroundTaskInfo[] {
|
||||
if (filter === 'all') return [...tasks];
|
||||
return tasks.filter((t) => !isTerminal(t.status));
|
||||
// The /tasks panel is for background task management. Foreground tasks
|
||||
// (detached === false) are shown in the main transcript instead, and only
|
||||
// appear here after being detached via Ctrl+B. `detached !== false` keeps
|
||||
// reconcile ghosts whose `detached` field may be undefined.
|
||||
const backgroundOnly = tasks.filter((t) => t.detached !== false);
|
||||
if (filter === 'all') return [...backgroundOnly];
|
||||
return backgroundOnly.filter((t) => !isTerminal(t.status));
|
||||
}
|
||||
|
||||
function compareTasks(a: BackgroundTaskInfo, b: BackgroundTaskInfo): number {
|
||||
|
|
@ -333,7 +338,11 @@ export class TasksBrowserApp extends Container implements Focusable {
|
|||
'textMuted',
|
||||
` filter=${this.props.filter === 'all' ? 'ALL' : 'ACTIVE'} `,
|
||||
);
|
||||
const counts = countByStatus(this.props.tasks);
|
||||
// Count only the tasks actually listed (background tasks after the
|
||||
// foreground-task filter), so a foreground-only session doesn't read
|
||||
// "1 running / 1 total" above an empty list.
|
||||
const visible = visibleTasks(this.props.tasks, this.props.filter);
|
||||
const counts = countByStatus(visible);
|
||||
const countSegments: string[] = [];
|
||||
if (counts.running > 0)
|
||||
countSegments.push(currentTheme.fg('success', ` ${String(counts.running)} running `));
|
||||
|
|
@ -343,7 +352,7 @@ export class TasksBrowserApp extends Container implements Focusable {
|
|||
countSegments.push(
|
||||
currentTheme.fg('error', ` ${String(counts.terminalFailed)} interrupted `),
|
||||
);
|
||||
const totals = currentTheme.fg('textMuted', ` ${String(this.props.tasks.length)} total `);
|
||||
const totals = currentTheme.fg('textMuted', ` ${String(visible.length)} total `);
|
||||
|
||||
const composed = title + filterText + countSegments.join('') + totals;
|
||||
return fitExactly(composed, width);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
matchesKey,
|
||||
Key,
|
||||
SelectList,
|
||||
visibleWidth,
|
||||
type SelectItem,
|
||||
type TUI,
|
||||
} from '@earendil-works/pi-tui';
|
||||
|
|
@ -15,6 +16,9 @@ import {
|
|||
import { currentTheme } from '#/tui/theme';
|
||||
import { createEditorTheme } from '#/tui/theme/pi-tui-theme';
|
||||
|
||||
import { printableChar } from '#/tui/utils/printable-key';
|
||||
|
||||
import { extractAtPrefix } from './file-mention-provider';
|
||||
import { WrappingSelectList } from './wrapping-select-list';
|
||||
|
||||
// oxlint-disable-next-line no-control-regex -- ESC (\x1b) is required to match ANSI SGR escape sequences
|
||||
|
|
@ -118,6 +122,10 @@ export class CustomEditor extends Editor {
|
|||
public onToggleToolExpand?: () => void;
|
||||
public onOpenExternalEditor?: () => void;
|
||||
public onCtrlS?: () => void;
|
||||
/** Return `true` to consume Ctrl+B; return `false`/`undefined` to fall through to the editor default (cursor-left). */
|
||||
public onCtrlB?: () => boolean;
|
||||
/** 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;
|
||||
|
|
@ -129,6 +137,10 @@ export class CustomEditor extends Editor {
|
|||
public onUpArrowEmpty?: () => boolean;
|
||||
public onDownArrowEmpty?: () => boolean;
|
||||
public onShiftTab?: () => void;
|
||||
/** 'bash' when entering a `!` shell command. The `!` is never part of the
|
||||
* text buffer — it is a separate mode + prompt symbol (see handleInput). */
|
||||
public inputMode: 'prompt' | 'bash' = 'prompt';
|
||||
public onInputModeChange?: (mode: 'prompt' | 'bash') => void;
|
||||
public connectedAbove = false;
|
||||
public borderHighlighted = false;
|
||||
/**
|
||||
|
|
@ -143,6 +155,11 @@ export class CustomEditor extends Editor {
|
|||
|
||||
private consumingPaste = false;
|
||||
private consumeBuffer = '';
|
||||
private argumentHints: ReadonlyMap<string, string> = new Map();
|
||||
|
||||
setArgumentHints(hints: ReadonlyMap<string, string>): void {
|
||||
this.argumentHints = hints;
|
||||
}
|
||||
|
||||
constructor(tui: TUI) {
|
||||
// paddingX: 4 reserves column 0 for the left vertical border (│),
|
||||
|
|
@ -216,6 +233,7 @@ export class CustomEditor extends Editor {
|
|||
const lines = super.render(width);
|
||||
if (lines.length < 3) return lines;
|
||||
const firstContentIdx = 1;
|
||||
const isBash = this.inputMode === 'bash';
|
||||
const text = this.getText().trimStart();
|
||||
if (text.startsWith('/')) {
|
||||
// Paint only the FIRST editor content line; multi-line slash commands
|
||||
|
|
@ -228,9 +246,20 @@ export class CustomEditor extends Editor {
|
|||
}
|
||||
}
|
||||
}
|
||||
const hint = this.computeArgumentHint();
|
||||
if (hint !== undefined) {
|
||||
const line = lines[firstContentIdx];
|
||||
if (line !== undefined) {
|
||||
lines[firstContentIdx] = injectArgumentHint(line, hint, this.getText().length, width);
|
||||
}
|
||||
}
|
||||
const firstContent = lines[firstContentIdx];
|
||||
if (firstContent !== undefined) {
|
||||
const withPrompt = injectPromptSymbol(firstContent);
|
||||
const withPrompt = injectPromptSymbol(
|
||||
firstContent,
|
||||
isBash ? '!' : '>',
|
||||
isBash ? (s) => this.borderColor(s) : undefined,
|
||||
);
|
||||
if (withPrompt !== undefined) {
|
||||
lines[firstContentIdx] = withPrompt;
|
||||
}
|
||||
|
|
@ -241,9 +270,26 @@ export class CustomEditor extends Editor {
|
|||
// side bars through the same hook to stay in sync.
|
||||
return wrapWithSideBorders(lines, (s) => this.borderColor(s), {
|
||||
connectedAbove: this.connectedAbove && !this.borderHighlighted,
|
||||
label: isBash ? ` ${currentTheme.boldFg('shellMode', '! shell mode')} ` : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private computeArgumentHint(): string | undefined {
|
||||
const text = this.getText();
|
||||
const match = /^\/(\S+)( ?)$/.exec(text);
|
||||
if (match === null) return undefined;
|
||||
const cmd = match[1];
|
||||
const trailingSpace = match[2] ?? '';
|
||||
if (cmd === undefined) return undefined;
|
||||
const hint = this.argumentHints.get(cmd);
|
||||
if (hint === undefined) return undefined;
|
||||
const { line, col } = this.getCursor();
|
||||
if (line !== 0) return undefined;
|
||||
const currentLine = this.getLines()[0] ?? '';
|
||||
if (col !== currentLine.length) return undefined;
|
||||
return trailingSpace.length > 0 ? hint : ` ${hint}`;
|
||||
}
|
||||
|
||||
override handleInput(data: string): void {
|
||||
const normalized = normalizeCapsLockedCtrl(data);
|
||||
if (isKeyRelease(normalized)) {
|
||||
|
|
@ -320,6 +366,19 @@ export class CustomEditor extends Editor {
|
|||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(normalized, Key.ctrl('b'))) {
|
||||
// Only consume the key when the handler actually detached something;
|
||||
// otherwise fall through so readline's backward-char still works at the
|
||||
// idle prompt.
|
||||
if (this.onCtrlB?.() === true) return;
|
||||
}
|
||||
|
||||
if (matchesKey(normalized, Key.ctrl('t'))) {
|
||||
// Only consume the key when the todo list actually has overflow to
|
||||
// expand/collapse; otherwise fall through to the editor default.
|
||||
if (this.onToggleTodoExpand?.() === true) return;
|
||||
}
|
||||
|
||||
if (matchesKey(normalized, 'shift+tab')) {
|
||||
this.onShiftTab?.();
|
||||
return;
|
||||
|
|
@ -329,6 +388,19 @@ export class CustomEditor extends Editor {
|
|||
this.onUndo?.();
|
||||
}
|
||||
|
||||
// Exit bash mode: Backspace/Escape on an empty `!` prompt returns to prompt
|
||||
// mode. Because the `!` is not in the buffer, "deleting" it is really
|
||||
// "delete on empty bash input".
|
||||
if (
|
||||
this.inputMode === 'bash' &&
|
||||
this.getText().length === 0 &&
|
||||
(matchesKey(normalized, Key.escape) || matchesKey(normalized, Key.backspace))
|
||||
) {
|
||||
this.inputMode = 'prompt';
|
||||
this.onInputModeChange?.('prompt');
|
||||
return;
|
||||
}
|
||||
|
||||
const newlineInput = getNewlineInput(normalized);
|
||||
if (newlineInput !== undefined) {
|
||||
this.onInsertNewline?.();
|
||||
|
|
@ -358,7 +430,79 @@ export class CustomEditor extends Editor {
|
|||
return;
|
||||
}
|
||||
|
||||
// Swallow Tab while the autocomplete dropdown is closed so it does not
|
||||
// trigger pi-tui's built-in file completion. When the dropdown is open,
|
||||
// fall through so pi-tui can still accept the selected item with Tab.
|
||||
if (matchesKey(normalized, Key.tab) && !this.isShowingAutocomplete()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter bash mode: typing `!` at the start of an empty prompt. The `!` is
|
||||
// not inserted into the buffer — it becomes the mode + prompt symbol, so the
|
||||
// cursor never has to skip over it and submit never has to strip it.
|
||||
if (
|
||||
this.inputMode === 'prompt' &&
|
||||
printableChar(normalized) === '!' &&
|
||||
this.getText().length === 0
|
||||
) {
|
||||
this.inputMode = 'bash';
|
||||
this.onInputModeChange?.('bash');
|
||||
return;
|
||||
}
|
||||
|
||||
const emptyPromptBeforeInput = this.inputMode === 'prompt' && this.getText().length === 0;
|
||||
super.handleInput(normalized);
|
||||
|
||||
// Enter bash mode when `!...` is pasted into an empty prompt. The typed path
|
||||
// above handles the single `!` keystroke; this catches bracketed / Ctrl-V
|
||||
// pastes whose content starts with `!`. Strip the leading `!` so the buffer
|
||||
// holds only the command, exactly like the typed path.
|
||||
if (emptyPromptBeforeInput && this.inputMode === 'prompt' && this.getText().startsWith('!')) {
|
||||
this.inputMode = 'bash';
|
||||
this.onInputModeChange?.('bash');
|
||||
this.setText(this.getText().slice(1));
|
||||
}
|
||||
|
||||
this.reopenAutocompleteAfterInput();
|
||||
}
|
||||
|
||||
private reopenAutocompleteAfterInput(): void {
|
||||
if (this.isShowingAutocomplete()) return;
|
||||
const { line, col } = this.getCursor();
|
||||
const textBeforeCursor = this.getLines()[line]?.slice(0, col) ?? '';
|
||||
const editor = this as unknown as {
|
||||
requestAutocomplete?: (options: { force: boolean; explicitTab: boolean }) => void;
|
||||
};
|
||||
if (editor.requestAutocomplete === undefined) return;
|
||||
const trigger = (): void => {
|
||||
// Use force:false so slash-aware logic runs: commands with argument
|
||||
// completions return their subcommands, commands without them return
|
||||
// null. force:true would bypass the slash branch and fall through to
|
||||
// path completion, wrongly popping up the file list.
|
||||
editor.requestAutocomplete?.({ force: false, explicitTab: false });
|
||||
};
|
||||
|
||||
// Reopen path / argument completion right after a `/` is typed
|
||||
// (e.g. `/add-dir /` or an `@dir/` mention).
|
||||
if (textBeforeCursor.endsWith('/')) {
|
||||
const isSlashArgument = textBeforeCursor.startsWith('/') && textBeforeCursor.includes(' ');
|
||||
const isAtMention = extractAtPrefix(textBeforeCursor) !== null;
|
||||
if (isSlashArgument || isAtMention) {
|
||||
trigger();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// After accepting a slash command name via Tab, pi-tui inserts a trailing
|
||||
// space and closes the menu without triggering argument completion. Reopen
|
||||
// it so subcommands (e.g. `/goal ` → status/pause/…) show immediately.
|
||||
if (
|
||||
textBeforeCursor.endsWith(' ') &&
|
||||
textBeforeCursor.startsWith('/') &&
|
||||
textBeforeCursor.includes(' ')
|
||||
) {
|
||||
trigger();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -443,6 +587,53 @@ function highlightVisibleRanges(
|
|||
return out + line.slice(rawCursor);
|
||||
}
|
||||
|
||||
// Mirrors the editor's paddingX (see constructor). The hint is spliced into
|
||||
// the first content line, which starts with this many spaces of left padding.
|
||||
const EDITOR_LEFT_PADDING = 4;
|
||||
// pi-tui renders the end-of-input cursor as an inverse-video space.
|
||||
const CURSOR_BLOCK = '\u001B[7m \u001B[0m';
|
||||
|
||||
/**
|
||||
* Splice a dimmed argument-hint ghost string into the first content line.
|
||||
*
|
||||
* The hint is purely visual: it is appended after the typed command (and
|
||||
* after the cursor block when one is rendered) so the cursor stays at the
|
||||
* end of the real input. It consumes trailing padding space, so the line
|
||||
* width is preserved; if it would overflow the box it is truncated with an
|
||||
* ellipsis. Returns the line unchanged when there is no room for a hint.
|
||||
*/
|
||||
function injectArgumentHint(
|
||||
line: string,
|
||||
hint: string,
|
||||
realTextLength: number,
|
||||
width: number,
|
||||
): string {
|
||||
const cursorIdx = line.indexOf(CURSOR_BLOCK);
|
||||
const cursorPresent = cursorIdx !== -1;
|
||||
const contentWidth = Math.max(1, width - EDITOR_LEFT_PADDING * 2);
|
||||
// Room left in the content area after the typed text (and cursor). The hint
|
||||
// must fit within this so the rendered line keeps its width.
|
||||
const available = contentWidth - realTextLength - (cursorPresent ? 1 : 0);
|
||||
const trimmed = truncateHint(hint, available);
|
||||
if (trimmed.length === 0) return line;
|
||||
const colored = currentTheme.fg('textDim', trimmed);
|
||||
const insertAt = cursorPresent
|
||||
? cursorIdx + CURSOR_BLOCK.length
|
||||
: mapVisibleIdxToRaw(line, EDITOR_LEFT_PADDING + realTextLength);
|
||||
// Everything after the insertion point is trailing padding + right padding
|
||||
// (plain spaces). Replace it with the hint followed by the remaining spaces
|
||||
// so the visible line width is preserved.
|
||||
const trailing = line.length - insertAt;
|
||||
return line.slice(0, insertAt) + colored + ' '.repeat(Math.max(0, trailing - trimmed.length));
|
||||
}
|
||||
|
||||
function truncateHint(hint: string, maxLen: number): string {
|
||||
if (maxLen <= 0) return '';
|
||||
if (hint.length <= maxLen) return hint;
|
||||
if (maxLen === 1) return '…';
|
||||
return `${hint.slice(0, maxLen - 1)}…`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlay a terminal-style `> ` prompt symbol on the first content line.
|
||||
* Column 0 is reserved for the left vertical border (overlaid later by
|
||||
|
|
@ -453,12 +644,17 @@ function highlightVisibleRanges(
|
|||
* default foreground colour renders the symbol. Returns `undefined` if the
|
||||
* line is too short or doesn't begin with the expected padding.
|
||||
*/
|
||||
export function injectPromptSymbol(line: string): string | undefined {
|
||||
export function injectPromptSymbol(
|
||||
line: string,
|
||||
symbol = '>',
|
||||
paint?: (s: string) => string,
|
||||
): string | undefined {
|
||||
if (line.length < 4) return undefined;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if (line[i] !== ' ') return undefined;
|
||||
}
|
||||
return ' > ' + line.slice(4);
|
||||
const rendered = paint ? paint(symbol) : symbol;
|
||||
return ' ' + rendered + ' ' + line.slice(4);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -472,21 +668,37 @@ export function injectPromptSymbol(line: string): string | undefined {
|
|||
* inner SGR intact; only column 0 and the last column are overlaid, and
|
||||
* only if they're literal spaces — that protects the cursor-overflow
|
||||
* case where the rightmost column is an SGR-tagged inverse cursor.
|
||||
*
|
||||
* When `options.label` is set, it is overlaid on the left of the top border
|
||||
* (e.g. the `! shell mode` badge), replacing the leading dashes. It is only
|
||||
* applied to a plain dash run, never to a `↑/↓ N more` scroll indicator.
|
||||
*/
|
||||
export function wrapWithSideBorders(
|
||||
lines: string[],
|
||||
paint: (s: string) => string,
|
||||
options: { readonly connectedAbove?: boolean } = {},
|
||||
options: { readonly connectedAbove?: boolean; readonly label?: string } = {},
|
||||
): string[] {
|
||||
let seenTop = false;
|
||||
return lines.map((line) => {
|
||||
const plain = stripSgr(line);
|
||||
if (plain.length > 0 && plain[0] === '─') {
|
||||
const isTop = !seenTop;
|
||||
const leftCorner = seenTop ? '╰' : options.connectedAbove === true ? '├' : '╭';
|
||||
const rightCorner = seenTop ? '╯' : options.connectedAbove === true ? '┤' : '╮';
|
||||
seenTop = true;
|
||||
if (plain.length === 1) return paint(leftCorner);
|
||||
const middle = plain.slice(1, -1);
|
||||
if (isTop && options.label !== undefined && /^─+$/.test(middle)) {
|
||||
const labelWidth = visibleWidth(options.label);
|
||||
if (labelWidth <= middle.length) {
|
||||
return (
|
||||
paint(leftCorner) +
|
||||
options.label +
|
||||
paint('─'.repeat(middle.length - labelWidth)) +
|
||||
paint(rightCorner)
|
||||
);
|
||||
}
|
||||
}
|
||||
return paint(leftCorner + middle + rightCorner);
|
||||
}
|
||||
if (line.length === 0) return line;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { readdirSync, statSync } from 'node:fs';
|
||||
import { basename, join } from 'node:path';
|
||||
import { basename, join, resolve } from 'node:path';
|
||||
|
||||
import {
|
||||
CombinedAutocompleteProvider,
|
||||
|
|
@ -20,6 +20,7 @@ export interface SlashAutocompleteCommand extends SlashCommand {
|
|||
|
||||
interface FsMentionCandidate {
|
||||
readonly path: string;
|
||||
readonly absolutePath: string;
|
||||
readonly isDirectory: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -27,19 +28,24 @@ interface FsMentionCandidate {
|
|||
* Kimi wrapper around pi-tui's combined autocomplete provider.
|
||||
*
|
||||
* File / folder mention behavior uses pi-tui's fd-backed provider when fd is
|
||||
* available. While managed fd is downloading (or when it is unavailable), a
|
||||
* small filesystem fallback keeps basic `@` file and folder completion usable.
|
||||
* Ordinary path completion is still handled by pi-tui's readdir-backed path
|
||||
* completer. This wrapper also keeps Kimi-specific slash-command guards.
|
||||
* available and only the current working directory is involved. While managed fd
|
||||
* is downloading, when it is unavailable, or when the session has additional
|
||||
* roots, a small filesystem fallback keeps `@` file and folder completion usable
|
||||
* across every root. Ordinary path completion is still handled by pi-tui's
|
||||
* readdir-backed path completer. This wrapper also keeps Kimi-specific
|
||||
* slash-command guards.
|
||||
*/
|
||||
export class FileMentionProvider implements AutocompleteProvider {
|
||||
private readonly inner: CombinedAutocompleteProvider;
|
||||
private readonly additionalDirs: readonly string[];
|
||||
|
||||
constructor(
|
||||
private readonly slashCommands: SlashAutocompleteCommand[],
|
||||
private readonly workDir: string,
|
||||
private readonly fdPath: string | null,
|
||||
additionalDirs: readonly string[] = [],
|
||||
) {
|
||||
this.additionalDirs = additionalDirs.map((dir) => normalizePath(resolve(workDir, dir)));
|
||||
// Build an expanded list that includes alias entries so that
|
||||
// inner's argument completion can find commands by alias too.
|
||||
const expanded: SlashAutocompleteCommand[] = [];
|
||||
|
|
@ -77,14 +83,24 @@ export class FileMentionProvider implements AutocompleteProvider {
|
|||
|
||||
const atPrefix = extractAtPrefix(textBeforeCursor);
|
||||
if (atPrefix !== null) {
|
||||
if (this.fdPath === null) {
|
||||
return getFsMentionSuggestions(this.workDir, atPrefix, options.signal);
|
||||
if (this.fdPath === null || this.additionalDirs.length > 0) {
|
||||
return getFsMentionSuggestions(
|
||||
this.workDir,
|
||||
this.additionalDirs,
|
||||
atPrefix,
|
||||
options.signal,
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await this.inner.getSuggestions(lines, cursorLine, cursorCol, options);
|
||||
} catch {
|
||||
// If fd fails to spawn unexpectedly, keep @ completion usable.
|
||||
return getFsMentionSuggestions(this.workDir, atPrefix, options.signal);
|
||||
return getFsMentionSuggestions(
|
||||
this.workDir,
|
||||
this.additionalDirs,
|
||||
atPrefix,
|
||||
options.signal,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -148,6 +164,11 @@ export class FileMentionProvider implements AutocompleteProvider {
|
|||
}
|
||||
}
|
||||
|
||||
const slashArgumentSuggestions = await getSlashArgumentSuggestions(this.slashCommands, textBeforeCursor);
|
||||
if (slashArgumentSuggestions !== null) {
|
||||
return slashArgumentSuggestions;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.inner.getSuggestions(lines, cursorLine, cursorCol, options);
|
||||
} catch {
|
||||
|
|
@ -166,7 +187,7 @@ export class FileMentionProvider implements AutocompleteProvider {
|
|||
}
|
||||
}
|
||||
|
||||
function extractAtPrefix(text: string): string | null {
|
||||
export function extractAtPrefix(text: string): string | null {
|
||||
let tokenStart = 0;
|
||||
for (let i = text.length - 1; i >= 0; i -= 1) {
|
||||
if (PATH_DELIMITERS.has(text[i] ?? '')) {
|
||||
|
|
@ -180,13 +201,14 @@ function extractAtPrefix(text: string): string | null {
|
|||
|
||||
function getFsMentionSuggestions(
|
||||
workDir: string,
|
||||
additionalDirs: readonly string[],
|
||||
atPrefix: string,
|
||||
signal: AbortSignal,
|
||||
): AutocompleteSuggestions | null {
|
||||
if (signal.aborted) return null;
|
||||
|
||||
const query = atPrefix.slice(1);
|
||||
const candidates = collectFsMentionCandidates(workDir, signal);
|
||||
const candidates = collectFsMentionCandidates(workDir, additionalDirs, signal);
|
||||
if (candidates.length === 0 || signal.aborted) return null;
|
||||
|
||||
const ranked = rankFsMentionCandidates(candidates, query).slice(0, MAX_FALLBACK_SUGGESTIONS);
|
||||
|
|
@ -198,44 +220,69 @@ function getFsMentionSuggestions(
|
|||
};
|
||||
}
|
||||
|
||||
function collectFsMentionCandidates(workDir: string, signal: AbortSignal): FsMentionCandidate[] {
|
||||
const result: FsMentionCandidate[] = [];
|
||||
const stack = [''];
|
||||
function collectFsMentionCandidates(
|
||||
workDir: string,
|
||||
additionalDirs: readonly string[],
|
||||
signal: AbortSignal,
|
||||
): FsMentionCandidate[] {
|
||||
const candidatesByAbsolutePath = new Map<string, FsMentionCandidate>();
|
||||
const roots = [
|
||||
{ root: normalizePath(resolve(workDir)), isAdditionalDir: false },
|
||||
...additionalDirs.map((dir) => ({
|
||||
root: normalizePath(resolve(workDir, dir)),
|
||||
isAdditionalDir: true,
|
||||
})),
|
||||
];
|
||||
let scanned = 0;
|
||||
|
||||
while (stack.length > 0 && result.length < MAX_FALLBACK_SCAN) {
|
||||
if (signal.aborted) break;
|
||||
const relativeDir = stack.pop() ?? '';
|
||||
const absoluteDir = relativeDir.length === 0 ? workDir : join(workDir, relativeDir);
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(absoluteDir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const { root, isAdditionalDir } of roots) {
|
||||
const stack = [''];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (signal.aborted || result.length >= MAX_FALLBACK_SCAN) break;
|
||||
if (entry.name === '.git') continue;
|
||||
|
||||
const relativePath = normalizePath(relativeDir.length === 0 ? entry.name : join(relativeDir, entry.name));
|
||||
const isSymlink = entry.isSymbolicLink();
|
||||
let isDirectory = entry.isDirectory();
|
||||
if (!isDirectory && isSymlink) {
|
||||
try {
|
||||
isDirectory = statSync(join(workDir, relativePath)).isDirectory();
|
||||
} catch {
|
||||
// Broken symlink or permission error — keep it as a file candidate.
|
||||
}
|
||||
while (stack.length > 0 && scanned < MAX_FALLBACK_SCAN) {
|
||||
if (signal.aborted) break;
|
||||
const relativeDir = stack.pop() ?? '';
|
||||
const absoluteDir = relativeDir.length === 0 ? root : join(root, relativeDir);
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(absoluteDir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push({ path: relativePath, isDirectory });
|
||||
if (isDirectory && !isSymlink) {
|
||||
stack.push(relativePath);
|
||||
for (const entry of entries) {
|
||||
if (signal.aborted || scanned >= MAX_FALLBACK_SCAN) break;
|
||||
if (entry.name === '.git') continue;
|
||||
|
||||
const relativePath = normalizePath(
|
||||
relativeDir.length === 0 ? entry.name : join(relativeDir, entry.name),
|
||||
);
|
||||
const absolutePath = normalizePath(join(absoluteDir, entry.name));
|
||||
const isSymlink = entry.isSymbolicLink();
|
||||
let isDirectory = entry.isDirectory();
|
||||
if (!isDirectory && isSymlink) {
|
||||
try {
|
||||
isDirectory = statSync(absolutePath).isDirectory();
|
||||
} catch {
|
||||
// Broken symlink or permission error — keep it as a file candidate.
|
||||
}
|
||||
}
|
||||
|
||||
scanned += 1;
|
||||
if (!candidatesByAbsolutePath.has(absolutePath)) {
|
||||
candidatesByAbsolutePath.set(absolutePath, {
|
||||
path: isAdditionalDir ? absolutePath : relativePath,
|
||||
absolutePath,
|
||||
isDirectory,
|
||||
});
|
||||
}
|
||||
if (isDirectory && !isSymlink) {
|
||||
stack.push(relativePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
return [...candidatesByAbsolutePath.values()];
|
||||
}
|
||||
|
||||
function rankFsMentionCandidates(
|
||||
|
|
@ -285,7 +332,7 @@ function toMentionItem(candidate: FsMentionCandidate): AutocompleteItem {
|
|||
return {
|
||||
value,
|
||||
label,
|
||||
description: valuePath,
|
||||
description: candidate.absolutePath,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -293,6 +340,52 @@ function normalizePath(path: string): string {
|
|||
return path.replaceAll('\\', '/');
|
||||
}
|
||||
|
||||
async function getSlashArgumentSuggestions(
|
||||
slashCommands: readonly SlashAutocompleteCommand[],
|
||||
textBeforeCursor: string,
|
||||
): Promise<AutocompleteSuggestions | null> {
|
||||
const parsed = parseSlashArgumentContext(textBeforeCursor, slashCommands);
|
||||
if (parsed === null) return null;
|
||||
|
||||
const items = await parsed.command.getArgumentCompletions?.(parsed.argumentPrefix);
|
||||
if (items === undefined || items === null || items.length === 0) return null;
|
||||
|
||||
return {
|
||||
prefix: parsed.argumentPrefix,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSlashArgumentContext(
|
||||
textBeforeCursor: string,
|
||||
slashCommands: readonly SlashAutocompleteCommand[],
|
||||
): { command: SlashAutocompleteCommand; argumentPrefix: string } | null {
|
||||
const whitespaceMatch = textBeforeCursor.match(/^\/(\S+)\s+(\S*)$/);
|
||||
if (whitespaceMatch !== null) {
|
||||
const [, commandName = '', argumentPrefix = ''] = whitespaceMatch;
|
||||
const command = findSlashCommand(slashCommands, commandName);
|
||||
if (command === undefined) return null;
|
||||
if (!textBeforeCursor.endsWith(' ') && argumentPrefix.length === 0) return null;
|
||||
return { command, argumentPrefix };
|
||||
}
|
||||
|
||||
const pathLikeMatch = textBeforeCursor.match(/^\/([^/\s]+)(\/.*)$/);
|
||||
const commandName = pathLikeMatch?.[1];
|
||||
const argumentPrefix = pathLikeMatch?.[2];
|
||||
if (commandName === undefined || argumentPrefix === undefined) return null;
|
||||
|
||||
const command = findSlashCommand(slashCommands, commandName);
|
||||
if (command === undefined) return null;
|
||||
return { command, argumentPrefix };
|
||||
}
|
||||
|
||||
function findSlashCommand(
|
||||
slashCommands: readonly SlashAutocompleteCommand[],
|
||||
commandName: string,
|
||||
): SlashAutocompleteCommand | undefined {
|
||||
return slashCommands.find((cmd) => cmd.name === commandName || (cmd.aliases ?? []).includes(commandName));
|
||||
}
|
||||
|
||||
function shouldSuppressLeadingWhitespaceSlashPath(
|
||||
textBeforeCursor: string,
|
||||
force: boolean | undefined,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ import type { ToolCallComponent, ToolCallSubagentSnapshot } from './tool-call';
|
|||
|
||||
const THROTTLE_MS = 200;
|
||||
|
||||
const DETACH_HINT_TEXT = 'Press Ctrl+B to run in background';
|
||||
|
||||
interface AgentEntry {
|
||||
readonly toolCallId: string;
|
||||
readonly tc: ToolCallComponent;
|
||||
|
|
@ -131,6 +133,9 @@ export class AgentGroupComponent extends Container {
|
|||
const isLast = idx === snapshots.length - 1;
|
||||
this.appendLines(snap, isLast);
|
||||
});
|
||||
if (this.shouldShowDetachHint(snapshots)) {
|
||||
this.bodyContainer.addChild(new Text(currentTheme.dim(DETACH_HINT_TEXT), 2, 0));
|
||||
}
|
||||
|
||||
this.lastFlushPhases.clear();
|
||||
this.entries.forEach((entry, i) => {
|
||||
|
|
@ -203,6 +208,21 @@ export class AgentGroupComponent extends Container {
|
|||
this.bodyContainer.addChild(new Text(` ${branch2} ${dim(activity)}`, 0, 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the Ctrl+B hint while at least one agent in the group is still
|
||||
* running in the foreground (i.e. can be detached). Hide it once every
|
||||
* agent is done, failed, or already backgrounded.
|
||||
*/
|
||||
private shouldShowDetachHint(snapshots: readonly ToolCallSubagentSnapshot[]): boolean {
|
||||
return snapshots.some(
|
||||
(s) =>
|
||||
s.phase === 'running' ||
|
||||
s.phase === 'queued' ||
|
||||
s.phase === 'spawning' ||
|
||||
s.phase === undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/** Releases throttle timers so destroyed components cannot refresh later. */
|
||||
override invalidate(): void {
|
||||
if (this._invalidating) {
|
||||
|
|
|
|||
|
|
@ -11,40 +11,82 @@ import { MESSAGE_INDENT } from '#/tui/constant/rendering';
|
|||
import { STATUS_BULLET } from '#/tui/constant/symbols';
|
||||
import { currentTheme } from '#/tui/theme';
|
||||
import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme';
|
||||
import { isRenderCacheEnabled } from '#/tui/utils/render-cache';
|
||||
|
||||
type AssistantMarkdownOptions = {
|
||||
transient?: boolean;
|
||||
};
|
||||
|
||||
export class AssistantMessageComponent implements Component {
|
||||
private contentContainer: Container;
|
||||
private markdown: Markdown | undefined;
|
||||
private markdownTransient = false;
|
||||
private lastText = '';
|
||||
private lastTransient = false;
|
||||
private showBullet: boolean;
|
||||
|
||||
private renderCache: { width: number; lines: string[] } | undefined;
|
||||
|
||||
constructor(showBullet: boolean = true) {
|
||||
this.showBullet = showBullet;
|
||||
this.contentContainer = new Container();
|
||||
}
|
||||
|
||||
setShowBullet(show: boolean): void {
|
||||
this.showBullet = show;
|
||||
private markRenderDirty(): void {
|
||||
this.renderCache = undefined;
|
||||
}
|
||||
|
||||
updateContent(text: string): void {
|
||||
const displayText = text;
|
||||
if (displayText === this.lastText) return;
|
||||
setShowBullet(show: boolean): void {
|
||||
if (this.showBullet === show) return;
|
||||
this.showBullet = show;
|
||||
this.markRenderDirty();
|
||||
}
|
||||
|
||||
updateContent(text: string, opts?: AssistantMarkdownOptions): void {
|
||||
const displayText = text.trim();
|
||||
const transient = opts?.transient === true;
|
||||
|
||||
if (displayText === this.lastText && transient === this.lastTransient) return;
|
||||
|
||||
this.lastText = displayText;
|
||||
this.contentContainer.clear();
|
||||
if (displayText.trim().length > 0) {
|
||||
this.contentContainer.addChild(new Markdown(displayText.trim(), 0, 0, createMarkdownTheme()));
|
||||
this.lastTransient = transient;
|
||||
this.markRenderDirty();
|
||||
|
||||
if (displayText.length === 0) {
|
||||
this.contentContainer.clear();
|
||||
this.markdown = undefined;
|
||||
this.markdownTransient = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.markdown === undefined || this.markdownTransient !== transient) {
|
||||
this.contentContainer.clear();
|
||||
this.markdown = new Markdown(displayText, 0, 0, createMarkdownTheme({ transient }));
|
||||
this.markdownTransient = transient;
|
||||
this.contentContainer.addChild(this.markdown);
|
||||
return;
|
||||
}
|
||||
|
||||
this.markdown.setText(displayText);
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
// Markdown caches ANSI colour codes keyed on (text, width). When the
|
||||
// theme changes the cached strings contain stale colours, so we rebuild
|
||||
// the Markdown child with the new theme.
|
||||
// the Markdown child with the new theme while preserving transient mode.
|
||||
this.markRenderDirty();
|
||||
this.contentContainer.clear();
|
||||
this.markdown = undefined;
|
||||
|
||||
if (this.lastText.trim().length > 0) {
|
||||
this.contentContainer.addChild(
|
||||
new Markdown(this.lastText.trim(), 0, 0, createMarkdownTheme()),
|
||||
this.markdown = new Markdown(
|
||||
this.lastText.trim(),
|
||||
0,
|
||||
0,
|
||||
createMarkdownTheme({ transient: this.lastTransient }),
|
||||
);
|
||||
this.markdownTransient = this.lastTransient;
|
||||
this.contentContainer.addChild(this.markdown);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -54,6 +96,14 @@ export class AssistantMessageComponent implements Component {
|
|||
const safeWidth = Math.max(0, width);
|
||||
if (safeWidth <= 0) return [''];
|
||||
|
||||
if (
|
||||
isRenderCacheEnabled() &&
|
||||
this.renderCache !== undefined &&
|
||||
this.renderCache.width === safeWidth
|
||||
) {
|
||||
return this.renderCache.lines;
|
||||
}
|
||||
|
||||
const prefix = this.showBullet ? STATUS_BULLET : MESSAGE_INDENT;
|
||||
const contentWidth = Math.max(1, safeWidth - visibleWidth(prefix));
|
||||
const contentLines = this.contentContainer.render(contentWidth);
|
||||
|
|
@ -64,6 +114,10 @@ export class AssistantMessageComponent implements Component {
|
|||
i === 0 && this.showBullet ? currentTheme.fg('text', STATUS_BULLET) : MESSAGE_INDENT;
|
||||
lines.push(p + contentLines[i]);
|
||||
}
|
||||
return lines.map((line) => truncateToWidth(line, safeWidth, '…'));
|
||||
const rendered = lines.map((line) => truncateToWidth(line, safeWidth, '…'));
|
||||
if (isRenderCacheEnabled()) {
|
||||
this.renderCache = { width: safeWidth, lines: rendered };
|
||||
}
|
||||
return rendered;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
134
apps/kimi-code/src/tui/components/messages/shell-run.ts
Normal file
134
apps/kimi-code/src/tui/components/messages/shell-run.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { Container, Text } from '@earendil-works/pi-tui';
|
||||
|
||||
import { currentTheme } from '#/tui/theme';
|
||||
|
||||
import { formatBashOutputForDisplay, sanitizeShellOutput } from '#/tui/utils/shell-output';
|
||||
|
||||
const RUNNING_TAIL_LINES = 5;
|
||||
const TIMER_INTERVAL_MS = 1000;
|
||||
// Cap the live running buffer so a command that spews output for minutes can't
|
||||
// grow memory without bound or make every render re-strip a multi-MB string.
|
||||
// Only affects the transient running tail; the final view uses the full
|
||||
// captured stdout/stderr passed to finish().
|
||||
const MAX_COMBINED_CHARS = 256 * 1024;
|
||||
const KEEP_COMBINED_CHARS = 64 * 1024;
|
||||
|
||||
/**
|
||||
* Live view for a user-initiated `!` shell command. Two phases:
|
||||
*
|
||||
* - running: dim, ANSI-stripped tail of the combined output, a `+N lines`
|
||||
* overflow marker, an elapsed `(Xs)` timer that ticks every second, and a
|
||||
* `(ctrl+b to run in background)` hint — matching claude-code's running card
|
||||
* so warnings are grey rather than red while the command works.
|
||||
* - finished: the standard `formatBashOutputForDisplay` view (stderr red only
|
||||
* on failure), the timer stopped and the running chrome removed.
|
||||
*
|
||||
* Hardened so a misbehaving command can never crash the TUI: the running
|
||||
* buffer is capped, and every render/render-request path swallows errors.
|
||||
*/
|
||||
export class ShellRunComponent extends Container {
|
||||
private readonly textComponent: Text;
|
||||
private combined = '';
|
||||
private running = true;
|
||||
private backgrounded = false;
|
||||
private disposed = false;
|
||||
private finalStdout = '';
|
||||
private finalStderr = '';
|
||||
private finalIsError?: boolean;
|
||||
private readonly startedAt = Date.now();
|
||||
private timer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
constructor(private readonly requestRender: () => void) {
|
||||
super();
|
||||
this.textComponent = new Text(this.renderText(), 0, 0);
|
||||
this.addChild(this.textComponent);
|
||||
this.timer = setInterval(() => this.tick(), TIMER_INTERVAL_MS);
|
||||
}
|
||||
|
||||
append(text: string): void {
|
||||
if (this.disposed || !this.running || text.length === 0) return;
|
||||
this.combined += text;
|
||||
if (this.combined.length > MAX_COMBINED_CHARS) {
|
||||
this.combined = this.combined.slice(-KEEP_COMBINED_CHARS);
|
||||
}
|
||||
this.flush();
|
||||
}
|
||||
|
||||
finish(stdout: string, stderr: string, isError?: boolean): void {
|
||||
if (this.disposed || !this.running) return;
|
||||
this.running = false;
|
||||
this.finalStdout = stdout;
|
||||
this.finalStderr = stderr;
|
||||
this.finalIsError = isError;
|
||||
this.clearTimer();
|
||||
this.flush();
|
||||
}
|
||||
|
||||
finishBackgrounded(): void {
|
||||
if (this.disposed || !this.running) return;
|
||||
this.running = false;
|
||||
this.backgrounded = true;
|
||||
this.clearTimer();
|
||||
this.flush();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
this.clearTimer();
|
||||
}
|
||||
|
||||
private tick(): void {
|
||||
if (!this.running) return;
|
||||
this.flush();
|
||||
}
|
||||
|
||||
private flush(): void {
|
||||
if (this.disposed) return;
|
||||
try {
|
||||
this.textComponent.setText(this.renderText());
|
||||
this.requestRender();
|
||||
} catch {
|
||||
// Never let a render/render-request error escape into a timer or event
|
||||
// handler — an uncaught exception there can take down the whole TUI.
|
||||
}
|
||||
}
|
||||
|
||||
private clearTimer(): void {
|
||||
if (this.timer !== undefined) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private renderText(): string {
|
||||
try {
|
||||
if (this.backgrounded) {
|
||||
return ` ${currentTheme.fg('textDim', 'Moved to background.')}`;
|
||||
}
|
||||
if (!this.running) {
|
||||
return formatBashOutputForDisplay(this.finalStdout, this.finalStderr, this.finalIsError)
|
||||
.split('\n')
|
||||
.map((line) => ` ${line}`)
|
||||
.join('\n');
|
||||
}
|
||||
const elapsed = Math.floor((Date.now() - this.startedAt) / 1000);
|
||||
const dim = (s: string): string => currentTheme.fg('textDim', s);
|
||||
const trimmed = sanitizeShellOutput(this.combined).trimEnd();
|
||||
let body: string;
|
||||
let extra = 0;
|
||||
if (trimmed.length === 0) {
|
||||
body = ` ${dim('Running…')}`;
|
||||
} else {
|
||||
const lines = trimmed.split('\n');
|
||||
const tail = lines.slice(-RUNNING_TAIL_LINES);
|
||||
extra = Math.max(0, lines.length - RUNNING_TAIL_LINES);
|
||||
body = tail.map((line) => ` ${dim(line)}`).join('\n');
|
||||
}
|
||||
const timing = ` ${dim(`${extra > 0 ? `+${extra} lines ` : ''}(${elapsed}s)`)}`;
|
||||
const hint = ` ${dim('(ctrl+b to run in background)')}`;
|
||||
return `${body}\n${timing}\n${hint}`;
|
||||
} catch {
|
||||
return ' (output unavailable)';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,20 +12,35 @@ export class StatusMessageComponent extends Container {
|
|||
super();
|
||||
this.content = content;
|
||||
this.color = color;
|
||||
const text = color === undefined
|
||||
? currentTheme.fg('textDim', content)
|
||||
: currentTheme.fg(color, content);
|
||||
this.textComponent = new Text(` ${text}`, 0, 0);
|
||||
this.textComponent = new Text(this.renderText(), 0, 0);
|
||||
this.addChild(this.textComponent);
|
||||
}
|
||||
|
||||
// Update the body in place (used for live-streamed `!` shell output) without
|
||||
// remounting the component.
|
||||
updateContent(content: string): void {
|
||||
this.content = content;
|
||||
this.textComponent.setText(this.renderText());
|
||||
}
|
||||
|
||||
override invalidate(): void {
|
||||
const text = this.color === undefined
|
||||
? currentTheme.fg('textDim', this.content)
|
||||
: currentTheme.fg(this.color, this.content);
|
||||
this.textComponent.setText(` ${text}`);
|
||||
this.textComponent.setText(this.renderText());
|
||||
super.invalidate();
|
||||
}
|
||||
|
||||
// Indent every line, not just the first. The `content` may be multi-line
|
||||
// (e.g. `!` shell output); prefixing the whole string once would only indent
|
||||
// the first line and leave the rest at column 0. Strip carriage returns
|
||||
// first: a trailing `\r` (e.g. from CRLF server error pages) is zero-width
|
||||
// for the line wrapper, so the padding spaces appended after it overwrite
|
||||
// the visible content and the line renders blank.
|
||||
private renderText(): string {
|
||||
const colored =
|
||||
this.color === undefined
|
||||
? currentTheme.fg('textDim', this.content)
|
||||
: currentTheme.fg(this.color, this.content);
|
||||
return colored.replaceAll('\r', '').split('\n').map((line) => ` ${line}`).join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
export class NoticeMessageComponent extends Container {
|
||||
|
|
|
|||
32
apps/kimi-code/src/tui/components/messages/step-summary.ts
Normal file
32
apps/kimi-code/src/tui/components/messages/step-summary.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { Component } from '@earendil-works/pi-tui';
|
||||
|
||||
import { currentTheme } from '#/tui/theme';
|
||||
|
||||
/**
|
||||
* A collapsed summary of older steps within a turn. Accumulates counts of
|
||||
* merged steps (thinking blocks and tool calls) and renders them as a single
|
||||
* muted line, e.g. `… thinking 5 times, call 50 tools`.
|
||||
*/
|
||||
export class StepSummaryComponent implements Component {
|
||||
private thinking = 0;
|
||||
private tool = 0;
|
||||
|
||||
get isEmpty(): boolean {
|
||||
return this.thinking === 0 && this.tool === 0;
|
||||
}
|
||||
|
||||
addCounts(thinking: number, tool: number): void {
|
||||
this.thinking += thinking;
|
||||
this.tool += tool;
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
render(_width: number): string[] {
|
||||
const parts: string[] = [];
|
||||
if (this.thinking > 0) parts.push(`thinking ${this.thinking} times`);
|
||||
if (this.tool > 0) parts.push(`call ${this.tool} tools`);
|
||||
if (parts.length === 0) return [];
|
||||
return [currentTheme.dim(`\u2026 ${parts.join(', ')}`)];
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import {
|
|||
} from '#/tui/constant/rendering';
|
||||
import { STATUS_BULLET } from '#/tui/constant/symbols';
|
||||
import { currentTheme } from '#/tui/theme';
|
||||
import { isRenderCacheEnabled } from '#/tui/utils/render-cache';
|
||||
|
||||
export type ThinkingRenderMode = 'live' | 'finalized';
|
||||
|
||||
|
|
@ -32,6 +33,8 @@ export class ThinkingComponent implements Component {
|
|||
// once the transcript accumulates many finalized thinking blocks.
|
||||
private readonly textComponent: Text;
|
||||
|
||||
private renderCache: { width: number; lines: string[] } | undefined;
|
||||
|
||||
constructor(
|
||||
text: string,
|
||||
showMarker: boolean = true,
|
||||
|
|
@ -48,13 +51,19 @@ export class ThinkingComponent implements Component {
|
|||
}
|
||||
}
|
||||
|
||||
private markRenderDirty(): void {
|
||||
this.renderCache = undefined;
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.markRenderDirty();
|
||||
this.textComponent.setText(this.styled(this.text));
|
||||
}
|
||||
|
||||
setText(text: string): void {
|
||||
if (this.text === text) return;
|
||||
this.text = text;
|
||||
this.markRenderDirty();
|
||||
this.textComponent.setText(this.styled(text));
|
||||
}
|
||||
|
||||
|
|
@ -64,6 +73,7 @@ export class ThinkingComponent implements Component {
|
|||
|
||||
finalize(): void {
|
||||
this.mode = 'finalized';
|
||||
this.markRenderDirty();
|
||||
this.stopSpinner();
|
||||
}
|
||||
|
||||
|
|
@ -74,12 +84,22 @@ export class ThinkingComponent implements Component {
|
|||
setExpanded(expanded: boolean): void {
|
||||
if (this.expanded === expanded) return;
|
||||
this.expanded = expanded;
|
||||
this.markRenderDirty();
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
if (
|
||||
isRenderCacheEnabled() &&
|
||||
this.renderCache !== undefined &&
|
||||
this.renderCache.width === width
|
||||
) {
|
||||
return this.renderCache.lines;
|
||||
}
|
||||
|
||||
const contentWidth = Math.max(1, width - MESSAGE_INDENT.length);
|
||||
const contentLines = this.text.length > 0 ? this.textComponent.render(contentWidth) : [''];
|
||||
|
||||
let rendered: string[];
|
||||
if (this.mode === 'live') {
|
||||
const visibleLines =
|
||||
contentLines.length > THINKING_PREVIEW_LINES
|
||||
|
|
@ -89,39 +109,45 @@ export class ThinkingComponent implements Component {
|
|||
'textDim',
|
||||
`${BRAILLE_SPINNER_FRAMES[this.spinnerFrame] ?? BRAILLE_SPINNER_FRAMES[0]} `,
|
||||
);
|
||||
return [
|
||||
rendered = [
|
||||
'',
|
||||
spinner + currentTheme.fg('textDim', 'thinking...'),
|
||||
...visibleLines.map((line) => MESSAGE_INDENT + line),
|
||||
];
|
||||
} else {
|
||||
const lines: string[] = [''];
|
||||
for (let i = 0; i < contentLines.length; i++) {
|
||||
const p = i === 0 && this.showMarker ? currentTheme.fg('textDim', STATUS_BULLET) : MESSAGE_INDENT;
|
||||
lines.push(p + contentLines[i]);
|
||||
}
|
||||
|
||||
if (this.expanded || contentLines.length <= THINKING_PREVIEW_LINES) {
|
||||
rendered = lines;
|
||||
} else {
|
||||
// Leading blank + first PREVIEW_LINES content lines + hint line.
|
||||
const truncated = lines.slice(0, 1 + THINKING_PREVIEW_LINES);
|
||||
const remaining = contentLines.length - THINKING_PREVIEW_LINES;
|
||||
const hint = `... (${String(remaining)} more lines, ctrl+o to expand)`;
|
||||
const indentWidth = Math.min(MESSAGE_INDENT.length, Math.max(0, width));
|
||||
const hintWidth = Math.max(0, width - indentWidth);
|
||||
truncated.push(
|
||||
' '.repeat(indentWidth) + currentTheme.dim(truncateToWidth(hint, hintWidth, '…')),
|
||||
);
|
||||
rendered = truncated;
|
||||
}
|
||||
}
|
||||
|
||||
const rendered: string[] = [''];
|
||||
for (let i = 0; i < contentLines.length; i++) {
|
||||
const p = i === 0 && this.showMarker ? currentTheme.fg('textDim', STATUS_BULLET) : MESSAGE_INDENT;
|
||||
rendered.push(p + contentLines[i]);
|
||||
if (isRenderCacheEnabled()) {
|
||||
this.renderCache = { width, lines: rendered };
|
||||
}
|
||||
|
||||
if (this.expanded || contentLines.length <= THINKING_PREVIEW_LINES) {
|
||||
return rendered;
|
||||
}
|
||||
|
||||
// Leading blank + first PREVIEW_LINES content lines + hint line.
|
||||
const truncated = rendered.slice(0, 1 + THINKING_PREVIEW_LINES);
|
||||
const remaining = contentLines.length - THINKING_PREVIEW_LINES;
|
||||
const hint = `... (${String(remaining)} more lines, ctrl+o to expand)`;
|
||||
const indentWidth = Math.min(MESSAGE_INDENT.length, Math.max(0, width));
|
||||
const hintWidth = Math.max(0, width - indentWidth);
|
||||
truncated.push(
|
||||
' '.repeat(indentWidth) + currentTheme.dim(truncateToWidth(hint, hintWidth, '…')),
|
||||
);
|
||||
return truncated;
|
||||
return rendered;
|
||||
}
|
||||
|
||||
private startSpinner(): void {
|
||||
if (this.ui === undefined || this.spinnerInterval !== undefined) return;
|
||||
this.spinnerInterval = setInterval(() => {
|
||||
this.spinnerFrame = (this.spinnerFrame + 1) % BRAILLE_SPINNER_FRAMES.length;
|
||||
this.markRenderDirty();
|
||||
this.ui?.requestRender();
|
||||
}, BRAILLE_SPINNER_INTERVAL_MS);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types';
|
|||
import type { TokenUsage } from '@moonshot-ai/kimi-code-sdk';
|
||||
import { appendStreamingArgsPreview } from '#/tui/utils/event-payload';
|
||||
import { decodeMcpToolName } from '#/tui/utils/mcp-tool-name';
|
||||
import { isRenderCacheEnabled } from '#/tui/utils/render-cache';
|
||||
|
||||
import { agentSwarmResultSummaryFromOutput } from './agent-swarm-progress';
|
||||
import { PlanBoxComponent } from './plan-box';
|
||||
|
|
@ -46,6 +47,10 @@ const PROGRESS_URL_RE = /https?:\/\/\S+/g;
|
|||
const ABORTED_MARK = '⊘';
|
||||
const MAX_LIVE_OUTPUT_CHARS = 50_000;
|
||||
|
||||
/** Delay before a long-running foreground Bash/Agent card advertises Ctrl+B. */
|
||||
const DETACH_HINT_DELAY_MS = 10_000;
|
||||
const DETACH_HINT_TEXT = 'Press Ctrl+B to run in background';
|
||||
|
||||
type SubagentTextKind = 'thinking' | 'text';
|
||||
type SubagentPhase = 'queued' | 'spawning' | 'running' | 'done' | 'failed' | 'backgrounded';
|
||||
|
||||
|
|
@ -409,7 +414,7 @@ function extractKeyArgument(
|
|||
};
|
||||
|
||||
// Glob: concatenate multiple args into a single summary so the header
|
||||
// shows pattern, optional explicit path, and include_dirs override.
|
||||
// shows pattern, optional explicit path, and ignored-file inclusion.
|
||||
if (toolName === 'Glob') {
|
||||
const pattern = args['pattern'];
|
||||
if (typeof pattern !== 'string' || pattern.length === 0) return null;
|
||||
|
|
@ -418,8 +423,8 @@ function extractKeyArgument(
|
|||
if (typeof path === 'string' && path.length > 0) {
|
||||
summary += ` · ${makeWorkspaceRelativePath(path, workspaceDir)}`;
|
||||
}
|
||||
if (args['include_dirs'] === false) {
|
||||
summary += ' · no dirs';
|
||||
if (args['include_ignored'] === true) {
|
||||
summary += ' · include ignored';
|
||||
}
|
||||
return truncateArgValue('pattern', summary);
|
||||
}
|
||||
|
|
@ -459,6 +464,8 @@ function tailNonEmptyLines(text: string, maxLines: number): string[] {
|
|||
}
|
||||
|
||||
class PrefixedWrappedLine implements Component {
|
||||
private renderCache: { width: number; lines: string[] } | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly firstPrefix: string,
|
||||
private readonly continuationPrefix: string,
|
||||
|
|
@ -469,12 +476,18 @@ class PrefixedWrappedLine implements Component {
|
|||
private readonly tailLines?: number,
|
||||
) { }
|
||||
|
||||
invalidate(): void { }
|
||||
invalidate(): void {
|
||||
this.renderCache = undefined;
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const safeWidth = Math.max(0, width);
|
||||
if (safeWidth <= 0) return [''];
|
||||
|
||||
if (isRenderCacheEnabled() && this.renderCache?.width === safeWidth) {
|
||||
return this.renderCache.lines;
|
||||
}
|
||||
|
||||
const prefixWidth = Math.max(
|
||||
visibleWidth(this.firstPrefix),
|
||||
visibleWidth(this.continuationPrefix),
|
||||
|
|
@ -485,11 +498,15 @@ class PrefixedWrappedLine implements Component {
|
|||
this.tailLines !== undefined && wrapped.length > this.tailLines
|
||||
? wrapped.slice(wrapped.length - this.tailLines)
|
||||
: wrapped;
|
||||
return lines
|
||||
const rendered = lines
|
||||
.map((line, index) =>
|
||||
index === 0 ? `${this.firstPrefix}${line}` : `${this.continuationPrefix}${line}`,
|
||||
)
|
||||
.map((line) => truncateToWidth(line, safeWidth, '…'));
|
||||
if (isRenderCacheEnabled()) {
|
||||
this.renderCache = { width: safeWidth, lines: rendered };
|
||||
}
|
||||
return rendered;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -533,6 +550,14 @@ export class ToolCallComponent extends Container {
|
|||
private subagentThinkingText = '';
|
||||
// ── Subagent lifecycle state from subagent.spawned/started/completed/failed ──
|
||||
private subagentPhase: SubagentPhase | undefined;
|
||||
/**
|
||||
* Distinguishes a foreground subagent that the user detached via Ctrl+B from
|
||||
* one that started in the background. Both set `subagentPhase = 'backgrounded'`,
|
||||
* but only the detached one should keep showing `◐ backgrounded` after its
|
||||
* spawn-success ToolResult lands — a started-in-background agent reads as
|
||||
* `done` once its result arrives.
|
||||
*/
|
||||
private detachedFromForeground = false;
|
||||
/**
|
||||
* Authoritative terminal phase for a backgrounded subagent. Set from
|
||||
* `BackgroundTaskInfo.status` via `setBackgroundTaskTerminalStatus` once
|
||||
|
|
@ -565,6 +590,13 @@ export class ToolCallComponent extends Container {
|
|||
private static readonly MAX_PROGRESS_LINES = 24;
|
||||
private liveOutput = '';
|
||||
|
||||
/**
|
||||
* Advertises `Ctrl+B` on a foreground Bash/Agent card that has been running
|
||||
* for {@link DETACH_HINT_DELAY_MS}. Cleared when the result lands.
|
||||
*/
|
||||
private detachHintTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private detachHintVisible = false;
|
||||
|
||||
/**
|
||||
* Registered by a group container (`AgentGroupComponent` or
|
||||
* `ReadGroupComponent`) when this component is borrowed as a hidden state
|
||||
|
|
@ -598,9 +630,52 @@ export class ToolCallComponent extends Container {
|
|||
this.buildSubagentBlock();
|
||||
this.syncStreamingProgressTimer();
|
||||
this.syncSubagentElapsedTimer();
|
||||
this.startDetachHintTimer();
|
||||
}
|
||||
|
||||
private renderCache:
|
||||
| { width: number; lines: string[]; childRefs: Component[]; childLines: string[][] }
|
||||
| undefined;
|
||||
|
||||
override render(width: number): string[] {
|
||||
const cache = this.renderCache;
|
||||
const cacheValid =
|
||||
isRenderCacheEnabled() &&
|
||||
cache !== undefined &&
|
||||
cache.width === width &&
|
||||
cache.childRefs.length === this.children.length;
|
||||
|
||||
const childRefs: Component[] = [];
|
||||
const childLines: string[][] = [];
|
||||
let allReused = cacheValid;
|
||||
|
||||
let i = 0;
|
||||
for (const child of this.children) {
|
||||
const lines = child.render(width);
|
||||
childRefs.push(child);
|
||||
childLines.push(lines);
|
||||
if (cacheValid && (cache.childRefs[i] !== child || cache.childLines[i] !== lines)) {
|
||||
allReused = false;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
if (allReused) {
|
||||
return cache!.lines;
|
||||
}
|
||||
|
||||
const out: string[] = [];
|
||||
for (const lines of childLines) {
|
||||
for (const line of lines) out.push(line);
|
||||
}
|
||||
if (isRenderCacheEnabled()) {
|
||||
this.renderCache = { width, lines: out, childRefs, childLines };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
override invalidate(): void {
|
||||
this.renderCache = undefined;
|
||||
this.headerText.setText(this.buildHeader());
|
||||
this.rebuildBody();
|
||||
super.invalidate();
|
||||
|
|
@ -624,6 +699,8 @@ export class ToolCallComponent extends Container {
|
|||
// show both the streamed status lines and the final output stacked.
|
||||
this.progressLines = [];
|
||||
this.liveOutput = '';
|
||||
this.detachHintVisible = false;
|
||||
this.stopDetachHintTimer();
|
||||
this.finalizeSubagentElapsedIfNeeded();
|
||||
this.syncStreamingProgressTimer();
|
||||
this.syncSubagentElapsedTimer();
|
||||
|
|
@ -682,6 +759,7 @@ export class ToolCallComponent extends Container {
|
|||
dispose(): void {
|
||||
this.stopStreamingProgressTimer();
|
||||
this.stopSubagentElapsedTimer();
|
||||
this.stopDetachHintTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -783,14 +861,11 @@ export class ToolCallComponent extends Container {
|
|||
// 'spawning' and keep showing `Initializing...`.
|
||||
// Intermediate states without a result still use `subagentPhase`.
|
||||
// `backgrounded` has no result because background agents do not enter the
|
||||
// transcript.
|
||||
const derivedPhase: ToolCallSubagentSnapshot['phase'] =
|
||||
this.backgroundTaskTerminalPhase ??
|
||||
(this.result !== undefined
|
||||
? this.result.is_error
|
||||
? 'failed'
|
||||
: 'done'
|
||||
: this.subagentPhase);
|
||||
// transcript — but a foreground subagent detached via Ctrl+B keeps
|
||||
// `subagentPhase === 'backgrounded'` even after its ToolResult lands, so
|
||||
// the group card shows `◐ backgrounded` rather than `✓ Completed`. Reuse
|
||||
// the standalone derivation so both paths agree.
|
||||
const derivedPhase = this.getDerivedSubagentPhase();
|
||||
const errorText =
|
||||
this.subagentError ?? (derivedPhase === 'failed' ? this.result?.output : undefined);
|
||||
return {
|
||||
|
|
@ -904,6 +979,46 @@ export class ToolCallComponent extends Container {
|
|||
this.streamingProgressTimer = undefined;
|
||||
}
|
||||
|
||||
/** Only foreground Bash/Agent calls can be detached via Ctrl+B. */
|
||||
private isDetachHintEligible(): boolean {
|
||||
return this.toolCall.name === 'Bash' || this.toolCall.name === 'Agent';
|
||||
}
|
||||
|
||||
private startDetachHintTimer(): void {
|
||||
if (!this.isDetachHintEligible()) return;
|
||||
if (this.result !== undefined) return;
|
||||
if (this.ui === undefined) return;
|
||||
if (this.toolCall.name === 'Agent') {
|
||||
// Subagents are long-running by nature; advertise Ctrl+B immediately
|
||||
// instead of waiting out the delay used for short Bash commands.
|
||||
if (this.detachHintVisible) return;
|
||||
this.detachHintVisible = true;
|
||||
this.rebuildBody();
|
||||
this.ui?.requestRender();
|
||||
return;
|
||||
}
|
||||
if (this.detachHintTimer !== undefined) return;
|
||||
this.detachHintTimer = setTimeout(() => {
|
||||
this.detachHintTimer = undefined;
|
||||
if (this.result !== undefined) return;
|
||||
this.detachHintVisible = true;
|
||||
this.rebuildBody();
|
||||
this.ui?.requestRender();
|
||||
}, DETACH_HINT_DELAY_MS);
|
||||
}
|
||||
|
||||
private stopDetachHintTimer(): void {
|
||||
if (this.detachHintTimer === undefined) return;
|
||||
clearTimeout(this.detachHintTimer);
|
||||
this.detachHintTimer = undefined;
|
||||
}
|
||||
|
||||
private buildDetachHintBlock(): void {
|
||||
if (!this.detachHintVisible) return;
|
||||
if (this.result !== undefined) return;
|
||||
this.addChild(new Text(currentTheme.dim(DETACH_HINT_TEXT), 2, 0));
|
||||
}
|
||||
|
||||
private syncSubagentElapsedTimer(): void {
|
||||
const phase = this.getDerivedSubagentPhase();
|
||||
const shouldTick =
|
||||
|
|
@ -1091,6 +1206,22 @@ export class ToolCallComponent extends Container {
|
|||
this.notifySnapshotChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a foreground subagent as detached-to-background. Called when a
|
||||
* `background.task.started` event arrives for this agent (i.e. the user
|
||||
* pressed Ctrl+B). Keeps the card showing `◐ backgrounded` instead of
|
||||
* flipping to `✓ Completed` when the spawn-success ToolResult lands.
|
||||
*/
|
||||
markBackgrounded(): void {
|
||||
if (this.detachedFromForeground) return;
|
||||
this.detachedFromForeground = true;
|
||||
this.subagentPhase = 'backgrounded';
|
||||
this.headerText.setText(this.buildHeader());
|
||||
this.rebuildContent();
|
||||
this.notifySnapshotChange();
|
||||
this.ui?.requestRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Subagent id for the backing AgentTool call, used by routing to find a
|
||||
* tool call's backing subagent when reconciling background task lifecycle
|
||||
|
|
@ -1340,6 +1471,7 @@ export class ToolCallComponent extends Container {
|
|||
this.children.pop();
|
||||
}
|
||||
this.buildProgressBlock();
|
||||
this.buildDetachHintBlock();
|
||||
this.buildLiveOutputBlock();
|
||||
this.buildContent();
|
||||
this.buildSubagentBlock();
|
||||
|
|
@ -1352,6 +1484,7 @@ export class ToolCallComponent extends Container {
|
|||
this.buildCallPreview();
|
||||
this.callPreviewEndIndex = this.children.length;
|
||||
this.buildProgressBlock();
|
||||
this.buildDetachHintBlock();
|
||||
this.buildLiveOutputBlock();
|
||||
this.buildContent();
|
||||
this.buildSubagentBlock();
|
||||
|
|
@ -1550,6 +1683,14 @@ export class ToolCallComponent extends Container {
|
|||
if (this.backgroundTaskTerminalPhase !== undefined) {
|
||||
return this.backgroundTaskTerminalPhase;
|
||||
}
|
||||
// A foreground subagent detached via Ctrl+B keeps showing `backgrounded`
|
||||
// even after its spawn-success ToolResult lands, so the card doesn't flip
|
||||
// to `✓ Completed` and look like the work actually finished. Agents that
|
||||
// started in the background (`detachedFromForeground === false`) read as
|
||||
// `done` once their result lands.
|
||||
if (this.detachedFromForeground && this.subagentPhase === 'backgrounded') {
|
||||
return 'backgrounded';
|
||||
}
|
||||
if (this.result !== undefined) return this.result.is_error ? 'failed' : 'done';
|
||||
return this.subagentPhase;
|
||||
}
|
||||
|
|
@ -1766,6 +1907,20 @@ export class ToolCallComponent extends Container {
|
|||
for (const line of lines) {
|
||||
this.addChild(new Text(line, 2, 0));
|
||||
}
|
||||
} else if (name === 'Bash' && this.result === undefined) {
|
||||
// While a long-running Bash call is in-flight (args finalized, no result
|
||||
// yet), surface its command in the body so the user can see what is
|
||||
// running and expand it with ctrl+o. Once the result lands, buildContent's
|
||||
// shellExecutionResultRenderer takes over command rendering.
|
||||
const command = str(this.toolCall.args['command']);
|
||||
if (command.length === 0) return;
|
||||
this.addChild(
|
||||
new ShellExecutionComponent({
|
||||
command,
|
||||
showCommand: true,
|
||||
commandPreviewLines: this.expanded ? undefined : COMMAND_PREVIEW_LINES,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,19 +8,29 @@ import { ImageThumbnail } from '#/tui/components/media/image-thumbnail';
|
|||
import { USER_MESSAGE_BULLET } from '#/tui/constant/symbols';
|
||||
import { currentTheme } from '#/tui/theme';
|
||||
import type { ImageAttachment } from '#/tui/utils/image-attachment-store';
|
||||
import { isRenderCacheEnabled } from '#/tui/utils/render-cache';
|
||||
|
||||
export class UserMessageComponent implements Component {
|
||||
private text: string;
|
||||
private readonly bullet?: string;
|
||||
private spacerComponent: Spacer;
|
||||
private imageThumbnails: ImageThumbnail[];
|
||||
|
||||
constructor(text: string, images?: ImageAttachment[]) {
|
||||
private renderCache: { width: number; lines: string[] } | undefined;
|
||||
|
||||
constructor(text: string, images?: ImageAttachment[], bullet?: string) {
|
||||
this.text = text;
|
||||
this.bullet = bullet;
|
||||
this.spacerComponent = new Spacer(1);
|
||||
this.imageThumbnails = images?.map((img) => new ImageThumbnail(img)) ?? [];
|
||||
}
|
||||
|
||||
private markRenderDirty(): void {
|
||||
this.renderCache = undefined;
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.markRenderDirty();
|
||||
for (const img of this.imageThumbnails) {
|
||||
img.invalidate?.();
|
||||
}
|
||||
|
|
@ -30,7 +40,16 @@ export class UserMessageComponent implements Component {
|
|||
const safeWidth = Math.max(0, width);
|
||||
if (safeWidth <= 0) return [''];
|
||||
|
||||
const bullet = currentTheme.boldFg('roleUser', USER_MESSAGE_BULLET);
|
||||
if (
|
||||
isRenderCacheEnabled() &&
|
||||
this.renderCache !== undefined &&
|
||||
this.renderCache.width === safeWidth
|
||||
) {
|
||||
return this.renderCache.lines;
|
||||
}
|
||||
|
||||
const marker = this.bullet ?? USER_MESSAGE_BULLET;
|
||||
const bullet = marker.length > 0 ? currentTheme.boldFg('roleUser', marker) : '';
|
||||
const bulletWidth = visibleWidth(bullet);
|
||||
const contentWidth = Math.max(1, safeWidth - bulletWidth);
|
||||
|
||||
|
|
@ -41,7 +60,8 @@ export class UserMessageComponent implements Component {
|
|||
lines.push(line);
|
||||
}
|
||||
|
||||
// Text — re-dye on every render so theme switches are reflected
|
||||
// Text is re-dyed from the current theme; invalidate() (theme change) clears
|
||||
// the render cache so the new colours are picked up on the next render.
|
||||
const coloredText = currentTheme.boldFg('roleUser', this.text);
|
||||
const textLines = new Text(coloredText, 0, 0).render(contentWidth);
|
||||
for (let i = 0; i < textLines.length; i++) {
|
||||
|
|
@ -57,6 +77,22 @@ export class UserMessageComponent implements Component {
|
|||
}
|
||||
}
|
||||
|
||||
return lines.map((line) => truncateToWidth(line, safeWidth, '…'));
|
||||
const rendered = lines.map((line) => {
|
||||
// Inline image sequences (Kitty / iTerm2) carry their own placement
|
||||
// information and have zero visible width, but pi-tui's truncateToWidth
|
||||
// treats the embedded base64 payload as visible text and would chop the
|
||||
// escape sequence in half, leaving garbage like "0m...". Skip truncation
|
||||
// for those lines; the image itself already respects maxWidthCells.
|
||||
if (isImageLine(line)) return line;
|
||||
return truncateToWidth(line, safeWidth, '…');
|
||||
});
|
||||
if (isRenderCacheEnabled()) {
|
||||
this.renderCache = { width: safeWidth, lines: rendered };
|
||||
}
|
||||
return rendered;
|
||||
}
|
||||
}
|
||||
|
||||
function isImageLine(line: string): boolean {
|
||||
return line.includes('\u001B_G') || line.includes('\u001B]1337;File=');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,38 @@
|
|||
import { Container, Spacer } from '@earendil-works/pi-tui';
|
||||
|
||||
import type { MoonLoader } from '../chrome/moon-loader';
|
||||
import type { MoonLoader } from '#/tui/components/chrome/moon-loader';
|
||||
|
||||
export type ActivityPaneMode = 'hidden' | 'waiting' | 'thinking' | 'composing' | 'tool';
|
||||
|
||||
export interface ActivityPaneOptions {
|
||||
readonly mode: ActivityPaneMode;
|
||||
readonly spinner?: MoonLoader;
|
||||
readonly tip?: string;
|
||||
}
|
||||
|
||||
export class ActivityPaneComponent extends Container {
|
||||
private spinnerRef?: MoonLoader;
|
||||
|
||||
constructor(options: ActivityPaneOptions) {
|
||||
super();
|
||||
this.spinnerRef = options.spinner;
|
||||
|
||||
if (options.mode === 'waiting' || options.mode === 'tool') {
|
||||
if (options.spinner !== undefined) {
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(options.spinner);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.mode === 'composing' && options.spinner !== undefined) {
|
||||
if (
|
||||
(options.mode === 'waiting' || options.mode === 'tool' || options.mode === 'composing') &&
|
||||
options.spinner !== undefined
|
||||
) {
|
||||
this.addChild(new Spacer(1));
|
||||
if (options.tip) {
|
||||
options.spinner.setTip(` · Tip: ${options.tip}`);
|
||||
}
|
||||
this.addChild(options.spinner);
|
||||
}
|
||||
}
|
||||
|
||||
override render(width: number): string[] {
|
||||
if (this.spinnerRef && 'setAvailableWidth' in this.spinnerRef) {
|
||||
this.spinnerRef.setAvailableWidth(width);
|
||||
}
|
||||
return super.render(width);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,26 +22,40 @@ export class QueuePaneComponent extends Container {
|
|||
this.messages = options.messages;
|
||||
|
||||
if (options.messages.length > 0) {
|
||||
// Bash commands (`! …`) are not steerable, so only advertise Ctrl-S when
|
||||
// there is at least one plain-text item that steering would actually send.
|
||||
const hasSteerable = options.messages.some((m) => m.mode !== 'bash');
|
||||
const canSteer = options.canSteerImmediately && hasSteerable;
|
||||
this.hint =
|
||||
options.isCompacting && !options.isStreaming
|
||||
? ' ↑ to edit · will send after compaction'
|
||||
: !options.canSteerImmediately
|
||||
? ' ↑ to edit · will send after current task'
|
||||
: ' ↑ to edit · ctrl-s to steer immediately';
|
||||
: canSteer
|
||||
? ' ↑ to edit · ctrl-s to steer immediately'
|
||||
: ' ↑ to edit · will send after current task';
|
||||
}
|
||||
}
|
||||
|
||||
override render(width: number): string[] {
|
||||
const accent = (text: string) => currentTheme.fg('accent', text);
|
||||
const shell = (text: string) => currentTheme.fg('shellMode', text);
|
||||
const dim = (text: string) => currentTheme.fg('textDim', text);
|
||||
const lines: string[] = [currentTheme.fg('border', '─'.repeat(width))];
|
||||
|
||||
for (const item of this.messages) {
|
||||
const singleLine = item.text.replaceAll(/\s+/g, ' ').trim();
|
||||
const prefix = ` ${SELECT_POINTER} `;
|
||||
const availableWidth = Math.max(1, width - visibleWidth(prefix));
|
||||
const truncated = truncateToWidth(singleLine, availableWidth, ELLIPSIS);
|
||||
lines.push(accent(prefix + truncated));
|
||||
if (item.mode === 'bash') {
|
||||
// Shell commands get a `$ ` prompt and the shell-mode hue so they read
|
||||
// as commands, not as plain text that would be sent to the model.
|
||||
const prompt = '$ ';
|
||||
const availableWidth = Math.max(1, width - visibleWidth(prefix) - visibleWidth(prompt));
|
||||
const truncated = truncateToWidth(singleLine, availableWidth, ELLIPSIS);
|
||||
lines.push(accent(prefix) + shell(prompt + truncated));
|
||||
} else {
|
||||
const availableWidth = Math.max(1, width - visibleWidth(prefix));
|
||||
const truncated = truncateToWidth(singleLine, availableWidth, ELLIPSIS);
|
||||
lines.push(accent(prefix + truncated));
|
||||
}
|
||||
}
|
||||
|
||||
if (this.hint !== undefined) {
|
||||
|
|
|
|||
3
apps/kimi-code/src/tui/constant/clipboard-image-hint.ts
Normal file
3
apps/kimi-code/src/tui/constant/clipboard-image-hint.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// Timing constants for the clipboard-image hint controller.
|
||||
export const FOCUS_DEBOUNCE_MS = 1_000;
|
||||
export const HINT_DISPLAY_MS = 4_000;
|
||||
|
|
@ -16,12 +16,15 @@ export {
|
|||
} from '#/constant/app';
|
||||
|
||||
export const FEEDBACK_STATUS_SUBMITTING = 'Submitting feedback…';
|
||||
export const FEEDBACK_STATUS_UPLOADING = 'Uploading attachments, this could take a few minutes…';
|
||||
export const FEEDBACK_STATUS_SUCCESS = 'Feedback submitted, thank you!';
|
||||
export const FEEDBACK_STATUS_CANCELLED = 'Feedback cancelled.';
|
||||
export const FEEDBACK_STATUS_NETWORK_ERROR = 'Network error, failed to submit feedback.';
|
||||
export const FEEDBACK_STATUS_FALLBACK = 'Opening GitHub Issues as fallback…';
|
||||
export const FEEDBACK_STATUS_NOT_SIGNED_IN =
|
||||
"You're not signed in. Opening GitHub Issues for feedback…";
|
||||
export const FEEDBACK_STATUS_UPLOAD_FAILED =
|
||||
'Feedback sent; attachment upload failed — see feedback-upload.log.';
|
||||
|
||||
export function feedbackHttpErrorMessage(status: number): string {
|
||||
return `Failed to submit feedback (HTTP ${String(status)}).`;
|
||||
|
|
@ -31,6 +34,10 @@ export function feedbackSessionLine(sessionId: string): string {
|
|||
return `Session: ${sessionId}`;
|
||||
}
|
||||
|
||||
export function feedbackIdLine(feedbackId: number): string {
|
||||
return `Feedback ID: ${String(feedbackId)}`;
|
||||
}
|
||||
|
||||
// Hint shown beneath session-level error messages in the TUI to point users
|
||||
// at the `/export-debug-zip` workflow so they can share diagnostics with us.
|
||||
export function errorReportHintLine(): string {
|
||||
|
|
|
|||
50
apps/kimi-code/src/tui/constant/tips.ts
Normal file
50
apps/kimi-code/src/tui/constant/tips.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
export interface ToolbarTip {
|
||||
readonly text: string;
|
||||
/**
|
||||
* Long/important tips render on their own. They never pair with a
|
||||
* neighbour and never appear as the second half of someone else's pair.
|
||||
*/
|
||||
readonly solo?: boolean;
|
||||
/**
|
||||
* Rotation weight: a higher value makes the tip recur more often. Defaults
|
||||
* to 1. Used to give newer/important features more airtime.
|
||||
*/
|
||||
readonly priority?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subset of toolbar tips shown behind the composing spinner.
|
||||
*/
|
||||
export const WORKING_TIPS: readonly ToolbarTip[] = [
|
||||
{ text: 'ctrl-s to add guidance without waiting for the turn to finish', priority: 2, solo: true },
|
||||
{ text: '/tasks to check progress and status for background tasks', priority: 2 },
|
||||
{ text: '/init: generate AGENTS.md', priority: 2 },
|
||||
{ text: 'Try /dance for a hidden Easter egg' },
|
||||
{ text: '/plugins: manage plugins — try the "superpowers" plugin', solo: true, priority: 3 },
|
||||
{
|
||||
text: '/plugins: manage plugins — try the "Kimi Datasource" for reliable financial, economic, and academic data',
|
||||
solo: true,
|
||||
priority: 3,
|
||||
},
|
||||
{ text: 'ask Kimi to schedule tasks, e.g. "remind me at 5pm"', solo: true, priority: 3 },
|
||||
{ text: '/sessions to browse and resume earlier sessions', solo: true },
|
||||
{ text: '/goal for multi-step work with a clear finish line', priority: 2, solo: true },
|
||||
{ text: '/goal next to queue follow-up work while the current goal keeps running', solo: true },
|
||||
{ text: '/web: use the Web UI for a better experience', solo: true },
|
||||
{ text: '@: mention files', priority: 2 },
|
||||
{ text: '! to run a shell command', priority: 2 },
|
||||
];
|
||||
|
||||
export const ALL_TIPS: readonly ToolbarTip[] = [
|
||||
...WORKING_TIPS,
|
||||
{ text: 'shift+enter: newline' },
|
||||
{ text: 'ctrl+c: cancel' },
|
||||
{ text: '/theme to switch the terminal UI theme' },
|
||||
{ text: '/auto when you want Kimi to handle approvals and keep going unattended' },
|
||||
{ text: '/yolo to skip most approvals for trusted batch work, only use it in repos you trust' },
|
||||
{ text: '/help: show commands' },
|
||||
{ text: '/compact compresses context when it gets long', priority: 2 },
|
||||
{ text: 'ctrl-o to hide or reveal tool output switching between a clean chat view and full execution details', priority: 2 },
|
||||
{ text: 'shift-tab to Plan mode to review the approach before Kimi edits files.', priority: 2 },
|
||||
{ text: '/model: switch model', priority: 2 },
|
||||
];
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk';
|
||||
import type { CreateSessionOptions, KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk';
|
||||
import type { SkillListSession } from '../commands';
|
||||
|
||||
import { OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE } from '../constant/kimi-tui';
|
||||
|
|
@ -11,6 +11,10 @@ import type { SessionEventHandler } from './session-event-handler';
|
|||
import type { AppState, KimiTUIOptions } from '../types';
|
||||
import type { TUIState } from '../tui-state';
|
||||
|
||||
type MutableCreateSessionOptions = {
|
||||
-readonly [P in keyof CreateSessionOptions]: CreateSessionOptions[P];
|
||||
};
|
||||
|
||||
export interface AuthFlowHost {
|
||||
state: TUIState;
|
||||
session: Session | undefined;
|
||||
|
|
@ -67,7 +71,7 @@ export class AuthFlowController {
|
|||
return;
|
||||
}
|
||||
|
||||
const session = await host.harness.createSession({
|
||||
const options: MutableCreateSessionOptions = {
|
||||
workDir: host.state.appState.workDir,
|
||||
model,
|
||||
thinking: level,
|
||||
|
|
@ -77,7 +81,11 @@ export class AuthFlowController {
|
|||
? 'yolo'
|
||||
: undefined,
|
||||
planMode: host.state.appState.planMode ? true : undefined,
|
||||
});
|
||||
};
|
||||
if (host.state.appState.additionalDirs.length > 0) {
|
||||
options.additionalDirs = [...host.state.appState.additionalDirs];
|
||||
}
|
||||
const session = await host.harness.createSession(options);
|
||||
await host.setSession(session);
|
||||
host.setAppState({
|
||||
sessionId: session.id,
|
||||
|
|
|
|||
|
|
@ -202,5 +202,8 @@ function formatBtwTurnEnd(event: TurnEndedEvent): string {
|
|||
if (event.reason === 'cancelled') {
|
||||
return 'Interrupted by user';
|
||||
}
|
||||
if (event.reason === 'filtered') {
|
||||
return 'Provider safety policy blocked the response.';
|
||||
}
|
||||
return `BTW turn ended with reason: ${event.reason}`;
|
||||
}
|
||||
|
|
|
|||
167
apps/kimi-code/src/tui/controllers/clipboard-image-hint.ts
Normal file
167
apps/kimi-code/src/tui/controllers/clipboard-image-hint.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import type { TUI } from '@earendil-works/pi-tui';
|
||||
|
||||
import { clipboardHasImage } from '#/utils/clipboard/clipboard-has-image';
|
||||
|
||||
import { FOCUS_DEBOUNCE_MS, HINT_DISPLAY_MS } from '../constant/clipboard-image-hint';
|
||||
import { TERMINAL_FOCUS_IN, TERMINAL_FOCUS_OUT } from '../utils/terminal-focus';
|
||||
import type { FooterComponent } from '../components/chrome/footer';
|
||||
|
||||
export interface ClipboardImageHintHost {
|
||||
readonly ui: TUI;
|
||||
readonly footer: FooterComponent;
|
||||
getModelSupportsImage(): boolean;
|
||||
requestRender(): void;
|
||||
}
|
||||
|
||||
function getPasteImageShortcut(): string {
|
||||
return process.platform === 'win32' ? 'Alt+V' : 'Ctrl+V';
|
||||
}
|
||||
|
||||
export class ClipboardImageHintController {
|
||||
private readonly host: ClipboardImageHintHost;
|
||||
private disposeInputListener: (() => void) | undefined;
|
||||
private debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private clearHintTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private lastHintText: string | undefined;
|
||||
private checkGeneration = 0;
|
||||
private focused = true;
|
||||
// Whether the controller has completed its first clipboard observation since
|
||||
// start. The first observation only establishes a baseline: an image already
|
||||
// in the clipboard when the session starts is not "new", so it must not
|
||||
// trigger a hint during initialization.
|
||||
private initialized = false;
|
||||
// Whether a detected clipboard image is allowed to trigger a hint. After
|
||||
// showing a hint for an image it disarms so the same lingering image does
|
||||
// not nag on every focus. A focus check that finds the clipboard empty
|
||||
// re-arms it, so the next genuinely new image notifies again.
|
||||
private armed = true;
|
||||
|
||||
constructor(host: ClipboardImageHintHost) {
|
||||
this.host = host;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.disposeInputListener = this.host.ui.addInputListener((data) => {
|
||||
this.handleInput(data);
|
||||
});
|
||||
void this.establishInitialBaseline();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.clearDebounceTimer();
|
||||
this.clearClearHintTimer();
|
||||
this.disposeInputListener?.();
|
||||
this.disposeInputListener = undefined;
|
||||
|
||||
this.checkGeneration += 1;
|
||||
this.clearOwnedHint();
|
||||
this.initialized = false;
|
||||
this.armed = true;
|
||||
}
|
||||
|
||||
private handleInput(data: string): void {
|
||||
if (data === TERMINAL_FOCUS_IN) {
|
||||
this.focused = true;
|
||||
this.scheduleCheck();
|
||||
return;
|
||||
}
|
||||
if (data === TERMINAL_FOCUS_OUT) {
|
||||
this.focused = false;
|
||||
this.clearDebounceTimer();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleCheck(): void {
|
||||
this.clearDebounceTimer();
|
||||
this.checkGeneration += 1;
|
||||
const generation = this.checkGeneration;
|
||||
this.debounceTimer = setTimeout(() => void this.runCheck(generation), FOCUS_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
private clearDebounceTimer(): void {
|
||||
if (this.debounceTimer !== undefined) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private clearClearHintTimer(): void {
|
||||
if (this.clearHintTimer !== undefined) {
|
||||
clearTimeout(this.clearHintTimer);
|
||||
this.clearHintTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private clearOwnedHint(): void {
|
||||
if (this.host.footer.getTransientHint() === this.lastHintText) {
|
||||
this.host.footer.setTransientHint(null);
|
||||
this.host.requestRender();
|
||||
}
|
||||
this.lastHintText = undefined;
|
||||
}
|
||||
|
||||
private async establishInitialBaseline(): Promise<void> {
|
||||
if (!this.host.getModelSupportsImage()) return;
|
||||
|
||||
this.checkGeneration += 1;
|
||||
const generation = this.checkGeneration;
|
||||
|
||||
let hasImage = false;
|
||||
try {
|
||||
hasImage = await clipboardHasImage();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (generation !== this.checkGeneration) return;
|
||||
|
||||
this.initialized = true;
|
||||
this.armed = !hasImage;
|
||||
}
|
||||
|
||||
private async runCheck(generation: number): Promise<void> {
|
||||
if (!this.focused) return;
|
||||
if (!this.host.getModelSupportsImage()) return;
|
||||
|
||||
let hasImage = false;
|
||||
try {
|
||||
hasImage = await clipboardHasImage();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (generation !== this.checkGeneration) return;
|
||||
if (!this.focused) return;
|
||||
|
||||
// First observation after start only establishes the baseline. An image
|
||||
// already in the clipboard when the session began is not "new", so we
|
||||
// record the state and stay quiet instead of nagging during initialization.
|
||||
if (!this.initialized) {
|
||||
this.initialized = true;
|
||||
this.armed = !hasImage;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasImage) {
|
||||
// Clipboard holds no image, so the next image that appears is a new one
|
||||
// worth notifying about. Re-arm and bail out.
|
||||
this.armed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Same image we already notified about — stay quiet until it changes.
|
||||
if (!this.armed) return;
|
||||
|
||||
const hintText = `Image in clipboard · ${getPasteImageShortcut()} to paste`;
|
||||
this.clearClearHintTimer();
|
||||
this.lastHintText = hintText;
|
||||
this.armed = false;
|
||||
this.host.footer.setTransientHint(hintText);
|
||||
this.host.requestRender();
|
||||
|
||||
this.clearHintTimer = setTimeout(() => {
|
||||
this.clearOwnedHint();
|
||||
}, HINT_DISPLAY_MS);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ import {
|
|||
} from '../constant/kimi-tui';
|
||||
import { formatErrorMessage } from '../utils/event-payload';
|
||||
import type { ImageAttachmentStore } from '../utils/image-attachment-store';
|
||||
import type { PendingExit } from '../types';
|
||||
import type { PendingExit, QueuedMessage } from '../types';
|
||||
import type { TUIState } from '../tui-state';
|
||||
import type { BtwPanelController } from './btw-panel';
|
||||
|
||||
|
|
@ -25,15 +25,19 @@ export interface EditorKeyboardHost {
|
|||
handleUserInput(text: string): void;
|
||||
readonly btwPanelController: BtwPanelController;
|
||||
steerMessage(session: Session, input: string[]): void;
|
||||
recallLastQueued(): string | undefined;
|
||||
recallLastQueued(): QueuedMessage | undefined;
|
||||
showError(msg: string): void;
|
||||
track(event: string, props?: Record<string, unknown>): void;
|
||||
updateEditorBorderHighlight(text?: string): void;
|
||||
updateQueueDisplay(): void;
|
||||
toggleToolOutputExpansion(): void;
|
||||
toggleTodoPanelExpansion(): void;
|
||||
detachCurrentForegroundTask(): void;
|
||||
cancelRunningShellCommand(): void;
|
||||
hideSessionPicker(): void;
|
||||
stop(exitCode?: number): Promise<void>;
|
||||
handlePlanToggle(next: boolean): void;
|
||||
handleInputModeChange(mode: 'prompt' | 'bash'): void;
|
||||
clearQueuedMessages(): void;
|
||||
setExternalEditorRunning(running: boolean): void;
|
||||
}
|
||||
|
|
@ -70,6 +74,9 @@ export class EditorKeyboardController {
|
|||
|
||||
if (host.state.appState.isCompacting) {
|
||||
this.clearPendingExit();
|
||||
|
||||
if (this.clearEditorTextIfPresent()) return;
|
||||
|
||||
this.cancelCurrentCompaction();
|
||||
return;
|
||||
}
|
||||
|
|
@ -86,10 +93,7 @@ export class EditorKeyboardController {
|
|||
if (host.state.appState.streamingPhase !== 'idle') {
|
||||
this.clearPendingExit();
|
||||
|
||||
if (editor.getText().length > 0) {
|
||||
editor.setText('');
|
||||
return;
|
||||
}
|
||||
if (this.clearEditorTextIfPresent()) return;
|
||||
|
||||
this.cancelCurrentStream();
|
||||
return;
|
||||
|
|
@ -145,6 +149,10 @@ export class EditorKeyboardController {
|
|||
host.handlePlanToggle(next);
|
||||
};
|
||||
|
||||
editor.onInputModeChange = (mode) => {
|
||||
host.handleInputModeChange(mode);
|
||||
};
|
||||
|
||||
editor.onOpenExternalEditor = () => {
|
||||
host.track('shortcut_editor');
|
||||
void this.openExternalEditor();
|
||||
|
|
@ -155,21 +163,41 @@ export class EditorKeyboardController {
|
|||
host.toggleToolOutputExpansion();
|
||||
};
|
||||
|
||||
editor.onToggleTodoExpand = (): boolean => {
|
||||
if (!host.state.todoPanel.hasOverflow()) return false;
|
||||
// Disarm a pending double-press exit confirmation so expanding the
|
||||
// todo list in between two Ctrl-C presses does not accidentally exit.
|
||||
this.clearPendingExit();
|
||||
host.track('shortcut_todo_expand');
|
||||
host.toggleTodoPanelExpansion();
|
||||
return true;
|
||||
};
|
||||
|
||||
editor.onCtrlS = () => {
|
||||
if (host.state.appState.streamingPhase === 'idle' || host.state.appState.isCompacting) return;
|
||||
if (
|
||||
host.state.appState.streamingPhase === 'idle' ||
|
||||
host.state.appState.streamingPhase === 'shell' ||
|
||||
host.state.appState.isCompacting
|
||||
)
|
||||
return;
|
||||
const text = editor.getText().trim();
|
||||
const queuedTexts = host.state.queuedMessages.map((m) => m.text);
|
||||
host.clearQueuedMessages();
|
||||
const editorIsBash = editor.inputMode === 'bash';
|
||||
|
||||
// Bash commands (`! …`) are not steerable: keep them queued so they run
|
||||
// after the current task instead of being injected into the turn as text.
|
||||
const queued = host.state.queuedMessages;
|
||||
const steerable = queued.filter((m) => m.mode !== 'bash');
|
||||
host.state.queuedMessages = queued.filter((m) => m.mode === 'bash');
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const q of queuedTexts) {
|
||||
const trimmed = q.trim();
|
||||
for (const m of steerable) {
|
||||
const trimmed = m.text.trim();
|
||||
if (trimmed.length > 0) parts.push(trimmed);
|
||||
}
|
||||
if (text.length > 0) parts.push(text);
|
||||
if (!editorIsBash && text.length > 0) parts.push(text);
|
||||
|
||||
if (parts.length > 0) {
|
||||
editor.setText('');
|
||||
if (!editorIsBash) editor.setText('');
|
||||
const session = host.session;
|
||||
if (host.state.appState.model.trim().length === 0 || session === undefined) {
|
||||
host.showError(LLM_NOT_SET_MESSAGE);
|
||||
|
|
@ -181,6 +209,17 @@ export class EditorKeyboardController {
|
|||
host.state.ui.requestRender();
|
||||
};
|
||||
|
||||
editor.onCtrlB = (): boolean => {
|
||||
// Shell command execution is treated as a streaming phase ('shell'), so
|
||||
// this gate already covers it; only idle + not-compacting falls through.
|
||||
if (host.state.appState.streamingPhase === 'idle' || host.state.appState.isCompacting) {
|
||||
return false;
|
||||
}
|
||||
host.track('shortcut_background_task');
|
||||
host.detachCurrentForegroundTask();
|
||||
return true;
|
||||
};
|
||||
|
||||
editor.onUndo = () => {
|
||||
host.track('undo');
|
||||
};
|
||||
|
|
@ -198,7 +237,14 @@ export class EditorKeyboardController {
|
|||
if (host.state.appState.streamingPhase === 'idle' && !host.state.appState.isCompacting) return false;
|
||||
const recalled = host.recallLastQueued();
|
||||
if (recalled !== undefined) {
|
||||
editor.setText(recalled);
|
||||
editor.setText(recalled.text);
|
||||
// Restore the queued item's mode so a recalled `!` command runs as a
|
||||
// shell command again instead of being submitted as a normal prompt.
|
||||
const mode = recalled.mode ?? 'prompt';
|
||||
if (editor.inputMode !== mode) {
|
||||
editor.inputMode = mode;
|
||||
editor.onInputModeChange?.(mode);
|
||||
}
|
||||
host.updateQueueDisplay();
|
||||
host.state.ui.requestRender();
|
||||
return true;
|
||||
|
|
@ -233,7 +279,17 @@ export class EditorKeyboardController {
|
|||
this.host.state.ui.requestRender();
|
||||
}
|
||||
|
||||
private clearEditorTextIfPresent(): boolean {
|
||||
const editor = this.host.state.editor;
|
||||
if (editor.getText().length === 0) return false;
|
||||
editor.setText('');
|
||||
return true;
|
||||
}
|
||||
|
||||
private cancelCurrentStream(): void {
|
||||
// Cancel any running `!` shell command (treated as a streaming phase) in
|
||||
// addition to the agent turn, so Esc / Ctrl+C interrupts it too.
|
||||
this.host.cancelRunningShellCommand();
|
||||
void this.host.session?.cancel();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -106,6 +106,8 @@ export interface SessionEventHost {
|
|||
restoreEditor(): void;
|
||||
restoreInputText(text: string): void;
|
||||
appendTranscriptEntry(entry: TranscriptEntry): void;
|
||||
handleShellOutput(event: { commandId: string; update: { kind: string; text?: string } }): void;
|
||||
handleShellStarted(event: { commandId: string; taskId: string }): void;
|
||||
sendNormalUserInput(text: string): void;
|
||||
updateTerminalTitle(): void;
|
||||
sendQueuedMessage(session: Session, item: QueuedMessage): void;
|
||||
|
|
@ -242,6 +244,8 @@ export class SessionEventHandler {
|
|||
case 'turn.step.completed': this.handleStepCompleted(event); break;
|
||||
case 'turn.step.retrying': break;
|
||||
case 'tool.progress': this.handleToolProgress(event); break;
|
||||
case 'shell.output': this.host.handleShellOutput(event); break;
|
||||
case 'shell.started': this.host.handleShellStarted(event); break;
|
||||
case 'assistant.delta': this.handleAssistantDelta(event); break;
|
||||
case 'hook.result': this.handleHookResult(event); break;
|
||||
case 'thinking.delta': this.handleThinkingDelta(event); break;
|
||||
|
|
@ -325,6 +329,9 @@ export class SessionEventHandler {
|
|||
if (event.reason === 'cancelled') {
|
||||
this.markActiveAgentSwarmsCancelled();
|
||||
}
|
||||
if (event.reason === 'filtered') {
|
||||
this.host.showStatus('Turn stopped: provider safety policy blocked the response.', 'error');
|
||||
}
|
||||
const todos = this.host.state.todoPanel.getTodos();
|
||||
if (todos.length > 0 && todos.every((t) => t.status === 'done')) {
|
||||
this.host.streamingUI.setTodoList([]);
|
||||
|
|
@ -356,6 +363,15 @@ export class SessionEventHandler {
|
|||
private handleStepCompleted(event: TurnStepCompletedEvent): void {
|
||||
this.host.streamingUI.flushNow();
|
||||
this.maybeShowDebugTiming(event);
|
||||
|
||||
if (event.providerFinishReason === 'filtered') {
|
||||
this.host.showNotice(
|
||||
'Provider safety policy blocked the response.',
|
||||
`The model output was filtered (${event.rawFinishReason ?? 'content_filter'}).`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.finishReason !== 'max_tokens') return;
|
||||
|
||||
const truncatedCount = this.host.streamingUI.markStepTruncated(
|
||||
|
|
@ -376,7 +392,14 @@ export class SessionEventHandler {
|
|||
private maybeShowDebugTiming(event: TurnStepCompletedEvent): void {
|
||||
if (process.env['KIMI_CODE_DEBUG'] !== '1') return;
|
||||
const text = formatStepDebugTiming(event);
|
||||
if (text !== undefined) this.host.showStatus(text);
|
||||
if (text === undefined) return;
|
||||
this.host.appendTranscriptEntry({
|
||||
id: nextTranscriptId(),
|
||||
kind: 'status',
|
||||
turnId: String(event.turnId),
|
||||
renderMode: 'plain',
|
||||
content: text,
|
||||
});
|
||||
}
|
||||
|
||||
private markActiveAgentSwarmsCancelled(): void {
|
||||
|
|
@ -385,9 +408,10 @@ export class SessionEventHandler {
|
|||
|
||||
private isAnthropicSessionActive(): boolean {
|
||||
const { state } = this.host;
|
||||
const providerKey = state.appState.availableModels[state.appState.model]?.provider;
|
||||
if (providerKey === undefined) return false;
|
||||
return state.appState.availableProviders[providerKey]?.type === 'anthropic';
|
||||
const model = state.appState.availableModels[state.appState.model];
|
||||
if (model === undefined) return false;
|
||||
if (model.protocol === 'anthropic') return true;
|
||||
return state.appState.availableProviders[model.provider]?.type === 'anthropic';
|
||||
}
|
||||
|
||||
private handleStepInterrupted(event: TurnStepInterruptedEvent): void {
|
||||
|
|
@ -986,6 +1010,9 @@ export class SessionEventHandler {
|
|||
|
||||
if (event.type === 'background.task.started') {
|
||||
if (info.kind === 'agent') {
|
||||
// A foreground subagent detached via Ctrl+B: flip its card to
|
||||
// `◐ backgrounded` so it doesn't look like it completed.
|
||||
this.host.streamingUI.markSubagentBackgrounded(info.agentId);
|
||||
this.syncBackgroundTaskBadge();
|
||||
this.host.tasksBrowserController.repaint();
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type {
|
|||
} from '@moonshot-ai/kimi-code-sdk';
|
||||
|
||||
import { ToolCallComponent } from '../components/messages/tool-call';
|
||||
import { currentTheme } from '../theme';
|
||||
import type { TodoItem } from '../components/chrome/todo-panel';
|
||||
import type {
|
||||
AppState,
|
||||
|
|
@ -21,6 +22,7 @@ import { formatErrorMessage, isTodoItemShape } from '../utils/event-payload';
|
|||
import { formatBackgroundAgentTranscript } from '../utils/background-agent-status';
|
||||
import { formatBackgroundTaskTranscript } from '../utils/background-task-status';
|
||||
import { buildGoalCompletionMessage } from '../utils/goal-completion';
|
||||
import { formatBashOutputForDisplay } from '../utils/shell-output';
|
||||
import {
|
||||
appStateFromResumeAgent,
|
||||
backgroundOrigin,
|
||||
|
|
@ -55,6 +57,23 @@ export interface SessionReplayHost {
|
|||
setAppState(patch: Partial<AppState>): void;
|
||||
showError(msg: string): void;
|
||||
appendTranscriptEntry(entry: TranscriptEntry): void;
|
||||
mergeAllTurnSteps(): void;
|
||||
}
|
||||
|
||||
function extractBashTag(
|
||||
text: string,
|
||||
tag: 'bash-input' | 'bash-stdout' | 'bash-stderr',
|
||||
): string | undefined {
|
||||
const match = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`).exec(text);
|
||||
return match?.[1] === undefined ? undefined : unescapeBashXml(match[1]);
|
||||
}
|
||||
|
||||
function unescapeBashXml(text: string): string {
|
||||
return text
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('&', '&');
|
||||
}
|
||||
|
||||
export class SessionReplayRenderer {
|
||||
|
|
@ -72,6 +91,7 @@ export class SessionReplayRenderer {
|
|||
this.hydrateSnapshot(main);
|
||||
this.renderRecords(main);
|
||||
this.applyTerminalBackgroundAgentStatuses(main);
|
||||
this.host.mergeAllTurnSteps();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = formatErrorMessage(error);
|
||||
|
|
@ -249,6 +269,28 @@ export class SessionReplayRenderer {
|
|||
if (message.origin?.kind === 'injection') {
|
||||
return;
|
||||
}
|
||||
if (message.origin?.kind === 'shell_command') {
|
||||
// A `!` command, replayed from records. Unwrap the XML tags back into the
|
||||
// same `$ cmd` + output view the live editor produced. (Must NOT fall into
|
||||
// the `injection` branch above — that returns without rendering.)
|
||||
this.flushAssistant(context);
|
||||
const text = contentPartsToText(message.content);
|
||||
if (message.origin.phase === 'input') {
|
||||
const cmd = (extractBashTag(text, 'bash-input') ?? text).trim();
|
||||
this.advanceTurn(context);
|
||||
this.host.appendTranscriptEntry(
|
||||
replayEntry(context, 'user', currentTheme.fg('shellMode', `$ ${cmd}`), 'plain', {
|
||||
bullet: '',
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const stdout = (extractBashTag(text, 'bash-stdout') ?? '').trim();
|
||||
const stderr = (extractBashTag(text, 'bash-stderr') ?? '').trim();
|
||||
const out = formatBashOutputForDisplay(stdout, stderr, message.origin.isError);
|
||||
this.host.appendTranscriptEntry(replayEntry(context, 'status', out, 'plain'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.origin?.kind === 'cron_job') {
|
||||
this.renderCronJob(context, message);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { Session } from '@moonshot-ai/kimi-code-sdk';
|
|||
|
||||
import { AgentGroupComponent } from '../components/messages/agent-group';
|
||||
import { AssistantMessageComponent } from '../components/messages/assistant-message';
|
||||
import { currentWorkingTip } from '../components/chrome/working-tips';
|
||||
import { CompactionComponent } from '../components/dialogs/compaction';
|
||||
import { ReadGroupComponent } from '../components/messages/read-group';
|
||||
import { ThinkingComponent } from '../components/messages/thinking';
|
||||
|
|
@ -34,6 +35,7 @@ export interface StreamingUIHost {
|
|||
deferUserMessages: boolean;
|
||||
shiftQueuedMessage(): QueuedMessage | undefined;
|
||||
pushTranscriptEntry(entry: TranscriptEntry): void;
|
||||
mergeCurrentTurnSteps(): void;
|
||||
}
|
||||
|
||||
export class StreamingUIController {
|
||||
|
|
@ -265,6 +267,41 @@ export class StreamingUIController {
|
|||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a foreground subagent card as detached-to-background (`◐ backgrounded`).
|
||||
* Routed from a `background.task.started` event whose `info.kind === 'agent'`,
|
||||
* keyed by `agentId`. Returns true iff a matching component was found.
|
||||
*
|
||||
* Gated to cards that are currently foreground-running: `background.task.started`
|
||||
* also fires for `Agent(run_in_background=true)` launches and for background
|
||||
* resumes, and those must not mutate older completed rows that happen to share
|
||||
* the same `agentId` (a resume's new card has no parsed `agent_id` yet, so the
|
||||
* search can otherwise hit the previous completed card).
|
||||
*/
|
||||
markSubagentBackgrounded(agentId: string | undefined): boolean {
|
||||
if (agentId === undefined) return false;
|
||||
const visit = (tc: ToolCallComponent): boolean => {
|
||||
if (tc.getSubagentAgentId() !== agentId) return false;
|
||||
const phase = tc.getSubagentSnapshot().phase;
|
||||
if (phase !== 'running' && phase !== 'queued' && phase !== 'spawning') return false;
|
||||
tc.markBackgrounded();
|
||||
return true;
|
||||
};
|
||||
for (const tc of this._pendingToolComponents.values()) {
|
||||
if (visit(tc)) return true;
|
||||
}
|
||||
for (const child of this.host.state.transcriptContainer.children) {
|
||||
if (child instanceof ToolCallComponent) {
|
||||
if (visit(child)) return true;
|
||||
} else if (child instanceof AgentGroupComponent) {
|
||||
for (const tc of child.getToolComponents()) {
|
||||
if (visit(tc)) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Registers a tool call that arrived via tool.call.started.
|
||||
* Clears any pending streaming state for this id, updates or creates the
|
||||
* component, and returns whether the call was new (no previous entry). */
|
||||
|
|
@ -564,12 +601,16 @@ export class StreamingUIController {
|
|||
const block = this._streamingBlock;
|
||||
if (block !== null) {
|
||||
block.entry.content = fullText;
|
||||
block.component.updateContent(fullText);
|
||||
block.component.updateContent(fullText, { transient: true });
|
||||
this.host.state.ui.requestRender();
|
||||
}
|
||||
}
|
||||
|
||||
onStreamingTextEnd(): void {
|
||||
const block = this._streamingBlock;
|
||||
if (block !== null) {
|
||||
block.component.updateContent(block.entry.content, { transient: false });
|
||||
}
|
||||
this._streamingBlock = null;
|
||||
}
|
||||
|
||||
|
|
@ -598,6 +639,7 @@ export class StreamingUIController {
|
|||
this._activeThinkingComponent.finalize();
|
||||
this._activeThinkingComponent = undefined;
|
||||
this.host.state.ui.requestRender();
|
||||
this.host.mergeCurrentTurnSteps();
|
||||
}
|
||||
|
||||
onToolCallStart(toolCall: ToolCallBlockData): void {
|
||||
|
|
@ -644,6 +686,7 @@ export class StreamingUIController {
|
|||
tc.setResult(result);
|
||||
this._pendingToolComponents.delete(toolCallId);
|
||||
state.ui.requestRender();
|
||||
this.host.mergeCurrentTurnSteps();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -658,6 +701,7 @@ export class StreamingUIController {
|
|||
state.transcriptContainer.addChild(completed);
|
||||
state.ui.requestRender();
|
||||
}
|
||||
this.host.mergeCurrentTurnSteps();
|
||||
}
|
||||
|
||||
setTodoList(todos: readonly TodoItem[]): void {
|
||||
|
|
@ -676,7 +720,7 @@ export class StreamingUIController {
|
|||
this._activeCompactionBlock.markDone();
|
||||
this._activeCompactionBlock = undefined;
|
||||
}
|
||||
const block = new CompactionComponent(state.ui, instruction);
|
||||
const block = new CompactionComponent(state.ui, instruction, currentWorkingTip()?.text);
|
||||
this._activeCompactionBlock = block;
|
||||
state.transcriptContainer.addChild(block);
|
||||
state.ui.requestRender();
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,7 @@
|
|||
import type { ApprovalRequest, ApprovalResponse, ToolInputDisplay } from '@moonshot-ai/kimi-code-sdk';
|
||||
|
||||
import type { ApprovalPanelResponse } from '#/tui/components/dialogs/approval-panel';
|
||||
import { goalStartOptions } from '#/tui/components/dialogs/goal-start-permission-prompt';
|
||||
import type { ApprovalPanelChoice, ApprovalPanelData, DisplayBlock } from '#/tui/reverse-rpc/types';
|
||||
|
||||
const DEFAULT_APPROVAL_CHOICES: ApprovalPanelChoice[] = [
|
||||
|
|
@ -176,6 +177,8 @@ function describeApproval(display: ToolInputDisplay, action: string): string {
|
|||
switch (display.kind) {
|
||||
case 'plan_review':
|
||||
return '';
|
||||
case 'goal_start':
|
||||
return 'Start a goal?';
|
||||
case 'generic':
|
||||
if (typeof display.detail === 'string' && display.detail.length > 0) {
|
||||
return display.detail;
|
||||
|
|
@ -320,6 +323,13 @@ function adaptDisplay(display: ToolInputDisplay): DisplayBlock[] {
|
|||
];
|
||||
case 'plan_review':
|
||||
return [];
|
||||
case 'goal_start': {
|
||||
const lines = [`Start goal: ${display.objective}`];
|
||||
if (typeof display.completionCriterion === 'string' && display.completionCriterion.length > 0) {
|
||||
lines.push(`Done when: ${display.completionCriterion}`);
|
||||
}
|
||||
return [{ type: 'brief', text: lines.join('\n') }];
|
||||
}
|
||||
case 'generic':
|
||||
return [];
|
||||
case 'todo_list':
|
||||
|
|
@ -335,10 +345,36 @@ function adaptChoices(toolName: string, display: ToolInputDisplay): ApprovalPane
|
|||
if (toolName === 'ExitPlanMode' || display.kind === 'plan_review') {
|
||||
return adaptPlanReviewChoices(display);
|
||||
}
|
||||
if (display.kind === 'goal_start') {
|
||||
return adaptGoalStartChoices(display);
|
||||
}
|
||||
|
||||
return DEFAULT_APPROVAL_CHOICES.map((choice) => cloneChoice(choice));
|
||||
}
|
||||
|
||||
function adaptGoalStartChoices(
|
||||
display: Extract<ToolInputDisplay, { kind: 'goal_start' }>,
|
||||
): ApprovalPanelChoice[] {
|
||||
// Reuse the exact options the /goal start menu shows. Each mode option starts
|
||||
// the goal under that permission mode (the policy reads selected_label); "Do
|
||||
// not start" declines so no goal is created.
|
||||
return goalStartOptions(display.mode).map((option) =>
|
||||
option.value === 'cancel'
|
||||
? {
|
||||
label: option.label,
|
||||
response: 'cancelled',
|
||||
selected_label: 'cancel',
|
||||
description: option.description,
|
||||
}
|
||||
: {
|
||||
label: option.label,
|
||||
response: 'approved',
|
||||
selected_label: option.value,
|
||||
description: option.description,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function adaptPlanReviewChoices(display: ToolInputDisplay): ApprovalPanelChoice[] {
|
||||
const optionChoices =
|
||||
display.kind === 'plan_review' && display.options !== undefined && display.options.length >= 2
|
||||
|
|
|
|||
|
|
@ -103,6 +103,9 @@ export interface ApprovalPanelChoice {
|
|||
response: 'approved' | 'approved_for_session' | 'rejected' | 'cancelled';
|
||||
selected_label?: string | undefined;
|
||||
requires_feedback?: boolean | undefined;
|
||||
// Optional helper text shown dim beneath the label. Omitted/empty renders
|
||||
// exactly as a plain label-only choice.
|
||||
description?: string | undefined;
|
||||
}
|
||||
|
||||
// ── Approval / Question view payloads ────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -71,6 +71,12 @@ export interface ColorPalette {
|
|||
/** User message: bullet & text, skill-activation name. The one role colour
|
||||
* with its own hue — assistant/thinking/status bullets reuse text/textDim. */
|
||||
roleUser: string;
|
||||
|
||||
// ── Shell mode ──
|
||||
/** Shell mode (`!`): the `!` prompt symbol, bash-mode editor border, and the
|
||||
* echoed `$ command` line. Its own hue (violet), distinct from
|
||||
* plan-mode (primary) and the user role (roleUser). */
|
||||
shellMode: string;
|
||||
}
|
||||
|
||||
export const darkColors: ColorPalette = {
|
||||
|
|
@ -97,6 +103,7 @@ export const darkColors: ColorPalette = {
|
|||
diffMeta: '#888888',
|
||||
|
||||
roleUser: '#FFCB6B',
|
||||
shellMode: '#BD93F9',
|
||||
};
|
||||
|
||||
export const lightColors: ColorPalette = {
|
||||
|
|
@ -123,6 +130,7 @@ export const lightColors: ColorPalette = {
|
|||
diffMeta: '#5F5F5F',
|
||||
|
||||
roleUser: '#9A4A00',
|
||||
shellMode: '#7C3AED',
|
||||
};
|
||||
|
||||
export type ResolvedTheme = 'dark' | 'light';
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@ import { currentTheme } from './theme';
|
|||
// eslint-disable-next-line no-control-regex -- intentionally matches the ESC byte that opens ANSI SGR sequences.
|
||||
const HEADING_HASH_PREFIX = /^((?:\u001B\[[0-9;]*m)*)#{1,6}[ \t]+/;
|
||||
|
||||
export function createMarkdownTheme(): MarkdownTheme {
|
||||
export function createMarkdownTheme(options?: { transient?: boolean }): MarkdownTheme {
|
||||
const transient = options?.transient === true;
|
||||
const stripHash = (text: string): string => text.replace(HEADING_HASH_PREFIX, '$1');
|
||||
|
||||
return {
|
||||
|
|
@ -44,6 +45,8 @@ export function createMarkdownTheme(): MarkdownTheme {
|
|||
strikethrough: (text) => chalk.strikethrough(text),
|
||||
underline: (text) => chalk.underline(text),
|
||||
highlightCode: (code: string, lang?: string) => {
|
||||
if (transient) return code.split('\n');
|
||||
|
||||
const normalizedLang = lang?.trim().toLowerCase();
|
||||
const language =
|
||||
normalizedLang !== undefined && supportsLanguage(normalizedLang) ? normalizedLang : 'text';
|
||||
|
|
|
|||
|
|
@ -45,7 +45,8 @@
|
|||
"diffRemovedStrong": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff removed lines (strong)" },
|
||||
"diffGutter": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff gutter color" },
|
||||
"diffMeta": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff meta color" },
|
||||
"roleUser": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "User message accent" }
|
||||
"roleUser": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "User message accent" },
|
||||
"shellMode": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Shell mode (`!`) prompt, editor border, and the echoed `$ command` line" }
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
|
|
|
|||
|
|
@ -26,9 +26,12 @@ export interface BannerState {
|
|||
export interface AppState {
|
||||
model: string;
|
||||
workDir: string;
|
||||
additionalDirs: readonly string[];
|
||||
sessionId: string;
|
||||
permissionMode: PermissionMode;
|
||||
planMode: boolean;
|
||||
/** 'bash' when the editor is in `!` shell-command mode. */
|
||||
inputMode: 'prompt' | 'bash';
|
||||
swarmMode: boolean;
|
||||
thinking: boolean;
|
||||
contextUsage: number;
|
||||
|
|
@ -36,7 +39,7 @@ export interface AppState {
|
|||
maxContextTokens: number;
|
||||
isCompacting: boolean;
|
||||
isReplaying: boolean;
|
||||
streamingPhase: 'idle' | 'waiting' | 'thinking' | 'composing';
|
||||
streamingPhase: 'idle' | 'waiting' | 'thinking' | 'composing' | 'shell';
|
||||
streamingStartTime: number;
|
||||
theme: ThemeName;
|
||||
version: string;
|
||||
|
|
@ -149,6 +152,8 @@ export interface TranscriptEntry {
|
|||
content: string;
|
||||
color?: ColorToken;
|
||||
detail?: string;
|
||||
/** Optional override for the leading bullet of a 'user' message entry. An empty string suppresses the bullet entirely (used by shell-command echoes so `$` replaces the sparkles marker). */
|
||||
bullet?: string;
|
||||
toolCallData?: ToolCallBlockData;
|
||||
backgroundAgentStatus?: BackgroundAgentStatusData;
|
||||
compactionData?: CompactionTranscriptData;
|
||||
|
|
@ -179,6 +184,9 @@ export interface QueuedMessage {
|
|||
readonly agentId?: string;
|
||||
readonly parts?: readonly PromptPart[];
|
||||
readonly imageAttachmentIds?: readonly number[];
|
||||
/** `bash` for a `!` shell command queued while another command is running;
|
||||
* undefined (=`prompt`) for a normal message. */
|
||||
readonly mode?: 'prompt' | 'bash';
|
||||
}
|
||||
|
||||
export const INITIAL_LIVE_PANE: LivePaneState = {
|
||||
|
|
@ -215,6 +223,7 @@ export interface PendingExit {
|
|||
|
||||
export interface LoginProgressSpinnerHandle {
|
||||
stop(opts: { ok: boolean; label: string }): void;
|
||||
setLabel(label: string): void;
|
||||
}
|
||||
|
||||
export type ProgressSpinnerHandle = LoginProgressSpinnerHandle;
|
||||
|
|
|
|||
32
apps/kimi-code/src/tui/utils/foreground-task.ts
Normal file
32
apps/kimi-code/src/tui/utils/foreground-task.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { BackgroundTaskInfo } from '@moonshot-ai/kimi-code-sdk';
|
||||
|
||||
function isDetachableForegroundTask(t: BackgroundTaskInfo): boolean {
|
||||
return (
|
||||
t.detached === false &&
|
||||
t.status === 'running' &&
|
||||
(t.kind === 'process' || t.kind === 'agent')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick all foreground tasks that `Ctrl+B` should detach: `detached === false`,
|
||||
* currently-running Bash (`process`) or subagent (`agent`) tasks, most recently
|
||||
* started first.
|
||||
*/
|
||||
export function pickForegroundTasks(
|
||||
tasks: readonly BackgroundTaskInfo[],
|
||||
): BackgroundTaskInfo[] {
|
||||
return tasks
|
||||
.filter(isDetachableForegroundTask)
|
||||
.sort((a, b) => b.startedAt - a.startedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the single most recently started foreground task. Kept for callers that
|
||||
* only need one; `Ctrl+B` uses {@link pickForegroundTasks} to detach them all.
|
||||
*/
|
||||
export function pickForegroundTask(
|
||||
tasks: readonly BackgroundTaskInfo[],
|
||||
): BackgroundTaskInfo | undefined {
|
||||
return pickForegroundTasks(tasks)[0];
|
||||
}
|
||||
|
|
@ -135,7 +135,7 @@ export function replayEntry(
|
|||
kind: TranscriptEntry['kind'],
|
||||
content: string,
|
||||
renderMode: TranscriptEntry['renderMode'],
|
||||
extras: { detail?: string } = {},
|
||||
extras: { detail?: string; bullet?: string } = {},
|
||||
): TranscriptEntry {
|
||||
return {
|
||||
id: nextTranscriptId(),
|
||||
|
|
@ -144,6 +144,7 @@ export function replayEntry(
|
|||
renderMode,
|
||||
content,
|
||||
detail: extras.detail,
|
||||
bullet: extras.bullet,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -250,6 +251,9 @@ function isReplayUserTurnRecord(record: AgentReplayRecord): boolean {
|
|||
return true;
|
||||
case 'skill_activation':
|
||||
return message.origin.trigger === 'user-slash';
|
||||
case 'shell_command':
|
||||
// A `!` command's input is a user-turn anchor; its output is not.
|
||||
return message.origin.phase === 'input';
|
||||
case 'background_task':
|
||||
case 'compaction_summary':
|
||||
case 'cron_job':
|
||||
|
|
|
|||
|
|
@ -50,6 +50,26 @@ export function pluginTrustLabel(plugin: PluginSummary): PluginTrustLabel {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true only for install sources that are unambiguously Kimi-built
|
||||
* official plugins — an https URL under the official Kimi CDN plugin path.
|
||||
* Everything else (local paths, GitHub repos, curated or third-party URLs)
|
||||
* is treated as unofficial and should be confirmed before install.
|
||||
*/
|
||||
export function isOfficialPluginSource(source: string): boolean {
|
||||
const trimmed = source.trim();
|
||||
if (!trimmed.startsWith('https://')) return false;
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
return (
|
||||
url.hostname === 'code.kimi.com' &&
|
||||
url.pathname.startsWith('/kimi-code/plugins/official/')
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function hostFromUrl(raw: string): string | undefined {
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
|
|
|
|||
|
|
@ -1,22 +1,17 @@
|
|||
import {
|
||||
KIMI_CODE_PLATFORM_ID,
|
||||
KIMI_CODE_PROVIDER_NAME,
|
||||
applyManagedKimiCodeConfig,
|
||||
applyOpenPlatformConfig,
|
||||
applyCustomRegistryProvider,
|
||||
fetchCustomRegistry,
|
||||
fetchManagedKimiCodeModels,
|
||||
fetchOpenPlatformModels,
|
||||
filterModelsByPrefix,
|
||||
getOpenPlatformById,
|
||||
isOpenPlatformId,
|
||||
removeCustomRegistryProvider,
|
||||
resolveKimiCodeRuntimeAuth,
|
||||
type CustomRegistrySource,
|
||||
type ManagedKimiConfigShape,
|
||||
refreshProviderModels,
|
||||
type ProviderChange,
|
||||
type RefreshProviderOptions,
|
||||
type RefreshProviderScope,
|
||||
type RefreshResult,
|
||||
} from '@moonshot-ai/kimi-code-oauth';
|
||||
import type { KimiConfig, KimiConfigPatch, ModelAlias, OAuthRef, ProviderConfig } from '@moonshot-ai/kimi-code-sdk';
|
||||
import type { KimiConfig, KimiConfigPatch, OAuthRef } from '@moonshot-ai/kimi-code-sdk';
|
||||
|
||||
/**
|
||||
* CLI-side host for provider-model refresh. Kept on the SDK's full config types
|
||||
* so existing TUI callers (and tests) don't change; the daemon uses the oauth
|
||||
* package's `ManagedKimiConfigShape`-typed host directly.
|
||||
*/
|
||||
export interface RefreshProviderHost {
|
||||
getConfig(): Promise<KimiConfig>;
|
||||
removeProvider(providerId: string): Promise<KimiConfig>;
|
||||
|
|
@ -24,542 +19,25 @@ export interface RefreshProviderHost {
|
|||
resolveOAuthToken(providerName: string, oauthRef?: OAuthRef): Promise<string>;
|
||||
}
|
||||
|
||||
export interface ProviderChange {
|
||||
readonly providerId: string;
|
||||
/** User-facing name when available. */
|
||||
readonly providerName: string;
|
||||
readonly added: number;
|
||||
readonly removed: number;
|
||||
}
|
||||
|
||||
export interface RefreshResult {
|
||||
/** Providers whose model list actually changed. */
|
||||
readonly changed: readonly ProviderChange[];
|
||||
/** Providers whose model list stayed identical after refresh. */
|
||||
readonly unchanged: readonly string[];
|
||||
readonly failed: ReadonlyArray<{ readonly provider: string; readonly reason: string }>;
|
||||
}
|
||||
|
||||
export type RefreshProviderScope = 'all' | 'oauth';
|
||||
|
||||
export interface RefreshProviderOptions {
|
||||
readonly scope?: RefreshProviderScope;
|
||||
}
|
||||
|
||||
function readCustomRegistrySource(provider: ProviderConfig): CustomRegistrySource | undefined {
|
||||
const source = provider.source;
|
||||
if (typeof source !== 'object' || source === null) return undefined;
|
||||
const candidate = source;
|
||||
if (candidate['kind'] !== 'apiJson') return undefined;
|
||||
const url = candidate['url'];
|
||||
const apiKey = candidate['apiKey'];
|
||||
if (typeof url !== 'string' || url.length === 0) return undefined;
|
||||
if (typeof apiKey !== 'string') return undefined;
|
||||
return { kind: 'apiJson', url, apiKey };
|
||||
}
|
||||
|
||||
function customRegistrySourceKey(source: CustomRegistrySource): string {
|
||||
return JSON.stringify([source.url]);
|
||||
}
|
||||
|
||||
function customRegistrySourceCredentialKey(source: CustomRegistrySource): string {
|
||||
return JSON.stringify([source.url, source.apiKey]);
|
||||
}
|
||||
|
||||
async function fetchCustomRegistryFromSources(
|
||||
sources: readonly CustomRegistrySource[],
|
||||
): Promise<{
|
||||
readonly entries: Awaited<ReturnType<typeof fetchCustomRegistry>>;
|
||||
readonly source: CustomRegistrySource;
|
||||
}> {
|
||||
let lastError: unknown;
|
||||
for (const source of sources) {
|
||||
try {
|
||||
return {
|
||||
entries: await fetchCustomRegistry(source),
|
||||
source,
|
||||
};
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
if (lastError instanceof Error) throw lastError;
|
||||
if (typeof lastError === 'string') throw new Error(lastError);
|
||||
throw new Error('No custom registry sources configured.');
|
||||
}
|
||||
|
||||
function asManaged(config: KimiConfig): ManagedKimiConfigShape {
|
||||
return config as unknown as ManagedKimiConfigShape;
|
||||
}
|
||||
|
||||
function collectModelIdsForAliases(config: KimiConfig, aliasKeys: ReadonlySet<string>): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const aliasKey of aliasKeys) {
|
||||
const alias = config.models?.[aliasKey];
|
||||
if (alias !== undefined && alias.model.length > 0) {
|
||||
ids.add(alias.model);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function providerAliasKeys(config: KimiConfig, providerId: string): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
for (const [alias, model] of Object.entries(config.models ?? {})) {
|
||||
if (model.provider === providerId) keys.add(alias);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function generatedProviderAliasKeys(
|
||||
config: KimiConfig,
|
||||
providerId: string,
|
||||
aliasPrefix: string,
|
||||
): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
for (const [alias, model] of Object.entries(config.models ?? {})) {
|
||||
if (model.provider === providerId && alias.startsWith(aliasPrefix)) {
|
||||
keys.add(alias);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function computeChanges(oldIds: Set<string>, newIds: Set<string>): { added: number; removed: number } {
|
||||
let added = 0;
|
||||
for (const id of newIds) {
|
||||
if (!oldIds.has(id)) added++;
|
||||
}
|
||||
let removed = 0;
|
||||
for (const id of oldIds) {
|
||||
if (!newIds.has(id)) removed++;
|
||||
}
|
||||
return { added, removed };
|
||||
}
|
||||
|
||||
interface ProviderModelSnapshot {
|
||||
readonly alias: string;
|
||||
readonly model: ModelAlias;
|
||||
}
|
||||
|
||||
// Compare the full model metadata for the relevant aliases, not just model IDs:
|
||||
// a registry can change capabilities (e.g. enabling reasoning) without changing
|
||||
// any model ID. Spreading the whole `ModelAlias` keeps this in sync with the
|
||||
// schema automatically; only `capabilities` needs normalizing because its order
|
||||
// is not meaningful.
|
||||
function providerModelSnapshot(
|
||||
config: KimiConfig,
|
||||
providerId: string,
|
||||
aliasKeys: ReadonlySet<string>,
|
||||
): string {
|
||||
const snapshots: ProviderModelSnapshot[] = [];
|
||||
for (const alias of aliasKeys) {
|
||||
const model = config.models?.[alias];
|
||||
if (model === undefined || model.provider !== providerId) continue;
|
||||
snapshots.push({
|
||||
alias,
|
||||
model: {
|
||||
...model,
|
||||
capabilities: model.capabilities === undefined ? undefined : model.capabilities.toSorted(),
|
||||
},
|
||||
});
|
||||
}
|
||||
snapshots.sort((a, b) => a.alias.localeCompare(b.alias));
|
||||
return JSON.stringify(snapshots);
|
||||
}
|
||||
|
||||
function providerModelsEqual(
|
||||
config: KimiConfig,
|
||||
nextConfig: KimiConfig,
|
||||
providerId: string,
|
||||
aliasKeys: ReadonlySet<string>,
|
||||
): boolean {
|
||||
return (
|
||||
providerModelSnapshot(config, providerId, aliasKeys) ===
|
||||
providerModelSnapshot(nextConfig, providerId, aliasKeys)
|
||||
);
|
||||
}
|
||||
|
||||
function providerConfigSnapshot(config: KimiConfig, providerId: string): string {
|
||||
return JSON.stringify(config.providers[providerId] ?? null);
|
||||
}
|
||||
|
||||
function providerConfigEqual(config: KimiConfig, nextConfig: KimiConfig, providerId: string): boolean {
|
||||
return providerConfigSnapshot(config, providerId) === providerConfigSnapshot(nextConfig, providerId);
|
||||
}
|
||||
|
||||
function providerRefreshAliasKeys(
|
||||
config: KimiConfig,
|
||||
nextConfig: KimiConfig,
|
||||
providerId: string,
|
||||
aliasPrefix: string,
|
||||
): Set<string> {
|
||||
const keys = generatedProviderAliasKeys(config, providerId, aliasPrefix);
|
||||
for (const key of providerAliasKeys(nextConfig, providerId)) keys.add(key);
|
||||
return keys;
|
||||
}
|
||||
|
||||
function preserveUserProviderAliases(
|
||||
config: KimiConfig,
|
||||
providerId: string,
|
||||
refreshedAliasKeys: ReadonlySet<string>,
|
||||
): Record<string, ModelAlias> {
|
||||
const preserved: Record<string, ModelAlias> = {};
|
||||
for (const [alias, model] of Object.entries(config.models ?? {})) {
|
||||
if (model.provider !== providerId || refreshedAliasKeys.has(alias)) continue;
|
||||
preserved[alias] = structuredClone(model);
|
||||
}
|
||||
return preserved;
|
||||
}
|
||||
|
||||
function restoreProviderAliases(config: KimiConfig, aliases: Record<string, ModelAlias>): void {
|
||||
if (Object.keys(aliases).length === 0) return;
|
||||
config.models = {
|
||||
...config.models,
|
||||
...aliases,
|
||||
};
|
||||
}
|
||||
|
||||
function restoreDefaultSelection(
|
||||
config: KimiConfig,
|
||||
defaultModel: string | undefined,
|
||||
defaultThinking: boolean | undefined,
|
||||
): void {
|
||||
if (defaultModel === undefined || config.models?.[defaultModel] === undefined) return;
|
||||
config.defaultModel = defaultModel;
|
||||
// A refresh may have just learned that the default model cannot disable
|
||||
// thinking — never restore a stale thinking-off selection onto it.
|
||||
const capabilities = config.models[defaultModel]?.capabilities ?? [];
|
||||
config.defaultThinking = capabilities.includes('always_thinking') ? true : defaultThinking;
|
||||
}
|
||||
|
||||
// `apply*` may leave `defaultModel` pointing at an alias that no longer exists
|
||||
// (e.g. the previously-selected model was dropped from the registry). The host's
|
||||
// `setConfig` deep-merge cannot clear a key, so the matching `removeProvider`
|
||||
// call handles disk cleanup while this drops the dangling reference in memory.
|
||||
function clampDanglingDefault(config: KimiConfig): void {
|
||||
if (config.defaultModel !== undefined && config.models?.[config.defaultModel] === undefined) {
|
||||
config.defaultModel = undefined;
|
||||
config.defaultThinking = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function clearDefaultThinkingWhenDefaultRemoved(
|
||||
config: KimiConfig,
|
||||
previousDefaultModel: string | undefined,
|
||||
): void {
|
||||
if (previousDefaultModel !== undefined && config.defaultModel === undefined) {
|
||||
config.defaultThinking = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function pickDefaultModel(config: KimiConfig, providerId: string, models: Array<{ id: string }>): string {
|
||||
const firstModel = models[0];
|
||||
if (firstModel === undefined) return '';
|
||||
|
||||
const existingDefault = config.defaultModel;
|
||||
if (existingDefault !== undefined) {
|
||||
const alias = config.models?.[existingDefault];
|
||||
if (alias !== undefined && alias.provider === providerId) {
|
||||
const stillAvailable = models.find((m) => m.id === alias.model);
|
||||
if (stillAvailable !== undefined) {
|
||||
return stillAvailable.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
return firstModel.id;
|
||||
}
|
||||
export type { ProviderChange, RefreshProviderOptions, RefreshProviderScope, RefreshResult };
|
||||
|
||||
/**
|
||||
* Refresh remote model metadata for the configured providers. Thin adapter over
|
||||
* the shared `refreshProviderModels` orchestrator in `@moonshot-ai/kimi-code-oauth`
|
||||
* (which is also what the daemon's scheduled/manual refresh uses).
|
||||
*/
|
||||
export async function refreshAllProviderModels(
|
||||
host: RefreshProviderHost,
|
||||
options: RefreshProviderOptions = {},
|
||||
): Promise<RefreshResult> {
|
||||
const changed: ProviderChange[] = [];
|
||||
const unchanged: string[] = [];
|
||||
const failed: Array<{ provider: string; reason: string }> = [];
|
||||
const scope = options.scope ?? 'all';
|
||||
|
||||
let config = await host.getConfig();
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 1. Managed Kimi Code (OAuth)
|
||||
// -------------------------------------------------------------------------
|
||||
const managedProvider = config.providers[KIMI_CODE_PROVIDER_NAME];
|
||||
if (
|
||||
managedProvider !== undefined &&
|
||||
managedProvider.type === 'kimi' &&
|
||||
managedProvider.oauth !== undefined
|
||||
) {
|
||||
try {
|
||||
const auth = resolveKimiCodeRuntimeAuth({
|
||||
configuredBaseUrl: managedProvider.baseUrl,
|
||||
configuredOAuthRef: managedProvider.oauth,
|
||||
});
|
||||
const accessToken = await host.resolveOAuthToken(KIMI_CODE_PROVIDER_NAME, auth.oauthRef);
|
||||
const models = await fetchManagedKimiCodeModels({
|
||||
accessToken,
|
||||
baseUrl: auth.baseUrl,
|
||||
});
|
||||
if (models.length > 0) {
|
||||
const next = structuredClone(config);
|
||||
applyManagedKimiCodeConfig(asManaged(next), {
|
||||
models,
|
||||
baseUrl: auth.baseUrl,
|
||||
oauthKey: auth.oauthRef.key,
|
||||
oauthHost: auth.oauthRef.oauthHost,
|
||||
preserveDefaultModel: true,
|
||||
});
|
||||
const refreshedAliasKeys = providerRefreshAliasKeys(
|
||||
config,
|
||||
next,
|
||||
KIMI_CODE_PROVIDER_NAME,
|
||||
`${KIMI_CODE_PLATFORM_ID}/`,
|
||||
);
|
||||
restoreProviderAliases(
|
||||
next,
|
||||
preserveUserProviderAliases(config, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys),
|
||||
);
|
||||
restoreDefaultSelection(next, config.defaultModel, config.defaultThinking);
|
||||
clampDanglingDefault(next);
|
||||
clearDefaultThinkingWhenDefaultRemoved(next, config.defaultModel);
|
||||
|
||||
if (providerModelsEqual(config, next, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys)) {
|
||||
unchanged.push(KIMI_CODE_PROVIDER_NAME);
|
||||
} else {
|
||||
const { added, removed } = computeChanges(
|
||||
collectModelIdsForAliases(config, refreshedAliasKeys),
|
||||
collectModelIdsForAliases(next, refreshedAliasKeys),
|
||||
);
|
||||
await host.removeProvider(KIMI_CODE_PROVIDER_NAME);
|
||||
config = await host.setConfig({
|
||||
providers: next.providers,
|
||||
models: next.models,
|
||||
defaultModel: next.defaultModel,
|
||||
defaultThinking: next.defaultThinking,
|
||||
});
|
||||
changed.push({
|
||||
providerId: KIMI_CODE_PROVIDER_NAME,
|
||||
providerName: 'Kimi Code',
|
||||
added,
|
||||
removed,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
failed.push({
|
||||
provider: KIMI_CODE_PROVIDER_NAME,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (scope === 'oauth') {
|
||||
return { changed, unchanged, failed };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 2. Open Platforms (moonshot-cn, moonshot-ai, …)
|
||||
// -------------------------------------------------------------------------
|
||||
const openPlatformIds = Object.keys(config.providers).filter((id) => isOpenPlatformId(id));
|
||||
for (const providerId of openPlatformIds) {
|
||||
const platform = getOpenPlatformById(providerId);
|
||||
if (platform === undefined) continue;
|
||||
|
||||
const providerConfig = config.providers[providerId];
|
||||
if (providerConfig === undefined) continue;
|
||||
const apiKey = providerConfig.apiKey;
|
||||
if (typeof apiKey !== 'string' || apiKey.length === 0) continue;
|
||||
|
||||
try {
|
||||
let models = await fetchOpenPlatformModels(platform, apiKey);
|
||||
models = filterModelsByPrefix(models, platform);
|
||||
if (models.length === 0) continue;
|
||||
|
||||
const selectedModelId = pickDefaultModel(config, providerId, models);
|
||||
const selectedModel = models.find((m) => m.id === selectedModelId);
|
||||
if (selectedModel === undefined) continue;
|
||||
const next = structuredClone(config);
|
||||
applyOpenPlatformConfig(asManaged(next), {
|
||||
platform,
|
||||
models,
|
||||
selectedModel,
|
||||
thinking: false,
|
||||
apiKey,
|
||||
});
|
||||
const refreshedAliasKeys = providerRefreshAliasKeys(
|
||||
config,
|
||||
next,
|
||||
providerId,
|
||||
`${providerId}/`,
|
||||
);
|
||||
restoreProviderAliases(next, preserveUserProviderAliases(config, providerId, refreshedAliasKeys));
|
||||
restoreDefaultSelection(next, config.defaultModel, config.defaultThinking);
|
||||
clampDanglingDefault(next);
|
||||
clearDefaultThinkingWhenDefaultRemoved(next, config.defaultModel);
|
||||
|
||||
if (providerModelsEqual(config, next, providerId, refreshedAliasKeys)) {
|
||||
unchanged.push(providerId);
|
||||
} else {
|
||||
const { added, removed } = computeChanges(
|
||||
collectModelIdsForAliases(config, refreshedAliasKeys),
|
||||
collectModelIdsForAliases(next, refreshedAliasKeys),
|
||||
);
|
||||
await host.removeProvider(providerId);
|
||||
config = await host.setConfig({
|
||||
providers: next.providers,
|
||||
models: next.models,
|
||||
defaultModel: next.defaultModel,
|
||||
defaultThinking: next.defaultThinking,
|
||||
});
|
||||
changed.push({
|
||||
providerId,
|
||||
providerName: platform.name,
|
||||
added,
|
||||
removed,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
failed.push({
|
||||
provider: providerId,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3. Custom Registry providers (grouped by URL, with API-key candidates)
|
||||
// -------------------------------------------------------------------------
|
||||
const customSources = new Map<
|
||||
string,
|
||||
return refreshProviderModels(
|
||||
{
|
||||
readonly sources: CustomRegistrySource[];
|
||||
readonly sourceKeys: Set<string>;
|
||||
readonly providerIds: string[];
|
||||
}
|
||||
>();
|
||||
for (const [providerId, providerConfig] of Object.entries(config.providers)) {
|
||||
if (providerId === KIMI_CODE_PROVIDER_NAME) continue;
|
||||
if (isOpenPlatformId(providerId)) continue;
|
||||
const source = readCustomRegistrySource(providerConfig);
|
||||
if (source === undefined) continue;
|
||||
const key = customRegistrySourceKey(source);
|
||||
const sourceKey = customRegistrySourceCredentialKey(source);
|
||||
const entry = customSources.get(key);
|
||||
if (entry !== undefined) {
|
||||
if (!entry.sourceKeys.has(sourceKey)) {
|
||||
entry.sources.push(source);
|
||||
entry.sourceKeys.add(sourceKey);
|
||||
}
|
||||
entry.providerIds.push(providerId);
|
||||
} else {
|
||||
customSources.set(key, {
|
||||
sources: [source],
|
||||
sourceKeys: new Set([sourceKey]),
|
||||
providerIds: [providerId],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const { sources, providerIds } of customSources.values()) {
|
||||
try {
|
||||
const { entries, source } = await fetchCustomRegistryFromSources(sources);
|
||||
// Build the whole batch on one clone so that several changed providers
|
||||
// from the same source do not overwrite each other's aliases, and so the
|
||||
// config we compare is exactly the config we persist.
|
||||
const next = structuredClone(config);
|
||||
const changedProviders: Array<{
|
||||
readonly providerId: string;
|
||||
readonly providerName: string;
|
||||
readonly added: number;
|
||||
readonly removed: number;
|
||||
}> = [];
|
||||
const providersToRemoveBeforeSet = new Set<string>();
|
||||
let hasUnreportedConfigChange = false;
|
||||
const remoteEntries = Object.values(entries);
|
||||
const remoteEntriesByProviderId = new Map(
|
||||
remoteEntries.map((entry) => [entry.id, entry]),
|
||||
);
|
||||
const providerIdsToSync = new Set(providerIds);
|
||||
for (const entry of remoteEntries) providerIdsToSync.add(entry.id);
|
||||
|
||||
for (const providerId of providerIdsToSync) {
|
||||
const entry = remoteEntriesByProviderId.get(providerId);
|
||||
if (entry === undefined) {
|
||||
const oldIds = collectModelIdsForAliases(config, providerAliasKeys(config, providerId));
|
||||
removeCustomRegistryProvider(asManaged(next), providerId);
|
||||
changedProviders.push({
|
||||
providerId,
|
||||
providerName: providerId,
|
||||
added: 0,
|
||||
removed: oldIds.size,
|
||||
});
|
||||
providersToRemoveBeforeSet.add(providerId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existed = config.providers[providerId] !== undefined;
|
||||
applyCustomRegistryProvider(asManaged(next), entry, source);
|
||||
const refreshedAliasKeys = providerRefreshAliasKeys(config, next, providerId, `${providerId}/`);
|
||||
if (existed) {
|
||||
restoreProviderAliases(next, preserveUserProviderAliases(config, providerId, refreshedAliasKeys));
|
||||
}
|
||||
|
||||
if (
|
||||
existed &&
|
||||
providerModelsEqual(config, next, providerId, refreshedAliasKeys) &&
|
||||
providerConfigEqual(config, next, providerId)
|
||||
) {
|
||||
unchanged.push(providerId);
|
||||
} else if (existed && providerModelsEqual(config, next, providerId, refreshedAliasKeys)) {
|
||||
unchanged.push(providerId);
|
||||
providersToRemoveBeforeSet.add(providerId);
|
||||
hasUnreportedConfigChange = true;
|
||||
} else {
|
||||
const { added, removed } = computeChanges(
|
||||
collectModelIdsForAliases(config, refreshedAliasKeys),
|
||||
collectModelIdsForAliases(next, refreshedAliasKeys),
|
||||
);
|
||||
changedProviders.push({
|
||||
providerId,
|
||||
providerName: entry.name || providerId,
|
||||
added,
|
||||
removed,
|
||||
});
|
||||
if (existed) providersToRemoveBeforeSet.add(providerId);
|
||||
}
|
||||
}
|
||||
|
||||
if (changedProviders.length > 0 || hasUnreportedConfigChange) {
|
||||
restoreDefaultSelection(next, config.defaultModel, config.defaultThinking);
|
||||
clampDanglingDefault(next);
|
||||
clearDefaultThinkingWhenDefaultRemoved(next, config.defaultModel);
|
||||
for (const providerId of providersToRemoveBeforeSet) {
|
||||
await host.removeProvider(providerId);
|
||||
}
|
||||
config = await host.setConfig({
|
||||
providers: next.providers,
|
||||
models: next.models,
|
||||
defaultModel: next.defaultModel,
|
||||
defaultThinking: next.defaultThinking,
|
||||
});
|
||||
for (const change of changedProviders) {
|
||||
changed.push({
|
||||
providerId: change.providerId,
|
||||
providerName: change.providerName,
|
||||
added: change.added,
|
||||
removed: change.removed,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
for (const providerId of providerIds) {
|
||||
failed.push({
|
||||
provider: providerId,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { changed, unchanged, failed };
|
||||
getConfig: () => host.getConfig(),
|
||||
removeProvider: (providerId) => host.removeProvider(providerId),
|
||||
setConfig: (patch) => host.setConfig(patch as unknown as KimiConfigPatch),
|
||||
resolveOAuthToken: (providerName, oauthRef) =>
|
||||
host.resolveOAuthToken(providerName, oauthRef as unknown as OAuthRef),
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
28
apps/kimi-code/src/tui/utils/render-cache.ts
Normal file
28
apps/kimi-code/src/tui/utils/render-cache.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* Render-cache toggle for TUI message components.
|
||||
*
|
||||
* The transcript re-renders the entire component tree on every frame, and
|
||||
* most message components rebuild their `render(width)` output from scratch
|
||||
* even when their content has not changed. Caching the rendered lines (keyed
|
||||
* on width + a dirty flag) turns an unchanged message's render into an O(1)
|
||||
* array reference return, which is the dominant per-frame cost once the
|
||||
* transcript grows long.
|
||||
*
|
||||
* The cache is on by default and can be disabled with
|
||||
* `KIMI_TUI_NO_RENDER_CACHE=1` as an escape hatch (and to let benchmarks
|
||||
* compare cached vs. uncached runs in the same process).
|
||||
*/
|
||||
|
||||
let enabled = process.env['KIMI_TUI_NO_RENDER_CACHE'] !== '1';
|
||||
|
||||
export function isRenderCacheEnabled(): boolean {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the cache at runtime. Intended for benchmarks / tests only;
|
||||
* production code should not call this.
|
||||
*/
|
||||
export function setRenderCacheEnabled(value: boolean): void {
|
||||
enabled = value;
|
||||
}
|
||||
71
apps/kimi-code/src/tui/utils/shell-output.ts
Normal file
71
apps/kimi-code/src/tui/utils/shell-output.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { currentTheme } from '#/tui/theme';
|
||||
|
||||
// Captured command output can contain terminal control sequences — colours,
|
||||
// cursor moves, alternate-screen switches, hyperlinks, `\r` spinners, bells, …
|
||||
// We render through pi-tui, which passes strings straight to the terminal, so
|
||||
// any sequence left intact is executed by the terminal and fights with pi-tui's
|
||||
// own cursor control (the "blank screen + leftover characters" symptom). Strip
|
||||
// everything a terminal would interpret as a command rather than printable text,
|
||||
// keeping only `\n` and `\t` (which the renderer understands).
|
||||
|
||||
// ESC [ <params> <intermediates> <final> — colours, cursor moves, clear, and
|
||||
// private modes such as ESC[?1049h (alt screen) / ESC[?25l (hide cursor).
|
||||
const CSI_PATTERN = /\u001B\[[0-9:;<=>?]*[ -/]*[@-~]/g;
|
||||
// ESC ] … <BEL> or ESC ] … ESC \ — window titles and OSC 8 hyperlinks.
|
||||
const OSC_PATTERN = /\u001B\][\s\S]*?(?:\u0007|\u001B\\)/g;
|
||||
// ESC <char> (and ESC <intermediate> <char>) — charset/keypad selection,
|
||||
// save/restore cursor (ESC 7 / ESC 8), full reset (ESC c), etc. Runs after the
|
||||
// CSI/OSC patterns, so it only catches sequences they didn't already consume.
|
||||
const ESC_SINGLE_PATTERN = /\u001B(?:[ -/][0-~]|[0-~])/g;
|
||||
// C0 control characters except \n (0x0A) and \t (0x09): NUL, BEL, \b, \r, …
|
||||
// plus a lone ESC (0x1B) that wasn't part of a sequence recognised above.
|
||||
const C0_CONTROL_PATTERN = /[\u0000-\u0008\u000B-\u001B\u001C-\u001F]/g;
|
||||
|
||||
/**
|
||||
* Strip every terminal control sequence from captured command output so it is
|
||||
* safe to render via pi-tui (which does not sanitize on its own).
|
||||
*
|
||||
* Never throws: a bad or pathological input falls back to stripping only the
|
||||
* C0 control characters, so rendering can never crash the TUI.
|
||||
*/
|
||||
export function sanitizeShellOutput(text: string): string {
|
||||
if (typeof text !== 'string') return '';
|
||||
if (text.length === 0) return text;
|
||||
try {
|
||||
return text
|
||||
.replace(OSC_PATTERN, '')
|
||||
.replace(CSI_PATTERN, '')
|
||||
.replace(ESC_SINGLE_PATTERN, '')
|
||||
.replace(C0_CONTROL_PATTERN, '');
|
||||
} catch {
|
||||
return text.replace(C0_CONTROL_PATTERN, '');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format captured stdout/stderr for the transcript. Sanitizes both streams and
|
||||
* dims them; stderr is red only on actual failure.
|
||||
*
|
||||
* Never throws: if anything goes wrong (theme lookup, huge input, …) it falls
|
||||
* back to a best-effort plain view so a render error can never crash the TUI.
|
||||
*/
|
||||
export function formatBashOutputForDisplay(stdout: string, stderr: string, isError?: boolean): string {
|
||||
try {
|
||||
const dim = (s: string): string => currentTheme.fg('textDim', s);
|
||||
const parts: string[] = [];
|
||||
const cleanStdout = sanitizeShellOutput(stdout).trimEnd();
|
||||
if (cleanStdout.length > 0) parts.push(dim(cleanStdout));
|
||||
const cleanStderr = sanitizeShellOutput(stderr).trimEnd();
|
||||
if (cleanStderr.length > 0) {
|
||||
// Dim grey normally; red only on actual failure (so warnings on a
|
||||
// successful command are not mistaken for errors).
|
||||
parts.push(isError ? currentTheme.fg('error', cleanStderr) : dim(cleanStderr));
|
||||
}
|
||||
return parts.length > 0 ? parts.join('\n') : dim('(no output)');
|
||||
} catch {
|
||||
const plain = [sanitizeShellOutput(String(stdout ?? '')), sanitizeShellOutput(String(stderr ?? ''))]
|
||||
.filter((s) => s.length > 0)
|
||||
.join('\n');
|
||||
return plain.length > 0 ? plain : '(no output)';
|
||||
}
|
||||
}
|
||||
94
apps/kimi-code/src/tui/utils/tab-strip.ts
Normal file
94
apps/kimi-code/src/tui/utils/tab-strip.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
/**
|
||||
* Shared tab strip renderer for tabbed dialogs (model selector, plugin
|
||||
* marketplace, …). The active tab is filled with the brand background, inactive
|
||||
* tabs are muted — matching the AskUserQuestion dialog. See
|
||||
* .agents/skills/write-tui/DESIGN.md §5.
|
||||
*
|
||||
* When the strip is wider than the terminal, it scrolls to keep the active tab
|
||||
* visible, framed by `<`/`>` markers.
|
||||
*/
|
||||
|
||||
import { visibleWidth } from '@earendil-works/pi-tui';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import type { ColorPalette } from '#/tui/theme/colors';
|
||||
|
||||
export interface RenderTabStripOptions {
|
||||
readonly labels: readonly string[];
|
||||
readonly activeIndex: number;
|
||||
readonly width: number;
|
||||
readonly colors: ColorPalette;
|
||||
}
|
||||
|
||||
/** Style one tab cell. Active and inactive cells have the same visible width so
|
||||
* switching never shifts the layout. */
|
||||
function styleTab(label: string, isActive: boolean, colors: ColorPalette): string {
|
||||
const cell = ` ${label} `;
|
||||
return isActive
|
||||
? chalk.bgHex(colors.primary).hex(colors.text).bold(cell)
|
||||
: chalk.hex(colors.textMuted)(cell);
|
||||
}
|
||||
|
||||
export function renderTabStrip(opts: RenderTabStripOptions): string {
|
||||
const { labels, activeIndex, width, colors } = opts;
|
||||
const segments = labels.map((label, i) => styleTab(label, i === activeIndex, colors));
|
||||
|
||||
// If everything fits with a leading space, show the whole strip. Account for
|
||||
// the single spaces `segments.join(' ')` inserts between tabs — otherwise the
|
||||
// strip is declared to fit at widths where the joined line is actually wider
|
||||
// and gets truncated instead of showing the `<`/`>` scroll markers.
|
||||
const totalSegmentWidth = segments.reduce((sum, s) => sum + visibleWidth(s), 0);
|
||||
const fullSeparatorWidth = Math.max(0, segments.length - 1);
|
||||
if (1 + totalSegmentWidth + fullSeparatorWidth <= width) {
|
||||
return ' ' + segments.join(' ');
|
||||
}
|
||||
|
||||
// Scrolling needed. Find the widest window that contains activeIndex.
|
||||
const segmentWidths = segments.map((s) => visibleWidth(s));
|
||||
let start = activeIndex;
|
||||
let end = activeIndex + 1;
|
||||
let contentWidth = segmentWidths[activeIndex] ?? 0;
|
||||
|
||||
const fits = (s: number, e: number, cw: number): boolean => {
|
||||
const needLeft = s > 0;
|
||||
const needRight = e < segments.length;
|
||||
const frameWidth = (needLeft ? 2 : 1) + (needRight ? 2 : 0);
|
||||
const separators = Math.max(0, e - s - 1);
|
||||
return cw + separators + frameWidth <= width;
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const leftW = start > 0 ? segmentWidths[start - 1]! : Infinity;
|
||||
const rightW = end < segments.length ? segmentWidths[end]! : Infinity;
|
||||
if (leftW === Infinity && rightW === Infinity) break;
|
||||
|
||||
if (leftW <= rightW) {
|
||||
if (fits(start - 1, end, contentWidth + leftW)) {
|
||||
contentWidth += leftW;
|
||||
start--;
|
||||
} else if (fits(start, end + 1, contentWidth + rightW)) {
|
||||
contentWidth += rightW;
|
||||
end++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else if (fits(start, end + 1, contentWidth + rightW)) {
|
||||
contentWidth += rightW;
|
||||
end++;
|
||||
} else if (fits(start - 1, end, contentWidth + leftW)) {
|
||||
contentWidth += leftW;
|
||||
start--;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const hasLeft = start > 0;
|
||||
const hasRight = end < segments.length;
|
||||
let strip = hasLeft ? chalk.hex(colors.textMuted)('< ') : ' ';
|
||||
strip += segments.slice(start, end).join(' ');
|
||||
if (hasRight) {
|
||||
strip += chalk.hex(colors.textMuted)(' >');
|
||||
}
|
||||
return strip;
|
||||
}
|
||||
109
apps/kimi-code/src/tui/utils/transcript-window.ts
Normal file
109
apps/kimi-code/src/tui/utils/transcript-window.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* Sliding window for the TUI transcript.
|
||||
*
|
||||
* The transcript grows unbounded as the conversation goes on. To keep the TUI
|
||||
* responsive and bounded, we only keep the most recent N *turns* (a turn = a
|
||||
* user prompt plus everything the assistant does in response, identified by a
|
||||
* shared `turnId`), and destroy older turns wholesale (component + entry).
|
||||
*
|
||||
* All threshold logic here is pure so it can be unit-tested in isolation; the
|
||||
* constants are the production defaults passed in by the TUI.
|
||||
*/
|
||||
|
||||
import type { TranscriptEntry } from '../types';
|
||||
|
||||
/**
|
||||
* Read a non-negative integer env var, falling back to `fallback` when it is
|
||||
* unset, empty, negative, or not an integer. `0` is a valid value (call sites
|
||||
* treat it as "feature disabled").
|
||||
*/
|
||||
export function readEnvInt(name: string, fallback: number): number {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw.trim() === '') return fallback;
|
||||
const value = Number(raw);
|
||||
if (!Number.isInteger(value) || value < 0) return fallback;
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Master switch for the sliding window. */
|
||||
export const TRANSCRIPT_WINDOW_ENABLED = true;
|
||||
|
||||
/** Keep the most recent N turns. `0` disables trimming. */
|
||||
export const TRANSCRIPT_MAX_TURNS = readEnvInt('KIMI_CODE_TUI_MAX_TURNS', 50);
|
||||
|
||||
/** Only the most recent E turns are allowed to expand (Ctrl+O). `0` disables expanding. */
|
||||
export const TRANSCRIPT_EXPAND_TURNS = readEnvInt('KIMI_CODE_TUI_EXPAND_TURNS', 3);
|
||||
|
||||
/** Only trim once the window exceeds maxTurns by this much (avoids churn). */
|
||||
export const TRANSCRIPT_HYSTERESIS = readEnvInt('KIMI_CODE_TUI_HYSTERESIS', 10);
|
||||
|
||||
/** Keep this many recent steps untouched inside a turn; older steps are merged into a summary. `0` disables merging. */
|
||||
export const TRANSCRIPT_KEEP_RECENT_STEPS = readEnvInt('KIMI_CODE_TUI_KEEP_RECENT_STEPS', 30);
|
||||
|
||||
export interface TranscriptTurn {
|
||||
readonly turnId: string | undefined;
|
||||
readonly entries: TranscriptEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Group consecutive entries into turns by `turnId`. Entries with the same
|
||||
* non-undefined `turnId` that are adjacent belong to the same turn.
|
||||
*
|
||||
* Entries with an undefined `turnId` are buffered and attached to the *next*
|
||||
* defined turn. This matters because a user message is appended (with
|
||||
* `turnId: undefined`) before its turn actually starts, so without this
|
||||
* buffering every user message would become its own single-entry turn at the
|
||||
* front and get trimmed first. Any undefined entries left at the tail (no
|
||||
* following turn) become their own turn.
|
||||
*/
|
||||
export function groupTurns(entries: readonly TranscriptEntry[]): TranscriptTurn[] {
|
||||
const turns: TranscriptTurn[] = [];
|
||||
let current: TranscriptTurn | undefined;
|
||||
let pendingUndefined: TranscriptEntry[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const turnId = entry.turnId;
|
||||
if (turnId === undefined) {
|
||||
pendingUndefined.push(entry);
|
||||
continue;
|
||||
}
|
||||
if (current !== undefined && current.turnId === turnId) {
|
||||
current.entries.push(entry);
|
||||
} else {
|
||||
current = { turnId, entries: [...pendingUndefined, entry] };
|
||||
pendingUndefined = [];
|
||||
turns.push(current);
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingUndefined.length > 0) {
|
||||
turns.push({ turnId: undefined, entries: pendingUndefined });
|
||||
}
|
||||
|
||||
return turns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide which entries to destroy so the remaining turns fit within
|
||||
* `maxTurns`. Returns an empty set when the turn count is within
|
||||
* `maxTurns + hysteresis`. Oldest turns are removed first; the most recent
|
||||
* turn is never removed (it is the active / just-finished turn).
|
||||
*/
|
||||
export function turnsToTrim(
|
||||
turns: readonly TranscriptTurn[],
|
||||
maxTurns: number,
|
||||
hysteresis: number,
|
||||
): Set<TranscriptEntry> {
|
||||
const toRemove = new Set<TranscriptEntry>();
|
||||
|
||||
if (turns.length <= maxTurns + hysteresis) return toRemove;
|
||||
|
||||
let remaining = turns.length;
|
||||
// `turns.length - 1` keeps the most recent turn off-limits.
|
||||
for (let i = 0; i < turns.length - 1 && remaining > maxTurns; i++) {
|
||||
const turn = turns[i]!;
|
||||
for (const entry of turn.entries) toRemove.add(entry);
|
||||
remaining--;
|
||||
}
|
||||
return toRemove;
|
||||
}
|
||||
158
apps/kimi-code/src/utils/clipboard/clipboard-common.ts
Normal file
158
apps/kimi-code/src/utils/clipboard/clipboard-common.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
|
||||
import type { ClipboardModule } from './clipboard-native';
|
||||
|
||||
export type RunCommandOptions = { timeoutMs?: number; env?: NodeJS.ProcessEnv };
|
||||
export type RunCommand = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: RunCommandOptions,
|
||||
) => { stdout: Buffer; ok: boolean };
|
||||
export type RunCommandAsync = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: RunCommandOptions,
|
||||
) => Promise<{ stdout: Buffer; ok: boolean }>;
|
||||
|
||||
export const SUPPORTED_IMAGE_MIME_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const;
|
||||
|
||||
export const DEFAULT_LIST_TIMEOUT_MS = 1000;
|
||||
export const DEFAULT_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
export function baseMimeType(raw: string): string {
|
||||
return raw.split(';')[0]?.trim().toLowerCase() ?? raw.toLowerCase();
|
||||
}
|
||||
|
||||
export function isSupportedImageMimeType(mime: string): boolean {
|
||||
const base = baseMimeType(mime);
|
||||
return (SUPPORTED_IMAGE_MIME_TYPES as readonly string[]).includes(base);
|
||||
}
|
||||
|
||||
export function parseTargetList(output: Buffer): string[] {
|
||||
return output
|
||||
.toString('utf-8')
|
||||
.split(/\r?\n/)
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
}
|
||||
|
||||
export function runCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: RunCommandOptions,
|
||||
): { stdout: Buffer; ok: boolean } {
|
||||
const result = spawnSync(command, args, {
|
||||
timeout: options?.timeoutMs ?? DEFAULT_LIST_TIMEOUT_MS,
|
||||
maxBuffer: DEFAULT_MAX_BUFFER_BYTES,
|
||||
env: options?.env,
|
||||
});
|
||||
if (result.error !== undefined || result.status !== 0) {
|
||||
return { ok: false, stdout: Buffer.alloc(0) };
|
||||
}
|
||||
const stdout = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout ?? '');
|
||||
return { ok: true, stdout };
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-blocking counterpart of `runCommand`. Used by the clipboard image probe
|
||||
* on the startup path so a slow or wedged helper (notably `powershell.exe` on
|
||||
* WSL, or a stuck `wl-paste`/`xclip`) cannot freeze the event loop. The child
|
||||
* is killed and the promise resolves with `ok: false` once `timeoutMs` elapses
|
||||
* or the captured stdout exceeds `DEFAULT_MAX_BUFFER_BYTES`.
|
||||
*/
|
||||
export function runCommandAsync(
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: RunCommandOptions,
|
||||
): Promise<{ stdout: Buffer; ok: boolean }> {
|
||||
const timeoutMs = options?.timeoutMs ?? DEFAULT_LIST_TIMEOUT_MS;
|
||||
return new Promise((resolve) => {
|
||||
let child;
|
||||
try {
|
||||
child = spawn(command, args, {
|
||||
env: options?.env,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
} catch {
|
||||
resolve({ ok: false, stdout: Buffer.alloc(0) });
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
let totalBytes = 0;
|
||||
let settled = false;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
|
||||
// Marks the promise as settled and clears the timeout. Returns true only for
|
||||
// the first caller, so each event handler below resolves at most once.
|
||||
const claim = (): boolean => {
|
||||
if (settled) return false;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
return true;
|
||||
};
|
||||
|
||||
timer = setTimeout(() => {
|
||||
child.kill();
|
||||
if (claim()) resolve({ ok: false, stdout: Buffer.alloc(0) });
|
||||
}, timeoutMs);
|
||||
|
||||
child.stdout?.on('data', (chunk: Buffer) => {
|
||||
totalBytes += chunk.length;
|
||||
if (totalBytes > DEFAULT_MAX_BUFFER_BYTES) {
|
||||
child.kill();
|
||||
if (claim()) resolve({ ok: false, stdout: Buffer.alloc(0) });
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
child.on('error', () => {
|
||||
if (claim()) resolve({ ok: false, stdout: Buffer.alloc(0) });
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
if (claim()) resolve({ ok: false, stdout: Buffer.alloc(0) });
|
||||
return;
|
||||
}
|
||||
if (claim()) resolve({ ok: true, stdout: Buffer.concat(chunks) });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function isWaylandSession(env: NodeJS.ProcessEnv): boolean {
|
||||
return Boolean(env['WAYLAND_DISPLAY']) || env['XDG_SESSION_TYPE'] === 'wayland';
|
||||
}
|
||||
|
||||
export function isWSL(env: NodeJS.ProcessEnv): boolean {
|
||||
if (env['WSL_DISTRO_NAME'] !== undefined || env['WSLENV'] !== undefined) return true;
|
||||
try {
|
||||
return /microsoft|wsl/i.test(readFileSync('/proc/version', 'utf-8'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isFileLikeNativeFormat(format: string): boolean {
|
||||
const f = format.toLowerCase();
|
||||
const base = baseMimeType(format);
|
||||
return (
|
||||
f.includes('file-url') ||
|
||||
f.includes('file url') ||
|
||||
f.includes('nsfilenames') ||
|
||||
f.includes('com.apple.finder') ||
|
||||
base === 'text/uri-list' ||
|
||||
base === 'public.url'
|
||||
);
|
||||
}
|
||||
|
||||
export function safeAvailableFormats(clip: ClipboardModule | null): string[] {
|
||||
if (clip?.availableFormats === undefined) return [];
|
||||
try {
|
||||
return clip.availableFormats();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
44
apps/kimi-code/src/utils/clipboard/clipboard-has-image.ts
Normal file
44
apps/kimi-code/src/utils/clipboard/clipboard-has-image.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { isFileLikeNativeFormat, safeAvailableFormats } from './clipboard-common';
|
||||
import { clipboard, type ClipboardModule } from './clipboard-native';
|
||||
|
||||
async function hasImageViaNative(clip: ClipboardModule | null): Promise<boolean> {
|
||||
if (clip === null) return false;
|
||||
|
||||
// Finder exposes file icons/thumbnails as image data when a non-image file
|
||||
// is copied. Treat file-like clipboard contents as "not a pasteable image"
|
||||
// to match the read path in clipboard-image.ts.
|
||||
const formats = safeAvailableFormats(clip);
|
||||
if (formats.some(isFileLikeNativeFormat)) return false;
|
||||
|
||||
try {
|
||||
return clip.hasImage();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clipboardHasImage(options?: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
platform?: NodeJS.Platform;
|
||||
clipboard?: ClipboardModule | null;
|
||||
}): Promise<boolean> {
|
||||
const env = options?.env ?? process.env;
|
||||
const platform = options?.platform ?? process.platform;
|
||||
const clip = options?.clipboard ?? clipboard;
|
||||
|
||||
if (env['TERMUX_VERSION'] !== undefined) return false;
|
||||
|
||||
// The focus-driven clipboard-image hint does not probe on Linux. The probe
|
||||
// would spawn wl-paste / xclip, which on Wayland perturbs seat focus and
|
||||
// re-triggers the terminal's focus event, creating a focus feedback loop
|
||||
// (window repeatedly gains/loses focus, IME candidate window cannot stay
|
||||
// focused — see issue #1090). macOS and Windows are fine: both use the
|
||||
// in-process native module, which neither spawns a subprocess nor perturbs
|
||||
// focus.
|
||||
//
|
||||
// Image *paste* is unaffected on all platforms: it reads the clipboard
|
||||
// through readClipboardMedia() on the explicit paste path, not here.
|
||||
if (platform !== 'darwin' && platform !== 'win32') return false;
|
||||
|
||||
return hasImageViaNative(clip);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue