merge: resolve conflict in client.ts hook condition

Keep both changes: SendMessageType.Cron skip from our branch
and hasHooksForEvent check from main.
This commit is contained in:
tanzhenxin 2026-03-30 18:06:59 +08:00
commit 9a8829c5a5
38 changed files with 1009 additions and 209 deletions

View file

@ -558,121 +558,4 @@ describe('AuthDialog', () => {
expect(handleAuthSelect).toHaveBeenCalledWith(undefined);
unmount();
});
it('shows API Key subtype menu and opens custom info', async () => {
const settings: LoadedSettings = new LoadedSettings(
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
originalSettings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
{
settings: {},
originalSettings: {},
path: '',
},
{
settings: {
security: { auth: { selectedType: undefined } },
ui: { customThemes: {} },
mcpServers: {},
},
originalSettings: {
security: { auth: { selectedType: undefined } },
ui: { customThemes: {} },
mcpServers: {},
},
path: '',
},
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
originalSettings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
true,
new Set(),
);
const { stdin, lastFrame, unmount } = renderAuthDialog(settings);
await wait();
// Move from Qwen OAuth -> Coding Plan -> API Key, then enter
stdin.write('\u001B[B');
stdin.write('\u001B[B');
stdin.write('\r');
await wait();
expect(lastFrame()).toContain('Select API Key Type');
expect(lastFrame()).toContain('Alibaba Cloud ModelStudio Standard API Key');
expect(lastFrame()).toContain('Custom API Key');
// Move to Custom API Key and enter
stdin.write('\u001B[B');
stdin.write('\r');
await wait();
expect(lastFrame()).toContain('Custom Configuration');
unmount();
});
it('shows Alibaba Cloud ModelStudio Standard API Key region endpoint', async () => {
const settings: LoadedSettings = new LoadedSettings(
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
originalSettings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
{
settings: {},
originalSettings: {},
path: '',
},
{
settings: {
security: { auth: { selectedType: undefined } },
ui: { customThemes: {} },
mcpServers: {},
},
originalSettings: {
security: { auth: { selectedType: undefined } },
ui: { customThemes: {} },
mcpServers: {},
},
path: '',
},
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
originalSettings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
true,
new Set(),
);
const { stdin, lastFrame, unmount } = renderAuthDialog(settings, {}, {});
await wait();
// Main -> API Key
stdin.write('\u001B[B');
stdin.write('\u001B[B');
stdin.write('\r');
await wait();
// API Key type -> Alibaba Cloud ModelStudio Standard API Key (default)
stdin.write('\r');
await wait();
// Region -> Singapore
stdin.write('\u001B[B');
stdin.write('\r');
await wait();
expect(lastFrame()).toContain(
'Enter Alibaba Cloud ModelStudio Standard API Key',
);
expect(lastFrame()).toContain(
'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
);
unmount();
});
});

View file

