mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
feat: preview undo ranges before confirmation
This commit is contained in:
parent
6de3d97d82
commit
d125bb62ed
12 changed files with 415 additions and 12 deletions
5
.changeset/undo-preview-confirmation.md
Normal file
5
.changeset/undo-preview-confirmation.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": minor
|
||||
---
|
||||
|
||||
Preview `/undo` ranges before confirming and restore the withdrawn prompt to the input box.
|
||||
|
|
@ -125,6 +125,7 @@ export interface SlashCommandHost {
|
|||
showSessionPicker(): Promise<void>;
|
||||
sendNormalUserInput(text: string): void;
|
||||
sendSkillActivation(session: Session, skillName: string, skillArgs: string): void;
|
||||
startUndoPreview(count: number): void;
|
||||
readonly skillCommandMap: Map<string, string>;
|
||||
|
||||
// Controller refs
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<boolean> {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
54
apps/kimi-code/src/tui/components/messages/undo-highlight.ts
Normal file
54
apps/kimi-code/src/tui/components/messages/undo-highlight.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
236
apps/kimi-code/src/tui/controllers/undo-preview.ts
Normal file
236
apps/kimi-code/src/tui/controllers/undo-preview.ts
Normal file
|
|
@ -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<void> {
|
||||
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}`;
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 [<text>]` | `/rename` | Without arguments, show the current session title; with an argument, set it as the new title (up to 200 characters). | Yes |
|
||||
| `/compact [<instruction>]` | — | 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 [<count>]` | — | Preview withdrawing the latest prompt, or the latest `<count>` 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 [<path>]` | `/export` | Export the current session as a Markdown file. With no argument, writes to `kimi-export-<short-id>-<timestamp>.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 |
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
| `/fork` | — | 基于当前会话 fork 一份新会话,保留完整对话历史。 | 否 |
|
||||
| `/title [<text>]` | `/rename` | 不带参数时显示当前会话标题;带参数时将其设置为新标题(最长 200 个字符)。 | 是 |
|
||||
| `/compact [<instruction>]` | — | 压缩当前对话上下文,释放 token 占用;可选附带一段自定义指令,提示模型在压缩时保留哪些信息。 | 否 |
|
||||
| `/undo [<count>]` | — | 预览撤回最近一次提示词,或最近 `<count>` 次提示词,按 `Enter` 确认。可用 `↑` 和 `↓` 调整范围,或按 `Esc` 取消。 | 否 |
|
||||
| `/init` | — | 分析当前代码库并生成 `AGENTS.md`。 | 否 |
|
||||
| `/export-md [<path>]` | `/export` | 将当前会话导出为 Markdown 文件。不带参数时写入工作目录下的 `kimi-export-<short-id>-<timestamp>.md`,传入路径可指定输出位置。 | 否 |
|
||||
| `/export-debug-zip` | — | 将当前会话导出为调试用的 ZIP 压缩包(与 [`kimi export`](./kimi-command.md#kimi-export) 行为一致)。压缩包始终包含当前活动的全局诊断日志。 | 否 |
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue