mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
feat: add undo selector (#615)
This commit is contained in:
parent
4603d8ad6e
commit
494554eac5
9 changed files with 475 additions and 7 deletions
6
.changeset/blue-undo-selector.md
Normal file
6
.changeset/blue-undo-selector.md
Normal 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.
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' ||
|
||||
|
|
|
|||
120
apps/kimi-code/src/tui/components/dialogs/undo-selector.ts
Normal file
120
apps/kimi-code/src/tui/components/dialogs/undo-selector.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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) 行为一致) | 否 |
|
||||
|
|
|
|||
|
|
@ -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'}`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue