Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/warm-dragons-rewind.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@moonshot-ai/kimi-code': minor
---

Add `/rewind` to restore conversation history and tracked workspace files from local, Git-independent checkpoints. Run `/rewind` to review and confirm the pending file changes.
1 change: 1 addition & 0 deletions apps/kimi-code/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@
"chalk": "^5.4.1",
"cli-highlight": "^2.1.11",
"commander": "^13.1.0",
"ignore": "^5.3.2",
"jimp": "^1.6.1",
"pathe": "^2.0.3",
"postject": "1.0.0-alpha.6",
Expand Down
9 changes: 9 additions & 0 deletions apps/kimi-code/src/tui/commands/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { TasksBrowserController } from '../controllers/tasks-browser';
import { tryHandleDanceCommand } from '../easter-eggs/dance';
import type { ResolvedTheme } from '../theme/colors';
import type { TUIState } from '../tui-state';
import type { WorkspaceCheckpointStore } from '../workspace-checkpoints';
import type {
AppState,
LoginProgressSpinnerHandle,
Expand Down Expand Up @@ -64,6 +65,7 @@ import {
} from './session';
import { handleSwarmCommand } from './swarm';
import { handleUndoCommand } from './undo';
import { handleRewindCommand } from './rewind';
import { handleWebCommand } from './web';

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -102,6 +104,7 @@ export {
handleTitleCommand,
} from './session';
export { handleUndoCommand } from './undo';
export { handleRewindCommand } from './rewind';
export { handleWebCommand } from './web';

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -166,6 +169,8 @@ export interface SlashCommandHost {
/** Reset the client-side cache-break baseline after the context was cut
* (/undo): the next step's cache-read drop is expected, not a break. */
noteContextCut?(): void;
/** Session-local filesystem before-images used by `/rewind`. */
getWorkspaceCheckpointStore?(): WorkspaceCheckpointStore | undefined;

// UI
showLoginProgressSpinner(label: string): LoginProgressSpinnerHandle;
Expand Down Expand Up @@ -342,6 +347,7 @@ const SESSION_REQUIRING_COMMANDS: ReadonlySet<BuiltinSlashCommandName> = new Set
'goal',
'init',
'plan',
'rewind',
'swarm',
'undo',
'web',
Expand Down Expand Up @@ -512,6 +518,9 @@ async function handleBuiltInSlashCommand(
case 'undo':
await handleUndoCommand(host, args);
return;
case 'rewind':
await handleRewindCommand(host, args);
return;
case 'web':
await handleWebCommand(host);
return;
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/src/tui/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export { handleGoalCommand, parseGoalCommand } from './goal';
export { goalArgumentCompletions } from './registry';
export { handleForkCommand, handleInitCommand, handleTitleCommand } from './session';
export { handleUndoCommand } from './undo';
export { handleRewindCommand } from './rewind';
export { handleWebCommand } from './web';
export {
promptApiKey,
Expand Down
7 changes: 7 additions & 0 deletions apps/kimi-code/src/tui/commands/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,13 @@ export const BUILTIN_SLASH_COMMANDS = [
priority: 80,
availability: 'idle-only',
},
{
name: 'rewind',
aliases: [],
description: 'Restore prompts and workspace files from local checkpoints',
priority: 80,
availability: 'idle-only',
},
{
name: 'editor',
aliases: [],
Expand Down
229 changes: 229 additions & 0 deletions apps/kimi-code/src/tui/commands/rewind.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import { join } from 'node:path';

import { ChoicePickerComponent } from '../components/dialogs/choice-picker';
import { UndoSelectorComponent } from '../components/dialogs/undo-selector';
import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui';
import { formatErrorMessage } from '../utils/event-payload';
import type { WorkspaceChange, WorkspaceRewindPlan } from '../workspace-checkpoints';
import type { SlashCommandHost } from './dispatch';
import {
createUndoChoices,
parseUndoCount,
resolveUndoAvailability,
undoByCount,
} from './undo';

export async function handleRewindCommand(
host: SlashCommandHost,
args: string = '',
): Promise<void> {
if (host.state.appState.streamingPhase !== 'idle') {
host.showError('Cannot rewind while streaming — press Esc or Ctrl-C first.');
return;
}
if (host.session === undefined) {
host.showError(NO_ACTIVE_SESSION_MESSAGE);
return;
}
const store = host.getWorkspaceCheckpointStore?.();
if (store === undefined) {
host.showError('Workspace rewind is unavailable because this session has no local checkpoint store.');
return;
}

const availability = await resolveUndoAvailability(host);
const checkpointCount = await store.availableCount();
const maxCount = Math.min(availability.maxCount, checkpointCount);
Comment on lines +34 to +36

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep rewind checkpoints aligned with mid-turn steers

This assumes the newest workspace checkpoints line up one-for-one with the newest undo anchors, but the streaming steerMessage() path still appends user transcript entries and calls session.steer directly without capturing a checkpoint. In a session with prompt A checkpointed and then a mid-turn steer B, /rewind 1 can offer B while prepareRewind(1) applies A's before-image and undoHistory(1) only removes B, restoring files too far back. Capture or invalidate checkpoints for the streaming steer path before using checkpointCount as an alignment guarantee.

Useful? React with 👍 / 👎.

const trimmed = args.trim();
if (trimmed.length === 0) {
const choices = createUndoChoices(
host.state.transcriptEntries,
host.state.transcriptContainer.children,
maxCount,
);
if (choices.length === 0) {
host.showStatus(checkpointCount === 0 ? 'No workspace checkpoints to rewind.' : 'Nothing to rewind.');
return;
}
host.mountEditorReplacement(
new UndoSelectorComponent({
title: 'Select messages and files to rewind',
choices,
onSelect: (choice) => {
void rewindByCount(host, choice.count, true).then((rewound) => {
if (rewound) host.restoreInputText(choice.input);
});
},
onCancel: () => {
host.restoreEditor();
},
}),
);
return;
}

const count = parseUndoCount(trimmed);
if (count === undefined) {
host.showError('Usage: /rewind [count], where count is a positive integer.');
return;
}
if (count > maxCount) {
host.showError(
`Cannot rewind ${formatPromptCount(count)}; only ${formatPromptCount(maxCount)} have both conversation history and workspace checkpoints.`,
);
return;
}
await rewindByCount(host, count, false);
}

async function rewindByCount(
host: SlashCommandHost,
count: number,
editorIsReplaced: boolean,
): Promise<boolean> {
const store = host.getWorkspaceCheckpointStore?.();
if (store === undefined) {
if (editorIsReplaced) host.restoreEditor();
host.showError('Workspace rewind is unavailable for this session.');
return false;
}

let plan: WorkspaceRewindPlan;
try {
plan = await store.prepareRewind(count);
} catch (error) {
if (editorIsReplaced) host.restoreEditor();
host.showError(`Cannot prepare rewind: ${formatErrorMessage(error)}`);
return false;
}

const confirmed = await confirmRewind(host, plan);
if (!confirmed) {
await store.releasePreview().catch(() => undefined);
return false;
}

try {
await store.apply(plan);
} catch (error) {
await store.releasePreview().catch(() => undefined);
host.showError(`Rewind aborted before conversation history changed: ${formatErrorMessage(error)}`);
return false;
}

const conversationUndone = await undoByCount(host, count, {
preserveWorkspaceCheckpoints: true,
});
if (!conversationUndone) {
try {
await store.rollback(plan);
} catch (error) {
host.showError(
`Conversation rewind failed and workspace rollback also failed: ${formatErrorMessage(error)}`,
);
return false;
}
await store.releasePreview().catch(() => undefined);
host.showStatus(
'Conversation rewind failed; workspace files were restored to their pre-rewind state.',
'warning',
);
return false;
}

try {
await store.commit(plan);
} catch (error) {
// The user-visible rewind already succeeded. Invalidate stale metadata so
// a later command cannot apply checkpoints against the wrong turn suffix.
await store.invalidate().catch(() => undefined);
host.showStatus(
`Rewind completed, but its checkpoint metadata could not be finalized: ${formatErrorMessage(error)}`,
'warning',
);
return true;
}

host.showStatus(
`Rewound ${formatPromptCount(count)} and ${formatFileCount(plan.changes.length)}.`,
'success',
);
return true;
}

function confirmRewind(host: SlashCommandHost, plan: WorkspaceRewindPlan): Promise<boolean> {
const summary = summarizeChanges(plan.changes);
return new Promise((resolveConfirmed) => {
let completed = false;
const finish = (confirmed: boolean): void => {
if (completed) return;
completed = true;
host.restoreEditor();
resolveConfirmed(confirmed);
};
host.mountEditorReplacement(
new ChoicePickerComponent({
title: `Rewind ${formatPromptCount(plan.count)} and workspace files?`,
hint: 'Review the workspace changes below · Enter/Space select · Esc cancel',
notice: summary.notice,
noticeTone: plan.changes.length === 0 ? 'success' : 'warning',
currentValue: 'cancel',
options: [
{
value: 'cancel',
label: 'Cancel',
description: 'Leave conversation history and workspace files unchanged.',
},
{
value: 'rewind',
label: 'Rewind conversation and files',
tone: 'danger',
description: summary.description,
},
],
onSelect: (value) => {
finish(value === 'rewind');
},
onCancel: () => {
finish(false);
},
}),
);
});
}

function summarizeChanges(changes: readonly WorkspaceChange[]): {
readonly notice: string;
readonly description: string;
} {
const created = changes.filter((change) => change.kind === 'created').length;
const modified = changes.filter((change) => change.kind === 'modified').length;
const deleted = changes.filter((change) => change.kind === 'deleted').length;
if (changes.length === 0) {
return {
notice: 'No tracked workspace files changed; only conversation history will be rewound.',
description: 'Withdraw the selected prompts from the active context.',
};
}
const preview = changes.slice(0, 8).map((change) => {
const action = change.kind === 'created' ? 'delete' : change.kind === 'deleted' ? 'restore' : 'restore';
return `${action.padEnd(7)} ${displayWorkspacePath(change)}`;
});
if (changes.length > preview.length) preview.push(`…and ${changes.length - preview.length} more`);
return {
notice: [`Workspace delta: ${created} created, ${modified} modified, ${deleted} deleted.`, ...preview].join('\n'),
description: `Restore ${formatFileCount(changes.length)} to the state before the selected prompts. Files ignored by .gitignore/.ignore, dependency trees, VCS metadata, and symlinks are outside the checkpoint.`,
};
}

function displayWorkspacePath(change: WorkspaceChange): string {
return JSON.stringify(join(change.root, change.path));
}

function formatPromptCount(count: number): string {
return `${count} ${count === 1 ? 'prompt' : 'prompts'}`;
}

function formatFileCount(count: number): string {
return `${count} workspace ${count === 1 ? 'file' : 'files'}`;
}
25 changes: 20 additions & 5 deletions apps/kimi-code/src/tui/commands/undo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import type { SlashCommandHost } from './dispatch';
// Undo command
// ---------------------------------------------------------------------------

interface UndoAvailability {
export interface UndoAvailability {
readonly maxCount: number;
readonly stoppedAtCompaction: boolean;
}
Expand Down Expand Up @@ -77,7 +77,11 @@ export async function handleUndoCommand(
await undoByCount(host, count);
}

async function undoByCount(host: SlashCommandHost, count: number): Promise<boolean> {
export async function undoByCount(
host: SlashCommandHost,
count: number,
options: { readonly preserveWorkspaceCheckpoints?: boolean } = {},
): Promise<boolean> {
const session = host.session;
if (session === undefined) {
host.showError(NO_ACTIVE_SESSION_MESSAGE);
Expand Down Expand Up @@ -122,6 +126,17 @@ async function undoByCount(host: SlashCommandHost, count: number): Promise<boole
renderWelcome(host);
}

if (options.preserveWorkspaceCheckpoints !== true) {
try {
await host.getWorkspaceCheckpointStore?.()?.discardLast(count);
} catch (error) {
host.showStatus(
`Conversation was undone, but workspace checkpoint cleanup failed: ${formatErrorMessage(error)}`,
'warning',
);
}
}

host.state.ui.requestRender();
return true;
}
Expand Down Expand Up @@ -162,15 +177,15 @@ async function showUndoSelector(host: SlashCommandHost): Promise<void> {
);
}

function parseUndoCount(args: string): number | undefined {
export function parseUndoCount(args: string): number | undefined {
const value = args.trim();
if (value.length === 0) return 1;
if (!/^[1-9]\d*$/.test(value)) return undefined;
const count = Number(value);
return Number.isSafeInteger(count) ? count : undefined;
}

async function resolveUndoAvailability(
export async function resolveUndoAvailability(
host: SlashCommandHost,
): Promise<UndoAvailability> {
const local = undoAvailabilityFromTranscript(
Expand Down Expand Up @@ -246,7 +261,7 @@ function isContextUndoAnchor(message: ContextMessage): boolean {
return false;
}

function createUndoChoices(
export function createUndoChoices(
entries: readonly TranscriptEntry[],
children: readonly Component[],
maxCount: number,
Expand Down
Loading