feat: add undo selector (#615)

This commit is contained in:
_Kerman 2026-06-10 14:22:02 +08:00 committed by GitHub
parent 4603d8ad6e
commit 494554eac5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 475 additions and 7 deletions

View file

@ -0,0 +1,6 @@
---
"@moonshot-ai/agent-core": minor
"@moonshot-ai/kimi-code": minor
---
Add an interactive undo selector and clearer undo-limit messages.

View file

@ -18,7 +18,12 @@ import type { AuthFlowController } from '../controllers/auth-flow';
import type { BtwPanelController } from '../controllers/btw-panel';
import type { StreamingUIController } from '../controllers/streaming-ui';
import type { TasksBrowserController } from '../controllers/tasks-browser';
import type { AppState, LoginProgressSpinnerHandle, QueuedMessage } from '../types';
import type {
AppState,
LoginProgressSpinnerHandle,
QueuedMessage,
TranscriptEntry,
} from '../types';
import type { TUIState } from '../tui-state';
import { handleLoginCommand, handleLogoutCommand } from './auth';
@ -109,6 +114,7 @@ export interface SlashCommandHost {
showError(msg: string): void;
showStatus(msg: string, color?: ColorToken): void;
showNotice(title: string, detail?: string): void;
appendTranscriptEntry(entry: TranscriptEntry): void;
track(event: string, props?: Record<string, unknown>): void;
mountEditorReplacement(panel: Component & Focusable): void;
restoreEditor(): void;

View file

@ -1,6 +1,13 @@
import type { Component } from '@earendil-works/pi-tui';
import type { ContextMessage } from '@moonshot-ai/kimi-code-sdk';
import { isKimiError } from '@moonshot-ai/kimi-code-sdk';
import { WelcomeComponent } from '../components/chrome/welcome';
import { CompactionComponent } from '../components/dialogs/compaction';
import {
UndoSelectorComponent,
type UndoChoice,
} from '../components/dialogs/undo-selector';
import { AgentGroupComponent } from '../components/messages/agent-group';
import { AgentSwarmProgressComponent } from '../components/messages/agent-swarm-progress';
import { AssistantMessageComponent } from '../components/messages/assistant-message';
@ -15,12 +22,24 @@ import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui';
import type { TranscriptEntry } from '../types';
import { formatErrorMessage } from '../utils/event-payload';
import { getTranscriptComponentEntry } from '../utils/transcript-component-metadata';
import { nextTranscriptId } from '../utils/transcript-id';
import type { SlashCommandHost } from './dispatch';
// ---------------------------------------------------------------------------
// Undo command
// ---------------------------------------------------------------------------
interface UndoAvailability {
readonly maxCount: number;
readonly stoppedAtCompaction: boolean;
}
type UndoSessionContext = Awaited<
ReturnType<NonNullable<SlashCommandHost['session']>['getContext']>
>;
const UNDO_LIMIT_STATUS_TURN_ID = 'undo-limit-status';
export async function handleUndoCommand(
host: SlashCommandHost,
args: string = '',
@ -30,7 +49,13 @@ export async function handleUndoCommand(
return;
}
const count = parseUndoCount(args);
const trimmed = args.trim();
if (trimmed.length === 0) {
await showUndoSelector(host);
return;
}
const count = parseUndoCount(trimmed);
if (count === undefined) {
host.showError('Usage: /undo [count], where count is a positive integer.');
return;
@ -42,19 +67,40 @@ export async function handleUndoCommand(
return;
}
const availability = await resolveUndoAvailability(host);
if (count > availability.maxCount) {
showUndoLimitStatus(host, formatUndoLimitMessage(count, availability));
return;
}
await undoByCount(host, count);
}
async function undoByCount(host: SlashCommandHost, count: number): Promise<boolean> {
const session = host.session;
if (session === undefined) {
host.showError(NO_ACTIVE_SESSION_MESSAGE);
return false;
}
const entries = host.state.transcriptEntries;
const lastUserIndex = findUndoAnchorEntryIndex(entries, count);
if (lastUserIndex === undefined) {
host.showError('Nothing to undo.');
return;
showUndoLimitStatus(host, 'Nothing to undo.');
return false;
}
try {
await session.undoHistory(count);
} catch (error) {
const limit = undoLimitFromError(error);
if (limit !== undefined) {
showUndoLimitStatus(host, formatUndoLimitMessage(limit.requestedCount, limit));
return false;
}
const message = formatErrorMessage(error);
host.showError(`Failed to undo: ${message}`);
return;
return false;
}
const children = host.state.transcriptContainer.children;
@ -74,6 +120,43 @@ export async function handleUndoCommand(
}
host.state.ui.requestRender();
return true;
}
async function showUndoSelector(host: SlashCommandHost): Promise<void> {
if (host.session === undefined) {
host.showError(NO_ACTIVE_SESSION_MESSAGE);
return;
}
const availability = await resolveUndoAvailability(host);
const choices = createUndoChoices(
host.state.transcriptEntries,
host.state.transcriptContainer.children,
availability.maxCount,
);
if (choices.length === 0) {
showUndoLimitStatus(host, formatNothingToUndoMessage(availability));
return;
}
host.mountEditorReplacement(
new UndoSelectorComponent({
choices,
onSelect: (choice) => {
void undoByCount(host, choice.count).then((undone) => {
if (undone) {
host.restoreInputText(choice.input);
return;
}
host.restoreEditor();
});
},
onCancel: () => {
host.restoreEditor();
},
}),
);
}
function parseUndoCount(args: string): number | undefined {
@ -84,6 +167,210 @@ function parseUndoCount(args: string): number | undefined {
return Number.isSafeInteger(count) ? count : undefined;
}
async function resolveUndoAvailability(
host: SlashCommandHost,
): Promise<UndoAvailability> {
const local = undoAvailabilityFromTranscript(
host.state.transcriptEntries,
host.state.transcriptContainer.children,
);
const context = await getSessionContext(host.session);
if (context === undefined) return local;
const activeContext = undoAvailabilityFromContext(context.history);
return {
maxCount: Math.min(local.maxCount, activeContext.maxCount),
stoppedAtCompaction:
local.stoppedAtCompaction || activeContext.stoppedAtCompaction,
};
}
async function getSessionContext(
session: SlashCommandHost['session'],
): Promise<UndoSessionContext | undefined> {
const getContext = (
session as { getContext?: () => Promise<UndoSessionContext> } | undefined
)?.getContext;
if (session === undefined || getContext === undefined) return undefined;
try {
return await getContext.call(session);
} catch {
return undefined;
}
}
function undoAvailabilityFromTranscript(
entries: readonly TranscriptEntry[],
children: readonly Component[],
): UndoAvailability {
const { anchors, stoppedAtCompaction } = activeUndoAnchorEntries(entries, children);
return {
maxCount: anchors.length,
stoppedAtCompaction,
};
}
function undoAvailabilityFromContext(
history: readonly ContextMessage[],
): UndoAvailability {
let maxCount = 0;
let stoppedAtCompaction = false;
for (let i = history.length - 1; i >= 0; i--) {
const message = history[i];
if (message === undefined) continue;
if (message.origin?.kind === 'injection') continue;
if (message.origin?.kind === 'compaction_summary') {
stoppedAtCompaction = true;
break;
}
if (isContextUndoAnchor(message)) maxCount++;
}
return { maxCount, stoppedAtCompaction };
}
function isContextUndoAnchor(message: ContextMessage): boolean {
if (message.role !== 'user') return false;
const origin = message.origin;
if (origin === undefined || origin.kind === 'user') return true;
if (origin.kind === 'skill_activation') {
return origin.trigger === 'user-slash';
}
return false;
}
function createUndoChoices(
entries: readonly TranscriptEntry[],
children: readonly Component[],
maxCount: number,
): readonly UndoChoice[] {
if (maxCount <= 0) return [];
const anchors = activeUndoAnchorEntries(entries, children).anchors.slice(-maxCount);
return anchors.map((entry, index) => ({
id: entry.id,
count: anchors.length - index,
input: formatUndoChoiceInput(entry),
label: formatUndoChoiceLabel(entry),
}));
}
function activeUndoAnchorEntries(
entries: readonly TranscriptEntry[],
children: readonly Component[],
): { readonly anchors: readonly TranscriptEntry[]; readonly stoppedAtCompaction: boolean } {
const lastCompactionChildIndex = children.findLastIndex(
(child) => child instanceof CompactionComponent,
);
if (lastCompactionChildIndex >= 0) {
return {
anchors: children
.slice(lastCompactionChildIndex + 1)
.map((child) => getTranscriptComponentEntry(child))
.filter((entry): entry is TranscriptEntry => entry !== undefined)
.filter(isUndoAnchorEntry),
stoppedAtCompaction: true,
};
}
const lastCompactionEntryIndex = entries.findLastIndex(
(entry) => entry.compactionData !== undefined,
);
const activeEntries =
lastCompactionEntryIndex >= 0 ? entries.slice(lastCompactionEntryIndex + 1) : entries;
return {
anchors: activeEntries.filter(isUndoAnchorEntry),
stoppedAtCompaction: lastCompactionEntryIndex >= 0,
};
}
function formatUndoChoiceLabel(
entry: TranscriptEntry,
): string {
if (entry.kind === 'skill_activation') {
const name = singleLine(
entry.skillName ?? entry.content.replace(/^Activated skill:\s*/, ''),
);
const args = singleLine(entry.skillArgs ?? '');
if (name.length === 0) return 'Skill: unknown';
return args.length > 0 ? `/${name} ${args}` : `/${name}`;
}
const content = singleLine(entry.content);
const imageCount = entry.imageAttachmentIds?.length ?? 0;
if (content.length > 0) return content;
if (imageCount > 0) {
return `User message (${String(imageCount)} ${imageCount === 1 ? 'image' : 'images'})`;
}
return 'User message';
}
function formatUndoChoiceInput(entry: TranscriptEntry): string {
if (entry.kind === 'skill_activation') {
const name = singleLine(
entry.skillName ?? entry.content.replace(/^Activated skill:\s*/, ''),
);
const args = singleLine(entry.skillArgs ?? '');
if (name.length === 0) return '';
return args.length > 0 ? `/${name} ${args}` : `/${name}`;
}
return entry.content;
}
function singleLine(text: string): string {
return text.replaceAll(/\s+/g, ' ').trim();
}
function formatUndoLimitMessage(
requestedCount: number,
availability: UndoAvailability,
): string {
const reason = availability.stoppedAtCompaction ? ' after the last compaction' : '';
const requested = formatPromptCount(requestedCount);
const max = formatPromptCount(availability.maxCount);
return `Cannot undo ${requested}; only ${max} can be undone in the active context${reason}.`;
}
function formatNothingToUndoMessage(availability: UndoAvailability): string {
if (availability.stoppedAtCompaction) {
return 'Nothing to undo after the last compaction.';
}
return 'Nothing to undo.';
}
function formatPromptCount(count: number): string {
return `${String(count)} ${count === 1 ? 'prompt' : 'prompts'}`;
}
function showUndoLimitStatus(host: SlashCommandHost, message: string): void {
host.appendTranscriptEntry({
id: nextTranscriptId(),
kind: 'status',
turnId: UNDO_LIMIT_STATUS_TURN_ID,
renderMode: 'plain',
content: message,
});
}
function undoLimitFromError(
error: unknown,
): (UndoAvailability & { readonly requestedCount: number }) | undefined {
if (!isKimiError(error)) return undefined;
const details = error.details;
if (details?.['reason'] !== 'undo_limit') return undefined;
const requestedCount = details['requestedCount'];
const maxCount = details['undoableCount'];
const stoppedAtCompaction = details['stoppedAtCompaction'];
if (
typeof requestedCount !== 'number' ||
typeof maxCount !== 'number' ||
typeof stoppedAtCompaction !== 'boolean'
) {
return undefined;
}
return { requestedCount, maxCount, stoppedAtCompaction };
}
function isUndoAnchorEntry(entry: TranscriptEntry): boolean {
return (
entry.kind === 'user' ||

View file

@ -0,0 +1,120 @@
import {
Container,
Key,
matchesKey,
truncateToWidth,
visibleWidth,
type Focusable,
} from '@earendil-works/pi-tui';
import { SELECT_POINTER } from '#/tui/constant/symbols';
import { currentTheme } from '#/tui/theme';
import { SearchableList } from '#/tui/utils/searchable-list';
const MAX_VISIBLE_CHOICES = 5;
const PREFERRED_SELECTED_OFFSET = 2;
export interface UndoChoice {
readonly id: string;
readonly count: number;
readonly input: string;
readonly label: string;
}
export interface UndoSelectorOptions {
readonly choices: readonly UndoChoice[];
readonly onSelect: (choice: UndoChoice) => void;
readonly onCancel: () => void;
}
export class UndoSelectorComponent extends Container implements Focusable {
focused = false;
private readonly opts: UndoSelectorOptions;
private readonly list: SearchableList<UndoChoice>;
private submitted = false;
constructor(opts: UndoSelectorOptions) {
super();
this.opts = opts;
this.list = new SearchableList({
items: opts.choices,
toSearchText: (choice) => choice.label,
initialIndex: Math.max(0, opts.choices.length - 1),
});
}
handleInput(data: string): void {
if (this.submitted) return;
if (matchesKey(data, Key.escape)) {
this.opts.onCancel();
return;
}
if (this.list.handleKey(data)) {
return;
}
if (matchesKey(data, Key.enter)) {
const selected = this.list.selected();
if (selected !== undefined) {
this.submitted = true;
this.opts.onSelect(selected);
}
}
}
override render(width: number): string[] {
const view = this.list.view();
const hintParts = ['↑↓ navigate', 'Enter select', 'Esc cancel'];
const lines: string[] = [
currentTheme.fg('primary', '─'.repeat(width)),
currentTheme.boldFg('primary', ' Select messages to undo'),
currentTheme.fg('textMuted', ' ' + hintParts.join(' · ')),
'',
];
if (view.items.length === 0) {
lines.push(currentTheme.fg('textMuted', ' No messages'));
} else {
const visibleCount = Math.min(MAX_VISIBLE_CHOICES, view.items.length);
const maxStart = view.items.length - visibleCount;
const start = Math.min(
Math.max(0, view.selectedIndex - PREFERRED_SELECTED_OFFSET),
maxStart,
);
const end = start + visibleCount;
for (let i = start; i < end; i++) {
const choice = view.items[i];
if (choice === undefined) continue;
lines.push(
this.renderChoiceLine(choice, i === view.selectedIndex, i > view.selectedIndex, width),
);
}
}
lines.push('');
lines.push(currentTheme.fg('primary', '─'.repeat(width)));
return lines.map((line) => truncateToWidth(line, width));
}
private renderChoiceLine(
choice: UndoChoice,
isSelected: boolean,
inUndoRange: boolean,
width: number,
): string {
const pointer = isSelected ? SELECT_POINTER : ' ';
const prefix = ` ${pointer} `;
const labelBudget = Math.max(8, width - visibleWidth(prefix));
const label = truncateToWidth(choice.label, labelBudget, '…');
const token = isSelected ? 'primary' : inUndoRange ? 'textDim' : 'text';
let line = currentTheme.fg(isSelected ? 'primary' : 'textDim', prefix);
line += isSelected
? currentTheme.boldFg(token, label)
: currentTheme.fg(token, label);
return line;
}
}

View file

@ -20,6 +20,7 @@ import { BtwPanelComponent } from '#/tui/components/panes/btw-panel';
import { WelcomeComponent } from '#/tui/components/chrome/welcome';
import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector';
import { TabbedModelSelectorComponent } from '#/tui/components/dialogs/tabbed-model-selector';
import { UndoSelectorComponent } from '#/tui/components/dialogs/undo-selector';
import {
PluginMcpSelectorComponent,
PluginMarketplaceSelectorComponent,
@ -250,6 +251,13 @@ function renderTranscript(driver: MessageDriver): string {
return driver.state.transcriptContainer.render(120).join('\n');
}
async function confirmUndoSelection(driver: MessageDriver): Promise<void> {
await vi.waitFor(() => {
expect(driver.state.editorContainer.children[0]).toBeInstanceOf(UndoSelectorComponent);
});
(driver.state.editorContainer.children[0] as UndoSelectorComponent).handleInput('\r');
}
function renderActivity(driver: MessageDriver): string {
return driver.state.activityContainer.render(120).join('\n');
}
@ -802,6 +810,7 @@ command = "vim"
driver.state.appState.streamingPhase = 'idle';
driver.handleUserInput('/undo');
await confirmUndoSelection(driver);
await vi.waitFor(() => {
expect(session.undoHistory).toHaveBeenCalledWith(1);
@ -829,6 +838,7 @@ command = "vim"
driver.state.appState.streamingPhase = 'idle';
driver.handleUserInput('/undo');
await confirmUndoSelection(driver);
await vi.waitFor(() => {
expect(driver.state.transcriptEntries).toEqual([]);
@ -852,7 +862,15 @@ command = "vim"
expect(stripSgr(renderTranscript(driver))).toContain('Auto mode: ON');
});
driver.handleUserInput('/undo 10');
await vi.waitFor(() => {
expect(stripSgr(renderTranscript(driver))).toContain(
'Cannot undo 10 prompts; only 1 prompt can be undone in the active context.',
);
});
driver.handleUserInput('/undo');
await confirmUndoSelection(driver);
await vi.waitFor(() => {
expect(session.undoHistory).toHaveBeenCalledWith(1);
@ -860,6 +878,7 @@ command = "vim"
const transcript = stripSgr(renderTranscript(driver));
expect(transcript).not.toContain('hello');
expect(transcript).not.toContain('Cannot undo 10 prompts');
expect(transcript).toContain('Auto mode: ON');
expect(driver.state.appState.permissionMode).toBe('auto');
});
@ -897,6 +916,7 @@ command = "vim"
});
driver.handleUserInput('/undo');
await confirmUndoSelection(driver);
await vi.waitFor(() => {
expect(session.undoHistory).toHaveBeenCalledWith(1);
@ -943,6 +963,7 @@ command = "vim"
driver.state.appState.streamingPhase = 'idle';
driver.handleUserInput('/undo');
await confirmUndoSelection(driver);
await vi.waitFor(() => {
expect(session.undoHistory).toHaveBeenCalledWith(1);
@ -986,6 +1007,7 @@ command = "vim"
});
driver.handleUserInput('/undo');
await confirmUndoSelection(driver);
await vi.waitFor(() => {
expect(session.undoHistory).toHaveBeenCalledWith(1);
@ -1064,6 +1086,7 @@ command = "vim"
driver.state.appState.streamingPhase = 'idle';
driver.handleUserInput('/undo');
await confirmUndoSelection(driver);
await vi.waitFor(() => {
expect(driver.state.transcriptEntries).toEqual([]);
@ -1092,6 +1115,7 @@ command = "vim"
driver.state.appState.streamingPhase = 'idle';
driver.handleUserInput('/undo');
await confirmUndoSelection(driver);
await vi.waitFor(() => {
expect(driver.state.transcriptEntries).toEqual([

View file

@ -32,6 +32,7 @@ Some commands are only available in the idle state. Executing these commands whi
| `/fork` | — | Fork a new session from the current one, preserving the full conversation history | No |
| `/title [<text>]` | `/rename` | Without arguments, display the current session title; with an argument, set a new title (max 200 characters) | Yes |
| `/compact [<instruction>]` | — | Compact the current conversation context to free up token usage; an optional custom instruction can hint to the model what to preserve | No |
| `/undo [<count>]` | — | Undo recent prompts from the active context. Without a count, opens a selector; with a count, undoes that many prompts. Prompts before the last compaction cannot be undone | No |
| `/reload` | — | Reload the current session and apply the latest `config.toml` settings (providers, models, etc.) and `tui.toml` UI preferences, without restarting the CLI | No |
| `/reload-tui` | — | Reload only the `tui.toml` UI preferences (theme, editor, notifications, etc.) without rebuilding the session | Yes |
| `/init` | — | Analyze the current codebase and generate `AGENTS.md` | No |

View file

@ -32,6 +32,7 @@
| `/fork` | — | 基于当前会话 fork 一份新会话,保留完整对话历史 | 否 |
| `/title [<text>]` | `/rename` | 不带参数时显示当前会话标题;带参数时设置为新标题(最长 200 字符) | 是 |
| `/compact [<instruction>]` | — | 压缩当前对话上下文,释放 token 占用;可附带自定义指令,提示模型压缩时保留哪些信息 | 否 |
| `/undo [<count>]` | — | 从当前上下文撤销最近的提示词。不带数量时打开选择器;带数量时撤销对应条数。最后一次上下文压缩之前的提示词不能撤销 | 否 |
| `/init` | — | 分析当前代码库并生成 `AGENTS.md` | 否 |
| `/export-md [<path>]` | `/export` | 将当前会话导出为 Markdown 文件 | 否 |
| `/export-debug-zip` | — | 将当前会话导出为调试用 ZIP 压缩包(与 [`kimi export`](./kimi-command.md#kimi-export) 行为一致) | 否 |

View file

@ -133,7 +133,15 @@ export class ContextMemory {
) {
throw new KimiError(
ErrorCodes.REQUEST_INVALID,
'Nothing to undo in the active context.',
formatUndoUnavailableMessage(count, removedUserCount, stoppedAtBoundary),
{
details: {
reason: 'undo_limit',
requestedCount: count,
undoableCount: removedUserCount,
stoppedAtCompaction: stoppedAtBoundary,
},
},
);
}
}
@ -345,3 +353,16 @@ function isRealUserPrompt(message: ContextMessage): boolean {
}
return false;
}
function formatUndoUnavailableMessage(
requestedCount: number,
undoableCount: number,
stoppedAtCompaction: boolean,
): string {
const reason = stoppedAtCompaction ? ' after the last compaction' : '';
return `Cannot undo ${formatPromptCount(requestedCount)}; only ${formatPromptCount(undoableCount)} can be undone in the active context${reason}.`;
function formatPromptCount(count: number): string {
return `${String(count)} ${count === 1 ? 'prompt' : 'prompts'}`;
}
}

View file

@ -561,7 +561,9 @@ describe('Agent context', () => {
expect(() => {
ctx.agent.context.undo(2);
}).toThrow('Nothing to undo in the active context.');
}).toThrow(
'Cannot undo 2 prompts; only 1 prompt can be undone in the active context after the last compaction.',
);
expect(ctx.agent.context.history).toEqual([
expect.objectContaining({