fix(tui): complete @ file mentions across additional workspace roots with fd (#1408)

* fix(tui): complete @ file mentions across additional workspace roots with fd

When additional workspace directories are added via /add-dir, @ file completion fell back to a readdir-based scanner capped at 2000 entries, so deeply nested files in large projects never appeared. Route @ completion through fd across every root instead, keeping the query pushed down to fd and deduplicating by absolute path. The readdir fallback remains for when fd is unavailable.

* fix(tui): preserve per-root full-path fallback for @ mentions

Address review feedback: decide the scoped-versus-full-path fallback per root instead of globally. When one root has the scoped directory but another does not, the latter still runs a whole-tree --full-path search with the original query, so a match that only exists under that root is not hidden just because a sibling root happens to contain the prefix directory.

* fix(tui): fall back to filesystem when fd binary is not executable

Address review: when fdPath is non-null but the binary cannot be spawned (managed fd removed or lost execute permission), @ completion returned null because pi-tui swallows the spawn error into an empty result, so the catch never ran. Probe fd with accessSync(X_OK) before delegating and use the filesystem fallback when it is not executable, while still returning null for genuine no-match results.

* fix(tui): trust bare fd command names when probing executability

Address review: when fd is discovered on the system PATH, detectSystemFdPath returns the bare name (fd/fdfind). accessSync checked that literal string relative to cwd and never searched PATH, so a valid system fd was treated as unavailable and @ completion fell back to the capped scanner. Trust bare names (spawn resolves them via PATH) and only probe absolute/relative paths, which is how the managed fd is referenced and which can go stale.

* chore: remove accidentally committed plan files

* test(pi-tui): stabilize paste-burst test by freezing the clock

The paste-burst heuristic uses an 8ms inter-character interval that a slow or busy CI runner can exceed between synchronous handleInput calls, which resets the burst and lets Enter submit. Freeze Date so the synchronous keystrokes always register as one burst, making the assertion deterministic.

* chore: ignore top-level plan directory
This commit is contained in:
liruifengv 2026-07-06 15:02:02 +08:00 committed by GitHub
parent e6e6dd53ce
commit fc259abdb4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 385 additions and 1504 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Fix `@` file completion missing deeply nested files in large projects after adding extra workspace directories.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/pi-tui": patch
---
Support searching multiple workspace roots for `@` file completion through fd, deduplicated by absolute path.

1
.gitignore vendored
View file

@ -24,3 +24,4 @@ docker-compose.yml
docs/superpowers/
reports/
.superpowers/
/plan/

View file

@ -1,4 +1,4 @@
import { readdirSync, statSync } from 'node:fs';
import { accessSync, constants as fsConstants, readdirSync, statSync } from 'node:fs';
import { basename, join, resolve } from 'node:path';
import {
@ -27,13 +27,13 @@ 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 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.
* File / folder mention behavior uses pi-tui's fd-backed provider whenever fd
* is available, fanning out across the working directory and any additional
* roots so `@` completion pushes the query down to fd instead of enumerating
* every file. A small filesystem fallback is used only while managed fd is
* downloading, when it is unavailable, or if fd fails to spawn. 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;
@ -56,7 +56,7 @@ export class FileMentionProvider implements AutocompleteProvider {
expanded.push({ ...cmd, name: alias });
}
}
this.inner = new CombinedAutocompleteProvider(expanded, workDir, fdPath);
this.inner = new CombinedAutocompleteProvider(expanded, workDir, fdPath, this.additionalDirs);
}
async getSuggestions(
@ -75,7 +75,11 @@ export class FileMentionProvider implements AutocompleteProvider {
// runs, so the file list never opens.
const atPrefix = extractAtPrefix(textBeforeCursor);
if (atPrefix !== null) {
if (this.fdPath === null || this.additionalDirs.length > 0) {
// fd backs `@` completion across every root (cwd + additional dirs). Fall
// back to the filesystem scanner when fd is unavailable, not executable
// (e.g. the managed binary was removed or lost execute permission), or if
// spawning it fails below. A genuine fd no-match still returns null.
if (this.fdPath === null || !isExecutableFd(this.fdPath)) {
return getFsMentionSuggestions(
this.workDir,
this.additionalDirs,
@ -227,6 +231,21 @@ export function extractAtPrefix(text: string): string | null {
return text.slice(tokenStart);
}
function isExecutableFd(fdPath: string): boolean {
// Bare command names (for example "fd" discovered on the system PATH) are
// trusted: spawn resolves them through PATH. Only absolute/relative paths are
// probed, which is how the managed fd is referenced and which can go stale.
if (!fdPath.includes('/') && !fdPath.includes('\\')) {
return true;
}
try {
accessSync(fdPath, fsConstants.X_OK);
return true;
} catch {
return false;
}
}
/**
* Match the `/add-dir` directory completer, which skips every entry whose name
* starts with `.` (see registry.ts). pi-tui's path completer sets `label` to

View file

@ -1,3 +1,4 @@
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
@ -11,6 +12,17 @@ function ctrl(): AbortSignal {
}
const NO_FD = null;
function resolveFdPath(): string | null {
const command = process.platform === 'win32' ? 'where' : 'which';
const result = spawnSync(command, ['fd'], { encoding: 'utf-8' });
if (result.status !== 0 || !result.stdout) return null;
const firstLine = result.stdout.split(/\r?\n/).find(Boolean);
return firstLine ? firstLine.trim() : null;
}
const FD_PATH = resolveFdPath();
const IS_FD_INSTALLED = Boolean(FD_PATH);
const GOAL_COMMAND = {
name: 'goal',
description: 'Start or manage a goal',
@ -298,6 +310,56 @@ describe('FileMentionProvider', () => {
);
});
it.runIf(IS_FD_INSTALLED)(
'uses fd for additionalDirs even when cwd is large enough to exhaust the fallback scanner',
async () => {
// Fill cwd with enough entries to push the filesystem fallback past its
// 2000-entry scan cap, so it would never reach the additional root. fd
// searches each root independently and still finds the deep target.
for (let i = 0; i < 2000; i++) {
writeFileSync(join(workDir, `filler-${i}.ts`), 'export {};');
}
const extraDir = createExtraDir();
mkdirSync(join(extraDir, 'deep'), { recursive: true });
writeFileSync(join(extraDir, 'deep', 'target-needle.ts'), 'export {};');
const provider = new FileMentionProvider([], workDir, FD_PATH!, [extraDir]);
const result = await provider.getSuggestions(['@target-needle'], 0, '@target-needle'.length, {
signal: ctrl(),
});
expect(result).not.toBeNull();
expect(result!.items.map((item) => item.value)).toContain(
`@${join(extraDir, 'deep', 'target-needle.ts').replaceAll('\\', '/')}`,
);
},
);
it.runIf(IS_FD_INSTALLED)(
'treats a bare fd command name as executable and resolves it via PATH',
async () => {
// A bare "fd" (system PATH lookup) must not be mistaken for unavailable;
// otherwise the large cwd would push the fallback scanner past its cap
// and hide the deep target in the additional root.
for (let i = 0; i < 2000; i++) {
writeFileSync(join(workDir, `filler-${i}.ts`), 'export {};');
}
const extraDir = createExtraDir();
mkdirSync(join(extraDir, 'deep'), { recursive: true });
writeFileSync(join(extraDir, 'deep', 'target-needle.ts'), 'export {};');
const provider = new FileMentionProvider([], workDir, 'fd', [extraDir]);
const result = await provider.getSuggestions(['@target-needle'], 0, '@target-needle'.length, {
signal: ctrl(),
});
expect(result).not.toBeNull();
expect(result!.items.map((item) => item.value)).toContain(
`@${join(extraDir, 'deep', 'target-needle.ts').replaceAll('\\', '/')}`,
);
},
);
it('keeps cwd @ mention values relative and additionalDir values absolute', async () => {
mkdirSync(join(workDir, 'src'), { recursive: true });
writeFileSync(join(workDir, 'src', 'Cwd.ts'), 'export {};');
@ -332,14 +394,19 @@ describe('FileMentionProvider', () => {
expect(overlapItems).toHaveLength(1);
});
it('does not bypass fd filtering with filesystem suggestions when fd returns no matches', async () => {
writeFileSync(join(workDir, 'README.md'), 'readme');
const provider = new FileMentionProvider([], workDir, join(workDir, 'missing-fd'));
it.runIf(IS_FD_INSTALLED)(
'does not bypass fd filtering with filesystem suggestions when fd returns no matches',
async () => {
writeFileSync(join(workDir, 'README.md'), 'readme');
const provider = new FileMentionProvider([], workDir, FD_PATH!);
const result = await provider.getSuggestions(['@read'], 0, 5, { signal: ctrl() });
const result = await provider.getSuggestions(['@zzz-no-match-xyz'], 0, '@zzz-no-match-xyz'.length, {
signal: ctrl(),
});
expect(result).toBeNull();
});
expect(result).toBeNull();
},
);
it('filesystem fallback returns folders and excludes .git', async () => {
mkdirSync(join(workDir, 'src'));

View file

@ -273,11 +273,18 @@ export interface AutocompleteProvider {
export class CombinedAutocompleteProvider implements AutocompleteProvider {
private commands: (SlashCommand | AutocompleteItem)[];
private basePath: string;
private additionalBasePaths: string[];
private fdPath: string | null;
constructor(commands: (SlashCommand | AutocompleteItem)[] = [], basePath: string, fdPath: string | null = null) {
constructor(
commands: (SlashCommand | AutocompleteItem)[] = [],
basePath: string,
fdPath: string | null = null,
additionalBasePaths: readonly string[] = [],
) {
this.commands = commands;
this.basePath = basePath;
this.additionalBasePaths = additionalBasePaths.map((p) => toDisplayPath(p));
this.fdPath = fdPath;
}
@ -518,7 +525,24 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
return path;
}
private resolveScopedFuzzyQuery(rawQuery: string): { baseDir: string; query: string; displayBase: string } | null {
private searchRoots(): string[] {
const seen = new Set<string>();
const roots: string[] = [];
for (const root of [this.basePath, ...this.additionalBasePaths]) {
const key = toDisplayPath(root);
if (seen.has(key)) continue;
seen.add(key);
roots.push(root);
}
return roots;
}
private resolveScopedFuzzyQuery(
rawQuery: string,
):
| { kind: "relative"; displayBase: string; query: string }
| { kind: "absolute"; baseDir: string; displayBase: string; query: string }
| null {
const normalizedQuery = toDisplayPath(rawQuery);
const slashIndex = normalizedQuery.lastIndexOf("/");
if (slashIndex === -1) {
@ -528,24 +552,22 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
const displayBase = normalizedQuery.slice(0, slashIndex + 1);
const query = normalizedQuery.slice(slashIndex + 1);
let baseDir: string;
if (displayBase.startsWith("~/")) {
baseDir = this.expandHomePath(displayBase);
} else if (displayBase.startsWith("/")) {
baseDir = displayBase;
} else {
baseDir = join(this.basePath, displayBase);
}
try {
if (!statSync(baseDir).isDirectory()) {
// Absolute (~/, /) scopes resolve to a single existing directory and do
// not fan out across additional roots. Relative scopes are expanded per
// search root by the caller.
if (displayBase.startsWith("~/") || displayBase.startsWith("/")) {
const baseDir = displayBase.startsWith("~/") ? this.expandHomePath(displayBase) : displayBase;
try {
if (!statSync(baseDir).isDirectory()) {
return null;
}
} catch {
return null;
}
} catch {
return null;
return { kind: "absolute", baseDir, displayBase, query };
}
return { baseDir, query, displayBase };
return { kind: "relative", displayBase, query };
}
private scopedPathForDisplay(displayBase: string, relativePath: string): string {
@ -716,51 +738,155 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
return score;
}
// Fuzzy file search using fd (fast, respects .gitignore)
// Fuzzy file search using fd (fast, respects .gitignore). Fans out across
// every search root (cwd + additional dirs) so `@` completion covers all
// roots while still pushing the query down to fd.
private async getFuzzyFileSuggestions(
query: string,
options: { isQuotedPrefix: boolean; signal: AbortSignal },
): Promise<AutocompleteItem[]> {
if (!this.fdPath || options.signal.aborted) {
const fdPath = this.fdPath;
if (!fdPath || options.signal.aborted) {
return [];
}
try {
const scopedQuery = this.resolveScopedFuzzyQuery(query);
const fdBaseDir = scopedQuery?.baseDir ?? this.basePath;
const fdQuery = scopedQuery?.query ?? query;
const entries = await walkDirectoryWithFd(fdBaseDir, this.fdPath, fdQuery, 100, options.signal);
const scoped = this.resolveScopedFuzzyQuery(query);
type RootTarget = {
baseDir: string;
pathRoot: string;
displayBase: string;
fdQuery: string;
isAdditional: boolean;
absolute: boolean;
};
const targets: RootTarget[] = [];
const pushFullPathTarget = (root: string) => {
// Whole-tree search for this root using the original query (which
// contains "/", so fd runs in --full-path mode).
targets.push({
baseDir: root,
pathRoot: root,
displayBase: "",
fdQuery: query,
isAdditional: root !== this.basePath,
absolute: false,
});
};
if (scoped?.kind === "absolute") {
targets.push({
baseDir: scoped.baseDir,
pathRoot: scoped.baseDir,
displayBase: scoped.displayBase,
fdQuery: scoped.query,
isAdditional: false,
absolute: true,
});
} else if (scoped?.kind === "relative") {
for (const root of this.searchRoots()) {
const baseDir = join(root, scoped.displayBase);
let isDir = false;
try {
isDir = statSync(baseDir).isDirectory();
} catch {
isDir = false;
}
if (isDir) {
targets.push({
baseDir,
pathRoot: root,
displayBase: scoped.displayBase,
fdQuery: scoped.query,
isAdditional: root !== this.basePath,
absolute: false,
});
} else {
// This root does not have the scoped directory. Fall back to a
// per-root full-path search so another root having the prefix
// does not hide matches that only exist under this root.
pushFullPathTarget(root);
}
}
} else {
for (const root of this.searchRoots()) {
pushFullPathTarget(root);
}
}
if (targets.length === 0) {
return [];
}
const perRoot = await Promise.all(
targets.map(async (target) => {
const entries = await walkDirectoryWithFd(target.baseDir, fdPath, target.fdQuery, 100, options.signal);
return entries.map((entry) => ({ entry, target }));
}),
);
if (options.signal.aborted) {
return [];
}
const scoredEntries = entries
.map((entry) => ({
...entry,
score: fdQuery ? this.scoreEntry(entry.path, fdQuery, entry.isDirectory) : 1,
}))
.filter((entry) => entry.score > 0);
type Scored = {
path: string;
isDirectory: boolean;
score: number;
target: RootTarget;
absPath: string;
};
const bestByAbs = new Map<string, Scored>();
for (const group of perRoot) {
for (const { entry, target } of group) {
const score = target.fdQuery ? this.scoreEntry(entry.path, target.fdQuery, entry.isDirectory) : 1;
if (score <= 0) continue;
const pathWithoutSlash = entry.isDirectory ? entry.path.slice(0, -1) : entry.path;
const absPath = target.absolute
? toDisplayPath(join(target.displayBase, pathWithoutSlash))
: toDisplayPath(join(target.pathRoot, target.displayBase, pathWithoutSlash));
const existing = bestByAbs.get(absPath);
if (!existing || score > existing.score) {
bestByAbs.set(absPath, {
path: entry.path,
isDirectory: entry.isDirectory,
score,
target,
absPath,
});
}
}
}
scoredEntries.sort((a, b) => b.score - a.score);
const topEntries = scoredEntries.slice(0, 20);
const scored = [...bestByAbs.values()];
scored.sort((a, b) => b.score - a.score);
const topEntries = scored.slice(0, 20);
const suggestions: AutocompleteItem[] = [];
for (const { path: entryPath, isDirectory } of topEntries) {
const pathWithoutSlash = isDirectory ? entryPath.slice(0, -1) : entryPath;
const displayPath = scopedQuery
? this.scopedPathForDisplay(scopedQuery.displayBase, pathWithoutSlash)
: pathWithoutSlash;
for (const item of topEntries) {
const pathWithoutSlash = item.isDirectory ? item.path.slice(0, -1) : item.path;
const entryName = basename(pathWithoutSlash);
const completionPath = isDirectory ? `${displayPath}/` : displayPath;
let displayPath: string;
if (item.target.absolute) {
displayPath = this.scopedPathForDisplay(item.target.displayBase, pathWithoutSlash);
} else if (item.target.isAdditional) {
displayPath = item.absPath;
} else {
displayPath = item.target.displayBase
? this.scopedPathForDisplay(item.target.displayBase, pathWithoutSlash)
: pathWithoutSlash;
}
const completionPath = item.isDirectory ? `${displayPath}/` : displayPath;
const value = buildCompletionValue(completionPath, {
isDirectory,
isDirectory: item.isDirectory,
isAtPrefix: true,
isQuotedPrefix: options.isQuotedPrefix,
});
suggestions.push({
value,
label: entryName + (isDirectory ? "/" : ""),
label: entryName + (item.isDirectory ? "/" : ""),
description: displayPath,
});
}

View file

@ -228,6 +228,87 @@ describe("CombinedAutocompleteProvider", () => {
assert.ok(!values?.includes("@packages/ai/src/autocomplete.ts"));
});
test("searches additional base paths with fd for @ mentions", async () => {
setupFolder(baseDir, {
files: { "shared-cwd.ts": "export {};" },
});
setupFolder(outsideDir, {
files: { "shared-extra.ts": "export {};" },
});
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath(), [outsideDir]);
const result = await getSuggestions(provider, ["@shared"], 0, 7);
const values = result?.items.map((item) => item.value) ?? [];
assert.ok(values.includes("@shared-cwd.ts"));
assert.ok(values.includes(`@${join(outsideDir, "shared-extra.ts").replace(/\\/g, "/")}`));
});
test("scopes @dir/ queries across every root", async () => {
setupFolder(baseDir, {
files: { "sub/cwd-file.ts": "export {};" },
});
setupFolder(outsideDir, {
files: { "sub/extra-file.ts": "export {};" },
});
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath(), [outsideDir]);
const result = await getSuggestions(provider, ["@sub/file"], 0, 9);
const values = result?.items.map((item) => item.value) ?? [];
assert.ok(values.includes("@sub/cwd-file.ts"));
assert.ok(values.includes(`@${join(outsideDir, "sub", "extra-file.ts").replace(/\\/g, "/")}`));
});
test("deduplicates entries when an additional root is inside cwd", async () => {
const nestedDir = join(baseDir, "extra");
setupFolder(nestedDir, {
files: { "Overlap.ts": "export {};" },
});
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath(), [nestedDir]);
const result = await getSuggestions(provider, ["@overlap"], 0, 8);
const values = result?.items.map((item) => item.value) ?? [];
const absValue = `@${join(nestedDir, "Overlap.ts").replace(/\\/g, "/")}`;
const relValue = "@extra/Overlap.ts";
const overlapCount = values.filter((v) => v === absValue || v === relValue).length;
assert.strictEqual(overlapCount, 1);
});
test("merges empty @ query across roots within the result cap", async () => {
const cwdFiles: Record<string, string> = {};
for (let i = 0; i < 15; i++) cwdFiles[`f${i}.ts`] = "export {};";
setupFolder(baseDir, { files: cwdFiles });
const extraFiles: Record<string, string> = {};
for (let i = 0; i < 15; i++) extraFiles[`g${i}.ts`] = "export {};";
setupFolder(outsideDir, { files: extraFiles });
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath(), [outsideDir]);
const result = await getSuggestions(provider, ["@"], 0, 1);
const count = result?.items.length ?? 0;
assert.ok(count <= 20, `expected <= 20 items, got ${count}`);
assert.ok(count > 0);
});
test("falls back to full-path per root when another root has the scoped directory", async () => {
setupFolder(baseDir, {
files: { "packages/tui/src/autocomplete.ts": "export {};" },
});
// outsideDir has a top-level src/ but no matching file; a global fallback
// would skip cwd's full-path search entirely and hide the cwd match.
setupFolder(outsideDir, {
files: { "src/other.ts": "export {};" },
});
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath(), [outsideDir]);
const result = await getSuggestions(provider, ["@src/auto"], 0, 9);
const values = result?.items.map((item) => item.value) ?? [];
assert.ok(values.includes("@packages/tui/src/autocomplete.ts"));
});
test("matches directory in middle of path with --full-path", async () => {
setupFolder(baseDir, {
files: {

View file

@ -1,5 +1,5 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { describe, it, mock } from "node:test";
import { stripVTControlCharacters } from "node:util";
import { type AutocompleteProvider, CombinedAutocompleteProvider } from "../src/autocomplete.ts";
import { Editor, wordWrapLine } from "../src/components/editor.ts";
@ -525,24 +525,34 @@ describe("Editor component", () => {
describe("Paste burst fallback", () => {
it("inserts a newline instead of submitting during a rapid burst", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);
let submitted = false;
editor.onSubmit = () => {
submitted = true;
};
// Freeze the clock so the 8 synchronous keystrokes are all seen as one
// burst regardless of host speed or scheduler jitter (the production
// heuristic uses an 8ms inter-char interval, which a slow CI runner can
// exceed between synchronous calls).
mock.timers.enable({ apis: ["Date"] });
mock.timers.setTime(1000);
try {
const editor = new Editor(createTestTUI(), defaultEditorTheme);
let submitted = false;
editor.onSubmit = () => {
submitted = true;
};
editor.handleInput("a");
editor.handleInput("b");
editor.handleInput("c");
editor.handleInput("d");
editor.handleInput("e");
editor.handleInput("f");
editor.handleInput("g");
editor.handleInput("h");
editor.handleInput("\r");
editor.handleInput("a");
editor.handleInput("b");
editor.handleInput("c");
editor.handleInput("d");
editor.handleInput("e");
editor.handleInput("f");
editor.handleInput("g");
editor.handleInput("h");
editor.handleInput("\r");
assert.strictEqual(submitted, false);
assert.strictEqual(editor.getText(), "abcdefgh\n");
assert.strictEqual(submitted, false);
assert.strictEqual(editor.getText(), "abcdefgh\n");
} finally {
mock.timers.reset();
}
});
it("can be disabled", () => {

View file

@ -1,530 +0,0 @@
# 多行粘贴误提交解决方案Bracketed Paste + Paste-Burst Fallback
## 1. 背景
在 iTerm2 + tmux 环境下,向 Kimi Code CLI 输入框粘贴包含换行的文本时,预期行为是整段文本进入输入框;但在某些环境下,回车前的语句会被直接提交执行。
实测结论:
- 当 bracketed paste 标记 `\x1b[200~` / `\x1b[201~` 正常到达时pi-tui 能正确识别成一次粘贴,不会逐行提交。
- 当 bracketed paste 标记没有到达时,粘贴内容里的 `\r` 会被当成裸 Enter触发提交。
- 单独的 `\n` 在 pi-tui 的 `Editor` 里会被当成换行插入;单独的 `\r` 才是主要提交来源。
关键代码位置:
- `packages/pi-tui/src/terminal.ts:147`:启动时写入 `\x1b[?2004h`,启用 bracketed paste。
- `packages/pi-tui/src/terminal.ts:412`:退出时写入 `\x1b[?2004l`,关闭 bracketed paste。
- `packages/pi-tui/src/stdin-buffer.ts:315-369`:识别 `\x1b[200~` / `\x1b[201~`,组装 bracketed paste。
- `packages/pi-tui/src/keys.ts:921`:裸 `\r` 被映射成 `enter`
- `packages/pi-tui/src/components/editor.ts:780`:单独的 `\n` 被当成换行。
- `packages/pi-tui/src/components/editor.ts:792``enter` 触发 `submitValue()`
## 2. 根因
bracketed paste 是由程序主动启用的,流程是:
```text
app 输出 \x1b[?2004h
→ terminal 把粘贴内容包成 \x1b[200~ ... \x1b[201~
→ app 识别标记,整段作为 paste 处理
```
如果中间链路正常:
```text
iTerm2 → tmux → pi-tui
```
粘贴内容会被作为一次 paste不会逐行提交。
如果 `\x1b[200~` / `\x1b[201~` 没有到达程序,程序只能看到裸字节流:
```text
line1\rline2\rline3\r
```
此时:
- `\r``enter``submitValue()`
- 于是第一行被直接提交。
需要注意:
- iTerm2 的 “Applications in terminal may access clipboard” 是 OSC 52 剪贴板访问,不是 bracketed paste 开关。
- tmux 的 `set-clipboard` / `allow-passthrough` 主要服务于 OSC 52 / escape passthrough也不是 bracketed paste 开关。
- 真正决定 bracketed paste 是否生效的是:终端是否在 app 启用 `\x1b[?2004h` 后,把 `\x1b[200~` / `\x1b[201~` 透传给 app。
## 3. 目标
解决方案需要满足:
1. bracketed paste 正常时,多行粘贴作为整体插入,不逐行提交。
2. bracketed paste 失效时,多行粘贴尽量不逐行提交。
3. 不影响正常手敲输入和正常 Enter 提交。
4. 可测试、可关闭、可灰度。
5. 不依赖用户手动修改 tmux 配置作为唯一修复手段。
## 4. 现有实现对比
### 4.1 kimi-code pi-tui
已有:
- 启用 `\x1b[?2004h`
- `StdinBuffer` 识别 `\x1b[200~` / `\x1b[201~`
- `Editor.handlePaste()` 处理粘贴内容
缺少:
- bracketed paste 标记完全没到时的启发式兜底。
### 4.2 oh-my-pi
oh-my-pi 是 pi-tui 的演进 fork核心机制相同但健壮性更强
- 同样启用 `\x1b[?2004h`
- `StdinBuffer` 组装 bracketed paste性能更好
- 有 paste watchdog防止结束标记丢失后卡死
- 有 paste byte limit限制内存
- `bracketed-paste.ts` 解码 tmux extended-keys 重编码的控制字节
但它仍然依赖 bracketed paste 标记到达,**没有解决“标记完全没到”的情况**。
### 4.3 Codex
Codex 的处理最值得参考。它是两层方案:
1. 正常路径:使用 crossterm 的 bracketed paste。
2. 兜底路径:在没有 bracketed paste 的终端上,用 `PasteBurst` 启发式识别“快速连续输入 + Enter”的粘贴突发。
参考:
- [`codex-rs/tui/Cargo.toml`](https://github.com/openai/codex/blob/main/codex-rs/tui/Cargo.toml)
- [`codex-rs/tui/src/tui.rs`](https://github.com/openai/codex/blob/main/codex-rs/tui/src/tui.rs)
- [`codex-rs/tui/src/tui/event_stream.rs`](https://github.com/openai/codex/blob/main/codex-rs/tui/src/tui/event_stream.rs)
- [`codex-rs/tui/src/bottom_pane/paste_burst.rs`](https://github.com/openai/codex/blob/main/codex-rs/tui/src/bottom_pane/paste_burst.rs)
- [`codex-rs/tui/src/bottom_pane/chat_composer.rs`](https://github.com/openai/codex/blob/main/codex-rs/tui/src/bottom_pane/chat_composer.rs)
Codex 的 `handle_paste()` 会把 `\r\n` / `\r` 归一化成 `\n`,避免粘贴里的 CR 触发提交。
## 5. 推荐方案
采用两层防御:
```text
第一层bracketed paste 正常路径
第二层paste-burst 启发式兜底
```
### 5.1 第一层:保持并增强 bracketed paste
保留现有 bracketed paste 流程,并可从 oh-my-pi backport 以下健壮性改进:
1. tmux extended-keys 重编码解码
处理 tmux 在 bracketed paste 内把控制字节重编码成:
- `\x1b[106;5u`
- `\x1b[27;5;106~`
2. paste watchdog
开始标记到了但结束标记丢失时,不能永远卡在 paste mode。
3. paste byte limit
防止异常输入无限增长内存。
4. O(n) paste 组装
避免大粘贴时字符串重复拼接导致卡顿。
这些改进能增强“标记到了之后”的稳定性,但不能解决“标记没到”的问题。
### 5.2 第二层:新增 paste-burst 启发式兜底
新增一个 `PasteBurst` 状态机,只在**没有 bracketed paste 标记**时生效,用于识别这种输入:
```text
短时间内连续收到多个普通字符 + Enter
```
典型触发场景:
```text
line1\rline2\rline3\r
```
但没有 `\x1b[200~` / `\x1b[201~` 包裹。
进入 paste-burst 后:
- 普通字符仍然按现有逻辑正常插入,避免改变当前输入体验。
- `PasteBurst` 只记录最近普通字符的时间与数量。
- 当短时间内累计到足够多的普通字符后,接下来的 Enter / `\r` 会被当成换行,而不是提交。
- burst 结束后的短窗口内Enter 仍按换行处理,避免粘贴末尾的 Enter 误提交。
## 6. PasteBurst 详细设计
### 6.1 放置位置
建议新增:
```text
packages/pi-tui/src/paste-burst.ts
```
`Editor` 使用:
```text
packages/pi-tui/src/components/editor.ts
```
理由:
- 裸 `\r` / `\n` 是在 `Editor.handleInput()` 里被解释成 submit / newline 的。
- `StdinBuffer` 只负责序列切分,不应引入输入框语义。
- `PasteBurst` 是纯状态机,便于单独测试。
### 6.2 输入分类
`PasteBurst` 只处理这些输入:
1. 单个可打印字符
包括 ASCII、中文、Emoji 等。
2. Enter
主要是裸 `\r`
3. 非字符输入
例如方向键、Ctrl/Alt 组合键、popup 导航键等。遇到这些输入时,应调用 `reset()` 清空 burst 状态,再按正常逻辑处理。
不要把带修饰键的输入喂给 `PasteBurst`,避免把快捷键误判成粘贴。
### 6.3 推荐参数
初始值建议参考 Codex并根据实测调整
```ts
const PASTE_BURST_MIN_CHARS = 8;
const PASTE_BURST_CHAR_INTERVAL_MS = 8;
const PASTE_BURST_ACTIVE_IDLE_TIMEOUT_MS = 30;
const PASTE_ENTER_SUPPRESS_WINDOW_MS = 120;
```
含义:
- `PASTE_BURST_MIN_CHARS`:连续多少个普通字符后才可能是粘贴。
- `PASTE_BURST_CHAR_INTERVAL_MS`:两个字符间隔小于该值才认为属于同一突发。
- `PASTE_BURST_ACTIVE_IDLE_TIMEOUT_MS`:进入 burst 后Enter 在这个窗口内仍按换行处理。
- `PASTE_ENTER_SUPPRESS_WINDOW_MS`burst 结束后的一小段时间内Enter 仍按换行处理,避免粘贴末尾的 Enter 误提交。
### 6.4 状态
```text
Idle
PendingFirstChar
Buffering
EnterSuppressWindow
```
状态说明:
- `Idle`:没有最近输入状态。
- `BurstActive`已经连续收到足够多的快速普通字符Enter 应按换行处理。
- `EnterSuppressWindow`burst 结束后的短窗口Enter 仍按换行处理。
### 6.5 核心方法
建议接口:
```ts
class PasteBurst {
onPlainChar(now: number): void;
shouldInsertNewlineInsteadOfSubmit(now: number): boolean;
extendWindow(now: number): void;
reset(): void;
}
```
### 6.6 ASCII / 非 ASCII 差异
为了尽量降低对正常手敲的影响,当前实现不 hold 任何字符:
- ASCII 和非 ASCII 字符都立即插入。
- `PasteBurst` 只根据输入时序判断接下来的 Enter 是否应该被当成换行。
- 这样不会引入“先显示一个字符,再突然变成 paste”的闪烁也不会影响 IME 输入。
### 6.7 与 `Editor.handleInput()` 集成
处理顺序建议:
1. 如果当前输入是 bracketed paste 标记:
- 走现有 `handlePaste()`
- 调用 `pasteBurst.reset()`,避免显式 paste 和 burst 状态互相污染。
2. 如果当前输入不是普通字符、也不是 Enter
- 调用 `pasteBurst.reset()`,避免状态泄漏到后续输入。
3. 如果当前输入是裸 Enter
- 先保留现有 backslash workaround。
- 如果 `shouldInsertNewlineInsteadOfSubmit(now)` 为 true`addNewLine()`,并 `extendWindow(now)`
- 否则走原来的 submit 逻辑。
4. 如果当前输入是单个可打印字符:
- 调用 `onPlainChar(now)` 记录时序。
- 然后按现有逻辑插入字符。
## 7. 配置与灰度
建议增加一个开关,避免影响所有用户:
```ts
disablePasteBurst?: boolean;
```
或者使用实验 flag
```text
KIMI_CODE_EXPERIMENTAL_PASTE_BURST
```
推荐发布顺序:
1. 默认关闭,先在测试和内部使用。
2. 手动验证 iTerm2 + tmux、Windows Terminal、普通 macOS Terminal。
3. 观察是否有正常手敲被误判成 paste。
4. 默认开启。
5. 保留关闭开关作为 escape hatch。
## 8. 测试计划
建议把测试加到现有 `Editor` 测试文件里,避免新增过多测试文件。
### 8.1 bracketed paste 路径不变
- 输入 `\x1b[200~a\r\nb\x1b[201~`
- 应作为一次 paste 插入
- 不触发 submit
### 8.2 非 bracketed paste burst 被识别
输入:
```text
a
b
c
<idle>
```
字符间隔小于 `PASTE_BURST_CHAR_INTERVAL_MS` 时:
- 字符仍按现有逻辑插入
- 不触发 submit
- 随后的 Enter 在 burst 窗口内按换行处理
### 8.3 burst 内 Enter 不提交
输入:
```text
abc\rdef\r
<idle>
```
期望:
- `\r` 被当成换行处理
- 不触发 submit
- 输入框保留多行文本
### 8.4 正常手敲不受影响
输入:
```text
a
<等待超过 interval>
b
<等待超过 interval>
Enter
```
期望:
- `a``b` 作为正常输入
- Enter 正常 submit
### 8.5 Enter suppression window
输入:
```text
abcdefgh
Enter
```
如果 Enter 距离上次 burst 活动小于 `PASTE_ENTER_SUPPRESS_WINDOW_MS`
- 先按换行处理
超过窗口后:
- 恢复 submit 行为
### 8.6 非 ASCII / IME
输入中文:
```text
```
快速连续到达时:
- 中文字符仍按现有逻辑立即插入
- 快速连续输入后的 Enter 应按换行处理
### 8.7 disable 开关
`disablePasteBurst = true`
- 所有输入按正常 typing 处理
- 不做 paste-burst Enter 抑制
## 9. 手动验证
### 9.1 bracketed paste 正常
在 tmux 里运行:
```sh
printf '\033[?2004h'; cat -A; printf '\033[?2004l'
```
粘贴多行文本,应看到:
```text
^[[200~ ...
...
^[[201~
```
### 9.2 CRLF 文件粘贴
使用:
```text
paste-test-crlf.txt
```
粘贴到 Kimi Code 输入框:
- 修复前:如果 bracketed paste 生效,也不会复现。
- 修复后:无论 bracketed paste 是否生效,都应尽量作为整段粘贴处理。
### 9.3 send-keys 强制模拟裸 Enter
```sh
tmux send-keys -t <target> -l '复现第一行'
tmux send-keys -t <target> Enter
tmux send-keys -t <target> -l '复现第二行'
tmux send-keys -t <target> Enter
```
修复前:裸 Enter 会提交。
修复后:如果输入足够快,应被 paste-burst 合并为一次粘贴,至少不应逐行误提交。
注意:`send-keys` 的时序可能受 tmux 和系统调度影响,不能完全等同于真实粘贴,只能作为近似验证。
## 10. 风险与缓解
### 10.1 快速手敲被误判成粘贴
风险:
```text
用户快速敲一个短命令 + Enter
```
可能被误判成 paste。
缓解:
- 只处理无修饰键的普通字符。
- 要求至少 `PASTE_BURST_MIN_CHARS` 个连续快速字符。
- 提供 disable 开关。
- 初始默认关闭,先灰度。
### 10.2 短 Enter suppression 影响正常提交
`PASTE_ENTER_SUPPRESS_WINDOW_MS` 可能让用户在快速输入后按 Enter 时被当成换行。
缓解:
- 窗口保持较短,例如 80-120ms。
- 只在前一个输入被判定为 burst 后开启。
- 正常慢速输入不开启。
### 10.3 慢速粘贴无法识别
如果终端把粘贴拆成很慢的 chunk间隔超过 `PASTE_BURST_CHAR_INTERVAL_MS`,启发式可能失效。
缓解:
- 可适当提高 interval。
- 但不能无限提高,否则会误伤正常输入。
- bracketed paste 仍是主路径,启发式只是兜底。
## 11. 环境侧建议
应用层启发式是兜底,环境侧仍建议保持健康配置。
`~/.tmux.conf`
```conf
set -g extended-keys on
```
然后重启 tmux
```sh
tmux kill-server
tmux new -s test
```
可选,不是 bracketed paste 必需:
```conf
set -g set-clipboard on
set -g allow-passthrough on
```
这两行主要用于 OSC 52 / escape passthrough不应被当作 bracketed paste 修复手段。
## 12. 推荐落地步骤
1. 新增 `PasteBurst` 状态机,先写单元测试。
2. 在 `Editor.handleInput()` 接入,默认关闭。
3. 确保 bracketed paste 显式路径不变。
4. 增加 disable 开关或实验 flag。
5. 跑现有测试,确认无回归。
6. 手动验证 iTerm2 + tmux、`send-keys`、CRLF 文件粘贴。
7. 内部试用一段时间。
8. 默认开启,保留关闭开关。
## 13. 结论
根本机制是 bracketed paste
```text
标记到了 → 一次粘贴 → 不逐行提交
```
但不能保证所有终端、tmux、SSH、Windows 环境都可靠传递标记。
因此完整方案是:
```text
bracketed paste 作为主路径;
paste-burst 启发式作为失效兜底。
```
这和 Codex 的方向一致。它不能从协议层面“根本消除”标记不到的情况,但能把实际误提交概率降到足够低,并且可测试、可关闭、可灰度。

View file

@ -1,303 +0,0 @@
# Thinking Effort 多档位切换方案
## 1. 背景与目标
新模型支持切换 reasoning effort`low` / `high` / `max` 三档。模型目录会为每个模型返回:
- `support_efforts = ["low", "high", "max"]`:该模型支持的档位列表(按模型不同)。
- `default_effort = "high"`:该模型的出厂默认档位。
目标:
1. 把 `support_efforts` / `default_effort` 像现有能力值一样存进 `config.toml`
2. 在 `/model` 选择器里,把模型下方的 thinking 控件从「On/Off 两段」扩展为「多档位」,并默认高亮默认值。
3. 用户切换档位时立即生效,并更新**全局** `thinking.effort`(复用现有字段,跨重启自动保留)。
非目标(本期不做):
- 按模型分别记住不同 effort先全局生效后续有需要再加
- 改动 agent-core 的 effort 解析逻辑(客户端始终下发具体档位,解析逻辑不变)。
## 2. 现状关键事实
- **数据流(上游→下游)**managed 模型目录 → `packages/oauth` 解析并写入 `config.toml``/models``config.toml` 透出TUI 直接读 `config.toml`
- **模型条目刷新**managed`kimi-code`)模型每次刷新会**整条删除再重建**`packages/oauth/src/managed-kimi-code.ts:464-478`),所以用户偏好**不能**存在模型条目里,会被冲掉。
- **能力存储**`config.toml``[models.<id>]` 下的 `capabilities = ["thinking", "always_thinking", "tool_use", ...]`(字符串标签数组)。
- **全局 effort 已存在**`ThinkingConfigSchema = { mode?, effort? }``resolveThinkingEffort` 已用 `thinking.effort` 作为 `'on'` 的默认值(缺省 `'high'`)。
- **底层已支持多档**`ThinkingEffort = 'off' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'``Session.setThinking(level: string)` 接受任意字符串。
- **kimi provider 当前发 `reasoning_effort`**`packages/kosong/src/providers/kimi.ts``withThinking` 目前把 effort 映射成 `reasoning_effort`(与 openai 一致),同时额外发一个 `thinking: { type }`。新接口要求改成在 `thinking` 对象里带 `effort`;过渡期 `reasoning_effort` **仍保留一起传**(加 TODO后续再删
- **TUI 当前是布尔 On/Off**`apps/kimi-code/src/tui/components/dialogs/model-selector.ts``←/→` 翻转布尔,`On` 左、`Off` 右。
## 3. 设计决策(已确认)
| 决策点 | 结论 |
|---|---|
| `support_efforts` / `default_effort` 存哪 | 模型条目 `[models.<id>]` 下的独立字段,与 `capabilities` 平级 |
| Off 行为 | 复用现有 `always_thinking` / `thinking` 标签:`always_thinking` 无 Off`thinking` 有 Off |
| 用户选择存哪 | **全局** `thinking.effort`(已有字段);不新增按模型的表 |
| 跨重启 | 切换写入 `thinking.effort`,现有解析逻辑会自动用它,跨重启自然保留 |
| `default_effort` 角色 | 模型出厂默认值(随刷新更新);用于选择器里非当前模型的初始高亮 |
| kimi 请求里的 effort | 新增 `thinking: { type, effort? }``keep` 恒为 `all`,本期不传;仅当模型有 `supportEfforts` 时带 effort`reasoning_effort` 过渡期**继续一起传**(加 TODO后续删除 |
## 4. config.toml 形态
```toml
default_model = "kimi-code/kimi-k2"
default_thinking = true
[thinking]
mode = "auto"
effort = "max" # 用户切换后更新这里(全局)
[models."kimi-code/kimi-k2"]
provider = "kimi-code"
model = "kimi-k2"
max_context_size = 262144
capabilities = ["thinking", "always_thinking", "tool_use"]
support_efforts = ["low", "high", "max"] # 新增(接口给,随刷新)
default_effort = "high" # 新增(接口给,随刷新)
```
## 5. 配置 schema 改动
### 5.1 `packages/agent-core/src/config/schema.ts`
`ModelAliasSchema` 新增两个可选字段(**通用**,任何 provider 的模型都可以有):
```ts
supportEfforts: z.array(z.string()).optional(),
defaultEffort: z.string().optional(),
```
> managed 模型由接口自动写入其它模型openai / anthropic / 自定义 provider 等)也可以**手动在 `config.toml``[models.<id>]` 里写 `support_efforts` / `default_effort`**UI 读到就会展示多档位切换。
`ThinkingConfigSchema` **不变**(复用现有 `effort`)。
### 5.2 `packages/protocol/src/modelCatalog.ts`
`modelCatalogItemSchema` 新增:
```ts
support_efforts: z.array(z.string()).optional(),
default_effort: z.string().optional(),
```
### 5.3 `packages/agent-core/src/services/modelCatalog/modelCatalog.ts`
`toProtocolModel()``alias.supportEfforts` / `alias.defaultEffort` 透到 `support_efforts` / `default_effort`,让 `/models` 返回。
## 6. OAuth 解析与写入
### 6.1 `packages/oauth/src/managed-kimi-code.ts`
- `ManagedKimiCodeModelInfo` 新增 `supportEfforts?: readonly string[]``defaultEffort?: string`
- `toModelInfo()`(约 `:364`)解析 `support_efforts`(字符串数组,过滤非字符串/空串)与 `default_effort`(非空字符串)。新增一个小工具 `parseStringArray()`
- 写 config 的循环(`:469-478`)把 `supportEfforts` / `defaultEffort` 写进模型条目。
### 6.2 `packages/oauth/src/open-platform.ts`
为保持一致,同步在 `toModelInfo()``:44`)解析、模型写入(`:173-182`带上这两个字段open-platform 路径共用 `ManagedKimiCodeModelInfo`)。
## 7. kosong kimi provider 改造(新 wire 格式)
文件:`packages/kosong/src/providers/kimi.ts`,并把 `supportEfforts``ModelAlias` 透到 provider见下方第 5 点)。
新接口格式:
```json
{ "thinking": { "type": "enabled", "keep": "all", "effort": "high" } }
```
- `keep` 恒为 `all`,本期**不传**。
- `effort` 放在 `thinking` 对象内(仅当模型有 `supportEfforts` 时)。
- `reasoning_effort` 过渡期**继续一起传**(加 `TODO`,后续再删)。
改动点:
1. **`ThinkingConfig` 接口**`:74-78`):新增 `effort?: string`(已有 `[key: string]: unknown` 兜底,显式声明更清晰)。
2. **`withThinking(effort)`**`:500-524`)重写:
- `kimiEffort(effort)` 映射(唯一映射,**删除旧的 `high/xhigh/max→high` clamp**`low→low``medium→medium``high→high``xhigh→high`kimi 无 xhigh`max→max`
- 构造 `thinking``effort === 'off'``{ type: 'disabled' }`;否则模型有 `supportEfforts`(非空)→ `{ type: 'enabled', effort: kimiEffort(effort) }`;否则(旧模型)→ `{ type: 'enabled' }`(无 effort
- `reasoningEffort`**仅当 `effort !== 'off'` 且模型有 `supportEfforts`** 时取 `kimiEffort(effort)`(与 `thinking.effort` 同值),否则 `undefined`;旁加 `TODO` 注释标明后续删除。
- 返回 `_withGenerationKwargs({ reasoning_effort: reasoningEffort })` + `withExtraBody({ thinking })`
3. **`thinkingEffort` getter**`:415-417`):从 `extra_body.thinking` 反推(不再读 `reasoning_effort`,旧的反推映射一并删除):
- `thinking` 未设置 → `null`
- `type === 'disabled'``'off'`
- `thinking.effort` 存在 → 反查为 `ThinkingEffort``'low'/'medium'/'high'/'xhigh'/'max'`,未知值兜底 `'high'`)。
- `type === 'enabled'` 但无 `effort`(旧模型)→ `'high'`(逻辑默认档)。
- 由此 `reasoningEffortToThinkingEffort` 在 kimi.ts 不再使用,移除该 import。
4. **`reasoning_effort` 字段**:本期保留,但与 `thinking.effort` **同值**(统一走 `kimiEffort`,旧的 clamp 映射删除),且**同样按 `supportEfforts` 门控**(旧模型不再发 `reasoning_effort`);代码里加 `TODO`,待新 wire 格式全量上线后删除(见第 11 节)。
5. **把 `supportEfforts` 透到 provider**`packages/agent-core/src/session/provider-manager.ts`
- `KimiOptions` 新增 `supportEfforts?: readonly string[]``KimiChatProvider` 构造时存为私有字段,`_clone()` 通过 `Object.assign` 自动保留。
- `toKosongProviderConfig` 新增 `supportEfforts` 参数,`case 'kimi'` 里写入 provider config。
- `resolveProviderConfig` 调用 `toKosongProviderConfig` 时传入 `alias.supportEfforts`
- `withThinking` 用该字段决定是否带 `effort`
> 注意:`thinking: { type }` 之前就已经在发,本次只是在该对象里(按能力)加 `effort``reasoning_effort` 暂时仍保留一起发,改动面很小。
> 其它 provideropenai / anthropic / google-genai 等)的 `withThinking` 已经各自把 `ThinkingEffort` 映射到自己的原生参数,**本期不改**。这些模型只要(手动)配了 `support_efforts`UI 就能切换档位,选中的档位照旧走各 provider 现有的 `withThinking` 传参逻辑。只有 kimi provider 需要按 `supportEfforts` 门控 `thinking.effort` / `reasoning_effort`
## 8. TUI 改造
### 8.1 `model-selector.ts`(核心)
**类型变化**
- `ModelSelection.thinking``boolean``string`(档位:`'off'``'on'`、或具体 effort 如 `'high'`)。
- `ModelSelectorOptions.currentThinking: boolean``currentThinkingLevel: string`(当前模型的运行时档位)。
- 内部 `thinkingOverrides: Map<alias, boolean>``Map<alias, string>`
**新增工具**
```ts
function effortsOf(model: ModelAlias): readonly string[] {
return model.supportEfforts ?? [];
}
```
**分段segments规则**
| 模型类型 | segments | 说明 |
|---|---|---|
| 不支持 thinking | `['off']`(渲染 On 不可选 + `[Off]`,保持现状) | 不变 |
| 普通 toggle无 effort | `['on', 'off']` | 不变 |
| always-on无 effort | `['on']` | 不变 |
| toggle + effort | `['off', ...supportEfforts]` | Off 在最左 |
| always-on + effort | `[...supportEfforts]` | 无 Off |
> effort 模型 Off 放最左;非 effort 模型保持 `On` 左 / `Off` 右,避免改动现有视觉。
**高亮draftFor**
- 有 `←/→` override → override。
- 当前模型 → `currentThinkingLevel`(运行时实际档位)。
- 其它 effort 模型 → `defaultEffort`(若在 `supportEfforts` 内),否则 `supportEfforts[0]`
- 其它非 effort 模型 → 保持现有逻辑capable 默认 `'on'`,否则 `'off'`)。
**键盘**
- `←`active 段左移一位(到最左停)。
- `→`active 段右移一位(到最右停)。
- 不循环;不支持的模型忽略。
**渲染 `renderThinkingControl`**
`segmentsFor(model)` 渲染所有段active 段用 `boldFg('primary', '[ label ]')`,其余 `fg('text', ' label ')`;不支持的侧保持 `textMuted` + `(Unsupported)`(仅非 effort 的 always-on/unsupported 路径保留)。段标签首字母大写(`off``Off``low``Low``max``Max`)。
**标题行**
- toggle / effort 模型可切换时显示 `Thinking (←→ to switch)`,否则 `Thinking`
### 8.2 `tabbed-model-selector.ts`
透传:`TabbedModelSelectorOptions.currentThinking``currentThinkingLevel``makeSelector` 里传 `currentThinkingLevel``onSelect``thinking` 现在是 string直接转发。
### 8.3 `commands/config.ts`
- `showModelPicker``currentThinkingLevel: host.state.appState.thinkingLevel ?? (host.state.appState.thinking ? 'on' : 'off')`
- `performModelSwitch(host, alias, level: string, persist)`
- `const prevLevel = host.state.appState.thinkingLevel ?? (host.state.appState.thinking ? 'on' : 'off')`
- session 路径:`alias !== prevModel``setModel(alias)``level !== prevLevel``setThinking(level)`(直接传档位字符串)。
- 无 session`authFlow.activateModelAfterLogin(alias, level)`
- `setAppState({ model: alias, thinking: level !== 'off', thinkingLevel: level })`
- 状态文案:`thinking on/off` → 直接显示档位(如 `thinking high`)。
- `persistModelSelection(host, alias, level: string)`
- `defaultModel = alias`
- `defaultThinking = level !== 'off'`
- 若 `level` 是具体 effort`level !== 'on' && level !== 'off'`)→ 写 `thinking: { effort: level }``setConfig` 深合并,保留 `thinking.mode`)。
- 仅在 `defaultModel` / `defaultThinking` / `thinking.effort` 任一变化时调用 `setConfig`
### 8.4 `commands/provider.ts`
`setDefaultModel(host, alias, thinking)``thinking``boolean` 改为 `level: string`,写 `defaultThinking: level !== 'off'`,并在具体 effort 时写 `thinking.effort`。两处 `onSelect` 透传 string。两处 `currentThinking``currentThinkingLevel`
### 8.5 `commands/prompts.ts``runModelSelector`
- 选项 `currentThinking``currentThinkingLevel: initialThinking ? 'on' : 'off'`
- `onSelect` 拿到 string level返回时转回 boolean`{ alias, thinking: level !== 'off' }`open-platform / catalog 模型无 effort 字段level 只会是 `'on'`/`'off'`)。
### 8.6 `types.ts` / `kimi-tui.ts` / `auth-flow.ts` / `footer.ts`
- `AppState` 新增 `thinkingLevel?: string`(运行时档位)。
- `kimi-tui.ts syncRuntimeState``:1201`):加 `thinkingLevel: status.thinkingLevel`
- `kimi-tui.ts` 初始 state`:193`)与 logout 重置:`thinkingLevel: 'off'`
- `kimi-tui.ts:1176-1177` reload 建 session`thinking``this.state.appState.thinkingLevel ?? (this.state.appState.thinking ? 'on' : 'off')`,保留具体档位。
- `auth-flow.ts activateModelAfterLogin(model, level?: string)`:参数由 `thinking?: boolean` 改为 `level?: string`,直接传给 `setThinking` / `CreateSessionOptions.thinking``refreshConfigAfterLogin``activateModelAfterLogin(defaultModel, config.defaultThinking === false ? 'off' : undefined)`
- `footer.ts:265`:有 effort 时显示档位,例如 `state.thinkingLevel` 为具体 effort 时渲染 ` thinking:max`,否则保持 ` thinking`
## 9. 逐文件改动清单
| 包 | 文件 | 改动 |
|---|---|---|
| kosong | `src/providers/kimi.ts` | `withThinking` 新增 `thinking: { type, effort? }`(仅当模型有 supportEfforts 时带 effort`reasoning_effort` 暂保留但与 effort 同值、同样按 supportEfforts 门控,旧 clamp 映射删除并加 TODO`thinkingEffort` getter 从 `thinking` 反推;`KimiOptions``supportEfforts` |
| agent-core | `src/session/provider-manager.ts` | `toKosongProviderConfig` 透传 `supportEfforts``resolveProviderConfig` 传入 `alias.supportEfforts` |
| agent-core | `src/config/schema.ts` | `ModelAliasSchema``supportEfforts` / `defaultEffort` |
| agent-core | `src/services/modelCatalog/modelCatalog.ts` | `toProtocolModel` 透出两字段 |
| protocol | `src/modelCatalog.ts` | `modelCatalogItemSchema``support_efforts` / `default_effort` |
| oauth | `src/managed-kimi-code.ts` | `ManagedKimiCodeModelInfo` + `toModelInfo` + 写 config |
| oauth | `src/open-platform.ts` | `toModelInfo` + 写 config同步 |
| kimi-code | `src/tui/components/dialogs/model-selector.ts` | 多档位 UI + 键盘 + 类型 |
| kimi-code | `src/tui/components/dialogs/tabbed-model-selector.ts` | 透传 `currentThinkingLevel` |
| kimi-code | `src/tui/commands/config.ts` | 档位化切换 + 持久化 `thinking.effort` |
| kimi-code | `src/tui/commands/provider.ts` | `setDefaultModel` 档位化 |
| kimi-code | `src/tui/commands/prompts.ts` | `runModelSelector` 适配 string level |
| kimi-code | `src/tui/types.ts` | `AppState.thinkingLevel` |
| kimi-code | `src/tui/kimi-tui.ts` | syncRuntimeState / 初始 / reload |
| kimi-code | `src/tui/controllers/auth-flow.ts` | `activateModelAfterLogin` 档位化 |
| kimi-code | `src/tui/components/chrome/footer.ts` | 显示 effort 档位 |
## 10. 测试计划
就近扩展,不新增泛化测试文件:
- `packages/kosong`kimi provider 测试)
- `withThinking('high')` 在模型有 `supportEfforts` 时发出 `thinking: { type: 'enabled', effort: 'high' }`,且 `reasoning_effort === 'high'`
- `withThinking('max')` 在模型有 `supportEfforts` 时:`thinking.effort === 'max'``reasoning_effort === 'max'`(同值,无 clamp
- `withThinking('high')` 在模型无 `supportEfforts` 时发出 `thinking: { type: 'enabled' }`(无 effort`reasoning_effort === undefined`(按能力门控,旧模型不再发)。
- `withThinking('off')` 发出 `thinking: { type: 'disabled' }``reasoning_effort === undefined`
- `thinkingEffort` getter`thinking` 反推(`{enabled, effort:'max'}``'max'``{enabled}``'high'``{disabled}``'off'`、未设置→`null`)。
- `packages/agent-core`provider-manager 测试)
- `resolveProviderConfig``alias.supportEfforts` 透到 kimi provider config。
- `apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts`
- 现有 On/Off 用例:`currentThinking``currentThinkingLevel``'on'`/`'off'`),断言 `thinking` 为字符串。
- 新增 effort 用例:
- effort 模型渲染 `[Off] [Low] [High] [Max]`toggle/ `[Low] [High] [Max]`always-on
- 默认高亮 `defaultEffort`
- `←/→` 在多档间移动并到端点停止Enter 下发具体档位。
- 当前模型高亮运行时档位(`currentThinkingLevel`)。
- 切到 Off 时下发的 `thinking === 'off'`
- `apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts`
- 透传 `currentThinkingLevel`;断言 string 档位透传。
- `packages/oauth` 现有测试
- `toModelInfo` 解析 `support_efforts` / `default_effort`
- 写 config 时模型条目包含 `supportEfforts` / `defaultEffort`
- `packages/agent-core` 配置相关测试
- `ModelAliasSchema` 接受新字段;`toProtocolModel` 透出。
跑测试:
```bash
pnpm --filter @moonshot-ai/kimi-code test -- model-selector tabbed-model-selector
pnpm --filter @moonshot-ai/oauth test
pnpm --filter @moonshot-ai/kosong test -- kimi
pnpm --filter @moonshot-ai/agent-core test -- modelCatalog provider-manager
```
类型检查 / lint
```bash
pnpm --filter @moonshot-ai/kimi-code typecheck
pnpm lint
```
## 11. 范围外 / 后续
- 删除 kimi provider 的 `reasoning_effort`(待新 `thinking.effort` wire 格式全量上线、所有 kimi 模型都接受后;代码里已留 `TODO`)。
- 按模型分别记住 effort先全局若需要再加 `[thinking.model_efforts]` 之类的覆盖表)。
- agent-core 解析感知 `default_effort`(本期客户端始终下发具体档位,无需改动)。
- kimi-web 端的多档 UI`/models` 透出字段后web 已具备 `ThinkingLevel` 模型,可另行接入)。

View file

@ -1,385 +0,0 @@
# Thinking 模型重构方案
> 前置工作:`plan/thinking-effort-switching.md`effort 多档切换)已完成。本方案是在其基础上对 thinking 模型的整体重构,目标是消除冗余开关、硬编码默认值和静态档位枚举,让 `support_efforts` 成为档位的唯一真相源。
## 1. 背景与目标
当前 thinking 的开关和级别逻辑横跨 config、agent-core、SDK、TUI、provider 五层,经过多轮演进后积累了以下问题:
- 配置层同时存在 `default_thinking`boolean`thinking.mode`auto/on/off`thinking.effort` 三个字段,语义重叠;`mode=auto``mode=on` 代码里完全等价,名存实亡。
- kosong 的 `ThinkingEffort = 'off' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'` 是全局硬编码枚举,但档位本应来自模型声明的 `support_efforts``kimiEffort()` 还得硬编码 `'xhigh' → 'high'` 之类的映射。
- `'on'` 是一个不在 `ThinkingEffort` 枚举里的幽灵档位,贯穿 TUI → SDK → agent-core语义在层间断裂。
- TUI 用 `AppState.thinking: boolean` + `AppState.thinkingLevel?: string` 两个字段存一个状态,五处重复 fallback。
- `DEFAULT_THINKING_EFFORT = 'high'` 硬编码兜底,与模型声明的 `default_effort` 重复。
- `always_thinking` 约束在 UI、agent-core getter、acp adapter 三处各 clamp 一次。
目标:
1. 配置收敛到 `[thinking]` 一处,删除 `default_thinking``thinking.mode`
2. `support_efforts` 成为 effort 档位的唯一真相源,删除全局 `ThinkingEffort` 枚举和 `kimiEffort` 映射。
3. 删除 `DEFAULT_THINKING_EFFORT` 硬编码,默认值完全由模型声明推导。
4. `'on'` 圈禁为 boolean 模型(无 `support_efforts`)专属的开启信号,不再贯穿全栈。
5. TUI 单一字段 `thinkingLevel`,删除 `thinking: boolean`
6. `always_thinking` 约束集中到 agent-core resolve 一处。
非目标(本期不做):
- 按模型分别记住 effort 偏好(`[thinking.model_efforts]`),仍用全局 `thinking.effort`,切模型时由 provider 归一。
- 真正实现 `mode=auto` 的"按任务/模型自适应",直接删除该值。
- kimi-web 端改造。
## 2. 设计原则
1. **单一真值**thinking 运行时状态在 agent-core 里就是一个 `ThinkingLevel`,不存在第二个 boolean 字段。
2. **`support_efforts` 是 effort 档位的唯一真相源**:档位是模型的属性,不是全局枚举。
3. **归一优先于校验**:不做 `isValidThinkingLevel` 严格校验抛错;非法 effort 在 provider 端宽容归一为 `undefined`
4. **`'off'``'on'` 是唯二保留字**`'off'` 关闭,`'on'` 专属于无 `support_efforts` 的 boolean 模型;其余档位都是模型声明。
5. **`always_thinking` 是约束,不是档位**:在 agent-core resolve 一处 clamp。
## 3. 设计决策(已确认)
| 决策点 | 结论 |
|---|---|
| `default_thinking` | **直接删除**不做兼容读取。breaking changechangeset 走 major + changelog 写迁移说明 |
| `thinking.mode` | **删除**`off``enabled=false` 表达,`on`/`auto``enabled=true` 表达 |
| `DEFAULT_THINKING_EFFORT = 'high'` | **删除**。默认值由 `defaultLevelFor(model)` 从模型声明推导 |
| `ThinkingEffort` 静态枚举 | **删除**。改为 `ThinkingLevel = 'off' | 'on' | (string & {})`,档位动态化 |
| `isValidThinkingLevel` 校验 | **不做**。非法 effort 在 provider 宽容归一为 `undefined`,不抛错 |
| effort 非法值归一 | provider 端:`supportEfforts.includes(level) ? level : undefined` |
| `always_thinking` clamp | agent-core `resolveThinkingLevel``level === 'off'` 时归一为 `defaultLevelFor(model)` |
| `kimiEffort()` 映射 | **删除**。effort 直接来自 `support_efforts`,是 kimi 原生值 |
| `wireEffortToThinkingEffort()` | **删除**。getter 直接读 wire 值 |
| TUI `AppState.thinking: boolean` | **删除**。只留 `thinkingLevel: ThinkingLevel` |
| `setThinking` 类型 | `ThinkingLevel`,透传无运行时校验 |
| 全局 effort 偏好 | 保留 `thinking.effort`,切模型时由 provider 归一(不合法则不带 effort |
## 4. 目标形态
### 4.1 config.toml
```toml
default_model = "kimi-code/kimi-k2"
[thinking]
enabled = true # 默认是否开启(替代 default_thinking + thinking.mode
effort = "high" # 开启时的默认档位low/medium/high/xhigh/max或模型声明的其他档
[models."kimi-code/kimi-k2"]
provider = "kimi-code"
model = "kimi-k2"
max_context_size = 262144
capabilities = ["thinking", "always_thinking", "tool_use"]
support_efforts = ["low", "high", "max"] # effort 档的唯一真相源
default_effort = "high"
```
迁移说明changelog旧配置 `default_thinking = true` 改为 `[thinking] enabled = true``default_thinking = false` 改为 `enabled = false``[thinking] mode = "off"` 改为 `enabled = false``mode = "on"` / `mode = "auto"` 删除该行(等价于 `enabled = true`)。
### 4.2 类型
```ts
// packages/kosong/src/provider.ts
export type ThinkingLevel = 'off' | 'on' | (string & {});
// ^^^^^^^^^^^^
// 模型声明的 effort 档,运行时 string
```
`ThinkingEffort` 类型**直接删除**,所有引用在同一个 PR 内全量替换为 `ThinkingLevel`**不留别名、不留兼容层**。
`ThinkingLevel` 在 TS 里塌缩成 `string`,主要作为**语义标注**:告诉调用者这里应该是 `'off'` / `'on'` / 模型 effort 档。运行时就是 `string`,不做强约束。
### 4.3 agent-core 运行时
```ts
// packages/agent-core/src/agent/config/index.ts
private _thinkingLevel: ThinkingLevel = 'off';
get thinkingLevel(): ThinkingLevel {
return this._thinkingLevel; // 不在这里 clamp always_thinking
}
```
`_thinkingLevel` 是 agent-core 内唯一的 thinking 状态字段。
### 4.4 配置 schema
```ts
// packages/agent-core/src/config/schema.ts
export const ThinkingConfigSchema = z.object({
enabled: z.boolean().optional(),
effort: z.string().optional(),
});
```
删除 `mode` 字段。
### 4.5 provider 层
```ts
// packages/kosong/src/providers/kimi.ts
withThinking(level: ThinkingLevel): KimiChatProvider {
let thinking: ThinkingConfig;
let reasoningEffort: string | undefined;
if (level === 'off') {
thinking = { type: 'disabled' };
} else {
// support_efforts 是 effort 档的唯一真相源:只下发声明里的值,
// 其余('on' / 'xhigh' / 非法值)一律归一为 undefined不带 effort
const effort = this._supportEfforts.includes(level) ? level : undefined;
thinking = effort !== undefined
? { type: 'enabled', effort }
: { type: 'enabled' };
// TODO: drop reasoning_effort once the new thinking.effort wire format is
// fully rolled out across all kimi models.
reasoningEffort = effort;
}
const oldExtra = this._generationKwargs.extra_body ?? {};
const keep = oldExtra.thinking?.keep;
if (keep !== undefined) {
thinking = { ...thinking, keep };
}
return this._withGenerationKwargs({
reasoning_effort: reasoningEffort,
extra_body: { ...oldExtra, thinking },
});
}
```
其它 provideropenai / anthropic / google-genai 等)的 `withThinking` 已经各自把 effort 映射到原生参数,本期不改。
### 4.6 TUI 层
```ts
// apps/kimi-code/src/tui/types.ts
AppState.thinkingLevel: ThinkingLevel; // 唯一字段,删除 thinking: boolean
```
boolean 模型的 UI 归一:
```ts
// model-selector.ts
function commitLevel(choice: ModelChoice, draft: string): ThinkingLevel {
if (draft === 'off') return 'off';
if (draft === 'on') {
return defaultLevelFor(choice.model); // boolean 模型On → 模型默认
}
return draft;
}
```
`ModelSelection.thinking` 类型收紧为 `ThinkingLevel`,不再有 `'on'` 漏出 UI 边界。
## 5. 关键归一规则
### 5.1 默认值推导
```ts
// packages/agent-core/src/agent/config/thinking.ts
function defaultLevelFor(model: ModelAlias): ThinkingLevel {
if (!supportsThinking(model)) return 'off';
const efforts = model.supportEfforts;
if (efforts?.length) return model.defaultEffort ?? middleOf(efforts);
return 'on'; // boolean 模型
}
```
| 模型 | 默认 |
|---|---|
| 不支持 thinking | `'off'` |
| 有 `support_efforts` | `default_effort``support_efforts` 中位 |
| 无 effort 的 boolean 模型 | `'on'` |
### 5.2 resolve
```ts
// packages/agent-core/src/agent/config/thinking.ts
export function resolveThinkingLevel(
requested: ThinkingLevel | undefined,
config: ThinkingConfig | undefined,
model: ModelAlias,
): ThinkingLevel {
let level: ThinkingLevel;
if (requested !== undefined) {
level = requested;
} else if (config?.enabled === false) {
level = 'off';
} else {
level = config?.effort ?? defaultLevelFor(model);
}
// always_thinking 模型强制开启:'off' 归一为默认档
if (level === 'off' && model.capabilities?.includes('always_thinking')) {
level = defaultLevelFor(model);
}
return level;
}
```
删除 `resolveThinkingEffort``'on'` 分支、`mode` 分支、`DEFAULT_THINKING_EFFORT`
### 5.3 provider effort 归一
```ts
const effort = this._supportEfforts.includes(level) ? level : undefined;
```
行为表:
| 模型 | 传入 level | wire `thinking` |
|---|---|---|
| effort 模型 `['low','high','max']` | `'high'` | `{ type: 'enabled', effort: 'high' }` |
| effort 模型 | `'max'` | `{ type: 'enabled', effort: 'max' }` |
| effort 模型 | `'xhigh'` | `{ type: 'enabled' }`(不带 effort |
| effort 模型 | `'on'` | `{ type: 'enabled' }` |
| effort 模型 | `'foo'` | `{ type: 'enabled' }` |
| boolean 模型(无 support_efforts | `'on'` | `{ type: 'enabled' }` |
| 任意 | `'off'` | `{ type: 'disabled' }` |
### 5.4 配置写入
```ts
// apps/kimi-code/src/tui/utils/thinking-config.ts
export function thinkingLevelToConfig(level: ThinkingLevel): ThinkingConfigPatch {
return level === 'off'
? { enabled: false }
: { enabled: true, effort: level };
}
```
删除 `config.ts` / `provider.ts` 里两处重复的 `level !== 'on' && level !== 'off' ? level : undefined`
## 6. 实施步骤(单个 PR
所有改动合并成**一个 PR** 一次性完成,不留兼容层、不留别名、不留 TODO 债。PR 内按以下顺序执行(仅用于把控改动节奏,不是独立 PR
### 步骤 1类型与 `'on'` 圈禁
目标:消除 `AppState.thinking` 双字段,把 `'on'` 圈禁在 UI 层。
- kosong 导出 `ThinkingLevel = 'off' | 'on' | (string & {})`**直接删除 `ThinkingEffort`**,本 PR 内全量替换所有引用,不留别名。
- node-sdk `setThinking(level: ThinkingLevel)`:保留透传,不做运行时校验(按决策)。
- TUI 删 `AppState.thinking: boolean`,统一用 `thinkingLevel: ThinkingLevel`
- `types.ts` 改字段
- `kimi-tui.ts``thinking` 初始化、`syncRuntimeState``thinking` 派生写入
- `footer.ts` / `status-panel.ts` / `info.ts` 等从 `thinkingLevel` 派生显示
- `model-selector.ts` 提交前归一:`'on'``defaultLevelFor(model)``ModelSelection.thinking: ThinkingLevel`
- 删 5 处 `thinkingLevel ?? (thinking ? 'on' : 'off')` fallback`isThinkingOn(level)` / `thinkingLabel(level)` helper。
- `prompts.ts` open-platform/catalog 路径:现在 `ModelSelection.thinking` 已是 `ThinkingLevel`,删除 `thinking !== 'off'` 转 boolean 的逻辑(按需调整返回类型)。
验证:现有测试通过;新增 `'on'` 归一、`isThinkingOn` helper 测试。
### 步骤 2配置收敛
目标:删 `default_thinking``thinking.mode`,收敛到 `[thinking] { enabled, effort }`
- `schema.ts` `ThinkingConfigSchema`:删 `mode``effort` 保持 `z.string().optional()`
- `schema.ts` 顶层:删 `defaultThinking``KimiConfigSchema`)。
- `thinking.ts` `resolveThinkingLevel`:按 5.2 重写;删 `resolveThinkingEffort``'on'` 分支、`mode` 分支、`DEFAULT_THINKING_EFFORT`;新增 `defaultLevelFor`
- `core-impl.ts` `createSession`:调用 `resolveThinkingLevel(options.thinking, config.thinking, model)`
- TUI `persistModelSelection` / `setDefaultModel`:写入走 `thinkingLevelToConfig`,不再写 `defaultThinking`
- `auth-flow.ts` `refreshConfigAfterLogin`:读 `config.thinking?.enabled` 替代 `config.defaultThinking`
- `env-model.ts`:删 `KIMI_MODEL_THINKING_MODE` 处理(或改为设置 `enabled`)。
- 文档:`config-files.md` / `env-vars.md` 更新字段说明changelog 写迁移说明。
验证:老 config.toml`default_thinking``mode`)按 major 迁移说明手动改写后行为一致;新写入只产 `[thinking] enabled/effort`
### 步骤 3always_thinking 集中 + provider 归一
目标:删三处 clamp`kimiEffort` / `wireEffortToThinkingEffort` 映射。
- `thinking.ts` `resolveThinkingLevel`:加 `always_thinking` clamp在步骤 2 一并写入,本步骤验证 + 删其它 clamp
- `config/index.ts` `thinkingLevel` getter`alwaysThinkingModel` clamp直接返回 `_thinkingLevel`
- `acp-adapter/session.ts` `setThinking`:删 `currentModelAlwaysThinking()` clamp`THINKING_ON_LEVEL = 'high'` 改为 `defaultLevelFor(currentModel)` 或从 agent-core status 取。
- `kimi.ts` `withThinking`:按 4.5 重写;删 `kimiEffort()` 函数;`reasoning_effort` 双发本步骤保留,待服务端全量后在步骤 4 删除(唯一依赖服务端的收尾项)。
- `kimi.ts` `thinkingEffort` getter`wireEffortToThinkingEffort()`,直接读 `thinking.effort`,无 effort 返回 `'on'`
- UI `model-selector.ts` `renderThinkingControl`:保留 `Off (Unsupported)` 纯展示(不承担 clamp
验证always_thinking 模型设 `'off'` 仍开启agent-core clampeffort 模型传 `'xhigh'` / `'foo'` wire 不带 effort`kimiEffort` / `wireEffortToThinkingEffort` 无引用。
### 步骤 4清理
目标:删除过渡期代码。
- 删 kimi `reasoning_effort` 双发(确认服务端所有 kimi 模型已接受新 wire 格式后执行;这是唯一依赖服务端的收尾项)。
- 删 `effectiveDefaultEffort`(被 `defaultLevelFor` 覆盖,确认无引用后删除)。
- 删 acp `THINKING_ON_LEVEL` 常量(如步骤 3 未删)。
- 文档最终校对。
## 7. 逐文件改动清单
| 包 | 文件 | 改动 |
|---|---|---|
| kosong | `src/provider.ts` | 删 `ThinkingEffort`;新增 `ThinkingLevel` |
| kosong | `src/providers/kimi.ts` | `withThinking` 重写;删 `kimiEffort` / `wireEffortToThinkingEffort`getter 简化 |
| node-sdk | `src/session.ts` | `setThinking(level: ThinkingLevel)` |
| node-sdk | `src/types.ts` | 导出 `ThinkingLevel` |
| agent-core | `src/config/schema.ts` | `ThinkingConfigSchema``mode`;顶层删 `defaultThinking` |
| agent-core | `src/agent/config/thinking.ts` | `resolveThinkingLevel` 重写;`defaultLevelFor`;删 `resolveThinkingEffort` / `DEFAULT_THINKING_EFFORT` / `effectiveDefaultEffort` |
| agent-core | `src/agent/config/index.ts` | `_thinkingLevel: ThinkingLevel`getter 删 always_thinking clamp |
| agent-core | `src/rpc/core-impl.ts` | `createSession` 调用新 resolve |
| agent-core | `src/config/env-model.ts` | 删 `KIMI_MODEL_THINKING_MODE` 或改设 `enabled` |
| acp-adapter | `src/session.ts` | 删 always_thinking clamp`THINKING_ON_LEVEL` 改动态 |
| kimi-code | `src/tui/types.ts` | 删 `thinking: boolean`,留 `thinkingLevel: ThinkingLevel` |
| kimi-code | `src/tui/kimi-tui.ts` | 删 `thinking` 初始化 / syncRuntimeState 写入 / createSession 派生 |
| kimi-code | `src/tui/commands/config.ts` | `performModelSwitch``thinkingLevel`;持久化走 `thinkingLevelToConfig` |
| kimi-code | `src/tui/commands/provider.ts` | `setDefaultModel` 同上 |
| kimi-code | `src/tui/commands/prompts.ts` | 适配 `ThinkingLevel`(删 boolean 转换) |
| kimi-code | `src/tui/controllers/auth-flow.ts` | 读 `config.thinking.enabled` |
| kimi-code | `src/tui/components/dialogs/model-selector.ts` | 提交前归一 `'on'``ModelSelection.thinking: ThinkingLevel` |
| kimi-code | `src/tui/components/dialogs/effort-selector.ts` | 类型 `ThinkingLevel` |
| kimi-code | `src/tui/components/chrome/footer.ts` | 从 `thinkingLevel` 派生 |
| kimi-code | `src/tui/components/messages/status-panel.ts` | 从 `thinkingLevel` 派生 |
| kimi-code | `src/tui/utils/thinking-config.ts`(新增) | `thinkingLevelToConfig` / `isThinkingOn` / `thinkingLabel` |
| docs | `en/configuration/config-files.md` | `[thinking]` 字段说明 |
| docs | `en/configuration/env-vars.md` | 删 `KIMI_MODEL_THINKING_MODE` 或更新 |
| docs | `zh/...` | 同步翻译 |
## 8. 测试计划
就近扩展,不新增泛化测试文件。
- `packages/kosong`kimi provider
- `withThinking('high')` 在 effort 模型发 `{ type: 'enabled', effort: 'high' }``reasoning_effort === 'high'`
- `withThinking('max')` 同上,`effort === 'max'`
- `withThinking('xhigh')` 在 effort 模型发 `{ type: 'enabled' }`(不带 effort`reasoning_effort === undefined`
- `withThinking('on')` 在 effort 模型发 `{ type: 'enabled' }`
- `withThinking('foo')` 在 effort 模型发 `{ type: 'enabled' }`
- `withThinking('on')` 在 boolean 模型发 `{ type: 'enabled' }`
- `withThinking('off')``{ type: 'disabled' }``reasoning_effort === undefined`
- `thinkingEffort` getter`{enabled, effort:'max'}``'max'``{enabled}``'on'``{disabled}``'off'`;未设置 → `null`
- `packages/agent-core`config / thinking
- `defaultLevelFor`:不支持 thinking → `'off'`effort 模型 → `defaultEffort` / 中位boolean 模型 → `'on'`
- `resolveThinkingLevel`requested 优先;`enabled=false``'off'`always_thinking + `'off'` → 默认档。
- `ThinkingConfigSchema` 拒绝 `mode``KimiConfigSchema` 拒绝 `defaultThinking`
- `apps/kimi-code`
- `model-selector.test.ts``'on'` 提交时归一为 `defaultLevelFor``ModelSelection.thinking` 类型 `ThinkingLevel`
- `effort-selector.test.ts`:类型 `ThinkingLevel`
- `thinking-config.test.ts`(新增或并入现有 commands 测试):`thinkingLevelToConfig('off')``{ enabled: false }``thinkingLevelToConfig('max')``{ enabled: true, effort: 'max' }`
- `auth-flow` 相关:`refreshConfigAfterLogin``thinking.enabled`
跑测试:
```bash
pnpm --filter @moonshot-ai/kosong test -- kimi
pnpm --filter @moonshot-ai/agent-core test -- thinking config
pnpm --filter @moonshot-ai/kimi-code test -- model-selector effort-selector
pnpm --filter @moonshot-ai/acp-adapter test
```
类型检查 / lint
```bash
pnpm --filter @moonshot-ai/kimi-code typecheck
pnpm lint
```
## 9. 范围外 / 后续
- 按模型分别记住 effort`[thinking.model_efforts]`):当前用全局 `thinking.effort`,切模型时由 provider 归一;若需要精确记忆再加。
- 真正实现 `mode=auto` 的"按任务/模型自适应":本期直接删除该值,未来按需设计。
- kimi-web 端:同步 `ThinkingLevel` 类型与配置字段。
- `setThinking` 类型严格化:当前 `ThinkingLevel` 塌缩为 `string`;若未来需要真正的运行时校验,可在 SDK 入口加 `isThinkingLevel`(非空 + 字符集白名单),但不依赖模型 `support_efforts`

View file

@ -1,152 +0,0 @@
# Thinking 模型重构 — 测试覆盖审查报告
> PR #1132(分支 `support-effort`)的测试缺口分析。生成于 2026-06-26供后续补测试用。
>
> 总体结论:**核心逻辑resolveThinkingEffort、kimi/anthropic provider、ACP toggle、TUI commitEffort覆盖较扎实本次重构引入的若干"归一/兼容"分支存在行为契约未锁定的缺口。没有发现会导致运行时崩溃P0的缺口所有缺口均为行为契约P1或 nice-to-haveP2。**
---
## 两个待确认的设计问题
### 1. `default_thinking` / `thinking.mode` 是「拒绝」还是「静默忽略」?
- **当前实现**:静默忽略 + 下次写入时剥离schema 非 strict`packages/agent-core/src/config/toml.ts` 写入时 `delete out['mode']` / `delete out['default_thinking']`)。
- **计划意图**「直接删除不做兼容读取」breaking change。
- **建议**:按"静默忽略 + 写入剥离"补测试锁定P1 第 4 项)。如果要 fail-fast需把 schema 改为 `.strict()` 并补 fail-fast 测试。
### 2. `commitEffort('on')``defaultEffort` 不在 `supportEfforts` 内时返回 `defaultEffort`,是否有意?
- **当前实现**`defaultThinkingEffortFor`agent-core 和 TUI 内联)都是 `model.defaultEffort ?? middleOf(supportEfforts)`**不校验 `defaultEffort` 是否在 `supportEfforts` 内**。
- **为什么合理**provider 端会归一(声明外的 effort 不下发 wire effort所以即使 `defaultEffort` 不在声明里也不会导致 wire 错误。
- **建议**:按"返回 defaultEffort由 provider 归一"补测试锁定P1 第 12 项)。
---
## P1 缺口(行为契约未锁定,建议本 PR 或 follow-up 尽快补)
### kosong
#### `openai-common.ts``thinkingEffortToReasoningEffort`
- **现有**`off/low/medium/high/xhigh/max` + 未知 `'extreme'` → undefined`openai-common-errors.test.ts:314-337`)。
- **缺口**`'on'` 没有显式断言(注释明确把 `'on'` 列为归一对象,但测试用 `'extreme'`)。
- **建议**:把 `it('normalizes unknown effort to undefined')` 改成 `it.each(['on', 'extreme', 'foo'])`
#### `anthropic.ts``clampEffort`
- **现有**xhigh/max 在 opus-4-5/4-6/4-7/4-8/fable/sonnet/haiku 上的 clamp 与透传、adaptive vs budget、`off`、low/medium 透传(`anthropic.test.ts:1084-1400`)。
- **缺口**`'on'` / 未知 effort`'foo'`)→ clamp 到 `'high'``anthropic.ts:342-350` 的最后 if 分支)**完全未覆盖**。
- **建议**`it.each(['on', 'foo'])` 在 adaptive 模型(如 `claude-opus-4-7`)上 → `output_config={effort:'high'}`;在非 adaptive 模型(如 `claude-sonnet-4-5`)上 → `thinking={type:'enabled',budget_tokens:32000}` 且无 `output_config`
#### `google-genai.ts``withThinking`
- **现有**:非 gemini-3 的 `high`/`off`gemini-3 的 `off/low/medium/high`getter 反射(`google-genai.test.ts:729-836`)。
- **缺口**
- `'on'` / 未知 effort`'foo'`)在 **gemini-3** 上 → 只 `include_thoughts:true`、不设 `thinking_level``google-genai.ts:829-852`)。未覆盖。
- `'on'` / 未知 effort 在 **非 gemini-3** 上 → 只 `include_thoughts:true`、不设 `thinking_budget``google-genai.ts:853-873`)。未覆盖。
- `'xhigh'`/`'max'`**gemini-3**`HIGH`fall-through。未覆盖。
- **建议**`it.each(['on','foo'])``gemini-3-pro-preview` 上 → `thinking_config={include_thoughts:true}`(无 `thinking_level`);在 `gemini-2.5-flash` 上 → `{include_thoughts:true}`(无 `thinking_budget`)。`it.each(['xhigh','max'])` 在 gemini-3 上 → `thinking_level:'HIGH'`
#### `kimi.ts``withThinking`
- **现有**:非 effort 模型 / effort 模型 / `max` / `off` / `xhigh`/`on`/`foo` 不在 supportEfforts / getter / 重复调用(`kimi.test.ts:708-799`)。
- **缺口**:空 `supportEfforts: []`(与 `undefined` 等价)未显式覆盖。
- **建议**:加 `createProvider(false, []).withThinking('high')``thinking={type:'enabled'}`、无 `reasoning_effort`
### agent-core
#### `config/schema.ts` — 删除 `default_thinking` / `thinking.mode`
- **现有**`[thinking] enabled/effort` 解析patch merge thinking`default_yolo` 的 drop-deprecated 范式(`configs.test.ts`)。
- **缺口**`default_thinking`(顶层)被忽略/剥离**无测试**`thinking.mode` 被忽略/剥离**无测试**。
- **建议**`parseConfigString('default_thinking = true\n[thinking]\nmode = "always"\neffort="high"\n')``config.thinking` 不含 `mode`、顶层无 `defaultThinking`,且 `writeConfigFile` 后文本不含 `default_thinking` / `mode`
#### `config/env-model.ts` — 删除 `KIMI_MODEL_THINKING_MODE` / `KIMI_MODEL_DEFAULT_THINKING`
- **现有**`KIMI_MODEL_THINKING_EFFORT` 映射;`KIMI_MODEL_ADAPTIVE_THINKING`write-back 隔离(`env-model.test.ts`)。
- **缺口**`KIMI_MODEL_THINKING_MODE` / `KIMI_MODEL_DEFAULT_THINKING` 被忽略**无测试**(回归保护,防止未来被误加回)。
- **建议**`applyEnvModelConfig(MIN, { KIMI_MODEL_THINKING_MODE:'always', KIMI_MODEL_DEFAULT_THINKING:'high' })``config.thinking``undefined`(或不含 effort/mode
#### `agent/config/index.ts``update()` clamp 集成
- **现有**always_thinking `'off'``'on'`provider 构建 enabledtoggleable `'off'` 保持;切到 always_thinking re-clamp 旧 `'off'``config-state.test.ts:163-228`)。
- **缺口**`modelAlias` 变化 + 旧 effort=`'off'` + 新 always_thinking 模型 + `config.effort='max'` → 应 clamp 到 `'max'`(而非 `defaultEffort`)。`update()` 在 modelAlias 分支把 `this._thinkingEffort` 作为 requested 传入clamp 时读 `config?.effort`——这条路径未在 ConfigState 集成层测。
- **建议**:先 `update({modelAlias: toggleable, thinkingEffort:'off'})`,再 `update({modelAlias: deep})` 且 kimiConfig 带 `thinking:{effort:'max'}` → 期望 `'max'`
#### `session/provider-manager.ts``supportEfforts` 透传给 kimi provider
- **现有**无直接测试。kimi provider 单元覆盖了 supportEfforts 行为,但**接线层**provider-manager 把 supportEfforts 写进 kimi provider config未测。
- **缺口**effort-capable alias 经 `ProviderManager.resolveProviderConfig` → kimi `ProviderConfig` 应携带 `supportEfforts`
- **建议**:构造 `supportEfforts:['low','high','max']` 的 kimi alias断言 `resolveProviderConfig(...).provider` 的 supportEfforts 透传(或经 `config.provider.thinkingEffort`/`modelParameters` 验证)。
### acp-adapter
#### `server.ts``resolveCurrentThinkingEnabled`
- **现有**:无直接测试。仅经 `newSession` 间接走 `getConfig``thinking` 字段 → `false` 分支。
- **缺口**(全 P1
- `getConfig` 缺失partial stub`false`
- `thinking.enabled === true``true``=== false``false`
- `thinking.effort` 为非空 string`enabled`)→ `true`**本次新增核心分支**
- `getConfig` 抛错 → `false`
- `thinking` 缺失 / `effort:''``false`
- **建议**:抽一个可直接调用 `resolveCurrentThinkingEnabled` 的测试(或经 `newSession` + 不同 `getConfig` 返回值断言 `configOptions.thinking.currentValue`),覆盖以上 5 条。
#### `server.ts``setupSessionFromExisting` resumedThinkingEffort 投影
- **现有**`thinkingEffort='high'` → toggle `on``session-resume.test.ts:142-189`)。
- **缺口**`thinkingEffort='off'``currentValue='off'` 未测;`thinkingEffort=''`(空串)→ `off` 未测。
- **建议**:加 `thinkingEffort:'off'``thinkingEffort:''` 两条,断言 `thinking.currentValue==='off'`
#### `session.ts``thinkingOnEffort`
- **现有**:默认返回 `'on'`fixture model 无 `supportEfforts`/`defaultEffort`)。
- **缺口**effort-capable model`defaultEffort='high'` 或 middle `supportEfforts`)→ 返回对应 effort而非 `'on'``harness` 缺失(`undefined`)→ `'on'`
- **建议**`makeHarness` 加 effort-capable model`supportEfforts:['low','high','max']``defaultEffort:'high'`),断言 `setThinkingCalls` 收到 `'high'`;另加无 harness 的 `AcpSession.setThinking(true)``'on'`
### apps/kimi-codeTUI
#### `tui/utils/thinking-config.ts``thinkingEffortToConfig` / `isThinkingOn`
- **现有**:无直接单元测试。仅经 `cli/provider.test.ts``kimi-tui-message-flow.test.ts` 间接覆盖。
- **缺口**`thinkingEffortToConfig('off')``{enabled:false}``('low')``{enabled:true,effort:'low'}``('on')``{enabled:true,effort:'on'}` 无直接单测。
- **建议**:新建 `test/tui/utils/thinking-config.test.ts`,对 `thinkingEffortToConfig` / `isThinkingOn` 做参数化断言。
#### `tui/components/dialogs/model-selector.ts``commitEffort`
- **现有**`'on'`→effort 模型 `defaultEffort``'on'`→middle`'on'`→boolean `'on'``model-selector.test.ts`)。
- **缺口**`commitEffort('on')``defaultEffort` **不在** `supportEfforts` 内 → 返回 `defaultEffort`(即使模型未声明支持)。该行为与 agent-core 一致,但 TUI 层未锁定(见"待确认问题 2")。
- **建议**:加 `effortModel(..., ['low','high'], 'max')` 非当前模型 → Enter → `onSelect` 收到 `thinking:'max'`(锁定现状)。
#### `tui/commands/config.ts``persistModelSelection` short-circuit
- **现有**runtime 不变但 `defaultModel` 不同 → 仍写入Alt+S 不持久化(`kimi-tui-message-flow.test.ts`)。
- **缺口**`defaultModel``thinking.enabled``thinking.effort` **三者完全相同** → 返回 `false`、不调用 `setConfig`short-circuit `config.ts:467-473`)。未直接测。
- **建议**:加一条 `getConfig` 返回 `defaultModel:'k2', thinking:{enabled:true,effort:'on'}`,选择 `k2` + `on``setConfig` 未被调用,且状态提示 "Already using ..."。
---
## P2 缺口nice-to-have
- `provider.ts`type-level 开放字符串断言(`const e: ThinkingEffort = 'any-custom-effort'`)。
- `kimi.ts``keep` 字段在 `withThinking` 后保留(`withExtraBody({thinking:{keep}}).withThinking(...)`)。
- `anthropic.ts``budgetTokensForEffort``'off'/'xhigh'/'max'``throw` 分支(生产路径被 clampEffort 保护)。
- `google-genai.ts`:非 gemini-3 的 `low`/`medium`/`xhigh`/`max` budget 矩阵gemini-3 getter 反射。
- `thinking.ts``defaultEffort` 不在 `supportEfforts` 内的语义命名;`requested='off'` + always_thinking ± `config.effort` 组合。
- `config-state.test.ts`modelAlias 切换保留非 `'off'` effortthinkingEffort + modelAlias 同时变化。
- `schema.ts``thinking.enabled` 非 boolean、`thinking.effort` 非 string → 报错的类型校验。
- `env-model.ts``KIMI_MODEL_THINKING_EFFORT` 与已有 `enabled` 的合并base config 带 `thinking:{enabled:true}`env 设 effort → `{enabled:true, effort}`)。
- `session-resume.test.ts``thinkingEffort` 大写/带空格mainConfig 缺 `thinkingEffort` 的 fallback 断言。
- `session.ts` `thinkingOnEffort`currentModelId 不在 catalog → `'on'`
- `promptThinkingSchema`:非 string`number`/`boolean`)→ 拒绝。
- `thinking-config.test.ts``isThinkingOn` 各分支。
- `commitEffort`:非 `'on'` draft 透传。
- `persistModelSelection`defaultModel 相同但 effort 不同 → 写入。
---
## 建议的补充顺序
### 本 PR 内补(简单 + 高价值)
1. openai-common`it.each(['on', 'extreme', 'foo'])` 显式含 `'on'`
2. anthropic `clampEffort``it.each(['on', 'foo'])` adaptive + budget 两条
3. schema`default_thinking` / `thinking.mode` 静默忽略 + 写入剥离
4. env-model`KIMI_MODEL_THINKING_MODE` / `KIMI_MODEL_DEFAULT_THINKING` 被忽略
5. ACP `resolveCurrentThinkingEnabled`5 条分支
6. TUI `thinkingEffortToConfig`:直接单元测试(`off` / `on` / `low`
7. kimi `withThinking`:空 `supportEfforts: []`
8. google-genai `withThinking``'on'` / 未知gemini-3 + 非 gemini-3
### follow-up避免本 PR 继续膨胀)
- provider-manager `supportEfforts` 透传
- ConfigState.update() modelAlias 切换 + config.effort 集成
- ACP resume `thinkingEffort='off'` / `''`
- commitEffort defaultEffort 不在声明内
- persistModelSelection short-circuit
- 全部 P2

View file

@ -1,63 +0,0 @@
# vis 支持拖入 zip 调试包
## 1. 背景与目标
`apps/vis` 是 kimi-code 会话的可视化调试工具。服务端已经支持导入他人通过
`/export-debug-zip` 导出的调试 zip`POST /api/imports`,解压到
`<home>/imported/<id>/` 后按普通 session 展示。Web 端目前只在 session 列表
左上角提供了一个「⬆ import debug zip」按钮点击后弹出系统文件选择器。
目标:让 vis 的 Web UI 支持**直接拖入** zip 调试包,省去点按钮、找文件的步骤。
## 2. 现状关键事实
- **服务端已就绪**`apps/vis/server/src/routes/imports.ts``POST /`
接收原始 zip 字节流,调用 `importSessionZip` 解压并校验(必须含
`agents/main/wire.jsonl`),返回 `{ sessionId, importMeta }`
- **前端 API 已就绪**`apps/vis/web/src/api.ts``api.importZip(file: File)`
把文件作为 body POST 到 `/api/imports``useSession.ts``useImportZip()`
在导入成功后 `invalidateQueries(['sessions'])` 刷新列表。
- **唯一缺口**:交互入口只有按钮 + 文件选择器,没有拖放。
- **现有导入入口**`SessionRail``handleImport``apps/vis/web/src/components/sessions/SessionRail.tsx`
负责调用 `useImportZip` + `navigate` 到导入后的 session失败时 `window.alert`
`SessionFilter` 的隐藏 `<input type="file">` 复用这个 handler。
## 3. 设计决策
| 决策点 | 结论 |
|---|---|
| 落区范围 | 整个窗口(`window` 级监听),任何位置拖入都可导入 |
| 拖入反馈 | 拖入文件时显示全屏 overlay「drop debug zip to import」上传中显示「importing…」 |
| 文件校验 | 前端按扩展名 / MIME 判断 `.zip`,非 zip 给 alert 提示;服务端仍做完整校验 |
| 成功后行为 | 复用现有逻辑:刷新列表并 `navigate` 到导入后的 session |
| 与按钮的关系 | 并存。拖放与文件选择器各用独立的 `useImportZip()`,互不阻塞 |
## 4. 实现方案
新增 `apps/vis/web/src/components/shared/ZipDropOverlay.tsx`
- `useEffect``window` 上注册 `dragenter` / `dragover` / `dragleave` / `drop`
- 用 `depth` 计数器处理子元素 enter/leave避免 overlay 闪烁。
- 只在 `dataTransfer.types``Files` 时响应,避免拦截文本拖拽。
- `dragover``preventDefault()` 并设 `dropEffect = 'copy'`,否则浏览器不会触发 `drop`
- `drop` 时取 `dataTransfer.files[0]`,非 zip 给 alert否则调用
`useImportZip().mutateAsync`,成功后 `navigate`,失败 alert。
- 通过 `isPending` 在 overlay 上区分「拖入中」与「上传中」。
纯函数 `isZipFile({ name, type })` 单独导出,便于在 node 环境下做单元测试。
`AppShell` 末尾渲染 `<ZipDropOverlay />``position: fixed`,不挤占布局)。
## 5. 验证
- `pnpm --filter @moonshot-ai/vis-web run typecheck` 通过。
- `pnpm --filter @moonshot-ai/vis-web run test` 通过(含新增 `isZipFile` 单测)。
- `pnpm --filter @moonshot-ai/vis-web run build` 通过。
- 手动:`pnpm run vis` 后,把 `/export-debug-zip` 产物拖进窗口,出现 overlay、
松开后导入并跳到对应 session拖入非 zip 文件出现提示且不导入。
## 6. 非目标
- 多文件批量导入(一次只处理第一个文件)。
- 上传进度条(服务端一次性缓冲 zip 字节,没有分块进度)。
- 把按钮与拖放的 `isPending` 状态合并(两者互斥触发,各自维护即可)。