Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/resume-cross-workdir-sessions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@moonshot-ai/agent-core": minor
"@moonshot-ai/kimi-code-sdk": minor
"@moonshot-ai/kimi-code": minor
---

Allow direct session resume to confirm and switch when the session belongs to another working directory.
30 changes: 16 additions & 14 deletions apps/kimi-code/src/tui/components/dialogs/choice-picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,24 +48,26 @@ const CURRENT_MARK = '← current';

function wrapDescription(text: string, width: number): string[] {
const maxWidth = Math.max(1, width);
const words = text
.trim()
.split(/\s+/)
.filter((word) => word.length > 0);
const lines: string[] = [];
let current = '';

for (const word of words) {
const candidate = current.length === 0 ? word : `${current} ${word}`;
if (visibleWidth(candidate) <= maxWidth) {
current = candidate;
continue;
for (const paragraph of text.trim().split(/\r?\n/)) {
const words = paragraph
.trim()
.split(/\s+/)
.filter((word) => word.length > 0);
let current = '';

for (const word of words) {
const candidate = current.length === 0 ? word : `${current} ${word}`;
if (visibleWidth(candidate) <= maxWidth) {
current = candidate;
continue;
}
if (current.length > 0) lines.push(current);
current = visibleWidth(word) <= maxWidth ? word : truncateToWidth(word, maxWidth, '…');
}

if (current.length > 0) lines.push(current);
current = visibleWidth(word) <= maxWidth ? word : truncateToWidth(word, maxWidth, '…');
}

if (current.length > 0) lines.push(current);
return lines;
}

Expand Down
14 changes: 14 additions & 0 deletions apps/kimi-code/src/tui/components/editor/custom-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ interface AutocompleteInternals {
readonly autocompleteDebounceTimer?: ReturnType<typeof setTimeout>;
}

interface HistoryInternals {
history: string[];
historyIndex: number;
}

/**
* Workaround for a pi-tui bug that surfaces when Kitty keyboard protocol
* is active AND caps_lock is on. In that state terminals emit, e.g.,
Expand Down Expand Up @@ -147,6 +152,15 @@ export class CustomEditor extends Editor {
super(tui, createEditorTheme(colors), { paddingX: 4 });
}

replaceHistory(entries: readonly string[]): void {
const history = this as unknown as HistoryInternals;
history.history = [];
history.historyIndex = -1;
for (const entry of entries) {
this.addToHistory(entry);
}
}

private expandPasteMarkerAtCursor(): boolean {
const { line, col } = this.getCursor();
const lines = this.getLines();
Expand Down
122 changes: 109 additions & 13 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,13 @@ export interface TUIStartupOptions {
readonly startupNotice?: string;
}

export type TUIStartupState = 'pending' | 'ready' | 'picker';
export type TUIStartupState = 'pending' | 'ready' | 'picker' | 'cross-workdir-confirm';

interface PendingCrossWorkDirResume {
readonly sessionId: string;
readonly sessionWorkDir: string;
readonly currentWorkDir: string;
}

export interface KimiTUIOptions {
initialAppState: AppState;
Expand Down Expand Up @@ -602,7 +608,8 @@ export class KimiTUI {
private readonly skillCommandMap = new Map<string, string>();
private readonly imageStore = new ImageAttachmentStore();
private readonly fdPath: string | null = detectFdPath();
private readonly gitLsFilesCache: GitLsFilesCache;
private gitLsFilesCache: GitLsFilesCache;
private pendingCrossWorkDirResume: PendingCrossWorkDirResume | undefined;
private sessionEventUnsubscribe: (() => void) | undefined;
private pendingExit: PendingExit | null = null;
private cancelInFlight: (() => void) | undefined;
Expand Down Expand Up @@ -734,12 +741,11 @@ export class KimiTUI {
try {
const file = getInputHistoryFile(this.state.appState.workDir);
const entries = await loadInputHistory(file);
for (const entry of entries) {
this.state.editor.addToHistory(entry.content);
}
this.state.editor.replaceHistory(entries.map((entry) => entry.content));
this.state.lastHistoryContent = entries.at(-1)?.content;
} catch {
/* history is best-effort */
this.state.editor.replaceHistory([]);
this.state.lastHistoryContent = undefined;
}
}

Expand Down Expand Up @@ -819,10 +825,13 @@ export class KimiTUI {
// transcript history should be replayed.
private async initMainTui(): Promise<boolean> {
const shouldReplayHistory = await this.init();
const awaitingCrossWorkDirConfirm = this.state.startupState === 'cross-workdir-confirm';

this.renderWelcome();
this.setupAutocomplete();
void this.loadPersistedInputHistory();
if (!awaitingCrossWorkDirConfirm) {
this.renderWelcome();
this.setupAutocomplete();
void this.loadPersistedInputHistory();
}
this.state.editorContainer.clear();
this.state.editorContainer.addChild(this.state.editor);
this.state.ui.setFocus(this.state.editor);
Expand Down Expand Up @@ -850,6 +859,10 @@ export class KimiTUI {
// else to do here until the user makes a choice.
return;
}
if (this.state.startupState === 'cross-workdir-confirm') {
this.bootstrapCrossWorkDirResume();
return;
}
if (shouldReplayHistory) {
await this.hydrateTranscriptFromReplay(this.requireSession());
}
Expand Down Expand Up @@ -898,12 +911,24 @@ export class KimiTUI {
}

if (startup.sessionFlag !== undefined) {
const sessions = await this.harness.listSessions({ workDir });
const sessions = await this.harness.listSessions({
workDir,
sessionId: startup.sessionFlag,
});
const target = sessions.find((candidate) => candidate.id === startup.sessionFlag);
if (target === undefined) {
throw new Error(`Session "${startup.sessionFlag}" not found.`);
}
session = await this.harness.resumeSession({ id: startup.sessionFlag });
if (target.workDir !== workDir) {
this.pendingCrossWorkDirResume = {
sessionId: target.id,
sessionWorkDir: target.workDir,
currentWorkDir: workDir,
};
this.state.startupState = 'cross-workdir-confirm';
return false;
}
session = await this.harness.resumeSession({ id: target.id });
shouldReplayHistory = true;
} else {
const sessions = await this.harness.listSessions({ workDir });
Expand Down Expand Up @@ -2094,6 +2119,7 @@ export class KimiTUI {

// Pulls runtime session status into the app state.
private async syncRuntimeState(session: Session = this.requireSession()): Promise<void> {
await this.syncSessionWorkDir(session);
const status = await session.getStatus();
this.setAppState({
sessionId: session.id,
Expand All @@ -2109,6 +2135,15 @@ export class KimiTUI {
});
}

private async syncSessionWorkDir(session: Session): Promise<void> {
if (this.state.appState.workDir === session.workDir) return;
process.chdir(session.workDir);
this.gitLsFilesCache = createGitLsFilesCache(session.workDir);
this.setAppState({ workDir: session.workDir });
this.setupAutocomplete();
await this.loadPersistedInputHistory();
}

// Applies current permission to the active session. Plan mode is applied by
// createSession when requested, so post-create setup must not enter it again.
private async activateRuntime(): Promise<void> {
Expand Down Expand Up @@ -2203,7 +2238,10 @@ export class KimiTUI {
}

// Switches to an existing session and replays its transcript.
private async resumeSession(targetSessionId: string): Promise<boolean> {
private async resumeSession(
targetSessionId: string,
applyStartupModel = false,
): Promise<boolean> {
if (targetSessionId === this.state.appState.sessionId) {
this.showStatus('Already on this session.');
return true;
Expand All @@ -2220,13 +2258,16 @@ export class KimiTUI {
let session: Session;
try {
session = await this.harness.resumeSession({ id: targetSessionId });
if (applyStartupModel && this.options.startup.model !== undefined) {
await session.setModel(this.options.startup.model);
}
await this.switchToSession(session, `Resumed session (${session.id}).`);
} catch (error) {
const msg = formatErrorMessage(error);
this.showError(`Failed to resume session ${targetSessionId}: ${msg}`);
return false;
}

await this.switchToSession(session, `Resumed session (${session.id}).`);
return true;
}

Expand Down Expand Up @@ -4525,6 +4566,61 @@ export class KimiTUI {
});
}

// Confirms an explicit `-r <id>` when the session belongs to another directory.
private bootstrapCrossWorkDirResume(): void {
const pending = this.pendingCrossWorkDirResume;
if (pending === undefined) {
void this.stop();
return;
}
const description = [
`Target directory: ${pending.sessionWorkDir}`,
`Current directory: ${pending.currentWorkDir}`,
].join('\n');
this.mountEditorReplacement(
new ChoicePickerComponent({
title: 'Resume session from another directory',
hint: '↑↓ navigate · Enter select · Esc cancel',
options: [
{
value: 'resume',
label: 'Switch and resume',
description,
},
{
value: 'cancel',
label: 'Cancel',
},
],
colors: this.state.theme.colors,
onSelect: (value) => {
if (value === 'resume') {
void this.confirmCrossWorkDirResume(pending);
return;
}
this.cancelCrossWorkDirResume();
},
onCancel: () => {
this.cancelCrossWorkDirResume();
},
}),
);
}

private async confirmCrossWorkDirResume(pending: PendingCrossWorkDirResume): Promise<void> {
const switched = await this.resumeSession(pending.sessionId, true);
if (!switched) return;
this.pendingCrossWorkDirResume = undefined;
this.state.startupState = 'ready';
this.restoreEditor();
}

private cancelCrossWorkDirResume(): void {
this.pendingCrossWorkDirResume = undefined;
this.restoreEditor();
void this.stop();
}

// Hides the session picker and restores the editor.
private hideSessionPicker(): void {
this.state.showingSessionPicker = false;
Expand Down
12 changes: 12 additions & 0 deletions apps/kimi-code/test/tui/components/editor/custom-editor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,3 +232,15 @@ describe('CustomEditor shortcut telemetry hooks', () => {
expect(onUndo).toHaveBeenCalledOnce();
});
});

describe('CustomEditor input history', () => {
it('replaces existing prompt history', () => {
const editor = makeEditor();

editor.addToHistory('old prompt');
editor.replaceHistory(['newer prompt']);
editor.handleInput('\u001B[A');

expect(editor.getText()).toBe('newer prompt');
});
});
1 change: 1 addition & 0 deletions apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ function makeStartupInput(): KimiTUIStartupInput {
function makeSession(overrides: Record<string, unknown> = {}) {
return {
id: 'ses-1',
workDir: '/tmp/proj-a',
model: 'k2',
summary: { title: null },
prompt: vi.fn(async () => {}),
Expand Down
Loading
Loading