-
Notifications
You must be signed in to change notification settings - Fork 1k
feat: add workspace rewind checkpoints #2785
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| 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'}`; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This assumes the newest workspace checkpoints line up one-for-one with the newest undo anchors, but the streaming
steerMessage()path still appendsusertranscript entries and callssession.steerdirectly without capturing a checkpoint. In a session with prompt A checkpointed and then a mid-turn steer B,/rewind 1can offer B whileprepareRewind(1)applies A's before-image andundoHistory(1)only removes B, restoring files too far back. Capture or invalidate checkpoints for the streaming steer path before usingcheckpointCountas an alignment guarantee.Useful? React with 👍 / 👎.