feat: preview undo ranges before confirmation

This commit is contained in:
_Kerman 2026-06-02 16:31:20 +08:00
parent 6de3d97d82
commit d125bb62ed
12 changed files with 415 additions and 12 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---
Preview `/undo` ranges before confirming and restore the withdrawn prompt to the input box.

View file

@ -125,6 +125,7 @@ export interface SlashCommandHost {
showSessionPicker(): Promise<void>;
sendNormalUserInput(text: string): void;
sendSkillActivation(session: Session, skillName: string, skillArgs: string): void;
startUndoPreview(count: number): void;
readonly skillCommandMap: Map<string, string>;
// Controller refs

View file

@ -5,6 +5,7 @@ import { AgentGroupComponent } from '../components/messages/agent-group';
import { AssistantMessageComponent } from '../components/messages/assistant-message';
import { BackgroundAgentStatusComponent } from '../components/messages/background-agent-status';
import { CronMessageComponent } from '../components/messages/cron-message';
import { CompactionComponent } from '../components/dialogs/compaction';
import { ReadGroupComponent } from '../components/messages/read-group';
import { SkillActivationComponent } from '../components/messages/skill-activation';
import { ThinkingComponent } from '../components/messages/thinking';
@ -24,28 +25,35 @@ export async function handleUndoCommand(
host: SlashCommandHost,
args: string = '',
): Promise<void> {
if (host.state.appState.streamingPhase !== 'idle') {
host.showError('Cannot undo while streaming — press Esc or Ctrl-C first.');
return;
}
const count = parseUndoCount(args);
if (count === undefined) {
host.showError('Usage: /undo [count], where count is a positive integer.');
return;
}
host.startUndoPreview(count);
}
export async function commitUndoCommand(
host: SlashCommandHost,
count: number,
): Promise<boolean> {
if (host.state.appState.streamingPhase !== 'idle') {
host.showError('Cannot undo while streaming — press Esc or Ctrl-C first.');
return false;
}
const session = host.session;
if (session === undefined) {
host.showError(NO_ACTIVE_SESSION_MESSAGE);
return;
return false;
}
const entries = host.state.transcriptEntries;
const lastUserIndex = findUndoAnchorEntryIndex(entries, count);
if (lastUserIndex === undefined) {
host.showError('Nothing to undo.');
return;
return false;
}
try {
@ -53,7 +61,7 @@ export async function handleUndoCommand(
} catch (error) {
const message = formatErrorMessage(error);
host.showError(`Failed to undo: ${message}`);
return;
return false;
}
const children = host.state.transcriptContainer.children;
@ -73,6 +81,7 @@ export async function handleUndoCommand(
}
host.state.ui.requestRender();
return true;
}
function parseUndoCount(args: string): number | undefined {
@ -83,7 +92,7 @@ function parseUndoCount(args: string): number | undefined {
return Number.isSafeInteger(count) ? count : undefined;
}
function isUndoAnchorEntry(entry: TranscriptEntry): boolean {
export function isUndoAnchorEntry(entry: TranscriptEntry): boolean {
return (
entry.kind === 'user' ||
(entry.kind === 'skill_activation' && entry.skillTrigger === 'user-slash')
@ -105,7 +114,7 @@ function findUndoAnchorEntryIndex(
return undefined;
}
function isUndoContextEntry(entry: TranscriptEntry): boolean {
export function isUndoContextEntry(entry: TranscriptEntry): boolean {
switch (entry.kind) {
case 'user':
case 'assistant':
@ -148,14 +157,19 @@ function removeUndoContextComponents(
}
}
function isUndoAnchorComponent(child: Component): boolean {
export function isUndoAnchorComponent(child: Component): boolean {
return (
child instanceof UserMessageComponent ||
(child instanceof SkillActivationComponent && child.trigger === 'user-slash')
);
}
function isUndoContextComponent(child: Component): boolean {
export function isUndoBoundaryComponent(child: Component): boolean {
const entry = getTranscriptComponentEntry(child);
return entry?.compactionData !== undefined || child instanceof CompactionComponent;
}
export function isUndoContextComponent(child: Component): boolean {
const entry = getTranscriptComponentEntry(child);
if (entry !== undefined) {
return isUndoContextEntry(entry);

View file

@ -111,6 +111,7 @@ export class CustomEditor extends Editor {
* through so pi-tui's built-in history navigation runs.
*/
public onUpArrowEmpty?: () => boolean;
public onDownArrowEmpty?: () => boolean;
public onShiftTab?: () => void;
/**
* Called when the user triggers "paste image" (Ctrl-V on Unix,
@ -320,6 +321,12 @@ export class CustomEditor extends Editor {
}
}
if (matchesKey(normalized, Key.down)) {
if (this.getText().length === 0 && this.onDownArrowEmpty) {
if (this.onDownArrowEmpty()) return;
}
}
if (matchesKey(normalized, Key.escape)) {
if (this.hasAutocompleteActivity()) {
this.cancelAutocompleteActivity();

View file

@ -0,0 +1,54 @@
import type { Component } from '@earendil-works/pi-tui';
import { visibleWidth } from '@earendil-works/pi-tui';
import chalk from 'chalk';
import type { ColorPalette } from '#/tui/theme/colors';
const HIGHLIGHT_MARK = '┃ ';
const ELLIPSIS = '…';
interface UndoHighlightOptions {
readonly extendBottom?: boolean;
readonly maxLines?: number;
}
export class UndoHighlightComponent implements Component {
private readonly childComponents: readonly Component[];
private readonly extendBottom: boolean;
private readonly maxLines: number | undefined;
constructor(
children: Component | readonly Component[],
private readonly colors: ColorPalette,
options: UndoHighlightOptions = {},
) {
this.childComponents = Array.isArray(children) ? children : [children];
this.extendBottom = options.extendBottom ?? false;
this.maxLines = options.maxLines;
}
invalidate(): void {
for (const child of this.childComponents) {
child.invalidate?.();
}
}
render(width: number): string[] {
const mark = chalk.hex(this.colors.undoHighlight).bold(HIGHLIGHT_MARK);
const markWidth = visibleWidth(mark);
const childWidth = Math.max(1, width - markWidth);
let childLines = this.childComponents.flatMap((child) => child.render(childWidth));
const maxLines = this.maxLines;
if (maxLines !== undefined && childLines.length > maxLines) {
childLines = [
...childLines.slice(0, maxLines),
chalk.hex(this.colors.undoHighlight)(ELLIPSIS),
];
}
const lines = childLines.map((line) => mark + line);
if (this.extendBottom) {
lines.push(mark);
}
return lines;
}
}

View file

@ -22,6 +22,11 @@ export interface EditorKeyboardHost {
cancelInFlight: (() => void) | undefined;
handleUserInput(text: string): void;
isUndoPreviewActive(): boolean;
moveUndoPreviewUp(): boolean;
moveUndoPreviewDown(): boolean;
commitUndoPreview(): void;
cancelUndoPreview(): boolean;
steerMessage(session: Session, input: string[]): void;
recallLastQueued(): string | undefined;
showError(msg: string): void;
@ -50,15 +55,29 @@ export class EditorKeyboardController {
const editor = host.state.editor;
editor.onSubmit = (text: string) => {
if (host.isUndoPreviewActive() && text.trim().length === 0) {
host.commitUndoPreview();
return;
}
if (host.isUndoPreviewActive()) host.cancelUndoPreview();
host.handleUserInput(text);
};
editor.onChange = (text: string) => {
if (this.pendingExit) this.clearPendingExit();
if (host.isUndoPreviewActive() && text.length > 0) {
host.cancelUndoPreview();
}
host.updateEditorBorderHighlight(text);
};
editor.onCtrlC = () => {
if (host.isUndoPreviewActive()) {
this.clearPendingExit();
host.cancelUndoPreview();
return;
}
if (host.cancelInFlight !== undefined) {
const cancel = host.cancelInFlight;
host.cancelInFlight = undefined;
@ -92,6 +111,12 @@ export class EditorKeyboardController {
};
editor.onCtrlD = () => {
if (host.isUndoPreviewActive()) {
this.clearPendingExit();
host.cancelUndoPreview();
return;
}
if (this.pendingExit?.kind === 'ctrl-d') {
this.clearPendingExit();
void host.stop();
@ -102,6 +127,10 @@ export class EditorKeyboardController {
editor.onEscape = () => {
if (this.pendingExit) this.clearPendingExit();
if (host.isUndoPreviewActive()) {
host.cancelUndoPreview();
return;
}
if (host.state.activeDialog === 'session-picker') {
host.hideSessionPicker();
return;
@ -177,6 +206,10 @@ export class EditorKeyboardController {
};
editor.onUpArrowEmpty = () => {
if (host.isUndoPreviewActive()) {
host.moveUndoPreviewUp();
return true;
}
if (host.state.appState.streamingPhase === 'idle' && !host.state.appState.isCompacting) return false;
const recalled = host.recallLastQueued();
if (recalled !== undefined) {
@ -188,6 +221,12 @@ export class EditorKeyboardController {
return false;
};
editor.onDownArrowEmpty = () => {
if (!host.isUndoPreviewActive()) return false;
host.moveUndoPreviewDown();
return true;
};
editor.onPasteImage = async () => this.handleClipboardImagePaste();
}

View file

@ -10,6 +10,7 @@ import { STREAMING_UI_FLUSH_MS } from '../constant/streaming';
import { hasDispose } from '../utils/component-capabilities';
import { appendStreamingArgsPreview, parseStreamingArgs } from '../utils/event-payload';
import { notifyTerminalOnce } from '../utils/terminal-notification';
import { markTranscriptComponent } from '../utils/transcript-component-metadata';
import { nextTranscriptId } from '../utils/transcript-id';
import type { TodoItem } from '../components/chrome/todo-panel';
import type {
@ -546,6 +547,7 @@ export class StreamingUIController {
);
this._streamingBlock = { component, entry };
this.host.pushTranscriptEntry(entry);
markTranscriptComponent(component, entry);
state.transcriptContainer.addChild(component);
state.ui.requestRender();
}

View file

@ -0,0 +1,236 @@
import type { Component } from '@earendil-works/pi-tui';
import { AssistantMessageComponent } from '../components/messages/assistant-message';
import { UndoHighlightComponent } from '../components/messages/undo-highlight';
import {
commitUndoCommand,
isUndoAnchorComponent,
isUndoBoundaryComponent,
isUndoContextComponent,
} from '../commands/undo';
import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui';
import type { SlashCommandHost } from '../commands/dispatch';
import type { TUIState } from '../tui-state';
import type { TranscriptEntry } from '../types';
import { getTranscriptComponentEntry } from '../utils/transcript-component-metadata';
export interface UndoPreviewHost extends SlashCommandHost {
state: TUIState;
updateEditorBorderHighlight(text?: string): void;
}
interface UndoPreviewState {
readonly originalChildren: readonly Component[];
readonly anchorIndices: readonly number[];
selectedCount: number;
}
export class UndoPreviewController {
private preview: UndoPreviewState | null = null;
constructor(private readonly host: UndoPreviewHost) {}
isActive(): boolean {
return this.preview !== null;
}
start(count: number): void {
if (this.host.state.appState.streamingPhase !== 'idle') {
this.host.showError('Cannot undo while streaming — press Esc or Ctrl-C first.');
return;
}
if (this.host.session === undefined) {
this.host.showError(NO_ACTIVE_SESSION_MESSAGE);
return;
}
this.cancel();
const originalChildren = [...this.host.state.transcriptContainer.children];
const range = undoPreviewRange(originalChildren);
const { anchorIndices } = range;
if (anchorIndices.length === 0) {
this.host.showError('Nothing to undo.');
return;
}
if (count > anchorIndices.length) {
this.host.showError(
range.hasBoundary
? 'Cannot undo past the most recent compaction boundary.'
: 'Cannot undo that many prompts.',
);
return;
}
this.preview = {
originalChildren,
anchorIndices,
selectedCount: count,
};
this.apply();
}
moveUp(): boolean {
const preview = this.preview;
if (preview === null || preview.selectedCount >= preview.anchorIndices.length) return false;
preview.selectedCount += 1;
this.apply();
return true;
}
moveDown(): boolean {
const preview = this.preview;
if (preview === null || preview.selectedCount <= 1) return false;
preview.selectedCount -= 1;
this.apply();
return true;
}
cancel(): boolean {
const preview = this.preview;
if (preview === null) return false;
this.restore(preview.originalChildren);
this.preview = null;
return true;
}
async commit(): Promise<void> {
const preview = this.preview;
if (preview === null) return;
const count = preview.selectedCount;
const selectedInput = selectedUndoInput(preview);
this.restore(preview.originalChildren);
this.preview = null;
const committed = await commitUndoCommand(this.host, count);
if (committed && selectedInput !== undefined) {
this.host.state.editor.setText(selectedInput);
this.host.updateEditorBorderHighlight(selectedInput);
this.host.state.ui.requestRender();
}
}
private apply(): void {
const preview = this.preview;
if (preview === null) return;
const selectedIndex = preview.anchorIndices[
preview.anchorIndices.length - preview.selectedCount
];
if (selectedIndex === undefined) return;
const nextAnchorIndex = preview.anchorIndices.find((index) => index > selectedIndex);
const selectedEndIndex = nextAnchorIndex ?? preview.originalChildren.length;
const selectedAnchor = preview.originalChildren[selectedIndex];
if (selectedAnchor === undefined) return;
const selectedBodyChildren = selectedPreviewBodyChildren(
preview.originalChildren.slice(selectedIndex + 1, selectedEndIndex),
);
const nextChildren: Component[] = [];
for (let index = 0; index < preview.originalChildren.length; index++) {
const child = preview.originalChildren[index];
if (child === undefined) continue;
if (index < selectedIndex) {
nextChildren.push(child);
continue;
}
if (index === selectedIndex) {
nextChildren.push(
new UndoHighlightComponent(selectedAnchor, this.host.state.theme.colors, {
extendBottom: selectedBodyChildren.length === 0,
}),
);
if (selectedBodyChildren.length > 0) {
nextChildren.push(
new UndoHighlightComponent(selectedBodyChildren, this.host.state.theme.colors, {
extendBottom: true,
maxLines: hasAssistantOutput(selectedBodyChildren) ? undefined : 5,
}),
);
}
continue;
}
if (index < selectedEndIndex) {
continue;
}
if (!isUndoContextComponent(child)) {
nextChildren.push(child);
}
}
this.replaceChildren(nextChildren);
}
private restore(children: readonly Component[]): void {
this.replaceChildren(children);
}
private replaceChildren(children: readonly Component[]): void {
const current = this.host.state.transcriptContainer.children;
current.splice(0, current.length, ...children);
this.host.state.transcriptContainer.invalidate();
this.host.state.ui.requestRender();
}
}
function undoPreviewRange(children: readonly Component[]): {
readonly anchorIndices: readonly number[];
readonly hasBoundary: boolean;
} {
let boundaryIndex = -1;
for (let i = children.length - 1; i >= 0; i--) {
const child = children[i];
if (child !== undefined && isUndoBoundaryComponent(child)) {
boundaryIndex = i;
break;
}
}
const indices: number[] = [];
for (let i = boundaryIndex + 1; i < children.length; i++) {
const child = children[i];
if (child !== undefined && isUndoAnchorComponent(child)) {
indices.push(i);
}
}
return { anchorIndices: indices, hasBoundary: boundaryIndex >= 0 };
}
function selectedPreviewBodyChildren(children: readonly Component[]): readonly Component[] {
const undoContextChildren = children.filter((child) => isUndoContextComponent(child));
const assistantChildren = undoContextChildren.filter((child) => isAssistantOutputComponent(child));
return assistantChildren.length > 0 ? assistantChildren : undoContextChildren;
}
function hasAssistantOutput(children: readonly Component[]): boolean {
return children.some((child) => isAssistantOutputComponent(child));
}
function isAssistantOutputComponent(child: Component): boolean {
const entry = getTranscriptComponentEntry(child);
if (entry !== undefined) {
return entry.kind === 'assistant' && entry.content.trim().length > 0;
}
return child instanceof AssistantMessageComponent;
}
function selectedUndoInput(preview: UndoPreviewState): string | undefined {
const selectedIndex = preview.anchorIndices[
preview.anchorIndices.length - preview.selectedCount
];
const child = selectedIndex === undefined ? undefined : preview.originalChildren[selectedIndex];
if (child === undefined) return undefined;
return undoInputForEntry(getTranscriptComponentEntry(child));
}
function undoInputForEntry(entry: TranscriptEntry | undefined): string | undefined {
if (entry?.kind === 'user') return entry.content;
if (entry?.kind !== 'skill_activation' || entry.skillTrigger !== 'user-slash') {
return undefined;
}
const skillName = entry.skillName?.trim();
if (skillName === undefined || skillName.length === 0) return undefined;
const args = entry.skillArgs?.trim();
return args === undefined || args.length === 0
? `/skill:${skillName}`
: `/skill:${skillName} ${args}`;
}

View file

@ -64,6 +64,7 @@ import * as slashCommands from './commands/dispatch';
import { SessionReplayRenderer } from './controllers/session-replay';
import { StreamingUIController } from './controllers/streaming-ui';
import { TasksBrowserController } from './controllers/tasks-browser';
import { UndoPreviewController } from './controllers/undo-preview';
import { installRainbowDance } from './easter-eggs/dance';
import { FileMentionProvider } from './components/editor/file-mention-provider';
import { AssistantMessageComponent } from './components/messages/assistant-message';
@ -216,6 +217,7 @@ export class KimiTUI {
readonly sessionEventHandler: SessionEventHandler;
readonly sessionReplay: SessionReplayRenderer;
readonly tasksBrowserController: TasksBrowserController;
readonly undoPreviewController: UndoPreviewController;
readonly editorKeyboard: EditorKeyboardController;
// The currently-mounted approval panel, if any. Kept so the full-screen
@ -287,6 +289,7 @@ export class KimiTUI {
this.sessionEventHandler = new SessionEventHandler(this);
this.sessionReplay = new SessionReplayRenderer(this);
this.tasksBrowserController = new TasksBrowserController(this);
this.undoPreviewController = new UndoPreviewController(this);
this.editorKeyboard = new EditorKeyboardController(this, this.imageStore);
this.editorKeyboard.install();
this.buildLayout();
@ -662,8 +665,35 @@ export class KimiTUI {
void slashCommands.handlePlanCommand(this, next ? 'on' : 'off');
}
startUndoPreview(count: number): void {
this.undoPreviewController.start(count);
}
isUndoPreviewActive(): boolean {
return this.undoPreviewController.isActive();
}
moveUndoPreviewUp(): boolean {
return this.undoPreviewController.moveUp();
}
moveUndoPreviewDown(): boolean {
return this.undoPreviewController.moveDown();
}
commitUndoPreview(): void {
void this.undoPreviewController.commit();
}
cancelUndoPreview(): boolean {
return this.undoPreviewController.cancel();
}
handleUserInput(text: string): void {
if (text.trim().length === 0) return;
if (this.undoPreviewController.isActive()) {
this.undoPreviewController.cancel();
}
if (this.state.appState.isReplaying) {
this.showError('Cannot send input while session history is replaying.');
return;
@ -900,6 +930,7 @@ export class KimiTUI {
}
pushTranscriptEntry(entry: TranscriptEntry): void {
this.undoPreviewController.cancel();
this.state.transcriptEntries.push(entry);
}
@ -1270,6 +1301,7 @@ export class KimiTUI {
}
appendTranscriptEntry(entry: TranscriptEntry): void {
this.undoPreviewController.cancel();
this.state.transcriptEntries.push(entry);
const component = this.createTranscriptComponent(entry);
if (component) {
@ -1324,6 +1356,7 @@ export class KimiTUI {
}
private clearTranscriptAndRedraw(): void {
this.undoPreviewController.cancel();
this.streamingUI.discardPending();
this.state.transcriptEntries = [];
this.streamingUI.disposeActiveCompactionBlock();
@ -1339,6 +1372,7 @@ export class KimiTUI {
}
showStatus(message: string, color?: string): void {
this.undoPreviewController.cancel();
this.state.transcriptContainer.addChild(
new StatusMessageComponent(message, this.state.theme.colors, color),
);
@ -1346,6 +1380,7 @@ export class KimiTUI {
}
showNotice(title: string, detail?: string): void {
this.undoPreviewController.cancel();
this.state.transcriptContainer.addChild(
new NoticeMessageComponent(title, detail, this.state.theme.colors),
);
@ -1361,6 +1396,7 @@ export class KimiTUI {
}
showProgressSpinner(label: string): LoginProgressSpinnerHandle {
this.undoPreviewController.cancel();
const tint = (s: string): string => chalk.hex(this.state.theme.colors.primary)(s);
const spinner = new MoonLoader(this.state.ui, 'braille', tint, label);
this.state.transcriptContainer.addChild(new Spacer(1));
@ -1378,6 +1414,7 @@ export class KimiTUI {
}
showLoginAuthorizationPrompt(auth: DeviceAuthorization): LoginProgressSpinnerHandle {
this.undoPreviewController.cancel();
openUrl(auth.verificationUriComplete);
this.state.transcriptContainer.addChild(
new DeviceCodeBoxComponent({

View file

@ -24,6 +24,7 @@ const dark = {
green300: '#7AD99B',
red400: '#E85454',
red300: '#F08585',
red700: '#B91C1C',
amber400: '#E8A838',
orange300: '#FFCB6B',
} as const;
@ -77,6 +78,9 @@ export interface ColorPalette {
// Status
status: string;
// Undo preview
undoHighlight: string;
}
export const darkColors: ColorPalette = {
@ -108,6 +112,7 @@ export const darkColors: ColorPalette = {
roleTool: dark.amber400,
status: dark.gray500,
undoHighlight: dark.red700,
};
export const lightColors: ColorPalette = {
@ -139,6 +144,7 @@ export const lightColors: ColorPalette = {
roleTool: light.amber800,
status: light.gray700,
undoHighlight: light.red700,
};
export type ResolvedTheme = 'dark' | 'light';

View file

@ -31,6 +31,7 @@ Some commands are only available in the idle state. Running them while the sessi
| `/fork` | — | Fork a new session from the current one, preserving the full conversation history. | No |
| `/title [<text>]` | `/rename` | Without arguments, show the current session title; with an argument, set it as the new title (up to 200 characters). | Yes |
| `/compact [<instruction>]` | — | Compact the current conversation context to free up token usage; optionally pass a custom instruction telling the model what to preserve during compaction. | No |
| `/undo [<count>]` | — | Preview withdrawing the latest prompt, or the latest `<count>` prompts, before confirming with `Enter`. Use `↑` and `↓` to adjust the range, or `Esc` to cancel. | No |
| `/init` | — | Analyze the current codebase and generate `AGENTS.md`. | No |
| `/export-md [<path>]` | `/export` | Export the current session as a Markdown file. With no argument, writes to `kimi-export-<short-id>-<timestamp>.md` in the working directory; pass a path to choose the output location. | No |
| `/export-debug-zip` | — | Export the current session as a debug ZIP archive (mirrors [`kimi export`](./kimi-command.md#kimi-export)). The archive always includes the active global diagnostic log. | No |

View file

@ -31,6 +31,7 @@
| `/fork` | — | 基于当前会话 fork 一份新会话,保留完整对话历史。 | 否 |
| `/title [<text>]` | `/rename` | 不带参数时显示当前会话标题;带参数时将其设置为新标题(最长 200 个字符)。 | 是 |
| `/compact [<instruction>]` | — | 压缩当前对话上下文,释放 token 占用;可选附带一段自定义指令,提示模型在压缩时保留哪些信息。 | 否 |
| `/undo [<count>]` | — | 预览撤回最近一次提示词,或最近 `<count>` 次提示词,按 `Enter` 确认。可用 `↑``↓` 调整范围,或按 `Esc` 取消。 | 否 |
| `/init` | — | 分析当前代码库并生成 `AGENTS.md`。 | 否 |
| `/export-md [<path>]` | `/export` | 将当前会话导出为 Markdown 文件。不带参数时写入工作目录下的 `kimi-export-<short-id>-<timestamp>.md`,传入路径可指定输出位置。 | 否 |
| `/export-debug-zip` | — | 将当前会话导出为调试用的 ZIP 压缩包(与 [`kimi export`](./kimi-command.md#kimi-export) 行为一致)。压缩包始终包含当前活动的全局诊断日志。 | 否 |