fix(tui): avoid submitting rapid multi-line paste bursts (#1305)

* fix(tui): avoid submitting rapid multi-line paste bursts

* chore(tui): remove paste burst settings picker

* fix(tui): reset paste burst on DEL backspace
This commit is contained in:
liruifengv 2026-07-03 14:21:02 +08:00 committed by GitHub
parent 01b65bdddc
commit 9091627257
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 817 additions and 13 deletions

View file

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

View file

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

View file

@ -19,7 +19,7 @@ import { PermissionSelectorComponent } from '../components/dialogs/permission-se
import { SettingsSelectorComponent, type SettingsSelection } from '../components/dialogs/settings-selector';
import { ThemeSelectorComponent } from '../components/dialogs/theme-selector';
import { UpdatePreferenceSelectorComponent } from '../components/dialogs/update-preference-selector';
import { saveTuiConfig } from '../config';
import { DEFAULT_TUI_CONFIG, saveTuiConfig, type TuiConfig } from '../config';
import type { ThemeName } from '#/tui/theme';
import { currentTheme, isBuiltInTheme, lightColors, loadCustomThemeMerged } from '#/tui/theme';
import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui';
@ -35,6 +35,16 @@ import type { SlashCommandHost } from './dispatch';
const MODEL_PICKER_REFRESH_TIMEOUT_MS = 2_000;
function currentTuiConfig(host: SlashCommandHost): TuiConfig {
return {
theme: host.state.appState.theme,
editorCommand: host.state.appState.editorCommand,
disablePasteBurst: host.state.appState.disablePasteBurst ?? DEFAULT_TUI_CONFIG.disablePasteBurst,
notifications: host.state.appState.notifications,
upgrade: host.state.appState.upgrade,
};
}
export async function handlePlanCommand(host: SlashCommandHost, args: string): Promise<void> {
const session = host.session;
if (session === undefined) {
@ -328,10 +338,8 @@ async function applyEditorChoice(host: SlashCommandHost, value: string): Promise
const editorCommand = value.length > 0 ? value : null;
try {
await saveTuiConfig({
theme: host.state.appState.theme,
...currentTuiConfig(host),
editorCommand,
notifications: host.state.appState.notifications,
upgrade: host.state.appState.upgrade,
});
} catch (error) {
host.showStatus(
@ -513,10 +521,8 @@ async function applyThemeChoice(host: SlashCommandHost, theme: ThemeName): Promi
try {
await saveTuiConfig({
...currentTuiConfig(host),
theme,
editorCommand: host.state.appState.editorCommand,
notifications: host.state.appState.notifications,
upgrade: host.state.appState.upgrade,
});
} catch (error) {
host.showStatus(
@ -656,9 +662,7 @@ export async function applyUpdatePreferenceChoice(
const upgrade = { autoInstall };
try {
await saveTuiConfig({
theme: host.state.appState.theme,
editorCommand: host.state.appState.editorCommand,
notifications: host.state.appState.notifications,
...currentTuiConfig(host as unknown as SlashCommandHost),
upgrade,
});
} catch (error) {

View file

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

View file

@ -114,6 +114,10 @@ function stripSgr(s: string): string {
return s.replace(ANSI_SGR, '');
}
interface CustomEditorOptions {
disablePasteBurst?: boolean;
}
export class CustomEditor extends Editor {
public onEscape?: () => void;
/**
@ -164,14 +168,14 @@ export class CustomEditor extends Editor {
this.argumentHints = hints;
}
constructor(tui: TUI) {
constructor(tui: TUI, options: CustomEditorOptions = {}) {
// paddingX: 4 reserves column 0 for the left vertical border (│),
// column 1 as a single space between border and prompt, column 2 for
// the `>` prompt token, and column 3 as the space between prompt and
// content. The right side mirrors with 3 padding columns and the right
// border at the last column.
const theme = createEditorTheme();
super(tui, theme, { paddingX: 4 });
super(tui, theme, { paddingX: 4, disablePasteBurst: options.disablePasteBurst });
// pi-tui keeps `createAutocompleteList` private; shadow it with an
// instance property so slash command menus render descriptions wrapped
@ -204,6 +208,10 @@ export class CustomEditor extends Editor {
};
}
override setDisablePasteBurst(disabled: boolean): void {
super.setDisablePasteBurst(disabled);
}
public setInputMode(mode: 'prompt' | 'bash'): void {
if (this.inputMode === mode) return;
this.inputMode = mode;

View file

@ -32,6 +32,7 @@ export const UpgradePreferencesSchema = z.object({
export const TuiConfigFileSchema = z.object({
theme: TuiThemeSchema.optional(),
disable_paste_burst: z.boolean().optional(),
editor: z
.object({
command: z.string().optional(),
@ -52,6 +53,7 @@ export const TuiConfigFileSchema = z.object({
export const TuiConfigSchema = z.object({
theme: TuiThemeSchema,
disablePasteBurst: z.boolean(),
editorCommand: z.string().nullable(),
notifications: NotificationsConfigSchema,
upgrade: UpgradePreferencesSchema,
@ -73,6 +75,7 @@ export const DEFAULT_UPGRADE_PREFERENCES: UpgradePreferences = {
export const DEFAULT_TUI_CONFIG: TuiConfig = TuiConfigSchema.parse({
theme: 'auto',
disablePasteBurst: false,
editorCommand: null,
notifications: DEFAULT_NOTIFICATIONS_CONFIG,
upgrade: DEFAULT_UPGRADE_PREFERENCES,
@ -132,6 +135,7 @@ export function normalizeTuiConfig(config: TuiConfigFileShape): TuiConfig {
const command = config.editor?.command?.trim();
return TuiConfigSchema.parse({
theme: config.theme ?? DEFAULT_TUI_CONFIG.theme,
disablePasteBurst: config.disable_paste_burst ?? DEFAULT_TUI_CONFIG.disablePasteBurst,
editorCommand: command === undefined || command.length === 0 ? null : command,
notifications: {
enabled: config.notifications?.enabled ?? DEFAULT_NOTIFICATIONS_CONFIG.enabled,
@ -150,6 +154,7 @@ export function renderTuiConfig(config: TuiConfig): string {
# Agent/runtime settings stay in ~/.kimi-code/config.toml.
theme = "${escapeTomlBasicString(config.theme)}" # "auto" | "dark" | "light" | custom theme name
disable_paste_burst = ${String(config.disablePasteBurst)} # true disables non-bracketed paste-burst fallback
[editor]
command = "${escapeTomlBasicString(config.editorCommand ?? '')}" # Empty uses $VISUAL / $EDITOR

View file

@ -217,6 +217,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState {
theme: input.tuiConfig.theme,
version: input.version,
editorCommand: input.tuiConfig.editorCommand,
disablePasteBurst: input.tuiConfig.disablePasteBurst,
notifications: input.tuiConfig.notifications,
upgrade: input.tuiConfig.upgrade,
availableModels: {},

View file

@ -10,6 +10,7 @@ import type { MoonLoader, SpinnerStyle } from './components/chrome/moon-loader';
import { TodoPanelComponent } from './components/chrome/todo-panel';
import type { SessionRow } from './components/dialogs/session-picker';
import { CustomEditor } from './components/editor/custom-editor';
import { DEFAULT_TUI_CONFIG } from './config';
import { CHROME_GUTTER } from './constant/rendering';
import type { TasksBrowserState } from './controllers/tasks-browser';
import { currentTheme, type Theme } from './theme';
@ -68,7 +69,9 @@ export function createTUIState(options: KimiTUIOptions): TUIState {
const queueContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER);
const btwPanelContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER);
const editorContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER);
const editor = new CustomEditor(ui);
const editor = new CustomEditor(ui, {
disablePasteBurst: initialAppState.disablePasteBurst ?? DEFAULT_TUI_CONFIG.disablePasteBurst,
});
const footer = new FooterComponent({ ...initialAppState }, () => {
ui.requestRender();
});

View file

@ -48,6 +48,8 @@ export interface AppState {
theme: ThemeName;
version: string;
editorCommand: string | null;
/** Mirrors the TUI config toggle; defaults to false when absent from older fixtures. */
disablePasteBurst?: boolean;
notifications: NotificationsConfig;
upgrade: UpgradePreferences;
availableModels: Record<string, ModelAlias>;

View file

@ -161,6 +161,7 @@ function installState(overrides: Partial<UpdateInstallState> = {}): UpdateInstal
function tuiConfig(overrides: Partial<TuiConfig> = {}): TuiConfig {
return {
theme: 'auto',
disablePasteBurst: false,
editorCommand: null,
notifications: { enabled: true, condition: 'unfocused' },
upgrade: { autoInstall: true },

View file

@ -29,6 +29,7 @@ function makeStartupInput(): KimiTUIStartupInput {
},
tuiConfig: {
theme: 'dark',
disablePasteBurst: false,
editorCommand: null,
notifications: { enabled: true, condition: 'unfocused' },
upgrade: { autoInstall: true },

View file

@ -137,6 +137,9 @@ function makeHost({
availableModels: {},
availableProviders: {},
},
editor: {
setDisablePasteBurst: vi.fn(),
},
theme: {
palette: {
success: '#00ff00',

View file

@ -42,6 +42,7 @@ describe('update preference commands', () => {
expect(mocks.saveTuiConfig).toHaveBeenCalledWith({
theme: 'auto',
editorCommand: null,
disablePasteBurst: false,
notifications: { enabled: true, condition: 'unfocused' },
upgrade: { autoInstall: false },
});

View file

@ -59,12 +59,22 @@ auto_install = false
expect(config).toEqual({
theme: 'light',
disablePasteBurst: false,
editorCommand: 'code --wait',
notifications: { enabled: false, condition: 'always' },
upgrade: { autoInstall: false },
});
});
it('parses disable_paste_burst', () => {
const config = parseTuiConfig(`
theme = "dark"
disable_paste_burst = true
`);
expect(config.disablePasteBurst).toBe(true);
});
it('normalizes an empty editor command to auto-detect', () => {
const config = parseTuiConfig(`
[editor]
@ -73,6 +83,7 @@ command = " "
expect(config).toEqual({
theme: 'auto',
disablePasteBurst: false,
editorCommand: null,
notifications: { enabled: true, condition: 'unfocused' },
upgrade: { autoInstall: true },
@ -104,6 +115,7 @@ command = " "
await saveTuiConfig(
{
theme: 'light',
disablePasteBurst: false,
editorCommand: 'vim',
notifications: { enabled: false, condition: 'always' },
upgrade: { autoInstall: false },
@ -113,6 +125,7 @@ command = " "
expect(await loadTuiConfig(filePath)).toEqual({
theme: 'light',
disablePasteBurst: false,
editorCommand: 'vim',
notifications: { enabled: false, condition: 'always' },
upgrade: { autoInstall: false },
@ -124,6 +137,7 @@ command = " "
await saveTuiConfig(
{
theme,
disablePasteBurst: DEFAULT_TUI_CONFIG.disablePasteBurst,
editorCommand: null,
notifications: DEFAULT_TUI_CONFIG.notifications,
upgrade: DEFAULT_TUI_CONFIG.upgrade,

View file

@ -128,6 +128,7 @@ function makeStartupInput(): KimiTUIStartupInput {
},
tuiConfig: {
theme: 'dark',
disablePasteBurst: false,
editorCommand: null,
notifications: { enabled: true, condition: 'unfocused' },
upgrade: { autoInstall: true },

View file

@ -88,6 +88,7 @@ function makeStartupInput(
},
tuiConfig: {
theme: 'dark',
disablePasteBurst: false,
editorCommand: null,
notifications: { enabled: true, condition: 'unfocused' },
upgrade: { autoInstall: true },

View file

@ -52,6 +52,7 @@ function makeStartupInput(): KimiTUIStartupInput {
},
tuiConfig: {
theme: 'dark',
disablePasteBurst: false,
editorCommand: null,
notifications: { enabled: true, condition: 'unfocused' },
upgrade: { autoInstall: true },

View file

@ -25,6 +25,7 @@ function makeStartupInput(): KimiTUIStartupInput {
},
tuiConfig: {
theme: 'dark',
disablePasteBurst: false,
editorCommand: null,
notifications: { enabled: true, condition: 'unfocused' },
upgrade: { autoInstall: true },

View file

@ -270,6 +270,7 @@ Alongside `config.toml`, the CLI keeps terminal-UI and client preferences in a c
| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `theme` | `string` | `auto` | Color theme: `auto` (follow the terminal), `dark`, `light`, or the name of a [custom theme](../customization/themes) |
| `disable_paste_burst` | `boolean` | `false` | Disable the non-bracketed paste-burst fallback that keeps rapid multi-line pastes from submitting line by line |
| `[editor].command` | `string` | `""` | External editor command for composing long input; empty falls back to `$VISUAL` / `$EDITOR` |
| `[notifications].enabled` | `boolean` | `true` | Whether desktop notifications are sent |
| `[notifications].notification_condition` | `string` | `unfocused` | When to notify: `unfocused` (only when the terminal is not focused) or `always` |
@ -278,6 +279,7 @@ Alongside `config.toml`, the CLI keeps terminal-UI and client preferences in a c
```toml
# ~/.kimi-code/tui.toml
theme = "auto" # "auto" | "dark" | "light" | custom theme name
disable_paste_burst = false # true disables non-bracketed paste-burst fallback
[editor]
command = "" # empty uses $VISUAL / $EDITOR

View file

@ -270,6 +270,7 @@ MCP server 的声明配置写在 `~/.kimi-code/mcp.json` 或项目内 `.kimi-cod
| 字段 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `theme` | `string` | `auto` | 配色主题:`auto`(跟随终端)、`dark``light`,或[自定义主题](../customization/themes)的名字 |
| `disable_paste_burst` | `boolean` | `false` | 禁用非 bracketed paste 的粘贴突发兜底;默认开启,避免快速多行粘贴被逐行提交 |
| `[editor].command` | `string` | `""` | 编写长输入用的外部编辑器命令;留空则回退到 `$VISUAL` / `$EDITOR` |
| `[notifications].enabled` | `boolean` | `true` | 是否发送桌面通知 |
| `[notifications].notification_condition` | `string` | `unfocused` | 何时通知:`unfocused`(仅终端失去焦点时)或 `always`(总是) |
@ -278,6 +279,7 @@ MCP server 的声明配置写在 `~/.kimi-code/mcp.json` 或项目内 `.kimi-cod
```toml
# ~/.kimi-code/tui.toml
theme = "auto" # "auto" | "dark" | "light" | 自定义主题名
disable_paste_burst = false # true 表示禁用非 bracketed paste 的粘贴突发兜底
[editor]
command = "" # 留空则使用 $VISUAL / $EDITOR

View file

@ -2,6 +2,7 @@ import type { AutocompleteProvider, AutocompleteSuggestions } from "../autocompl
import { getKeybindings } from "../keybindings.ts";
import { decodePrintableKey, matchesKey } from "../keys.ts";
import { KillRing } from "../kill-ring.ts";
import { PasteBurst } from "../paste-burst.ts";
import { type Component, CURSOR_MARKER, type Focusable, type TUI } from "../tui.ts";
import { UndoStack } from "../undo-stack.ts";
import {
@ -237,6 +238,7 @@ export interface EditorTheme {
export interface EditorOptions {
paddingX?: number;
autocompleteMaxVisible?: number;
disablePasteBurst?: boolean;
}
const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
@ -306,6 +308,10 @@ export class Editor implements Component, Focusable {
private pasteBuffer: string = "";
private isInPaste: boolean = false;
// Non-bracketed paste-burst fallback
private pasteBurst = new PasteBurst();
private disablePasteBurst: boolean = false;
// Prompt history for up/down navigation
private history: string[] = [];
private historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc.
@ -361,6 +367,7 @@ export class Editor implements Component, Focusable {
this.paddingX = Number.isFinite(paddingX) ? Math.max(0, Math.floor(paddingX)) : 0;
const maxVisible = options.autocompleteMaxVisible ?? 5;
this.autocompleteMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5;
this.disablePasteBurst = options.disablePasteBurst ?? false;
}
/** Set of currently valid paste IDs, for marker-aware segmentation. */
@ -397,6 +404,13 @@ export class Editor implements Component, Focusable {
}
}
setDisablePasteBurst(disabled: boolean): void {
this.disablePasteBurst = disabled;
if (disabled) {
this.pasteBurst.reset();
}
}
setAutocompleteProvider(provider: AutocompleteProvider): void {
this.cancelAutocomplete();
this.autocompleteProvider = provider;
@ -688,6 +702,7 @@ export class Editor implements Component, Focusable {
if (data.includes("\x1b[200~")) {
this.isInPaste = true;
this.pasteBuffer = "";
this.pasteBurst.reset();
data = data.replace("\x1b[200~", "");
}
@ -702,6 +717,7 @@ export class Editor implements Component, Focusable {
this.isInPaste = false;
const remaining = this.pasteBuffer.substring(endIndex + 6);
this.pasteBuffer = "";
this.pasteBurst.reset();
if (remaining.length > 0) {
this.handleInput(remaining);
}
@ -710,6 +726,14 @@ export class Editor implements Component, Focusable {
return;
}
const isEnterKey = data !== "\n" && kb.matches(data, "tui.input.submit");
const charCode = data.charCodeAt(0);
const printableForBurst = decodePrintableKey(data) ??
(data.length === 1 && charCode >= 32 && charCode !== 0x7f ? data : undefined);
if (!this.disablePasteBurst && !isEnterKey && printableForBurst === undefined) {
this.pasteBurst.reset();
}
// Ctrl+C - let parent handle (exit/clear)
if (kb.matches(data, "tui.input.copy")) {
return;
@ -873,6 +897,12 @@ export class Editor implements Component, Focusable {
return;
}
if (!this.disablePasteBurst && this.pasteBurst.shouldInsertNewlineInsteadOfSubmit(Date.now())) {
this.addNewLine();
this.pasteBurst.extendWindow(Date.now());
return;
}
this.submitValue();
return;
}
@ -940,12 +970,18 @@ export class Editor implements Component, Focusable {
const printable = decodePrintableKey(data);
if (printable !== undefined) {
if (!this.disablePasteBurst) {
this.pasteBurst.onPlainChar(Date.now());
}
this.insertCharacter(printable);
return;
}
// Regular characters
if (data.charCodeAt(0) >= 32) {
if (!this.disablePasteBurst) {
this.pasteBurst.onPlainChar(Date.now());
}
this.insertCharacter(data);
}
}

View file

@ -0,0 +1,61 @@
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;
/**
* Detects non-bracketed paste bursts: a rapid stream of plain characters
* followed by Enter, where a bare Enter would otherwise submit the draft.
*
* This is a heuristic fallback for terminals that do not surface bracketed
* paste markers. It intentionally does not buffer characters; the editor still
* inserts typed text normally, and this class only decides whether an imminent
* Enter should insert a newline instead of submitting.
*/
export class PasteBurst {
private lastPlainCharAt?: number;
private consecutivePlainChars = 0;
private activeUntil = 0;
private enterSuppressUntil = 0;
onPlainChar(now: number): void {
if (
this.lastPlainCharAt !== undefined &&
now - this.lastPlainCharAt <= PASTE_BURST_CHAR_INTERVAL_MS
) {
this.consecutivePlainChars++;
} else {
this.consecutivePlainChars = 1;
}
this.lastPlainCharAt = now;
if (this.consecutivePlainChars >= PASTE_BURST_MIN_CHARS) {
this.extendWindow(now);
}
}
shouldInsertNewlineInsteadOfSubmit(now: number): boolean {
if (now <= this.activeUntil || now <= this.enterSuppressUntil) {
return true;
}
return (
this.lastPlainCharAt !== undefined &&
this.consecutivePlainChars >= PASTE_BURST_MIN_CHARS &&
now - this.lastPlainCharAt <= PASTE_BURST_CHAR_INTERVAL_MS
);
}
extendWindow(now: number): void {
this.activeUntil = now + PASTE_BURST_ACTIVE_IDLE_TIMEOUT_MS;
this.enterSuppressUntil = now + PASTE_ENTER_SUPPRESS_WINDOW_MS;
}
reset(): void {
this.lastPlainCharAt = undefined;
this.consecutivePlainChars = 0;
this.activeUntil = 0;
this.enterSuppressUntil = 0;
}
}

View file

@ -3,6 +3,7 @@ import { describe, it } from "node:test";
import { stripVTControlCharacters } from "node:util";
import { type AutocompleteProvider, CombinedAutocompleteProvider } from "../src/autocomplete.ts";
import { Editor, wordWrapLine } from "../src/components/editor.ts";
import { PasteBurst } from "../src/paste-burst.ts";
import { TUI } from "../src/tui.ts";
import { visibleWidth } from "../src/utils.ts";
import { defaultEditorTheme } from "./test-themes.ts";
@ -38,6 +39,60 @@ async function flushAutocomplete(): Promise<void> {
await new Promise((resolve) => setImmediate(resolve));
}
describe("PasteBurst", () => {
it("does not suppress Enter for slow typing", () => {
const burst = new PasteBurst();
burst.onPlainChar(0);
burst.onPlainChar(20);
assert.strictEqual(burst.shouldInsertNewlineInsteadOfSubmit(40), false);
});
it("suppresses Enter during a rapid paste-like burst", () => {
const burst = new PasteBurst();
burst.onPlainChar(0);
burst.onPlainChar(1);
burst.onPlainChar(2);
burst.onPlainChar(3);
burst.onPlainChar(4);
burst.onPlainChar(5);
burst.onPlainChar(6);
burst.onPlainChar(7);
assert.strictEqual(burst.shouldInsertNewlineInsteadOfSubmit(8), true);
});
it("keeps suppressing Enter briefly after a burst", () => {
const burst = new PasteBurst();
burst.onPlainChar(0);
burst.onPlainChar(1);
burst.onPlainChar(2);
burst.onPlainChar(3);
burst.onPlainChar(4);
burst.onPlainChar(5);
burst.onPlainChar(6);
burst.onPlainChar(7);
assert.strictEqual(burst.shouldInsertNewlineInsteadOfSubmit(100), true);
assert.strictEqual(burst.shouldInsertNewlineInsteadOfSubmit(200), false);
});
it("resets after explicit paste handling", () => {
const burst = new PasteBurst();
burst.onPlainChar(0);
burst.onPlainChar(1);
burst.onPlainChar(2);
burst.onPlainChar(3);
burst.onPlainChar(4);
burst.onPlainChar(5);
burst.onPlainChar(6);
burst.onPlainChar(7);
burst.reset();
assert.strictEqual(burst.shouldInsertNewlineInsteadOfSubmit(8), false);
});
});
describe("Editor component", () => {
describe("Prompt history navigation", () => {
it("does nothing on Up arrow when history is empty", () => {
@ -468,6 +523,65 @@ 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;
};
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");
});
it("can be disabled", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme, { disablePasteBurst: true });
let submitted = "";
editor.onSubmit = (text) => {
submitted = text;
};
editor.handleInput("a");
editor.handleInput("b");
editor.handleInput("c");
editor.handleInput("\r");
assert.strictEqual(submitted, "abc");
});
it("resets when DEL backspace arrives after a burst", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);
let submitted = "";
editor.onSubmit = (text) => {
submitted = text;
};
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("\x7f");
editor.handleInput("\r");
assert.strictEqual(submitted, "abcdefg");
});
});
describe("Backslash+Enter newline workaround", () => {
it("inserts backslash immediately (no buffering)", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);

View file

@ -0,0 +1,530 @@
# 多行粘贴误提交解决方案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 的方向一致。它不能从协议层面“根本消除”标记不到的情况,但能把实际误提交概率降到足够低,并且可测试、可关闭、可灰度。