Sync upstream Gemini-CLI v0.8.2 (#838)

This commit is contained in:
tanzhenxin 2025-10-23 09:27:04 +08:00 committed by GitHub
parent 096fabb5d6
commit eb95c131be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
644 changed files with 70389 additions and 23709 deletions

View file

@ -0,0 +1,22 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { createContext, useContext } from 'react';
export interface AppState {
version: string;
startupWarnings: string[];
}
export const AppContext = createContext<AppState | null>(null);
export const useAppContext = () => {
const context = useContext(AppContext);
if (!context) {
throw new Error('useAppContext must be used within an AppProvider');
}
return context;
};

View file

@ -0,0 +1,18 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useContext } from 'react';
import { type Config } from '@qwen-code/qwen-code-core';
export const ConfigContext = React.createContext<Config | undefined>(undefined);
export const useConfig = () => {
const context = useContext(ConfigContext);
if (context === undefined) {
throw new Error('useConfig must be used within a ConfigProvider');
}
return context;
};

View file

@ -9,7 +9,15 @@ import { renderHook, act, waitFor } from '@testing-library/react';
import type { Mock } from 'vitest';
import { vi } from 'vitest';
import type { Key } from './KeypressContext.js';
import { KeypressProvider, useKeypressContext } from './KeypressContext.js';
import {
KeypressProvider,
useKeypressContext,
DRAG_COMPLETION_TIMEOUT_MS,
// CSI_END_O,
// SS3_END,
SINGLE_QUOTE,
DOUBLE_QUOTE,
} from './KeypressContext.js';
import { useStdin } from 'ink';
import { EventEmitter } from 'node:events';
@ -351,6 +359,25 @@ describe('KeypressContext - Kitty Protocol', () => {
}),
);
});
it('should recognize Ctrl+Backspace in kitty protocol', async () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
// Modifier 5 is Ctrl
act(() => {
stdin.sendKittySequence(`\x1b[127;5u`);
});
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'backspace',
kittyProtocol: true,
ctrl: true,
}),
);
});
});
describe('paste mode', () => {
@ -1232,10 +1259,13 @@ describe('KeypressContext - Kitty Protocol', () => {
'[DEBUG] Kitty buffer accumulating:',
expect.stringContaining('\x1b[27u'),
);
expect(consoleLogSpy).toHaveBeenCalledWith(
'[DEBUG] Kitty sequence parsed successfully:',
expect.stringContaining('\x1b[27u'),
const parsedCall = consoleLogSpy.mock.calls.find(
(args) =>
typeof args[0] === 'string' &&
args[0].includes('[DEBUG] Kitty sequence parsed successfully'),
);
expect(parsedCall).toBeTruthy();
expect(parsedCall?.[1]).toEqual(expect.stringContaining('\x1b[27u'));
});
it('should log kitty buffer overflow when debugKeystrokeLogging is true', async () => {
@ -1365,4 +1395,345 @@ describe('KeypressContext - Kitty Protocol', () => {
);
});
});
describe('Parameterized functional keys', () => {
it.each([
// Parameterized
{ sequence: `\x1b[1;2H`, expected: { name: 'home', shift: true } },
{ sequence: `\x1b[1;5F`, expected: { name: 'end', ctrl: true } },
{ sequence: `\x1b[1;1P`, expected: { name: 'f1' } },
{ sequence: `\x1b[1;3Q`, expected: { name: 'f2', meta: true } },
{ sequence: `\x1b[3~`, expected: { name: 'delete' } },
{ sequence: `\x1b[5~`, expected: { name: 'pageup' } },
{ sequence: `\x1b[6~`, expected: { name: 'pagedown' } },
{ sequence: `\x1b[1~`, expected: { name: 'home' } },
{ sequence: `\x1b[4~`, expected: { name: 'end' } },
{ sequence: `\x1b[2~`, expected: { name: 'insert' } },
// Legacy Arrows
{
sequence: `\x1b[A`,
expected: { name: 'up', ctrl: false, meta: false, shift: false },
},
{
sequence: `\x1b[B`,
expected: { name: 'down', ctrl: false, meta: false, shift: false },
},
{
sequence: `\x1b[C`,
expected: { name: 'right', ctrl: false, meta: false, shift: false },
},
{
sequence: `\x1b[D`,
expected: { name: 'left', ctrl: false, meta: false, shift: false },
},
// Legacy Home/End
{
sequence: `\x1b[H`,
expected: { name: 'home', ctrl: false, meta: false, shift: false },
},
{
sequence: `\x1b[F`,
expected: { name: 'end', ctrl: false, meta: false, shift: false },
},
])(
'should recognize sequence "$sequence" as $expected.name',
({ sequence, expected }) => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
act(() => stdin.sendKittySequence(sequence));
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining(expected),
);
},
);
});
describe('Shift+Tab forms', () => {
it.each([
{ sequence: `\x1b[Z`, description: 'legacy reverse Tab' },
{ sequence: `\x1b[1;2Z`, description: 'parameterized reverse Tab' },
])(
'should recognize $description "$sequence" as Shift+Tab',
({ sequence }) => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
act(() => stdin.sendKittySequence(sequence));
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({ name: 'tab', shift: true }),
);
},
);
});
describe('Double-tap and batching', () => {
it('should emit two delete events for double-tap CSI[3~', async () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
act(() => stdin.sendKittySequence(`\x1b[3~`));
act(() => stdin.sendKittySequence(`\x1b[3~`));
expect(keyHandler).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ name: 'delete' }),
);
expect(keyHandler).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ name: 'delete' }),
);
});
it('should parse two concatenated tilde-coded sequences in one chunk', async () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
act(() => stdin.sendKittySequence(`\x1b[3~\x1b[5~`));
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({ name: 'delete' }),
);
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({ name: 'pageup' }),
);
});
it('should ignore incomplete CSI then parse the next complete sequence', async () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
// Incomplete ESC sequence then a complete Delete
act(() => {
// Provide an incomplete ESC sequence chunk with a real ESC character
stdin.pressKey({
name: undefined,
ctrl: false,
meta: false,
shift: false,
sequence: '\x1b[1;',
});
});
act(() => stdin.sendKittySequence(`\x1b[3~`));
expect(keyHandler).toHaveBeenCalledTimes(1);
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({ name: 'delete' }),
);
});
});
});
describe('Drag and Drop Handling', () => {
let stdin: MockStdin;
const mockSetRawMode = vi.fn();
const wrapper = ({ children }: { children: React.ReactNode }) => (
<KeypressProvider kittyProtocolEnabled={true}>{children}</KeypressProvider>
);
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
stdin = new MockStdin();
(useStdin as Mock).mockReturnValue({
stdin,
setRawMode: mockSetRawMode,
});
});
afterEach(() => {
vi.useRealTimers();
});
describe('drag start by quotes', () => {
it('should start collecting when single quote arrives and not broadcast immediately', async () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => {
result.current.subscribe(keyHandler);
});
act(() => {
stdin.pressKey({
name: undefined,
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: SINGLE_QUOTE,
});
});
expect(keyHandler).not.toHaveBeenCalled();
});
it('should start collecting when double quote arrives and not broadcast immediately', async () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => {
result.current.subscribe(keyHandler);
});
act(() => {
stdin.pressKey({
name: undefined,
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: DOUBLE_QUOTE,
});
});
expect(keyHandler).not.toHaveBeenCalled();
});
});
describe('drag collection and completion', () => {
it('should collect single character inputs during drag mode', async () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => {
result.current.subscribe(keyHandler);
});
// Start by single quote
act(() => {
stdin.pressKey({
name: undefined,
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: SINGLE_QUOTE,
});
});
// Send single character
act(() => {
stdin.pressKey({
name: undefined,
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: 'a',
});
});
// Character should not be immediately broadcast
expect(keyHandler).not.toHaveBeenCalled();
// Fast-forward to completion timeout
act(() => {
vi.advanceTimersByTime(DRAG_COMPLETION_TIMEOUT_MS + 10);
});
// Should broadcast the collected path as paste (includes starting quote)
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: '',
paste: true,
sequence: `${SINGLE_QUOTE}a`,
}),
);
});
it('should collect multiple characters and complete on timeout', async () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => {
result.current.subscribe(keyHandler);
});
// Start by single quote
act(() => {
stdin.pressKey({
name: undefined,
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: SINGLE_QUOTE,
});
});
// Send multiple characters
act(() => {
stdin.pressKey({
name: undefined,
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: 'p',
});
});
act(() => {
stdin.pressKey({
name: undefined,
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: 'a',
});
});
act(() => {
stdin.pressKey({
name: undefined,
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: 't',
});
});
act(() => {
stdin.pressKey({
name: undefined,
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: 'h',
});
});
// Characters should not be immediately broadcast
expect(keyHandler).not.toHaveBeenCalled();
// Fast-forward to completion timeout
act(() => {
vi.advanceTimersByTime(DRAG_COMPLETION_TIMEOUT_MS + 10);
});
// Should broadcast the collected path as paste (includes starting quote)
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: '',
paste: true,
sequence: `${SINGLE_QUOTE}path`,
}),
);
});
});
});

View file

@ -29,6 +29,11 @@ import {
KITTY_KEYCODE_NUMPAD_ENTER,
KITTY_KEYCODE_TAB,
MAX_KITTY_SEQUENCE_LENGTH,
KITTY_MODIFIER_BASE,
KITTY_MODIFIER_EVENT_TYPES_OFFSET,
MODIFIER_SHIFT_BIT,
MODIFIER_ALT_BIT,
MODIFIER_CTRL_BIT,
} from '../utils/platformConstants.js';
import { FOCUS_IN, FOCUS_OUT } from '../hooks/useFocus.js';
@ -36,6 +41,9 @@ import { FOCUS_IN, FOCUS_OUT } from '../hooks/useFocus.js';
const ESC = '\u001B';
export const PASTE_MODE_PREFIX = `${ESC}[200~`;
export const PASTE_MODE_SUFFIX = `${ESC}[201~`;
export const DRAG_COMPLETION_TIMEOUT_MS = 100; // Broadcast full path after 100ms if no more input
export const SINGLE_QUOTE = "'";
export const DOUBLE_QUOTE = '"';
export interface Key {
name: string;
@ -83,6 +91,9 @@ export function KeypressProvider({
}) {
const { stdin, setRawMode } = useStdin();
const subscribers = useRef<Set<KeypressHandler>>(new Set()).current;
const isDraggingRef = useRef(false);
const dragBufferRef = useRef('');
const draggingTimerRef = useRef<NodeJS.Timeout | null>(null);
const subscribe = useCallback(
(handler: KeypressHandler) => {
@ -99,7 +110,17 @@ export function KeypressProvider({
);
useEffect(() => {
setRawMode(true);
const clearDraggingTimer = () => {
if (draggingTimerRef.current) {
clearTimeout(draggingTimerRef.current);
draggingTimerRef.current = null;
}
};
const wasRaw = stdin.isRaw;
if (wasRaw === false) {
setRawMode(true);
}
const keypressStream = new PassThrough();
let usePassthrough = false;
@ -116,48 +137,244 @@ export function KeypressProvider({
let rawDataBuffer = Buffer.alloc(0);
let rawFlushTimeout: NodeJS.Timeout | null = null;
const parseKittySequence = (sequence: string): Key | null => {
const kittyPattern = new RegExp(`^${ESC}\\[(\\d+)(;(\\d+))?([u~])$`);
const match = sequence.match(kittyPattern);
if (!match) return null;
const keyCode = parseInt(match[1], 10);
const modifiers = match[3] ? parseInt(match[3], 10) : 1;
const modifierBits = modifiers - 1;
const shift = (modifierBits & 1) === 1;
const alt = (modifierBits & 2) === 2;
const ctrl = (modifierBits & 4) === 4;
const keyNameMap: Record<number, string> = {
[CHAR_CODE_ESC]: 'escape',
[KITTY_KEYCODE_TAB]: 'tab',
[KITTY_KEYCODE_BACKSPACE]: 'backspace',
[KITTY_KEYCODE_ENTER]: 'return',
[KITTY_KEYCODE_NUMPAD_ENTER]: 'return',
};
if (keyCode in keyNameMap) {
// Parse a single complete kitty sequence from the start (prefix) of the
// buffer and return both the Key and the number of characters consumed.
// This lets us "peel off" one complete event when multiple sequences arrive
// in a single chunk, preventing buffer overflow and fragmentation.
// Parse a single complete kitty/parameterized/legacy sequence from the start
// of the buffer and return both the parsed Key and the number of characters
// consumed. This enables peel-and-continue parsing for batched input.
const parseKittyPrefix = (
buffer: string,
): { key: Key; length: number } | null => {
// In older terminals ESC [ Z was used as Cursor Backward Tabulation (CBT)
// In newer terminals the same functionality of key combination for moving
// backward through focusable elements is Shift+Tab, hence we will
// map ESC [ Z to Shift+Tab
// 0) Reverse Tab (legacy): ESC [ Z
// Treat as Shift+Tab for UI purposes.
// Regex parts:
// ^ - start of buffer
// ESC [ - CSI introducer
// Z - legacy reverse tab
const revTabLegacy = new RegExp(`^${ESC}\\[Z`);
let m = buffer.match(revTabLegacy);
if (m) {
return {
name: keyNameMap[keyCode],
ctrl,
meta: alt,
shift,
paste: false,
sequence,
kittyProtocol: true,
key: {
name: 'tab',
ctrl: false,
meta: false,
shift: true,
paste: false,
sequence: buffer.slice(0, m[0].length),
kittyProtocol: true,
},
length: m[0].length,
};
}
if (keyCode >= 97 && keyCode <= 122 && ctrl) {
const letter = String.fromCharCode(keyCode);
// 1) Reverse Tab (parameterized): ESC [ 1 ; <mods> Z
// Parameterized reverse Tab: ESC [ 1 ; <mods> Z
const revTabParam = new RegExp(`^${ESC}\\[1;(\\d+)Z`);
m = buffer.match(revTabParam);
if (m) {
let mods = parseInt(m[1], 10);
if (mods >= KITTY_MODIFIER_EVENT_TYPES_OFFSET) {
mods -= KITTY_MODIFIER_EVENT_TYPES_OFFSET;
}
const bits = mods - KITTY_MODIFIER_BASE;
const alt = (bits & MODIFIER_ALT_BIT) === MODIFIER_ALT_BIT;
const ctrl = (bits & MODIFIER_CTRL_BIT) === MODIFIER_CTRL_BIT;
return {
name: letter,
ctrl: true,
meta: alt,
shift,
paste: false,
sequence,
kittyProtocol: true,
key: {
name: 'tab',
ctrl,
meta: alt,
// Reverse tab implies Shift behavior; force shift regardless of mods
shift: true,
paste: false,
sequence: buffer.slice(0, m[0].length),
kittyProtocol: true,
},
length: m[0].length,
};
}
// 2) Parameterized functional: ESC [ 1 ; <mods> (A|B|C|D|H|F|P|Q|R|S)
// 2) Parameterized functional: ESC [ 1 ; <mods> (A|B|C|D|H|F|P|Q|R|S)
// Arrows, Home/End, F1F4 with modifiers encoded in <mods>.
const arrowPrefix = new RegExp(`^${ESC}\\[1;(\\d+)([ABCDHFPQSR])`);
m = buffer.match(arrowPrefix);
if (m) {
let mods = parseInt(m[1], 10);
if (mods >= KITTY_MODIFIER_EVENT_TYPES_OFFSET) {
mods -= KITTY_MODIFIER_EVENT_TYPES_OFFSET;
}
const bits = mods - KITTY_MODIFIER_BASE;
const shift = (bits & MODIFIER_SHIFT_BIT) === MODIFIER_SHIFT_BIT;
const alt = (bits & MODIFIER_ALT_BIT) === MODIFIER_ALT_BIT;
const ctrl = (bits & MODIFIER_CTRL_BIT) === MODIFIER_CTRL_BIT;
const sym = m[2];
const symbolToName: { [k: string]: string } = {
A: 'up',
B: 'down',
C: 'right',
D: 'left',
H: 'home',
F: 'end',
P: 'f1',
Q: 'f2',
R: 'f3',
S: 'f4',
};
const name = symbolToName[sym] || '';
if (!name) return null;
return {
key: {
name,
ctrl,
meta: alt,
shift,
paste: false,
sequence: buffer.slice(0, m[0].length),
kittyProtocol: true,
},
length: m[0].length,
};
}
// 3) CSI-u form: ESC [ <code> ; <mods> (u|~)
// 3) CSI-u and tilde-coded functional keys: ESC [ <code> ; <mods> (u|~)
// 'u' terminator: Kitty CSI-u; '~' terminator: tilde-coded function keys.
const csiUPrefix = new RegExp(`^${ESC}\\[(\\d+)(;(\\d+))?([u~])`);
m = buffer.match(csiUPrefix);
if (m) {
const keyCode = parseInt(m[1], 10);
let modifiers = m[3] ? parseInt(m[3], 10) : KITTY_MODIFIER_BASE;
if (modifiers >= KITTY_MODIFIER_EVENT_TYPES_OFFSET) {
modifiers -= KITTY_MODIFIER_EVENT_TYPES_OFFSET;
}
const modifierBits = modifiers - KITTY_MODIFIER_BASE;
const shift =
(modifierBits & MODIFIER_SHIFT_BIT) === MODIFIER_SHIFT_BIT;
const alt = (modifierBits & MODIFIER_ALT_BIT) === MODIFIER_ALT_BIT;
const ctrl = (modifierBits & MODIFIER_CTRL_BIT) === MODIFIER_CTRL_BIT;
const terminator = m[4];
// Tilde-coded functional keys (Delete, Insert, PageUp/Down, Home/End)
if (terminator === '~') {
let name: string | null = null;
switch (keyCode) {
case 1:
name = 'home';
break;
case 2:
name = 'insert';
break;
case 3:
name = 'delete';
break;
case 4:
name = 'end';
break;
case 5:
name = 'pageup';
break;
case 6:
name = 'pagedown';
break;
default:
break;
}
if (name) {
return {
key: {
name,
ctrl,
meta: alt,
shift,
paste: false,
sequence: buffer.slice(0, m[0].length),
kittyProtocol: true,
},
length: m[0].length,
};
}
}
const kittyKeyCodeToName: { [key: number]: string } = {
[CHAR_CODE_ESC]: 'escape',
[KITTY_KEYCODE_TAB]: 'tab',
[KITTY_KEYCODE_BACKSPACE]: 'backspace',
[KITTY_KEYCODE_ENTER]: 'return',
[KITTY_KEYCODE_NUMPAD_ENTER]: 'return',
};
const name = kittyKeyCodeToName[keyCode];
if (name) {
return {
key: {
name,
ctrl,
meta: alt,
shift,
paste: false,
sequence: buffer.slice(0, m[0].length),
kittyProtocol: true,
},
length: m[0].length,
};
}
// Ctrl+letters
if (
ctrl &&
keyCode >= 'a'.charCodeAt(0) &&
keyCode <= 'z'.charCodeAt(0)
) {
const letter = String.fromCharCode(keyCode);
return {
key: {
name: letter,
ctrl: true,
meta: alt,
shift,
paste: false,
sequence: buffer.slice(0, m[0].length),
kittyProtocol: true,
},
length: m[0].length,
};
}
}
// 4) Legacy function keys (no parameters): ESC [ (A|B|C|D|H|F)
// Arrows + Home/End without modifiers.
const legacyFuncKey = new RegExp(`^${ESC}\\[([ABCDHF])`);
m = buffer.match(legacyFuncKey);
if (m) {
const sym = m[1];
const nameMap: { [key: string]: string } = {
A: 'up',
B: 'down',
C: 'right',
D: 'left',
H: 'home',
F: 'end',
};
const name = nameMap[sym]!;
return {
key: {
name,
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: buffer.slice(0, m[0].length),
kittyProtocol: true,
},
length: m[0].length,
};
}
@ -171,6 +388,9 @@ export function KeypressProvider({
};
const handleKeypress = (_: unknown, key: Key) => {
if (key.sequence === FOCUS_IN || key.sequence === FOCUS_OUT) {
return;
}
if (key.name === 'paste-start') {
isPaste = true;
return;
@ -194,6 +414,27 @@ export function KeypressProvider({
return;
}
if (
key.sequence === SINGLE_QUOTE ||
key.sequence === DOUBLE_QUOTE ||
isDraggingRef.current
) {
isDraggingRef.current = true;
dragBufferRef.current += key.sequence;
clearDraggingTimer();
draggingTimerRef.current = setTimeout(() => {
isDraggingRef.current = false;
const seq = dragBufferRef.current;
dragBufferRef.current = '';
if (seq) {
broadcast({ ...key, name: '', paste: true, sequence: seq });
}
}, DRAG_COMPLETION_TIMEOUT_MS);
return;
}
if (key.name === 'return' && waitingForEnterAfterBackslash) {
if (backslashTimeout) {
clearTimeout(backslashTimeout);
@ -285,18 +526,51 @@ export function KeypressProvider({
);
}
const kittyKey = parseKittySequence(kittySequenceBuffer);
if (kittyKey) {
if (debugKeystrokeLogging) {
console.log(
'[DEBUG] Kitty sequence parsed successfully:',
kittySequenceBuffer,
);
// Try to peel off as many complete sequences as are available at the
// start of the buffer. This handles batched inputs cleanly. If the
// prefix is incomplete or invalid, skip to the next CSI introducer
// (ESC[) so that a following valid sequence can still be parsed.
let parsedAny = false;
while (kittySequenceBuffer) {
const parsed = parseKittyPrefix(kittySequenceBuffer);
if (!parsed) {
// Look for the next potential CSI start beyond index 0
const nextStart = kittySequenceBuffer.indexOf(`${ESC}[`, 1);
if (nextStart > 0) {
if (debugKeystrokeLogging) {
console.log(
'[DEBUG] Skipping incomplete/invalid CSI prefix:',
kittySequenceBuffer.slice(0, nextStart),
);
}
kittySequenceBuffer = kittySequenceBuffer.slice(nextStart);
continue;
}
break;
}
kittySequenceBuffer = '';
broadcast(kittyKey);
return;
if (debugKeystrokeLogging) {
const parsedSequence = kittySequenceBuffer.slice(
0,
parsed.length,
);
if (kittySequenceBuffer.length > parsed.length) {
console.log(
'[DEBUG] Kitty sequence parsed successfully (prefix):',
parsedSequence,
);
} else {
console.log(
'[DEBUG] Kitty sequence parsed successfully:',
parsedSequence,
);
}
}
// Consume the parsed prefix and broadcast it.
kittySequenceBuffer = kittySequenceBuffer.slice(parsed.length);
broadcast(parsed.key);
parsedAny = true;
}
if (parsedAny) return;
if (config?.getDebugMode() || debugKeystrokeLogging) {
const codes = Array.from(kittySequenceBuffer).map((ch) =>
@ -470,7 +744,9 @@ export function KeypressProvider({
rl.close();
// Restore the terminal to its original state.
setRawMode(false);
if (wasRaw === false) {
setRawMode(false);
}
if (backslashTimeout) {
clearTimeout(backslashTimeout);
@ -494,6 +770,23 @@ export function KeypressProvider({
});
pasteBuffer = Buffer.alloc(0);
}
if (draggingTimerRef.current) {
clearTimeout(draggingTimerRef.current);
draggingTimerRef.current = null;
}
if (isDraggingRef.current && dragBufferRef.current) {
broadcast({
name: '',
ctrl: false,
meta: false,
shift: false,
paste: true,
sequence: dragBufferRef.current,
});
isDraggingRef.current = false;
dragBufferRef.current = '';
}
};
}, [
stdin,

View file

@ -113,6 +113,88 @@ describe('SessionStatsContext', () => {
expect(stats?.lastPromptTokenCount).toBe(100);
});
it('should not update metrics if the data is the same', () => {
const contextRef: MutableRefObject<
ReturnType<typeof useSessionStats> | undefined
> = { current: undefined };
let renderCount = 0;
const CountingTestHarness = () => {
contextRef.current = useSessionStats();
renderCount++;
return null;
};
render(
<SessionStatsProvider>
<CountingTestHarness />
</SessionStatsProvider>,
);
expect(renderCount).toBe(1);
const metrics: SessionMetrics = {
models: {
'gemini-pro': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
tokens: {
prompt: 10,
candidates: 20,
total: 30,
cached: 0,
thoughts: 0,
tool: 0,
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
};
act(() => {
uiTelemetryService.emit('update', { metrics, lastPromptTokenCount: 10 });
});
expect(renderCount).toBe(2);
act(() => {
uiTelemetryService.emit('update', { metrics, lastPromptTokenCount: 10 });
});
expect(renderCount).toBe(2);
const newMetrics = {
...metrics,
models: {
'gemini-pro': {
api: { totalRequests: 2, totalErrors: 0, totalLatencyMs: 200 },
tokens: {
prompt: 20,
candidates: 40,
total: 60,
cached: 0,
thoughts: 0,
tool: 0,
},
},
},
};
act(() => {
uiTelemetryService.emit('update', {
metrics: newMetrics,
lastPromptTokenCount: 20,
});
});
expect(renderCount).toBe(3);
});
it('should throw an error when useSessionStats is used outside of a provider', () => {
// Suppress console.error for this test since we expect an error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

View file

@ -14,10 +14,129 @@ import {
useEffect,
} from 'react';
import type { SessionMetrics, ModelMetrics } from '@qwen-code/qwen-code-core';
import type {
SessionMetrics,
ModelMetrics,
ToolCallStats,
} from '@qwen-code/qwen-code-core';
import { uiTelemetryService, sessionId } from '@qwen-code/qwen-code-core';
// --- Interface Definitions ---
export enum ToolCallDecision {
ACCEPT = 'accept',
REJECT = 'reject',
MODIFY = 'modify',
AUTO_ACCEPT = 'auto_accept',
}
function areModelMetricsEqual(a: ModelMetrics, b: ModelMetrics): boolean {
if (
a.api.totalRequests !== b.api.totalRequests ||
a.api.totalErrors !== b.api.totalErrors ||
a.api.totalLatencyMs !== b.api.totalLatencyMs
) {
return false;
}
if (
a.tokens.prompt !== b.tokens.prompt ||
a.tokens.candidates !== b.tokens.candidates ||
a.tokens.total !== b.tokens.total ||
a.tokens.cached !== b.tokens.cached ||
a.tokens.thoughts !== b.tokens.thoughts ||
a.tokens.tool !== b.tokens.tool
) {
return false;
}
return true;
}
function areToolCallStatsEqual(a: ToolCallStats, b: ToolCallStats): boolean {
if (
a.count !== b.count ||
a.success !== b.success ||
a.fail !== b.fail ||
a.durationMs !== b.durationMs
) {
return false;
}
if (
a.decisions[ToolCallDecision.ACCEPT] !==
b.decisions[ToolCallDecision.ACCEPT] ||
a.decisions[ToolCallDecision.REJECT] !==
b.decisions[ToolCallDecision.REJECT] ||
a.decisions[ToolCallDecision.MODIFY] !==
b.decisions[ToolCallDecision.MODIFY] ||
a.decisions[ToolCallDecision.AUTO_ACCEPT] !==
b.decisions[ToolCallDecision.AUTO_ACCEPT]
) {
return false;
}
return true;
}
function areMetricsEqual(a: SessionMetrics, b: SessionMetrics): boolean {
if (a === b) return true;
if (!a || !b) return false;
// Compare files
if (
a.files.totalLinesAdded !== b.files.totalLinesAdded ||
a.files.totalLinesRemoved !== b.files.totalLinesRemoved
) {
return false;
}
// Compare tools
const toolsA = a.tools;
const toolsB = b.tools;
if (
toolsA.totalCalls !== toolsB.totalCalls ||
toolsA.totalSuccess !== toolsB.totalSuccess ||
toolsA.totalFail !== toolsB.totalFail ||
toolsA.totalDurationMs !== toolsB.totalDurationMs
) {
return false;
}
// Compare tool decisions
if (
toolsA.totalDecisions[ToolCallDecision.ACCEPT] !==
toolsB.totalDecisions[ToolCallDecision.ACCEPT] ||
toolsA.totalDecisions[ToolCallDecision.REJECT] !==
toolsB.totalDecisions[ToolCallDecision.REJECT] ||
toolsA.totalDecisions[ToolCallDecision.MODIFY] !==
toolsB.totalDecisions[ToolCallDecision.MODIFY] ||
toolsA.totalDecisions[ToolCallDecision.AUTO_ACCEPT] !==
toolsB.totalDecisions[ToolCallDecision.AUTO_ACCEPT]
) {
return false;
}
// Compare tools.byName
const toolsByNameAKeys = Object.keys(toolsA.byName);
const toolsByNameBKeys = Object.keys(toolsB.byName);
if (toolsByNameAKeys.length !== toolsByNameBKeys.length) return false;
for (const key of toolsByNameAKeys) {
const toolA = toolsA.byName[key];
const toolB = toolsB.byName[key];
if (!toolB || !areToolCallStatsEqual(toolA, toolB)) {
return false;
}
}
// Compare models
const modelsAKeys = Object.keys(a.models);
const modelsBKeys = Object.keys(b.models);
if (modelsAKeys.length !== modelsBKeys.length) return false;
for (const key of modelsAKeys) {
if (!b.models[key] || !areModelMetricsEqual(a.models[key], b.models[key])) {
return false;
}
}
return true;
}
export type { SessionMetrics, ModelMetrics };
@ -80,11 +199,19 @@ export const SessionStatsProvider: React.FC<{ children: React.ReactNode }> = ({
metrics: SessionMetrics;
lastPromptTokenCount: number;
}) => {
setStats((prevState) => ({
...prevState,
metrics,
lastPromptTokenCount,
}));
setStats((prevState) => {
if (
prevState.lastPromptTokenCount === lastPromptTokenCount &&
areMetricsEqual(prevState.metrics, metrics)
) {
return prevState;
}
return {
...prevState,
metrics,
lastPromptTokenCount,
};
});
};
uiTelemetryService.on('update', handleUpdate);

View file

@ -0,0 +1,11 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { createContext, useContext } from 'react';
export const ShellFocusContext = createContext<boolean>(true);
export const useShellFocusState = () => useContext(ShellFocusContext);

View file

@ -0,0 +1,69 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { createContext, useContext } from 'react';
import { type Key } from '../hooks/useKeypress.js';
import { type IdeIntegrationNudgeResult } from '../IdeIntegrationNudge.js';
import { type FolderTrustChoice } from '../components/FolderTrustDialog.js';
import { type AuthType, type EditorType } from '@qwen-code/qwen-code-core';
import { type SettingScope } from '../../config/settings.js';
import type { AuthState } from '../types.js';
import { type VisionSwitchOutcome } from '../components/ModelSwitchDialog.js';
export interface UIActions {
handleThemeSelect: (
themeName: string | undefined,
scope: SettingScope,
) => void;
handleThemeHighlight: (themeName: string | undefined) => void;
handleAuthSelect: (
authType: AuthType | undefined,
scope: SettingScope,
) => void;
setAuthState: (state: AuthState) => void;
onAuthError: (error: string) => void;
// Qwen OAuth handlers
handleQwenAuthTimeout: () => void;
handleQwenAuthCancel: () => void;
handleEditorSelect: (
editorType: EditorType | undefined,
scope: SettingScope,
) => void;
exitEditorDialog: () => void;
closeSettingsDialog: () => void;
closeModelDialog: () => void;
closePermissionsDialog: () => void;
setShellModeActive: (value: boolean) => void;
vimHandleInput: (key: Key) => boolean;
handleIdePromptComplete: (result: IdeIntegrationNudgeResult) => void;
handleFolderTrustSelect: (choice: FolderTrustChoice) => void;
setConstrainHeight: (value: boolean) => void;
onEscapePromptChange: (show: boolean) => void;
refreshStatic: () => void;
handleFinalSubmit: (value: string) => void;
handleClearScreen: () => void;
onWorkspaceMigrationDialogOpen: () => void;
onWorkspaceMigrationDialogClose: () => void;
handleProQuotaChoice: (choice: 'auth' | 'continue') => void;
// Vision switch dialog
handleVisionSwitchSelect: (outcome: VisionSwitchOutcome) => void;
// Welcome back dialog
handleWelcomeBackSelection: (choice: 'continue' | 'restart') => void;
handleWelcomeBackClose: () => void;
// Subagent dialogs
closeSubagentCreateDialog: () => void;
closeAgentsManagerDialog: () => void;
}
export const UIActionsContext = createContext<UIActions | null>(null);
export const useUIActions = () => {
const context = useContext(UIActionsContext);
if (!context) {
throw new Error('useUIActions must be used within a UIActionsProvider');
}
return context;
};

View file

@ -0,0 +1,158 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { createContext, useContext } from 'react';
import type {
HistoryItem,
ThoughtSummary,
ConsoleMessageItem,
ShellConfirmationRequest,
ConfirmationRequest,
LoopDetectionConfirmationRequest,
QuitConfirmationRequest,
HistoryItemWithoutId,
StreamingState,
} from '../types.js';
import type { DeviceAuthorizationInfo } from '../hooks/useQwenAuth.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import type { TextBuffer } from '../components/shared/text-buffer.js';
import type {
IdeContext,
ApprovalMode,
UserTierId,
IdeInfo,
FallbackIntent,
} from '@qwen-code/qwen-code-core';
import type { DOMElement } from 'ink';
import type { SessionStatsState } from '../contexts/SessionContext.js';
import type { ExtensionUpdateState } from '../state/extensions.js';
import type { UpdateObject } from '../utils/updateCheck.js';
export interface ProQuotaDialogRequest {
failedModel: string;
fallbackModel: string;
resolve: (intent: FallbackIntent) => void;
}
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
export interface UIState {
history: HistoryItem[];
historyManager: UseHistoryManagerReturn;
isThemeDialogOpen: boolean;
themeError: string | null;
isAuthenticating: boolean;
isConfigInitialized: boolean;
authError: string | null;
isAuthDialogOpen: boolean;
// Qwen OAuth state
isQwenAuth: boolean;
isQwenAuthenticating: boolean;
deviceAuth: DeviceAuthorizationInfo | null;
authStatus:
| 'idle'
| 'polling'
| 'success'
| 'error'
| 'timeout'
| 'rate_limit';
authMessage: string | null;
editorError: string | null;
isEditorDialogOpen: boolean;
corgiMode: boolean;
debugMessage: string;
quittingMessages: HistoryItem[] | null;
isSettingsDialogOpen: boolean;
isModelDialogOpen: boolean;
isPermissionsDialogOpen: boolean;
slashCommands: readonly SlashCommand[];
pendingSlashCommandHistoryItems: HistoryItemWithoutId[];
commandContext: CommandContext;
shellConfirmationRequest: ShellConfirmationRequest | null;
confirmationRequest: ConfirmationRequest | null;
confirmUpdateExtensionRequests: ConfirmationRequest[];
loopDetectionConfirmationRequest: LoopDetectionConfirmationRequest | null;
quitConfirmationRequest: QuitConfirmationRequest | null;
geminiMdFileCount: number;
streamingState: StreamingState;
initError: string | null;
pendingGeminiHistoryItems: HistoryItemWithoutId[];
thought: ThoughtSummary | null;
shellModeActive: boolean;
userMessages: string[];
buffer: TextBuffer;
inputWidth: number;
suggestionsWidth: number;
isInputActive: boolean;
shouldShowIdePrompt: boolean;
isFolderTrustDialogOpen: boolean;
isTrustedFolder: boolean | undefined;
constrainHeight: boolean;
showErrorDetails: boolean;
filteredConsoleMessages: ConsoleMessageItem[];
ideContextState: IdeContext | undefined;
showToolDescriptions: boolean;
ctrlCPressedOnce: boolean;
ctrlDPressedOnce: boolean;
showEscapePrompt: boolean;
elapsedTime: number;
currentLoadingPhrase: string;
historyRemountKey: number;
messageQueue: string[];
showAutoAcceptIndicator: ApprovalMode;
showWorkspaceMigrationDialog: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
workspaceExtensions: any[]; // Extension[]
// Quota-related state
userTier: UserTierId | undefined;
proQuotaRequest: ProQuotaDialogRequest | null;
currentModel: string;
contextFileNames: string[];
errorCount: number;
availableTerminalHeight: number | undefined;
mainAreaWidth: number;
staticAreaMaxItemHeight: number;
staticExtraHeight: number;
dialogsVisible: boolean;
pendingHistoryItems: HistoryItemWithoutId[];
nightly: boolean;
branchName: string | undefined;
sessionStats: SessionStatsState;
terminalWidth: number;
terminalHeight: number;
mainControlsRef: React.MutableRefObject<DOMElement | null>;
currentIDE: IdeInfo | null;
updateInfo: UpdateObject | null;
showIdeRestartPrompt: boolean;
ideTrustRestartReason: RestartReason;
isRestarting: boolean;
extensionsUpdateState: Map<string, ExtensionUpdateState>;
activePtyId: number | undefined;
embeddedShellFocused: boolean;
// Vision switch dialog
isVisionSwitchDialogOpen: boolean;
// Welcome back dialog
showWelcomeBackDialog: boolean;
welcomeBackInfo: {
hasHistory: boolean;
lastPrompt?: string;
} | null;
welcomeBackChoice: 'continue' | 'restart' | null;
// Subagent dialogs
isSubagentCreateDialogOpen: boolean;
isAgentsManagerDialogOpen: boolean;
}
export const UIStateContext = createContext<UIState | null>(null);
export const useUIState = () => {
const context = useContext(UIStateContext);
if (!context) {
throw new Error('useUIState must be used within a UIStateProvider');
}
return context;
};