mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-03 06:00:49 +00:00
Sync upstream Gemini-CLI v0.8.2 (#838)
This commit is contained in:
parent
096fabb5d6
commit
eb95c131be
644 changed files with 70389 additions and 23709 deletions
22
packages/cli/src/ui/contexts/AppContext.tsx
Normal file
22
packages/cli/src/ui/contexts/AppContext.tsx
Normal 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;
|
||||
};
|
||||
18
packages/cli/src/ui/contexts/ConfigContext.tsx
Normal file
18
packages/cli/src/ui/contexts/ConfigContext.tsx
Normal 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;
|
||||
};
|
||||
|
|
@ -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`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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, F1–F4 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,
|
||||
|
|
|
|||
|
|
@ -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(() => {});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
11
packages/cli/src/ui/contexts/ShellFocusContext.tsx
Normal file
11
packages/cli/src/ui/contexts/ShellFocusContext.tsx
Normal 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);
|
||||
69
packages/cli/src/ui/contexts/UIActionsContext.tsx
Normal file
69
packages/cli/src/ui/contexts/UIActionsContext.tsx
Normal 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;
|
||||
};
|
||||
158
packages/cli/src/ui/contexts/UIStateContext.tsx
Normal file
158
packages/cli/src/ui/contexts/UIStateContext.tsx
Normal 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;
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue