mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
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:
parent
01b65bdddc
commit
9091627257
24 changed files with 817 additions and 13 deletions
5
.changeset/pi-tui-paste-burst.md
Normal file
5
.changeset/pi-tui-paste-burst.md
Normal 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.
|
||||
5
.changeset/tui-paste-burst-config.md
Normal file
5
.changeset/tui-paste-burst-config.md
Normal 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.
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ function makeStartupInput(): KimiTUIStartupInput {
|
|||
},
|
||||
tuiConfig: {
|
||||
theme: 'dark',
|
||||
disablePasteBurst: false,
|
||||
editorCommand: null,
|
||||
notifications: { enabled: true, condition: 'unfocused' },
|
||||
upgrade: { autoInstall: true },
|
||||
|
|
|
|||
|
|
@ -137,6 +137,9 @@ function makeHost({
|
|||
availableModels: {},
|
||||
availableProviders: {},
|
||||
},
|
||||
editor: {
|
||||
setDisablePasteBurst: vi.fn(),
|
||||
},
|
||||
theme: {
|
||||
palette: {
|
||||
success: '#00ff00',
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -128,6 +128,7 @@ function makeStartupInput(): KimiTUIStartupInput {
|
|||
},
|
||||
tuiConfig: {
|
||||
theme: 'dark',
|
||||
disablePasteBurst: false,
|
||||
editorCommand: null,
|
||||
notifications: { enabled: true, condition: 'unfocused' },
|
||||
upgrade: { autoInstall: true },
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ function makeStartupInput(
|
|||
},
|
||||
tuiConfig: {
|
||||
theme: 'dark',
|
||||
disablePasteBurst: false,
|
||||
editorCommand: null,
|
||||
notifications: { enabled: true, condition: 'unfocused' },
|
||||
upgrade: { autoInstall: true },
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ function makeStartupInput(): KimiTUIStartupInput {
|
|||
},
|
||||
tuiConfig: {
|
||||
theme: 'dark',
|
||||
disablePasteBurst: false,
|
||||
editorCommand: null,
|
||||
notifications: { enabled: true, condition: 'unfocused' },
|
||||
upgrade: { autoInstall: true },
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ function makeStartupInput(): KimiTUIStartupInput {
|
|||
},
|
||||
tuiConfig: {
|
||||
theme: 'dark',
|
||||
disablePasteBurst: false,
|
||||
editorCommand: null,
|
||||
notifications: { enabled: true, condition: 'unfocused' },
|
||||
upgrade: { autoInstall: true },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
61
packages/pi-tui/src/paste-burst.ts
Normal file
61
packages/pi-tui/src/paste-burst.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
530
plan/bracketed-paste-paste-burst-solution.md
Normal file
530
plan/bracketed-paste-paste-burst-solution.md
Normal 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 的方向一致。它不能从协议层面“根本消除”标记不到的情况,但能把实际误提交概率降到足够低,并且可测试、可关闭、可灰度。
|
||||
Loading…
Add table
Add a link
Reference in a new issue