Merge remote-tracking branch 'origin/main' into fix/pr2371-btw-complete

# Conflicts:
#	packages/cli/src/ui/AppContainer.tsx
#	packages/cli/src/ui/hooks/useGeminiStream.ts
#	packages/cli/src/ui/layouts/DefaultAppLayout.tsx
#	packages/cli/src/ui/types.ts
#	packages/core/src/core/client.test.ts
This commit is contained in:
yiliang114 2026-03-20 00:55:29 +08:00
commit bd77eef46f
406 changed files with 55514 additions and 6431 deletions

View file

@ -0,0 +1,308 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview AgentViewContext React context for in-process agent view switching.
*
* Tracks which view is active (main or an agent tab) and the set of registered
* AgentInteractive instances. Consumed by AgentTabBar, AgentChatView, and
* DefaultAppLayout to implement tab-based agent navigation.
*
* Kept separate from UIStateContext to avoid bloating the main state with
* in-process-only concerns and to make the feature self-contained.
*/
import {
createContext,
useContext,
useCallback,
useMemo,
useState,
} from 'react';
import {
type AgentInteractive,
type ApprovalMode,
type Config,
} from '@qwen-code/qwen-code-core';
import { useArenaInProcess } from '../hooks/useArenaInProcess.js';
// ─── Types ──────────────────────────────────────────────────
export interface RegisteredAgent {
interactiveAgent: AgentInteractive;
/** Model identifier shown in tabs and paths (e.g. "glm-5"). */
modelId: string;
/** Human-friendly model name (e.g. "GLM 5"). */
modelName?: string;
color: string;
}
export interface AgentViewState {
/** 'main' or an agentId */
activeView: string;
/** Registered in-process agents keyed by agentId */
agents: ReadonlyMap<string, RegisteredAgent>;
/** Whether any agent tab's embedded shell currently has input focus. */
agentShellFocused: boolean;
/** Current text in the active agent tab's input buffer (empty when on main). */
agentInputBufferText: string;
/** Whether the tab bar has keyboard focus (vs the agent input). */
agentTabBarFocused: boolean;
/** Per-agent approval modes (keyed by agentId). */
agentApprovalModes: ReadonlyMap<string, ApprovalMode>;
}
export interface AgentViewActions {
switchToMain(): void;
switchToAgent(agentId: string): void;
switchToNext(): void;
switchToPrevious(): void;
registerAgent(
agentId: string,
interactiveAgent: AgentInteractive,
modelId: string,
color: string,
modelName?: string,
): void;
unregisterAgent(agentId: string): void;
unregisterAll(): void;
setAgentShellFocused(focused: boolean): void;
setAgentInputBufferText(text: string): void;
setAgentTabBarFocused(focused: boolean): void;
setAgentApprovalMode(agentId: string, mode: ApprovalMode): void;
}
// ─── Context ────────────────────────────────────────────────
const AgentViewStateContext = createContext<AgentViewState | null>(null);
const AgentViewActionsContext = createContext<AgentViewActions | null>(null);
// ─── Defaults (used when no provider is mounted) ────────────
const DEFAULT_STATE: AgentViewState = {
activeView: 'main',
agents: new Map(),
agentShellFocused: false,
agentInputBufferText: '',
agentTabBarFocused: false,
agentApprovalModes: new Map(),
};
const noop = () => {};
const DEFAULT_ACTIONS: AgentViewActions = {
switchToMain: noop,
switchToAgent: noop,
switchToNext: noop,
switchToPrevious: noop,
registerAgent: noop,
unregisterAgent: noop,
unregisterAll: noop,
setAgentShellFocused: noop,
setAgentInputBufferText: noop,
setAgentTabBarFocused: noop,
setAgentApprovalMode: noop,
};
// ─── Hook: useAgentViewState ────────────────────────────────
export function useAgentViewState(): AgentViewState {
return useContext(AgentViewStateContext) ?? DEFAULT_STATE;
}
// ─── Hook: useAgentViewActions ──────────────────────────────
export function useAgentViewActions(): AgentViewActions {
return useContext(AgentViewActionsContext) ?? DEFAULT_ACTIONS;
}
// ─── Provider ───────────────────────────────────────────────
interface AgentViewProviderProps {
config?: Config;
children: React.ReactNode;
}
export function AgentViewProvider({
config,
children,
}: AgentViewProviderProps) {
const [activeView, setActiveView] = useState<string>('main');
const [agents, setAgents] = useState<Map<string, RegisteredAgent>>(
() => new Map(),
);
const [agentShellFocused, setAgentShellFocused] = useState(false);
const [agentInputBufferText, setAgentInputBufferText] = useState('');
const [agentTabBarFocused, setAgentTabBarFocused] = useState(false);
const [agentApprovalModes, setAgentApprovalModes] = useState<
Map<string, ApprovalMode>
>(() => new Map());
// ── Navigation ──
const switchToMain = useCallback(() => {
setActiveView('main');
setAgentTabBarFocused(false);
}, []);
const switchToAgent = useCallback(
(agentId: string) => {
if (agents.has(agentId)) {
setActiveView(agentId);
}
},
[agents],
);
const switchToNext = useCallback(() => {
const ids = ['main', ...agents.keys()];
const currentIndex = ids.indexOf(activeView);
const nextIndex = (currentIndex + 1) % ids.length;
setActiveView(ids[nextIndex]!);
}, [agents, activeView]);
const switchToPrevious = useCallback(() => {
const ids = ['main', ...agents.keys()];
const currentIndex = ids.indexOf(activeView);
const prevIndex = (currentIndex - 1 + ids.length) % ids.length;
setActiveView(ids[prevIndex]!);
}, [agents, activeView]);
// ── Registration ──
const registerAgent = useCallback(
(
agentId: string,
interactiveAgent: AgentInteractive,
modelId: string,
color: string,
modelName?: string,
) => {
setAgents((prev) => {
const next = new Map(prev);
next.set(agentId, {
interactiveAgent,
modelId,
color,
modelName,
});
return next;
});
// Seed approval mode from the agent's own config
const mode = interactiveAgent.getCore().runtimeContext.getApprovalMode();
setAgentApprovalModes((prev) => {
const next = new Map(prev);
next.set(agentId, mode);
return next;
});
},
[],
);
const unregisterAgent = useCallback((agentId: string) => {
setAgents((prev) => {
if (!prev.has(agentId)) return prev;
const next = new Map(prev);
next.delete(agentId);
return next;
});
setAgentApprovalModes((prev) => {
if (!prev.has(agentId)) return prev;
const next = new Map(prev);
next.delete(agentId);
return next;
});
setActiveView((current) => (current === agentId ? 'main' : current));
}, []);
const unregisterAll = useCallback(() => {
setAgents(new Map());
setAgentApprovalModes(new Map());
setActiveView('main');
setAgentTabBarFocused(false);
}, []);
const setAgentApprovalMode = useCallback(
(agentId: string, mode: ApprovalMode) => {
// Update the agent's runtime config so tool scheduling picks it up
const agent = agents.get(agentId);
if (agent) {
agent.interactiveAgent.getCore().runtimeContext.setApprovalMode(mode);
}
// Update UI state
setAgentApprovalModes((prev) => {
const next = new Map(prev);
next.set(agentId, mode);
return next;
});
},
[agents],
);
// ── Memoized values ──
const state: AgentViewState = useMemo(
() => ({
activeView,
agents,
agentShellFocused,
agentInputBufferText,
agentTabBarFocused,
agentApprovalModes,
}),
[
activeView,
agents,
agentShellFocused,
agentInputBufferText,
agentTabBarFocused,
agentApprovalModes,
],
);
const actions: AgentViewActions = useMemo(
() => ({
switchToMain,
switchToAgent,
switchToNext,
switchToPrevious,
registerAgent,
unregisterAgent,
unregisterAll,
setAgentShellFocused,
setAgentInputBufferText,
setAgentTabBarFocused,
setAgentApprovalMode,
}),
[
switchToMain,
switchToAgent,
switchToNext,
switchToPrevious,
registerAgent,
unregisterAgent,
unregisterAll,
setAgentShellFocused,
setAgentInputBufferText,
setAgentTabBarFocused,
setAgentApprovalMode,
],
);
// ── Arena in-process bridge ──
// Bridge arena manager events to agent registration. The hook is kept
// in its own file for separation of concerns; it's called here so the
// provider is the single owner of agent tab lifecycle.
useArenaInProcess(config ?? null, actions);
return (
<AgentViewStateContext.Provider value={state}>
<AgentViewActionsContext.Provider value={actions}>
{children}
</AgentViewActionsContext.Provider>
</AgentViewStateContext.Provider>
);
}

View file

@ -1367,6 +1367,75 @@ describe('KeypressContext - Kitty Protocol', () => {
}),
);
});
it('drops unsupported Kitty CSI-u keys without blocking later input', () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
act(() => stdin.sendKittySequence(`\x1b[57358u`)); // CAPS_LOCK
act(() =>
stdin.pressKey({
name: 'a',
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: 'a',
}),
);
expect(keyHandler).toHaveBeenCalledTimes(1);
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'a',
sequence: 'a',
}),
);
});
it('recovers plain text that arrives in the same chunk after an unsupported CSI-u key', () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
act(() =>
stdin.pressKey({
name: '',
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: '\x1b[57358ua',
}),
);
expect(keyHandler).toHaveBeenCalledTimes(1);
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'a',
sequence: 'a',
kittyProtocol: true,
}),
);
});
it('drops unsupported CSI-u variants with event metadata and keeps parsing', () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
act(() => stdin.sendKittySequence(`\x1b[57358;1:1u\x1b[100u`));
expect(keyHandler).toHaveBeenCalledTimes(1);
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'd',
sequence: 'd',
kittyProtocol: true,
}),
);
});
});
describe('Kitty keypad private-use keys', () => {

View file

@ -178,6 +178,25 @@ export function KeypressProvider({
let rawDataBuffer = Buffer.alloc(0);
let rawFlushTimeout: NodeJS.Timeout | null = null;
const createPrintableKey = (char: string): Key => {
const printableName =
char === ' '
? 'space'
: /^[A-Za-z]$/.test(char)
? char.toLowerCase()
: char;
return {
name: printableName,
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: char,
kittyProtocol: true,
};
};
// 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
@ -415,22 +434,11 @@ export function KeypressProvider({
keyCode <= 0x10ffff &&
!(keyCode >= 0xe000 && keyCode <= 0xf8ff)
) {
const char = String.fromCodePoint(keyCode);
const printableName =
char === ' '
? 'space'
: /^[A-Za-z]$/.test(char)
? char.toLowerCase()
: char;
return {
key: {
name: printableName,
ctrl: false,
...createPrintableKey(String.fromCodePoint(keyCode)),
meta: alt,
shift,
paste: false,
sequence: char,
kittyProtocol: true,
},
length: m[0].length,
};
@ -490,6 +498,42 @@ export function KeypressProvider({
return null;
};
const getCompleteCsiSequenceLength = (buffer: string): number | null => {
if (!buffer.startsWith(`${ESC}[`)) {
return null;
}
for (let i = 2; i < buffer.length; i++) {
const code = buffer.charCodeAt(i);
if (code >= 0x40 && code <= 0x7e) {
return i + 1;
}
if (code < 0x20 || code > 0x3f) {
return 0;
}
}
return null;
};
const parsePlainTextPrefix = (
buffer: string,
): { key: Key; length: number } | null => {
if (!buffer || buffer.startsWith(ESC)) {
return null;
}
const [char] = Array.from(buffer);
if (!char) {
return null;
}
return {
key: createPrintableKey(char),
length: char.length,
};
};
const broadcast = (key: Key) => {
for (const handler of subscribers) {
handler(key);
@ -653,47 +697,82 @@ export function KeypressProvider({
// 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;
let bufferedInputHandled = 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) {
if (parsed) {
if (debugKeystrokeLogging) {
const parsedSequence = kittySequenceBuffer.slice(
0,
parsed.length,
);
if (kittySequenceBuffer.length > parsed.length) {
debugLogger.debug(
'[DEBUG] Skipping incomplete/invalid CSI prefix:',
kittySequenceBuffer.slice(0, nextStart),
'[DEBUG] Kitty sequence parsed successfully (prefix):',
parsedSequence,
);
} else {
debugLogger.debug(
'[DEBUG] Kitty sequence parsed successfully:',
parsedSequence,
);
}
kittySequenceBuffer = kittySequenceBuffer.slice(nextStart);
continue;
}
break;
// Consume the parsed prefix and broadcast it.
kittySequenceBuffer = kittySequenceBuffer.slice(parsed.length);
broadcast(parsed.key);
bufferedInputHandled = true;
continue;
}
if (debugKeystrokeLogging) {
const parsedSequence = kittySequenceBuffer.slice(
0,
parsed.length,
const completeUnsupportedCsiLength =
getCompleteCsiSequenceLength(kittySequenceBuffer);
if (completeUnsupportedCsiLength) {
if (debugKeystrokeLogging) {
debugLogger.debug(
'[DEBUG] Dropping unsupported complete CSI sequence:',
kittySequenceBuffer.slice(0, completeUnsupportedCsiLength),
);
}
kittySequenceBuffer = kittySequenceBuffer.slice(
completeUnsupportedCsiLength,
);
if (kittySequenceBuffer.length > parsed.length) {
bufferedInputHandled = true;
continue;
}
const plainTextPrefix = parsePlainTextPrefix(kittySequenceBuffer);
if (plainTextPrefix) {
if (debugKeystrokeLogging) {
debugLogger.debug(
'[DEBUG] Kitty sequence parsed successfully (prefix):',
parsedSequence,
);
} else {
debugLogger.debug(
'[DEBUG] Kitty sequence parsed successfully:',
parsedSequence,
'[DEBUG] Recovered plain text after kitty sequence:',
plainTextPrefix.key.sequence,
);
}
kittySequenceBuffer = kittySequenceBuffer.slice(
plainTextPrefix.length,
);
broadcast(plainTextPrefix.key);
bufferedInputHandled = true;
continue;
}
// Consume the parsed prefix and broadcast it.
kittySequenceBuffer = kittySequenceBuffer.slice(parsed.length);
broadcast(parsed.key);
parsedAny = true;
// Look for the next potential CSI start beyond index 0
const nextStart = kittySequenceBuffer.indexOf(`${ESC}[`, 1);
if (nextStart > 0) {
if (debugKeystrokeLogging) {
debugLogger.debug(
'[DEBUG] Skipping incomplete/invalid CSI prefix:',
kittySequenceBuffer.slice(0, nextStart),
);
}
kittySequenceBuffer = kittySequenceBuffer.slice(nextStart);
bufferedInputHandled = true;
continue;
}
break;
}
if (parsedAny) return;
if (bufferedInputHandled) return;
if (config?.getDebugMode() || debugKeystrokeLogging) {
const codes = Array.from(kittySequenceBuffer).map((ch) =>

View file

@ -17,6 +17,7 @@ import {
import { type SettingScope } from '../../config/settings.js';
import { type CodingPlanRegion } from '../../constants/codingPlan.js';
import type { AuthState } from '../types.js';
import { type ArenaDialogType } from '../hooks/useArenaCommand.js';
// OpenAICredentials type (previously imported from OpenAIKeyPrompt)
export interface OpenAICredentials {
apiKey: string;
@ -54,7 +55,11 @@ export interface UIActions {
exitEditorDialog: () => void;
closeSettingsDialog: () => void;
closeModelDialog: () => void;
openArenaDialog: (type: Exclude<ArenaDialogType, null>) => void;
closeArenaDialog: () => void;
handleArenaModelsSelected?: (models: string[]) => void;
dismissCodingPlanUpdate: () => void;
closeTrustDialog: () => void;
closePermissionsDialog: () => void;
setShellModeActive: (value: boolean) => void;
vimHandleInput: (key: Key) => boolean;

View file

@ -34,6 +34,7 @@ import type { UpdateObject } from '../utils/updateCheck.js';
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
import { type CodingPlanUpdateRequest } from '../hooks/useCodingPlanUpdates.js';
import { type ArenaDialogType } from '../hooks/useArenaCommand.js';
export interface UIState {
history: HistoryItem[];
@ -53,6 +54,8 @@ export interface UIState {
quittingMessages: HistoryItem[] | null;
isSettingsDialogOpen: boolean;
isModelDialogOpen: boolean;
isTrustDialogOpen: boolean;
activeArenaDialog: ArenaDialogType;
isPermissionsDialogOpen: boolean;
isApprovalModeDialogOpen: boolean;
isResumeDialogOpen: boolean;
@ -135,6 +138,8 @@ export interface UIState {
isMcpDialogOpen: boolean;
// Feedback dialog
isFeedbackDialogOpen: boolean;
// Per-task token tracking
taskStartTokens: number;
}
export const UIStateContext = createContext<UIState | null>(null);