fix(core): prevent UI shift after vim edit (#5315)
This commit is contained in:
parent
8d993156e7
commit
aacae1de43
|
@ -505,6 +505,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||||
performMemoryRefresh,
|
performMemoryRefresh,
|
||||||
modelSwitchedFromQuotaError,
|
modelSwitchedFromQuotaError,
|
||||||
setModelSwitchedFromQuotaError,
|
setModelSwitchedFromQuotaError,
|
||||||
|
refreshStatic,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Input handling
|
// Input handling
|
||||||
|
|
|
@ -93,6 +93,7 @@ export const useGeminiStream = (
|
||||||
performMemoryRefresh: () => Promise<void>,
|
performMemoryRefresh: () => Promise<void>,
|
||||||
modelSwitchedFromQuotaError: boolean,
|
modelSwitchedFromQuotaError: boolean,
|
||||||
setModelSwitchedFromQuotaError: React.Dispatch<React.SetStateAction<boolean>>,
|
setModelSwitchedFromQuotaError: React.Dispatch<React.SetStateAction<boolean>>,
|
||||||
|
onEditorClose: () => void,
|
||||||
) => {
|
) => {
|
||||||
const [initError, setInitError] = useState<string | null>(null);
|
const [initError, setInitError] = useState<string | null>(null);
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
@ -133,6 +134,7 @@ export const useGeminiStream = (
|
||||||
config,
|
config,
|
||||||
setPendingHistoryItem,
|
setPendingHistoryItem,
|
||||||
getPreferredEditor,
|
getPreferredEditor,
|
||||||
|
onEditorClose,
|
||||||
);
|
);
|
||||||
|
|
||||||
const pendingToolCallGroupDisplay = useMemo(
|
const pendingToolCallGroupDisplay = useMemo(
|
||||||
|
|
|
@ -70,6 +70,7 @@ export function useReactToolScheduler(
|
||||||
React.SetStateAction<HistoryItemWithoutId | null>
|
React.SetStateAction<HistoryItemWithoutId | null>
|
||||||
>,
|
>,
|
||||||
getPreferredEditor: () => EditorType | undefined,
|
getPreferredEditor: () => EditorType | undefined,
|
||||||
|
onEditorClose: () => void,
|
||||||
): [TrackedToolCall[], ScheduleFn, MarkToolsAsSubmittedFn] {
|
): [TrackedToolCall[], ScheduleFn, MarkToolsAsSubmittedFn] {
|
||||||
const [toolCallsForDisplay, setToolCallsForDisplay] = useState<
|
const [toolCallsForDisplay, setToolCallsForDisplay] = useState<
|
||||||
TrackedToolCall[]
|
TrackedToolCall[]
|
||||||
|
@ -140,6 +141,7 @@ export function useReactToolScheduler(
|
||||||
onToolCallsUpdate: toolCallsUpdateHandler,
|
onToolCallsUpdate: toolCallsUpdateHandler,
|
||||||
getPreferredEditor,
|
getPreferredEditor,
|
||||||
config,
|
config,
|
||||||
|
onEditorClose,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
config,
|
config,
|
||||||
|
@ -147,6 +149,7 @@ export function useReactToolScheduler(
|
||||||
allToolCallsCompleteHandler,
|
allToolCallsCompleteHandler,
|
||||||
toolCallsUpdateHandler,
|
toolCallsUpdateHandler,
|
||||||
getPreferredEditor,
|
getPreferredEditor,
|
||||||
|
onEditorClose,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -136,6 +136,7 @@ describe('CoreToolScheduler', () => {
|
||||||
onAllToolCallsComplete,
|
onAllToolCallsComplete,
|
||||||
onToolCallsUpdate,
|
onToolCallsUpdate,
|
||||||
getPreferredEditor: () => 'vscode',
|
getPreferredEditor: () => 'vscode',
|
||||||
|
onEditorClose: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
@ -205,6 +206,7 @@ describe('CoreToolScheduler with payload', () => {
|
||||||
onAllToolCallsComplete,
|
onAllToolCallsComplete,
|
||||||
onToolCallsUpdate,
|
onToolCallsUpdate,
|
||||||
getPreferredEditor: () => 'vscode',
|
getPreferredEditor: () => 'vscode',
|
||||||
|
onEditorClose: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
@ -482,6 +484,7 @@ describe('CoreToolScheduler edit cancellation', () => {
|
||||||
onAllToolCallsComplete,
|
onAllToolCallsComplete,
|
||||||
onToolCallsUpdate,
|
onToolCallsUpdate,
|
||||||
getPreferredEditor: () => 'vscode',
|
getPreferredEditor: () => 'vscode',
|
||||||
|
onEditorClose: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
@ -571,6 +574,7 @@ describe('CoreToolScheduler YOLO mode', () => {
|
||||||
onAllToolCallsComplete,
|
onAllToolCallsComplete,
|
||||||
onToolCallsUpdate,
|
onToolCallsUpdate,
|
||||||
getPreferredEditor: () => 'vscode',
|
getPreferredEditor: () => 'vscode',
|
||||||
|
onEditorClose: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
|
|
@ -224,6 +224,7 @@ interface CoreToolSchedulerOptions {
|
||||||
onToolCallsUpdate?: ToolCallsUpdateHandler;
|
onToolCallsUpdate?: ToolCallsUpdateHandler;
|
||||||
getPreferredEditor: () => EditorType | undefined;
|
getPreferredEditor: () => EditorType | undefined;
|
||||||
config: Config;
|
config: Config;
|
||||||
|
onEditorClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CoreToolScheduler {
|
export class CoreToolScheduler {
|
||||||
|
@ -234,6 +235,7 @@ export class CoreToolScheduler {
|
||||||
private onToolCallsUpdate?: ToolCallsUpdateHandler;
|
private onToolCallsUpdate?: ToolCallsUpdateHandler;
|
||||||
private getPreferredEditor: () => EditorType | undefined;
|
private getPreferredEditor: () => EditorType | undefined;
|
||||||
private config: Config;
|
private config: Config;
|
||||||
|
private onEditorClose: () => void;
|
||||||
|
|
||||||
constructor(options: CoreToolSchedulerOptions) {
|
constructor(options: CoreToolSchedulerOptions) {
|
||||||
this.config = options.config;
|
this.config = options.config;
|
||||||
|
@ -242,6 +244,7 @@ export class CoreToolScheduler {
|
||||||
this.onAllToolCallsComplete = options.onAllToolCallsComplete;
|
this.onAllToolCallsComplete = options.onAllToolCallsComplete;
|
||||||
this.onToolCallsUpdate = options.onToolCallsUpdate;
|
this.onToolCallsUpdate = options.onToolCallsUpdate;
|
||||||
this.getPreferredEditor = options.getPreferredEditor;
|
this.getPreferredEditor = options.getPreferredEditor;
|
||||||
|
this.onEditorClose = options.onEditorClose;
|
||||||
}
|
}
|
||||||
|
|
||||||
private setStatusInternal(
|
private setStatusInternal(
|
||||||
|
@ -563,6 +566,7 @@ export class CoreToolScheduler {
|
||||||
modifyContext as ModifyContext<typeof waitingToolCall.request.args>,
|
modifyContext as ModifyContext<typeof waitingToolCall.request.args>,
|
||||||
editorType,
|
editorType,
|
||||||
signal,
|
signal,
|
||||||
|
this.onEditorClose,
|
||||||
);
|
);
|
||||||
this.setArgsInternal(callId, updatedParams);
|
this.setArgsInternal(callId, updatedParams);
|
||||||
this.setStatusInternal(callId, 'awaiting_approval', {
|
this.setStatusInternal(callId, 'awaiting_approval', {
|
||||||
|
|
|
@ -94,6 +94,7 @@ describe('modifyWithEditor', () => {
|
||||||
mockModifyContext,
|
mockModifyContext,
|
||||||
'vscode' as EditorType,
|
'vscode' as EditorType,
|
||||||
abortSignal,
|
abortSignal,
|
||||||
|
vi.fn(),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mockModifyContext.getCurrentContent).toHaveBeenCalledWith(
|
expect(mockModifyContext.getCurrentContent).toHaveBeenCalledWith(
|
||||||
|
@ -148,6 +149,7 @@ describe('modifyWithEditor', () => {
|
||||||
mockModifyContext,
|
mockModifyContext,
|
||||||
'vscode' as EditorType,
|
'vscode' as EditorType,
|
||||||
abortSignal,
|
abortSignal,
|
||||||
|
vi.fn(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const stats = await fsp.stat(diffDir);
|
const stats = await fsp.stat(diffDir);
|
||||||
|
@ -165,6 +167,7 @@ describe('modifyWithEditor', () => {
|
||||||
mockModifyContext,
|
mockModifyContext,
|
||||||
'vscode' as EditorType,
|
'vscode' as EditorType,
|
||||||
abortSignal,
|
abortSignal,
|
||||||
|
vi.fn(),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mkdirSpy).not.toHaveBeenCalled();
|
expect(mkdirSpy).not.toHaveBeenCalled();
|
||||||
|
@ -183,6 +186,7 @@ describe('modifyWithEditor', () => {
|
||||||
mockModifyContext,
|
mockModifyContext,
|
||||||
'vscode' as EditorType,
|
'vscode' as EditorType,
|
||||||
abortSignal,
|
abortSignal,
|
||||||
|
vi.fn(),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mockCreatePatch).toHaveBeenCalledWith(
|
expect(mockCreatePatch).toHaveBeenCalledWith(
|
||||||
|
@ -211,6 +215,7 @@ describe('modifyWithEditor', () => {
|
||||||
mockModifyContext,
|
mockModifyContext,
|
||||||
'vscode' as EditorType,
|
'vscode' as EditorType,
|
||||||
abortSignal,
|
abortSignal,
|
||||||
|
vi.fn(),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mockCreatePatch).toHaveBeenCalledWith(
|
expect(mockCreatePatch).toHaveBeenCalledWith(
|
||||||
|
@ -241,6 +246,7 @@ describe('modifyWithEditor', () => {
|
||||||
mockModifyContext,
|
mockModifyContext,
|
||||||
'vscode' as EditorType,
|
'vscode' as EditorType,
|
||||||
abortSignal,
|
abortSignal,
|
||||||
|
vi.fn(),
|
||||||
),
|
),
|
||||||
).rejects.toThrow('Editor failed to open');
|
).rejects.toThrow('Editor failed to open');
|
||||||
|
|
||||||
|
@ -267,6 +273,7 @@ describe('modifyWithEditor', () => {
|
||||||
mockModifyContext,
|
mockModifyContext,
|
||||||
'vscode' as EditorType,
|
'vscode' as EditorType,
|
||||||
abortSignal,
|
abortSignal,
|
||||||
|
vi.fn(),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledTimes(2);
|
expect(consoleErrorSpy).toHaveBeenCalledTimes(2);
|
||||||
|
@ -290,6 +297,7 @@ describe('modifyWithEditor', () => {
|
||||||
mockModifyContext,
|
mockModifyContext,
|
||||||
'vscode' as EditorType,
|
'vscode' as EditorType,
|
||||||
abortSignal,
|
abortSignal,
|
||||||
|
vi.fn(),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mockOpenDiff).toHaveBeenCalledOnce();
|
expect(mockOpenDiff).toHaveBeenCalledOnce();
|
||||||
|
@ -311,6 +319,7 @@ describe('modifyWithEditor', () => {
|
||||||
mockModifyContext,
|
mockModifyContext,
|
||||||
'vscode' as EditorType,
|
'vscode' as EditorType,
|
||||||
abortSignal,
|
abortSignal,
|
||||||
|
vi.fn(),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mockOpenDiff).toHaveBeenCalledOnce();
|
expect(mockOpenDiff).toHaveBeenCalledOnce();
|
||||||
|
|
|
@ -138,6 +138,7 @@ export async function modifyWithEditor<ToolParams>(
|
||||||
modifyContext: ModifyContext<ToolParams>,
|
modifyContext: ModifyContext<ToolParams>,
|
||||||
editorType: EditorType,
|
editorType: EditorType,
|
||||||
_abortSignal: AbortSignal,
|
_abortSignal: AbortSignal,
|
||||||
|
onEditorClose: () => void,
|
||||||
): Promise<ModifyResult<ToolParams>> {
|
): Promise<ModifyResult<ToolParams>> {
|
||||||
const currentContent = await modifyContext.getCurrentContent(originalParams);
|
const currentContent = await modifyContext.getCurrentContent(originalParams);
|
||||||
const proposedContent =
|
const proposedContent =
|
||||||
|
@ -150,7 +151,7 @@ export async function modifyWithEditor<ToolParams>(
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await openDiff(oldPath, newPath, editorType);
|
await openDiff(oldPath, newPath, editorType, onEditorClose);
|
||||||
const result = getUpdatedParams(
|
const result = getUpdatedParams(
|
||||||
oldPath,
|
oldPath,
|
||||||
newPath,
|
newPath,
|
||||||
|
|
|
@ -331,7 +331,7 @@ describe('editor utils', () => {
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
(spawn as Mock).mockReturnValue(mockSpawn);
|
(spawn as Mock).mockReturnValue(mockSpawn);
|
||||||
await openDiff('old.txt', 'new.txt', editor);
|
await openDiff('old.txt', 'new.txt', editor, () => {});
|
||||||
const diffCommand = getDiffCommand('old.txt', 'new.txt', editor)!;
|
const diffCommand = getDiffCommand('old.txt', 'new.txt', editor)!;
|
||||||
expect(spawn).toHaveBeenCalledWith(
|
expect(spawn).toHaveBeenCalledWith(
|
||||||
diffCommand.command,
|
diffCommand.command,
|
||||||
|
@ -361,9 +361,9 @@ describe('editor utils', () => {
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
(spawn as Mock).mockReturnValue(mockSpawn);
|
(spawn as Mock).mockReturnValue(mockSpawn);
|
||||||
await expect(openDiff('old.txt', 'new.txt', editor)).rejects.toThrow(
|
await expect(
|
||||||
'spawn error',
|
openDiff('old.txt', 'new.txt', editor, () => {}),
|
||||||
);
|
).rejects.toThrow('spawn error');
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`should reject if ${editor} exits with non-zero code`, async () => {
|
it(`should reject if ${editor} exits with non-zero code`, async () => {
|
||||||
|
@ -375,9 +375,9 @@ describe('editor utils', () => {
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
(spawn as Mock).mockReturnValue(mockSpawn);
|
(spawn as Mock).mockReturnValue(mockSpawn);
|
||||||
await expect(openDiff('old.txt', 'new.txt', editor)).rejects.toThrow(
|
await expect(
|
||||||
`${editor} exited with code 1`,
|
openDiff('old.txt', 'new.txt', editor, () => {}),
|
||||||
);
|
).rejects.toThrow(`${editor} exited with code 1`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -385,7 +385,7 @@ describe('editor utils', () => {
|
||||||
for (const editor of execSyncEditors) {
|
for (const editor of execSyncEditors) {
|
||||||
it(`should call execSync for ${editor} on non-windows`, async () => {
|
it(`should call execSync for ${editor} on non-windows`, async () => {
|
||||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||||
await openDiff('old.txt', 'new.txt', editor);
|
await openDiff('old.txt', 'new.txt', editor, () => {});
|
||||||
expect(execSync).toHaveBeenCalledTimes(1);
|
expect(execSync).toHaveBeenCalledTimes(1);
|
||||||
const diffCommand = getDiffCommand('old.txt', 'new.txt', editor)!;
|
const diffCommand = getDiffCommand('old.txt', 'new.txt', editor)!;
|
||||||
const expectedCommand = `${
|
const expectedCommand = `${
|
||||||
|
@ -399,7 +399,7 @@ describe('editor utils', () => {
|
||||||
|
|
||||||
it(`should call execSync for ${editor} on windows`, async () => {
|
it(`should call execSync for ${editor} on windows`, async () => {
|
||||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||||
await openDiff('old.txt', 'new.txt', editor);
|
await openDiff('old.txt', 'new.txt', editor, () => {});
|
||||||
expect(execSync).toHaveBeenCalledTimes(1);
|
expect(execSync).toHaveBeenCalledTimes(1);
|
||||||
const diffCommand = getDiffCommand('old.txt', 'new.txt', editor)!;
|
const diffCommand = getDiffCommand('old.txt', 'new.txt', editor)!;
|
||||||
const expectedCommand = `${diffCommand.command} ${diffCommand.args.join(
|
const expectedCommand = `${diffCommand.command} ${diffCommand.args.join(
|
||||||
|
@ -417,11 +417,46 @@ describe('editor utils', () => {
|
||||||
.spyOn(console, 'error')
|
.spyOn(console, 'error')
|
||||||
.mockImplementation(() => {});
|
.mockImplementation(() => {});
|
||||||
// @ts-expect-error Testing unsupported editor
|
// @ts-expect-error Testing unsupported editor
|
||||||
await openDiff('old.txt', 'new.txt', 'foobar');
|
await openDiff('old.txt', 'new.txt', 'foobar', () => {});
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
'No diff tool available. Install a supported editor.',
|
'No diff tool available. Install a supported editor.',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('onEditorClose callback', () => {
|
||||||
|
it('should call onEditorClose for execSync editors', async () => {
|
||||||
|
(execSync as Mock).mockReturnValue(Buffer.from(`/usr/bin/`));
|
||||||
|
const onEditorClose = vi.fn();
|
||||||
|
await openDiff('old.txt', 'new.txt', 'vim', onEditorClose);
|
||||||
|
expect(execSync).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onEditorClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onEditorClose for execSync editors when an error is thrown', async () => {
|
||||||
|
(execSync as Mock).mockImplementation(() => {
|
||||||
|
throw new Error('test error');
|
||||||
|
});
|
||||||
|
const onEditorClose = vi.fn();
|
||||||
|
openDiff('old.txt', 'new.txt', 'vim', onEditorClose);
|
||||||
|
expect(execSync).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onEditorClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not call onEditorClose for spawn editors', async () => {
|
||||||
|
const onEditorClose = vi.fn();
|
||||||
|
const mockSpawn = {
|
||||||
|
on: vi.fn((event, cb) => {
|
||||||
|
if (event === 'close') {
|
||||||
|
cb(0);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
(spawn as Mock).mockReturnValue(mockSpawn);
|
||||||
|
await openDiff('old.txt', 'new.txt', 'vscode', onEditorClose);
|
||||||
|
expect(spawn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onEditorClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('allowEditorTypeInSandbox', () => {
|
describe('allowEditorTypeInSandbox', () => {
|
||||||
|
|
|
@ -164,6 +164,7 @@ export async function openDiff(
|
||||||
oldPath: string,
|
oldPath: string,
|
||||||
newPath: string,
|
newPath: string,
|
||||||
editor: EditorType,
|
editor: EditorType,
|
||||||
|
onEditorClose: () => void,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const diffCommand = getDiffCommand(oldPath, newPath, editor);
|
const diffCommand = getDiffCommand(oldPath, newPath, editor);
|
||||||
if (!diffCommand) {
|
if (!diffCommand) {
|
||||||
|
@ -206,10 +207,16 @@ export async function openDiff(
|
||||||
process.platform === 'win32'
|
process.platform === 'win32'
|
||||||
? `${diffCommand.command} ${diffCommand.args.join(' ')}`
|
? `${diffCommand.command} ${diffCommand.args.join(' ')}`
|
||||||
: `${diffCommand.command} ${diffCommand.args.map((arg) => `"${arg}"`).join(' ')}`;
|
: `${diffCommand.command} ${diffCommand.args.map((arg) => `"${arg}"`).join(' ')}`;
|
||||||
|
try {
|
||||||
execSync(command, {
|
execSync(command, {
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error in onEditorClose callback:', e);
|
||||||
|
} finally {
|
||||||
|
onEditorClose();
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue