diff --git a/.changeset/undo-preview-confirmation.md b/.changeset/undo-preview-confirmation.md new file mode 100644 index 00000000..2b233a23 --- /dev/null +++ b/.changeset/undo-preview-confirmation.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Preview `/undo` ranges before confirming and restore the withdrawn prompt to the input box. diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index 259fb02d..9f903411 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -125,6 +125,7 @@ export interface SlashCommandHost { showSessionPicker(): Promise; sendNormalUserInput(text: string): void; sendSkillActivation(session: Session, skillName: string, skillArgs: string): void; + startUndoPreview(count: number): void; readonly skillCommandMap: Map; // Controller refs diff --git a/apps/kimi-code/src/tui/commands/undo.ts b/apps/kimi-code/src/tui/commands/undo.ts index 752fd27a..70ec32df 100644 --- a/apps/kimi-code/src/tui/commands/undo.ts +++ b/apps/kimi-code/src/tui/commands/undo.ts @@ -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 { - 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 { + 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); diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts index 3ceff7a9..d33f78ff 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -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(); diff --git a/apps/kimi-code/src/tui/components/messages/undo-highlight.ts b/apps/kimi-code/src/tui/components/messages/undo-highlight.ts new file mode 100644 index 00000000..662e7218 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/undo-highlight.ts @@ -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; + } +} diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index 75473d7a..6c617908 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -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(); } diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index 095fe2b6..8d967b8f 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -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(); } diff --git a/apps/kimi-code/src/tui/controllers/undo-preview.ts b/apps/kimi-code/src/tui/controllers/undo-preview.ts new file mode 100644 index 00000000..16056039 --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/undo-preview.ts @@ -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 { + 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}`; +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 1e239e50..6b05658a 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -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({ diff --git a/apps/kimi-code/src/tui/theme/colors.ts b/apps/kimi-code/src/tui/theme/colors.ts index 4a9b37ba..b178be29 100644 --- a/apps/kimi-code/src/tui/theme/colors.ts +++ b/apps/kimi-code/src/tui/theme/colors.ts @@ -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'; diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index 4f2a56a0..0247652a 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -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 []` | `/rename` | Without arguments, show the current session title; with an argument, set it as the new title (up to 200 characters). | Yes | | `/compact []` | — | 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 []` | — | Preview withdrawing the latest prompt, or the latest `` 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 []` | `/export` | Export the current session as a Markdown file. With no argument, writes to `kimi-export--.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 | diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index af504f2c..c60d9b00 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -31,6 +31,7 @@ | `/fork` | — | 基于当前会话 fork 一份新会话,保留完整对话历史。 | 否 | | `/title []` | `/rename` | 不带参数时显示当前会话标题;带参数时将其设置为新标题(最长 200 个字符)。 | 是 | | `/compact []` | — | 压缩当前对话上下文,释放 token 占用;可选附带一段自定义指令,提示模型在压缩时保留哪些信息。 | 否 | +| `/undo []` | — | 预览撤回最近一次提示词,或最近 `` 次提示词,按 `Enter` 确认。可用 `↑` 和 `↓` 调整范围,或按 `Esc` 取消。 | 否 | | `/init` | — | 分析当前代码库并生成 `AGENTS.md`。 | 否 | | `/export-md []` | `/export` | 将当前会话导出为 Markdown 文件。不带参数时写入工作目录下的 `kimi-export--.md`,传入路径可指定输出位置。 | 否 | | `/export-debug-zip` | — | 将当前会话导出为调试用的 ZIP 压缩包(与 [`kimi export`](./kimi-command.md#kimi-export) 行为一致)。压缩包始终包含当前活动的全局诊断日志。 | 否 |