@ -140,6 +140,71 @@ describe('clearCommand', () => {
expect(mockContext.ui.clear).toHaveBeenCalledTimes(1);
});
it('should clear UI before resetChat for immediate responsiveness', async () => {
if (!clearCommand.action) {
throw new Error('clearCommand must have an action.');
}
const callOrder: string[] = [];
(mockContext.ui.clear as ReturnType<typeof vi.fn>).mockImplementation(
() => {
callOrder.push('ui.clear');
},
);
mockResetChat.mockImplementation(async () => {
callOrder.push('resetChat');
});
await clearCommand.action(mockContext, '');
// ui.clear should be called before resetChat for immediate UI feedback
const clearIndex = callOrder.indexOf('ui.clear');
const resetIndex = callOrder.indexOf('resetChat');
expect(clearIndex).toBeGreaterThanOrEqual(0);
expect(resetIndex).toBeGreaterThanOrEqual(0);
expect(clearIndex).toBeLessThan(resetIndex);
});
it('should not await hook events (fire-and-forget)', async () => {
if (!clearCommand.action) {
throw new Error('clearCommand must have an action.');
}
// Make hooks take a long time - they should not block
let sessionEndResolved = false;
let sessionStartResolved = false;
mockFireSessionEndEvent.mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(() => {
sessionEndResolved = true;
resolve(undefined);
}, 5000);
}),
);
mockFireSessionStartEvent.mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(() => {
sessionStartResolved = true;
resolve(undefined);
}, 5000);
}),
);
await clearCommand.action(mockContext, '');
// The action should complete immediately without waiting for hooks
expect(mockContext.ui.clear).toHaveBeenCalledTimes(1);
expect(mockResetChat).toHaveBeenCalledTimes(1);
// Hooks should have been called but not necessarily resolved
expect(mockFireSessionEndEvent).toHaveBeenCalled();
expect(mockFireSessionStartEvent).toHaveBeenCalled();
// Hooks should NOT have resolved yet since they have 5s timeouts
expect(sessionEndResolved).toBe(false);
expect(sessionStartResolved).toBe(false);
});
it('should not attempt to reset chat if config service is not available', async () => {
if (!clearCommand.action) {
throw new Error('clearCommand must have an action.');

View file

@ -27,14 +27,13 @@ export const clearCommand: SlashCommand = {
const { config } = context.services;
if (config) {
// Fire SessionEnd event before clearing (current session ends)
try {
await config
.getHookSystem()
?.fireSessionEndEvent(SessionEndReason.Clear);
} catch (err) {
config.getDebugLogger().warn(`SessionEnd hook failed: ${err}`);
}
// Fire SessionEnd event (non-blocking to avoid UI lag)
config
.getHookSystem()
?.fireSessionEndEvent(SessionEndReason.Clear)
.catch((err) => {
config.getDebugLogger().warn(`SessionEnd hook failed: ${err}`);
});
const newSessionId = config.startNewSession();
@ -54,6 +53,9 @@ export const clearCommand: SlashCommand = {
context.session.startNewSession(newSessionId);
}
// Clear UI first for immediate responsiveness
context.ui.clear();
const geminiClient = config.getGeminiClient();
if (geminiClient) {
context.ui.setDebugMessage(
@ -66,22 +68,20 @@ export const clearCommand: SlashCommand = {
context.ui.setDebugMessage(t('Starting a new session and clearing.'));
}
// Fire SessionStart event after clearing (new session starts)
try {
await config
.getHookSystem()
?.fireSessionStartEvent(
SessionStartSource.Clear,
config.getModel() ?? '',
String(config.getApprovalMode()) as PermissionMode,
);
} catch (err) {
config.getDebugLogger().warn(`SessionStart hook failed: ${err}`);
}
// Fire SessionStart event (non-blocking to avoid UI lag)
config
.getHookSystem()
?.fireSessionStartEvent(
SessionStartSource.Clear,
config.getModel() ?? '',
String(config.getApprovalMode()) as PermissionMode,
)
.catch((err) => {
config.getDebugLogger().warn(`SessionStart hook failed: ${err}`);
});
} else {
context.ui.setDebugMessage(t('Starting a new session and clearing.'));
context.ui.clear();
}
context.ui.clear();
},
};

View file

@ -540,7 +540,16 @@ export function KeypressProvider({
}
};
// Matches terminal query responses (DA1, DA2, Kitty protocol query)
// that may arrive late from startup detection in kittyProtocolDetector.
// These are never valid user input.
// eslint-disable-next-line no-control-regex
const TERMINAL_RESPONSE_RE = /^\x1b\[[?>][\d;]*[uc]$/;
const handleKeypress = async (_: unknown, key: Key) => {
if (TERMINAL_RESPONSE_RE.test(key.sequence)) {
return;
}
if (key.sequence === FOCUS_IN || key.sequence === FOCUS_OUT) {
return;
}

View file

@ -37,11 +37,20 @@ export async function detectAndEnableKittyProtocol(): Promise<boolean> {
const onTimeout = () => {
timeoutId = undefined;
process.stdin.removeListener('data', handleData);
if (!originalRawMode) {
process.stdin.setRawMode(false);
}
detectionComplete = true;
resolve(false);
// Keep a drain handler briefly to consume any late-arriving terminal
// responses that would otherwise leak into the application input.
const drainHandler = () => {};
process.stdin.on('data', drainHandler);
setTimeout(() => {
process.stdin.removeListener('data', drainHandler);
if (!originalRawMode) {
process.stdin.setRawMode(false);
}
detectionComplete = true;
resolve(false);
}, 100);
};
const handleData = (data: Buffer) => {