merge main

This commit is contained in:
DennisYu07 2026-03-19 17:12:19 +08:00
commit 6f914e4f4e
228 changed files with 28059 additions and 2618 deletions

View file

@ -58,11 +58,11 @@ import { AcpFileSystemService } from './service/filesystem.js';
import { Readable, Writable } from 'node:stream';
import type { LoadedSettings } from '../config/settings.js';
import { SettingScope } from '../config/settings.js';
import type { ApprovalModeValue } from './session/types.js';
import { z } from 'zod';
import type { CliArgs } from '../config/config.js';
import { loadCliConfig } from '../config/config.js';
import { Session } from './session/Session.js';
import type { ApprovalModeValue } from './session/types.js';
import { formatAcpModelId } from '../utils/acpModelUtils.js';
const debugLogger = createDebugLogger('ACP_AGENT');

View file

@ -16,7 +16,7 @@ import type {
ToolCallConfirmationDetails,
ToolResult,
ChatRecord,
SubAgentEventEmitter,
AgentEventEmitter,
} from '@qwen-code/qwen-code-core';
import {
AuthType,
@ -530,7 +530,7 @@ export class Session implements SessionContext {
// Access eventEmitter from TaskTool invocation
const taskEventEmitter = (
invocation as {
eventEmitter: SubAgentEventEmitter;
eventEmitter: AgentEventEmitter;
}
).eventEmitter;
@ -539,7 +539,7 @@ export class Session implements SessionContext {
const subagentType = (args['subagent_type'] as string) ?? '';
// Create a SubAgentTracker for this tool execution
const subAgentTracker = new SubAgentTracker(
const subSubAgentTracker = new SubAgentTracker(
this,
this.client,
parentToolCallId,
@ -547,7 +547,7 @@ export class Session implements SessionContext {
);
// Set up sub-agent tool tracking
subAgentCleanupFunctions = subAgentTracker.setup(
subAgentCleanupFunctions = subSubAgentTracker.setup(
taskEventEmitter,
abortSignal,
);

View file

@ -10,26 +10,26 @@ import type { SessionContext } from './types.js';
import type {
Config,
ToolRegistry,
SubAgentEventEmitter,
SubAgentToolCallEvent,
SubAgentToolResultEvent,
SubAgentApprovalRequestEvent,
SubAgentStreamTextEvent,
AgentEventEmitter,
AgentToolCallEvent,
AgentToolResultEvent,
AgentApprovalRequestEvent,
AgentStreamTextEvent,
ToolEditConfirmationDetails,
ToolInfoConfirmationDetails,
} from '@qwen-code/qwen-code-core';
import {
SubAgentEventType,
AgentEventType,
ToolConfirmationOutcome,
TodoWriteTool,
} from '@qwen-code/qwen-code-core';
import type { AgentSideConnection } from '@agentclientprotocol/sdk';
import { EventEmitter } from 'node:events';
// Helper to create a mock SubAgentToolCallEvent with required fields
// Helper to create a mock AgentToolCallEvent with required fields
function createToolCallEvent(
overrides: Partial<SubAgentToolCallEvent> & { name: string; callId: string },
): SubAgentToolCallEvent {
overrides: Partial<AgentToolCallEvent> & { name: string; callId: string },
): AgentToolCallEvent {
return {
subagentId: 'test-subagent',
round: 1,
@ -40,14 +40,14 @@ function createToolCallEvent(
};
}
// Helper to create a mock SubAgentToolResultEvent with required fields
// Helper to create a mock AgentToolResultEvent with required fields
function createToolResultEvent(
overrides: Partial<SubAgentToolResultEvent> & {
overrides: Partial<AgentToolResultEvent> & {
name: string;
callId: string;
success: boolean;
},
): SubAgentToolResultEvent {
): AgentToolResultEvent {
return {
subagentId: 'test-subagent',
round: 1,
@ -56,15 +56,15 @@ function createToolResultEvent(
};
}
// Helper to create a mock SubAgentApprovalRequestEvent with required fields
// Helper to create a mock AgentApprovalRequestEvent with required fields
function createApprovalEvent(
overrides: Partial<SubAgentApprovalRequestEvent> & {
overrides: Partial<AgentApprovalRequestEvent> & {
name: string;
callId: string;
confirmationDetails: SubAgentApprovalRequestEvent['confirmationDetails'];
respond: SubAgentApprovalRequestEvent['respond'];
confirmationDetails: AgentApprovalRequestEvent['confirmationDetails'];
respond: AgentApprovalRequestEvent['respond'];
},
): SubAgentApprovalRequestEvent {
): AgentApprovalRequestEvent {
return {
subagentId: 'test-subagent',
round: 1,
@ -102,10 +102,10 @@ function createInfoConfirmation(
};
}
// Helper to create a mock SubAgentStreamTextEvent with required fields
// Helper to create a mock AgentStreamTextEvent with required fields
function createStreamTextEvent(
overrides: Partial<SubAgentStreamTextEvent> & { text: string },
): SubAgentStreamTextEvent {
overrides: Partial<AgentStreamTextEvent> & { text: string },
): AgentStreamTextEvent {
return {
subagentId: 'test-subagent',
round: 1,
@ -120,7 +120,7 @@ describe('SubAgentTracker', () => {
let sendUpdateSpy: ReturnType<typeof vi.fn>;
let requestPermissionSpy: ReturnType<typeof vi.fn>;
let tracker: SubAgentTracker;
let eventEmitter: SubAgentEventEmitter;
let eventEmitter: AgentEventEmitter;
let abortController: AbortController;
beforeEach(() => {
@ -151,7 +151,7 @@ describe('SubAgentTracker', () => {
'parent-call-123',
'test-subagent',
);
eventEmitter = new EventEmitter() as unknown as SubAgentEventEmitter;
eventEmitter = new EventEmitter() as unknown as AgentEventEmitter;
abortController = new AbortController();
});
@ -169,19 +169,19 @@ describe('SubAgentTracker', () => {
tracker.setup(eventEmitter, abortController.signal);
expect(onSpy).toHaveBeenCalledWith(
SubAgentEventType.TOOL_CALL,
AgentEventType.TOOL_CALL,
expect.any(Function),
);
expect(onSpy).toHaveBeenCalledWith(
SubAgentEventType.TOOL_RESULT,
AgentEventType.TOOL_RESULT,
expect.any(Function),
);
expect(onSpy).toHaveBeenCalledWith(
SubAgentEventType.TOOL_WAITING_APPROVAL,
AgentEventType.TOOL_WAITING_APPROVAL,
expect.any(Function),
);
expect(onSpy).toHaveBeenCalledWith(
SubAgentEventType.STREAM_TEXT,
AgentEventType.STREAM_TEXT,
expect.any(Function),
);
});
@ -193,19 +193,19 @@ describe('SubAgentTracker', () => {
cleanups[0]();
expect(offSpy).toHaveBeenCalledWith(
SubAgentEventType.TOOL_CALL,
AgentEventType.TOOL_CALL,
expect.any(Function),
);
expect(offSpy).toHaveBeenCalledWith(
SubAgentEventType.TOOL_RESULT,
AgentEventType.TOOL_RESULT,
expect.any(Function),
);
expect(offSpy).toHaveBeenCalledWith(
SubAgentEventType.TOOL_WAITING_APPROVAL,
AgentEventType.TOOL_WAITING_APPROVAL,
expect.any(Function),
);
expect(offSpy).toHaveBeenCalledWith(
SubAgentEventType.STREAM_TEXT,
AgentEventType.STREAM_TEXT,
expect.any(Function),
);
});
@ -222,7 +222,7 @@ describe('SubAgentTracker', () => {
description: 'Reading file',
});
eventEmitter.emit(SubAgentEventType.TOOL_CALL, event);
eventEmitter.emit(AgentEventType.TOOL_CALL, event);
// Allow async operations to complete
await vi.waitFor(() => {
@ -258,7 +258,7 @@ describe('SubAgentTracker', () => {
args: { todos: [] },
});
eventEmitter.emit(SubAgentEventType.TOOL_CALL, event);
eventEmitter.emit(AgentEventType.TOOL_CALL, event);
// Give time for any async operation
await new Promise((resolve) => setTimeout(resolve, 10));
@ -276,7 +276,7 @@ describe('SubAgentTracker', () => {
args: {},
});
eventEmitter.emit(SubAgentEventType.TOOL_CALL, event);
eventEmitter.emit(AgentEventType.TOOL_CALL, event);
await new Promise((resolve) => setTimeout(resolve, 10));
@ -290,7 +290,7 @@ describe('SubAgentTracker', () => {
// First emit tool call to store state
eventEmitter.emit(
SubAgentEventType.TOOL_CALL,
AgentEventType.TOOL_CALL,
createToolCallEvent({
name: 'read_file',
callId: 'call-123',
@ -306,7 +306,7 @@ describe('SubAgentTracker', () => {
resultDisplay: 'File contents',
});
eventEmitter.emit(SubAgentEventType.TOOL_RESULT, resultEvent);
eventEmitter.emit(AgentEventType.TOOL_RESULT, resultEvent);
await vi.waitFor(() => {
expect(sendUpdateSpy).toHaveBeenCalledWith(
@ -334,7 +334,7 @@ describe('SubAgentTracker', () => {
resultDisplay: undefined,
});
eventEmitter.emit(SubAgentEventType.TOOL_RESULT, resultEvent);
eventEmitter.emit(AgentEventType.TOOL_RESULT, resultEvent);
await vi.waitFor(() => {
expect(sendUpdateSpy).toHaveBeenCalledWith(
@ -356,7 +356,7 @@ describe('SubAgentTracker', () => {
// Store args via tool call
eventEmitter.emit(
SubAgentEventType.TOOL_CALL,
AgentEventType.TOOL_CALL,
createToolCallEvent({
name: TodoWriteTool.Name,
callId: 'call-todo',
@ -377,7 +377,7 @@ describe('SubAgentTracker', () => {
}),
});
eventEmitter.emit(SubAgentEventType.TOOL_RESULT, resultEvent);
eventEmitter.emit(AgentEventType.TOOL_RESULT, resultEvent);
await vi.waitFor(() => {
expect(sendUpdateSpy).toHaveBeenCalledWith({
@ -393,7 +393,7 @@ describe('SubAgentTracker', () => {
tracker.setup(eventEmitter, abortController.signal);
eventEmitter.emit(
SubAgentEventType.TOOL_CALL,
AgentEventType.TOOL_CALL,
createToolCallEvent({
name: 'test_tool',
callId: 'call-cleanup',
@ -402,7 +402,7 @@ describe('SubAgentTracker', () => {
);
eventEmitter.emit(
SubAgentEventType.TOOL_RESULT,
AgentEventType.TOOL_RESULT,
createToolResultEvent({
name: 'test_tool',
callId: 'call-cleanup',
@ -413,7 +413,7 @@ describe('SubAgentTracker', () => {
// Emit another result for same callId - should not have stored args
sendUpdateSpy.mockClear();
eventEmitter.emit(
SubAgentEventType.TOOL_RESULT,
AgentEventType.TOOL_RESULT,
createToolResultEvent({
name: 'test_tool',
callId: 'call-cleanup',
@ -447,7 +447,7 @@ describe('SubAgentTracker', () => {
respond: respondSpy,
});
eventEmitter.emit(SubAgentEventType.TOOL_WAITING_APPROVAL, event);
eventEmitter.emit(AgentEventType.TOOL_WAITING_APPROVAL, event);
await vi.waitFor(() => {
expect(requestPermissionSpy).toHaveBeenCalled();
@ -483,7 +483,7 @@ describe('SubAgentTracker', () => {
respond: respondSpy,
});
eventEmitter.emit(SubAgentEventType.TOOL_WAITING_APPROVAL, event);
eventEmitter.emit(AgentEventType.TOOL_WAITING_APPROVAL, event);
await vi.waitFor(() => {
expect(respondSpy).toHaveBeenCalledWith(
@ -504,7 +504,7 @@ describe('SubAgentTracker', () => {
respond: respondSpy,
});
eventEmitter.emit(SubAgentEventType.TOOL_WAITING_APPROVAL, event);
eventEmitter.emit(AgentEventType.TOOL_WAITING_APPROVAL, event);
await vi.waitFor(() => {
expect(respondSpy).toHaveBeenCalledWith(ToolConfirmationOutcome.Cancel);
@ -525,7 +525,7 @@ describe('SubAgentTracker', () => {
respond: respondSpy,
});
eventEmitter.emit(SubAgentEventType.TOOL_WAITING_APPROVAL, event);
eventEmitter.emit(AgentEventType.TOOL_WAITING_APPROVAL, event);
await vi.waitFor(() => {
expect(respondSpy).toHaveBeenCalledWith(ToolConfirmationOutcome.Cancel);
@ -548,7 +548,7 @@ describe('SubAgentTracker', () => {
respond: vi.fn(),
});
eventEmitter.emit(SubAgentEventType.TOOL_WAITING_APPROVAL, event);
eventEmitter.emit(AgentEventType.TOOL_WAITING_APPROVAL, event);
await vi.waitFor(() => {
expect(requestPermissionSpy).toHaveBeenCalled();
@ -572,7 +572,7 @@ describe('SubAgentTracker', () => {
text: 'Hello, this is a response from the model.',
});
eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event);
eventEmitter.emit(AgentEventType.STREAM_TEXT, event);
await vi.waitFor(() => {
expect(sendUpdateSpy).toHaveBeenCalled();
@ -593,15 +593,15 @@ describe('SubAgentTracker', () => {
tracker.setup(eventEmitter, abortController.signal);
eventEmitter.emit(
SubAgentEventType.STREAM_TEXT,
AgentEventType.STREAM_TEXT,
createStreamTextEvent({ text: 'First chunk ' }),
);
eventEmitter.emit(
SubAgentEventType.STREAM_TEXT,
AgentEventType.STREAM_TEXT,
createStreamTextEvent({ text: 'Second chunk ' }),
);
eventEmitter.emit(
SubAgentEventType.STREAM_TEXT,
AgentEventType.STREAM_TEXT,
createStreamTextEvent({ text: 'Third chunk' }),
);
@ -640,7 +640,7 @@ describe('SubAgentTracker', () => {
text: 'This should not be emitted',
});
eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event);
eventEmitter.emit(AgentEventType.STREAM_TEXT, event);
await new Promise((resolve) => setTimeout(resolve, 10));
@ -655,7 +655,7 @@ describe('SubAgentTracker', () => {
thought: true,
});
eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event);
eventEmitter.emit(AgentEventType.STREAM_TEXT, event);
await vi.waitFor(() => {
expect(sendUpdateSpy).toHaveBeenCalled();
@ -680,7 +680,7 @@ describe('SubAgentTracker', () => {
thought: false,
});
eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event);
eventEmitter.emit(AgentEventType.STREAM_TEXT, event);
await vi.waitFor(() => {
expect(sendUpdateSpy).toHaveBeenCalled();
@ -705,7 +705,7 @@ describe('SubAgentTracker', () => {
text: 'Default behavior text.',
});
eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event);
eventEmitter.emit(AgentEventType.STREAM_TEXT, event);
await vi.waitFor(() => {
expect(sendUpdateSpy).toHaveBeenCalled();

View file

@ -5,18 +5,18 @@
*/
import type {
SubAgentEventEmitter,
SubAgentToolCallEvent,
SubAgentToolResultEvent,
SubAgentApprovalRequestEvent,
SubAgentUsageEvent,
SubAgentStreamTextEvent,
AgentEventEmitter,
AgentToolCallEvent,
AgentToolResultEvent,
AgentApprovalRequestEvent,
AgentUsageEvent,
AgentStreamTextEvent,
ToolCallConfirmationDetails,
AnyDeclarativeTool,
AnyToolInvocation,
} from '@qwen-code/qwen-code-core';
import {
SubAgentEventType,
AgentEventType,
ToolConfirmationOutcome,
createDebugLogger,
} from '@qwen-code/qwen-code-core';
@ -106,12 +106,12 @@ export class SubAgentTracker {
/**
* Sets up event listeners for a sub-agent's tool events.
*
* @param eventEmitter - The SubAgentEventEmitter from TaskTool
* @param eventEmitter - The AgentEventEmitter from TaskTool
* @param abortSignal - Signal to abort tracking if parent is cancelled
* @returns Array of cleanup functions to remove listeners
*/
setup(
eventEmitter: SubAgentEventEmitter,
eventEmitter: AgentEventEmitter,
abortSignal: AbortSignal,
): Array<() => void> {
const onToolCall = this.createToolCallHandler(abortSignal);
@ -120,19 +120,19 @@ export class SubAgentTracker {
const onUsageMetadata = this.createUsageMetadataHandler(abortSignal);
const onStreamText = this.createStreamTextHandler(abortSignal);
eventEmitter.on(SubAgentEventType.TOOL_CALL, onToolCall);
eventEmitter.on(SubAgentEventType.TOOL_RESULT, onToolResult);
eventEmitter.on(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval);
eventEmitter.on(SubAgentEventType.USAGE_METADATA, onUsageMetadata);
eventEmitter.on(SubAgentEventType.STREAM_TEXT, onStreamText);
eventEmitter.on(AgentEventType.TOOL_CALL, onToolCall);
eventEmitter.on(AgentEventType.TOOL_RESULT, onToolResult);
eventEmitter.on(AgentEventType.TOOL_WAITING_APPROVAL, onApproval);
eventEmitter.on(AgentEventType.USAGE_METADATA, onUsageMetadata);
eventEmitter.on(AgentEventType.STREAM_TEXT, onStreamText);
return [
() => {
eventEmitter.off(SubAgentEventType.TOOL_CALL, onToolCall);
eventEmitter.off(SubAgentEventType.TOOL_RESULT, onToolResult);
eventEmitter.off(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval);
eventEmitter.off(SubAgentEventType.USAGE_METADATA, onUsageMetadata);
eventEmitter.off(SubAgentEventType.STREAM_TEXT, onStreamText);
eventEmitter.off(AgentEventType.TOOL_CALL, onToolCall);
eventEmitter.off(AgentEventType.TOOL_RESULT, onToolResult);
eventEmitter.off(AgentEventType.TOOL_WAITING_APPROVAL, onApproval);
eventEmitter.off(AgentEventType.USAGE_METADATA, onUsageMetadata);
eventEmitter.off(AgentEventType.STREAM_TEXT, onStreamText);
// Clean up any remaining states
this.toolStates.clear();
},
@ -146,7 +146,7 @@ export class SubAgentTracker {
abortSignal: AbortSignal,
): (...args: unknown[]) => void {
return (...args: unknown[]) => {
const event = args[0] as SubAgentToolCallEvent;
const event = args[0] as AgentToolCallEvent;
if (abortSignal.aborted) return;
// Look up tool and build invocation for metadata
@ -187,7 +187,7 @@ export class SubAgentTracker {
abortSignal: AbortSignal,
): (...args: unknown[]) => void {
return (...args: unknown[]) => {
const event = args[0] as SubAgentToolResultEvent;
const event = args[0] as AgentToolResultEvent;
if (abortSignal.aborted) return;
const state = this.toolStates.get(event.callId);
@ -215,7 +215,7 @@ export class SubAgentTracker {
abortSignal: AbortSignal,
): (...args: unknown[]) => Promise<void> {
return async (...args: unknown[]) => {
const event = args[0] as SubAgentApprovalRequestEvent;
const event = args[0] as AgentApprovalRequestEvent;
if (abortSignal.aborted) return;
const state = this.toolStates.get(event.callId);
@ -292,7 +292,7 @@ export class SubAgentTracker {
abortSignal: AbortSignal,
): (...args: unknown[]) => void {
return (...args: unknown[]) => {
const event = args[0] as SubAgentUsageEvent;
const event = args[0] as AgentUsageEvent;
if (abortSignal.aborted) return;
this.messageEmitter.emitUsageMetadata(
@ -312,7 +312,7 @@ export class SubAgentTracker {
abortSignal: AbortSignal,
): (...args: unknown[]) => void {
return (...args: unknown[]) => {
const event = args[0] as SubAgentStreamTextEvent;
const event = args[0] as AgentStreamTextEvent;
if (abortSignal.aborted) return;
// Emit streamed text as agent message or thought based on the flag

View file

@ -5,6 +5,7 @@
*/
import type { GenerateContentResponseUsageMetadata } from '@google/genai';
import type { SubagentMeta } from '../types.js';
import type { Usage } from '@agentclientprotocol/sdk';
import { BaseEmitter } from './BaseEmitter.js';
@ -77,7 +78,7 @@ export class MessageEmitter extends BaseEmitter {
usageMetadata: GenerateContentResponseUsageMetadata,
text: string = '',
durationMs?: number,
subagentMeta?: import('../types.js').SubagentMeta,
subagentMeta?: SubagentMeta,
): Promise<void> {
const usage: Usage = {
inputTokens: usageMetadata.promptTokenCount ?? 0,

View file

@ -0,0 +1,77 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule, Argv } from 'yargs';
import {
handleQwenAuth,
runInteractiveAuth,
showAuthStatus,
} from './auth/handler.js';
import { t } from '../i18n/index.js';
// Define subcommands separately
const qwenOauthCommand = {
command: 'qwen-oauth',
describe: t('Authenticate using Qwen OAuth'),
handler: async () => {
await handleQwenAuth('qwen-oauth', {});
},
};
const codePlanCommand = {
command: 'coding-plan',
describe: t('Authenticate using Alibaba Cloud Coding Plan'),
builder: (yargs: Argv) =>
yargs
.option('region', {
alias: 'r',
describe: t('Region for Coding Plan (china/global)'),
type: 'string',
})
.option('key', {
alias: 'k',
describe: t('API key for Coding Plan'),
type: 'string',
}),
handler: async (argv: { region?: string; key?: string }) => {
const region = argv['region'] as string | undefined;
const key = argv['key'] as string | undefined;
// If region and key are provided, use them directly
if (region && key) {
await handleQwenAuth('coding-plan', { region, key });
} else {
// Otherwise, prompt interactively
await handleQwenAuth('coding-plan', {});
}
},
};
const statusCommand = {
command: 'status',
describe: t('Show current authentication status'),
handler: async () => {
await showAuthStatus();
},
};
export const authCommand: CommandModule = {
command: 'auth',
describe: t(
'Configure Qwen authentication information with Qwen-OAuth or Alibaba Cloud Coding Plan',
),
builder: (yargs: Argv) =>
yargs
.command(qwenOauthCommand)
.command(codePlanCommand)
.command(statusCommand)
.demandCommand(0) // Don't require a subcommand
.version(false),
handler: async () => {
// This handler is for when no subcommand is provided - show interactive menu
await runInteractiveAuth();
},
};

View file

@ -0,0 +1,500 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
AuthType,
getErrorMessage,
type Config,
type ProviderModelConfig as ModelConfig,
} from '@qwen-code/qwen-code-core';
import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js';
import { t } from '../../i18n/index.js';
import {
getCodingPlanConfig,
isCodingPlanConfig,
CodingPlanRegion,
CODING_PLAN_ENV_KEY,
} from '../../constants/codingPlan.js';
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
import { backupSettingsFile } from '../../utils/settingsUtils.js';
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
import { loadCliConfig } from '../../config/config.js';
import type { CliArgs } from '../../config/config.js';
import { InteractiveSelector } from './interactiveSelector.js';
interface QwenAuthOptions {
region?: string;
key?: string;
}
interface CodingPlanSettings {
region?: CodingPlanRegion;
version?: string;
}
interface MergedSettingsWithCodingPlan {
security?: {
auth?: {
selectedType?: string;
};
};
codingPlan?: CodingPlanSettings;
model?: {
name?: string;
};
modelProviders?: Record<string, ModelConfig[]>;
env?: Record<string, string>;
}
/**
* Handles the authentication process based on the specified command and options
*/
export async function handleQwenAuth(
command: 'qwen-oauth' | 'coding-plan',
options: QwenAuthOptions,
) {
try {
const settings = loadSettings();
// Create a minimal argv for config loading
const minimalArgv: CliArgs = {
query: undefined,
model: undefined,
sandbox: undefined,
sandboxImage: undefined,
debug: undefined,
prompt: undefined,
promptInteractive: undefined,
yolo: undefined,
approvalMode: undefined,
telemetry: undefined,
checkpointing: undefined,
telemetryTarget: undefined,
telemetryOtlpEndpoint: undefined,
telemetryOtlpProtocol: undefined,
telemetryLogPrompts: undefined,
telemetryOutfile: undefined,
allowedMcpServerNames: undefined,
allowedTools: undefined,
acp: undefined,
experimentalAcp: undefined,
experimentalLsp: undefined,
experimentalHooks: undefined,
extensions: [],
listExtensions: undefined,
openaiLogging: undefined,
openaiApiKey: undefined,
openaiBaseUrl: undefined,
openaiLoggingDir: undefined,
proxy: undefined,
includeDirectories: undefined,
tavilyApiKey: undefined,
googleApiKey: undefined,
googleSearchEngineId: undefined,
webSearchDefault: undefined,
screenReader: undefined,
inputFormat: undefined,
outputFormat: undefined,
includePartialMessages: undefined,
chatRecording: undefined,
continue: undefined,
resume: undefined,
sessionId: undefined,
maxSessionTurns: undefined,
coreTools: undefined,
excludeTools: undefined,
authType: undefined,
channel: undefined,
systemPrompt: undefined,
appendSystemPrompt: undefined,
};
// Create a minimal config to access settings and storage
const config = await loadCliConfig(
settings.merged,
minimalArgv,
process.cwd(),
[], // No extensions for auth command
);
if (command === 'qwen-oauth') {
await handleQwenOAuth(config, settings);
} else if (command === 'coding-plan') {
await handleCodePlanAuth(config, settings, options);
}
// Exit after authentication is complete
writeStdoutLine(t('Authentication completed successfully.'));
process.exit(0);
} catch (error) {
writeStderrLine(getErrorMessage(error));
process.exit(1);
}
}
/**
* Handles Qwen OAuth authentication
*/
async function handleQwenOAuth(
config: Config,
settings: LoadedSettings,
): Promise<void> {
writeStdoutLine(t('Starting Qwen OAuth authentication...'));
try {
await config.refreshAuth(AuthType.QWEN_OAUTH);
// Persist the auth type
const authTypeScope = getPersistScopeForModelSelection(settings);
settings.setValue(
authTypeScope,
'security.auth.selectedType',
AuthType.QWEN_OAUTH,
);
writeStdoutLine(t('Successfully authenticated with Qwen OAuth.'));
process.exit(0);
} catch (error) {
writeStderrLine(
t('Failed to authenticate with Qwen OAuth: {{error}}', {
error: getErrorMessage(error),
}),
);
process.exit(1);
}
}
/**
* Handles Alibaba Cloud Coding Plan authentication
*/
async function handleCodePlanAuth(
config: Config,
settings: LoadedSettings,
options: QwenAuthOptions,
): Promise<void> {
const { region, key } = options;
let selectedRegion: CodingPlanRegion;
let selectedKey: string;
// If region and key are provided as options, use them
if (region && key) {
selectedRegion =
region.toLowerCase() === 'global'
? CodingPlanRegion.GLOBAL
: CodingPlanRegion.CHINA;
selectedKey = key;
} else {
// Otherwise, prompt interactively
selectedRegion = await promptForRegion();
selectedKey = await promptForKey();
}
writeStdoutLine(t('Processing Alibaba Cloud Coding Plan authentication...'));
try {
// Get configuration based on region
const { template, version } = getCodingPlanConfig(selectedRegion);
// Get persist scope
const authTypeScope = getPersistScopeForModelSelection(settings);
// Backup settings file before modification
const settingsFile = settings.forScope(authTypeScope);
backupSettingsFile(settingsFile.path);
// Store api-key in settings.env (unified env key)
settings.setValue(authTypeScope, `env.${CODING_PLAN_ENV_KEY}`, selectedKey);
// Sync to process.env immediately so refreshAuth can read the apiKey
process.env[CODING_PLAN_ENV_KEY] = selectedKey;
// Generate model configs from template
const newConfigs = template.map((templateConfig) => ({
...templateConfig,
envKey: CODING_PLAN_ENV_KEY,
}));
// Get existing configs
const existingConfigs =
(settings.merged.modelProviders as Record<string, ModelConfig[]>)?.[
AuthType.USE_OPENAI
] || [];
// Filter out all existing Coding Plan configs (mutually exclusive)
const nonCodingPlanConfigs = existingConfigs.filter(
(existing) => !isCodingPlanConfig(existing.baseUrl, existing.envKey),
);
// Add new Coding Plan configs at the beginning
const updatedConfigs = [...newConfigs, ...nonCodingPlanConfigs];
// Persist to modelProviders
settings.setValue(
authTypeScope,
`modelProviders.${AuthType.USE_OPENAI}`,
updatedConfigs,
);
// Also persist authType
settings.setValue(
authTypeScope,
'security.auth.selectedType',
AuthType.USE_OPENAI,
);
// Persist coding plan region
settings.setValue(authTypeScope, 'codingPlan.region', selectedRegion);
// Persist coding plan version (single field for backward compatibility)
settings.setValue(authTypeScope, 'codingPlan.version', version);
// If there are configs, use the first one as the model
if (updatedConfigs.length > 0 && updatedConfigs[0]?.id) {
settings.setValue(
authTypeScope,
'model.name',
(updatedConfigs[0] as ModelConfig).id,
);
}
// Refresh auth with the new configuration
await config.refreshAuth(AuthType.USE_OPENAI);
writeStdoutLine(
t('Successfully authenticated with Alibaba Cloud Coding Plan.'),
);
} catch (error) {
writeStderrLine(
t('Failed to authenticate with Coding Plan: {{error}}', {
error: getErrorMessage(error),
}),
);
process.exit(1);
}
}
/**
* Prompts the user to select a region using an interactive selector
*/
async function promptForRegion(): Promise<CodingPlanRegion> {
const selector = new InteractiveSelector(
[
{
value: CodingPlanRegion.CHINA,
label: t('中国 (China)'),
description: t('阿里云百炼 (aliyun.com)'),
},
{
value: CodingPlanRegion.GLOBAL,
label: t('Global'),
description: t('Alibaba Cloud (alibabacloud.com)'),
},
],
t('Select region for Coding Plan:'),
);
return await selector.select();
}
/**
* Prompts the user to enter an API key
*/
async function promptForKey(): Promise<string> {
// Create a simple password-style input (without echoing characters)
const stdin = process.stdin;
const stdout = process.stdout;
stdout.write(t('Enter your Coding Plan API key: '));
// Set raw mode to capture keystrokes
const wasRaw = stdin.isRaw;
if (stdin.setRawMode) {
stdin.setRawMode(true);
}
stdin.resume();
return new Promise<string>((resolve, reject) => {
let input = '';
const onData = (chunk: string) => {
for (const char of chunk) {
switch (char) {
case '\r': // Enter
case '\n':
stdin.removeListener('data', onData);
if (stdin.setRawMode) {
stdin.setRawMode(wasRaw);
}
stdout.write('\n'); // New line after input
resolve(input);
return;
case '\x03': // Ctrl+C
stdin.removeListener('data', onData);
if (stdin.setRawMode) {
stdin.setRawMode(wasRaw);
}
stdout.write('^C\n');
reject(new Error('Interrupted'));
return;
case '\x08': // Backspace
case '\x7F': // Delete
if (input.length > 0) {
input = input.slice(0, -1);
// Move cursor back, print space, move back again
stdout.write('\x1B[D \x1B[D');
}
break;
default:
// Add character to input
input += char;
// Print asterisk instead of the actual character for security
stdout.write('*');
break;
}
}
};
stdin.on('data', onData);
});
}
/**
* Runs the interactive authentication flow
*/
export async function runInteractiveAuth() {
const selector = new InteractiveSelector(
[
{
value: 'qwen-oauth' as const,
label: t('Qwen OAuth'),
description: t('Free · Up to 1,000 requests/day · Qwen latest models'),
},
{
value: 'coding-plan' as const,
label: t('Alibaba Cloud Coding Plan'),
description: t(
'Paid · Up to 6,000 requests/5 hrs · All Alibaba Cloud Coding Plan Models',
),
},
],
t('Select authentication method:'),
);
const choice = await selector.select();
if (choice === 'coding-plan') {
await handleQwenAuth('coding-plan', {});
} else {
await handleQwenAuth('qwen-oauth', {});
}
}
/**
* Shows the current authentication status
*/
export async function showAuthStatus(): Promise<void> {
try {
const settings = loadSettings();
const mergedSettings = settings.merged as MergedSettingsWithCodingPlan;
writeStdoutLine(t('\n=== Authentication Status ===\n'));
// Check for selected auth type
const selectedType = mergedSettings.security?.auth?.selectedType;
if (!selectedType) {
writeStdoutLine(t('⚠️ No authentication method configured.\n'));
writeStdoutLine(t('Run one of the following commands to get started:\n'));
writeStdoutLine(
t(
' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)',
),
);
writeStdoutLine(
t(
' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n',
),
);
writeStdoutLine(t('Or simply run:'));
writeStdoutLine(
t(' qwen auth - Interactive authentication setup\n'),
);
process.exit(0);
}
// Display status based on auth type
if (selectedType === AuthType.QWEN_OAUTH) {
writeStdoutLine(t('✓ Authentication Method: Qwen OAuth'));
writeStdoutLine(t(' Type: Free tier'));
writeStdoutLine(t(' Limit: Up to 1,000 requests/day'));
writeStdoutLine(t(' Models: Qwen latest models\n'));
} else if (selectedType === AuthType.USE_OPENAI) {
// Check for Coding Plan configuration
const codingPlanRegion = mergedSettings.codingPlan?.region;
const codingPlanVersion = mergedSettings.codingPlan?.version;
const modelName = mergedSettings.model?.name;
// Check if API key is set in environment
const hasApiKey =
!!process.env[CODING_PLAN_ENV_KEY] ||
!!mergedSettings.env?.[CODING_PLAN_ENV_KEY];
if (hasApiKey) {
writeStdoutLine(
t('✓ Authentication Method: Alibaba Cloud Coding Plan'),
);
if (codingPlanRegion) {
const regionDisplay =
codingPlanRegion === CodingPlanRegion.CHINA
? t('中国 (China) - 阿里云百炼')
: t('Global - Alibaba Cloud');
writeStdoutLine(t(' Region: {{region}}', { region: regionDisplay }));
}
if (modelName) {
writeStdoutLine(
t(' Current Model: {{model}}', { model: modelName }),
);
}
if (codingPlanVersion) {
writeStdoutLine(
t(' Config Version: {{version}}', {
version: codingPlanVersion.substring(0, 8) + '...',
}),
);
}
writeStdoutLine(t(' Status: API key configured\n'));
} else {
writeStdoutLine(
t(
'⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)',
),
);
writeStdoutLine(
t(' Issue: API key not found in environment or settings\n'),
);
writeStdoutLine(t(' Run `qwen auth coding-plan` to re-configure.\n'));
}
} else {
writeStdoutLine(
t('✓ Authentication Method: {{type}}', { type: selectedType }),
);
writeStdoutLine(t(' Status: Configured\n'));
}
process.exit(0);
} catch (error) {
writeStderrLine(
t('Failed to check authentication status: {{error}}', {
error: getErrorMessage(error),
}),
);
process.exit(1);
}
}

View file

@ -0,0 +1,421 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { InteractiveSelector } from './interactiveSelector.js';
import { stdin, stdout } from 'node:process';
describe('InteractiveSelector', () => {
const mockOptions = [
{ value: 'option1', label: 'Option 1', description: 'First option' },
{ value: 'option2', label: 'Option 2', description: 'Second option' },
{ value: 'option3', label: 'Option 3', description: 'Third option' },
];
const mockPrompt = 'Select an option:';
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('constructor', () => {
it('should create an instance with default prompt', () => {
const selector = new InteractiveSelector(mockOptions);
expect(selector).toBeInstanceOf(InteractiveSelector);
});
it('should create an instance with custom prompt', () => {
const selector = new InteractiveSelector(mockOptions, mockPrompt);
expect(selector).toBeInstanceOf(InteractiveSelector);
});
});
describe('select', () => {
it('should reject if raw mode is not available', async () => {
// Mock stdin without setRawMode
const originalSetRawMode = stdin.setRawMode;
(stdin as any).setRawMode = undefined;
const selector = new InteractiveSelector(mockOptions, mockPrompt);
await expect(selector.select()).rejects.toThrow(
'Raw mode not available. Please run in an interactive terminal.',
);
// Restore
(stdin as any).setRawMode = originalSetRawMode;
});
it('should select first option with Enter key', async () => {
const mockSetRawMode = vi.fn();
const mockResume = vi.fn();
const mockSetEncoding = vi.fn();
const mockRemoveListener = vi.fn();
const mockOn = vi.fn((event: any, callback: any) => {
// Simulate Enter key press
setTimeout(() => callback('\r'), 0);
return stdin;
});
(stdin as any).isRaw = false;
(stdin as any).setRawMode = mockSetRawMode;
(stdin as any).resume = mockResume;
(stdin as any).setEncoding = mockSetEncoding;
(stdin as any).removeListener = mockRemoveListener;
(stdin as any).on = mockOn;
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(mockOptions, mockPrompt);
const result = await selector.select();
expect(result).toBe('option1');
expect(mockSetRawMode).toHaveBeenCalledWith(true);
expect(mockResume).toHaveBeenCalled();
stdoutWriteSpy.mockRestore();
});
it('should select second option after arrow down then Enter', async () => {
let dataCallback!: (chunk: string) => void;
const mockSetRawMode = vi.fn();
const mockResume = vi.fn();
const mockOn = vi.fn((event: any, callback: any) => {
dataCallback = callback;
return stdin;
});
const mockRemoveListener = vi.fn();
(stdin as any).isRaw = false;
(stdin as any).setRawMode = mockSetRawMode;
(stdin as any).resume = mockResume;
(stdin as any).on = mockOn;
(stdin as any).removeListener = mockRemoveListener;
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(mockOptions, mockPrompt);
const selectPromise = selector.select();
// Simulate arrow down
dataCallback('\x1B[B');
// Simulate Enter
setTimeout(() => dataCallback('\r'), 0);
const result = await selectPromise;
expect(result).toBe('option2');
stdoutWriteSpy.mockRestore();
});
it('should handle arrow up navigation', async () => {
let dataCallback!: (chunk: string) => void;
const mockSetRawMode = vi.fn();
const mockResume = vi.fn();
const mockOn = vi.fn((event: any, callback: any) => {
dataCallback = callback;
return stdin;
});
const mockRemoveListener = vi.fn();
(stdin as any).isRaw = false;
(stdin as any).setRawMode = mockSetRawMode;
(stdin as any).resume = mockResume;
(stdin as any).on = mockOn;
(stdin as any).removeListener = mockRemoveListener;
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(mockOptions, mockPrompt);
const selectPromise = selector.select();
// Move down twice
dataCallback('\x1B[B');
dataCallback('\x1B[B');
// Move up once
dataCallback('\x1B[A');
// Simulate Enter
setTimeout(() => dataCallback('\r'), 0);
const result = await selectPromise;
expect(result).toBe('option2');
stdoutWriteSpy.mockRestore();
});
it('should reject with Ctrl+C', async () => {
let dataCallback!: (chunk: string) => void;
const mockSetRawMode = vi.fn();
const mockResume = vi.fn();
const mockOn = vi.fn((event: any, callback: any) => {
dataCallback = callback;
return stdin;
});
const mockRemoveListener = vi.fn();
(stdin as any).isRaw = false;
(stdin as any).setRawMode = mockSetRawMode;
(stdin as any).resume = mockResume;
(stdin as any).on = mockOn;
(stdin as any).removeListener = mockRemoveListener;
const selector = new InteractiveSelector(mockOptions, mockPrompt);
const selectPromise = selector.select();
// Simulate Ctrl+C
setTimeout(() => dataCallback('\x03'), 0);
await expect(selectPromise).rejects.toThrow('Interrupted');
});
it('should wrap around when navigating past last option', async () => {
let dataCallback!: (chunk: string) => void;
const mockSetRawMode = vi.fn();
const mockResume = vi.fn();
const mockOn = vi.fn((event: any, callback: any) => {
dataCallback = callback;
return stdin;
});
const mockRemoveListener = vi.fn();
(stdin as any).isRaw = false;
(stdin as any).setRawMode = mockSetRawMode;
(stdin as any).resume = mockResume;
(stdin as any).on = mockOn;
(stdin as any).removeListener = mockRemoveListener;
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(mockOptions, mockPrompt);
const selectPromise = selector.select();
// Move down past last option (should wrap to first)
dataCallback('\x1B[B');
dataCallback('\x1B[B');
dataCallback('\x1B[B'); // Now at option1 again (wrapped)
// Simulate Enter
setTimeout(() => dataCallback('\r'), 0);
const result = await selectPromise;
expect(result).toBe('option1');
stdoutWriteSpy.mockRestore();
});
it('should wrap around when navigating before first option', async () => {
let dataCallback!: (chunk: string) => void;
const mockSetRawMode = vi.fn();
const mockResume = vi.fn();
const mockOn = vi.fn((event: any, callback: any) => {
dataCallback = callback;
return stdin;
});
const mockRemoveListener = vi.fn();
(stdin as any).isRaw = false;
(stdin as any).setRawMode = mockSetRawMode;
(stdin as any).resume = mockResume;
(stdin as any).on = mockOn;
(stdin as any).removeListener = mockRemoveListener;
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(mockOptions, mockPrompt);
const selectPromise = selector.select();
// Move up from first option (should wrap to last)
dataCallback('\x1B[A');
// Simulate Enter
setTimeout(() => dataCallback('\r'), 0);
const result = await selectPromise;
expect(result).toBe('option3');
stdoutWriteSpy.mockRestore();
});
it('should ignore arrow left/right keys', async () => {
let dataCallback!: (chunk: string) => void;
const mockSetRawMode = vi.fn();
const mockResume = vi.fn();
const mockOn = vi.fn((event: any, callback: any) => {
dataCallback = callback;
return stdin;
});
const mockRemoveListener = vi.fn();
(stdin as any).isRaw = false;
(stdin as any).setRawMode = mockSetRawMode;
(stdin as any).resume = mockResume;
(stdin as any).on = mockOn;
(stdin as any).removeListener = mockRemoveListener;
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(mockOptions, mockPrompt);
const selectPromise = selector.select();
// Press arrow right (should be ignored)
dataCallback('\x1B[C');
// Press arrow left (should be ignored)
dataCallback('\x1B[D');
// Press Enter - should still select first option
setTimeout(() => dataCallback('\r'), 0);
const result = await selectPromise;
expect(result).toBe('option1');
stdoutWriteSpy.mockRestore();
});
it('should handle newline character as Enter', async () => {
let dataCallback!: (chunk: string) => void;
const mockSetRawMode = vi.fn();
const mockResume = vi.fn();
const mockOn = vi.fn((event: any, callback: any) => {
dataCallback = callback;
return stdin;
});
const mockRemoveListener = vi.fn();
(stdin as any).isRaw = false;
(stdin as any).setRawMode = mockSetRawMode;
(stdin as any).resume = mockResume;
(stdin as any).on = mockOn;
(stdin as any).removeListener = mockRemoveListener;
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(mockOptions, mockPrompt);
const selectPromise = selector.select();
// Simulate newline
setTimeout(() => dataCallback('\n'), 0);
const result = await selectPromise;
expect(result).toBe('option1');
stdoutWriteSpy.mockRestore();
});
});
describe('renderMenu', () => {
it('should render menu with correct formatting', () => {
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(mockOptions, mockPrompt);
// Access private method for testing
(selector as any).renderMenu();
expect(stdoutWriteSpy).toHaveBeenCalled();
const output = stdoutWriteSpy.mock.calls.map((call) => call[0]).join('');
expect(output).toContain('Select an option:');
expect(output).toContain('Option 1');
expect(output).toContain('Option 2');
expect(output).toContain('Option 3');
expect(output).toContain('First option');
expect(output).toContain('Second option');
expect(output).toContain('Third option');
expect(output).toContain('↑ ↓');
expect(output).toContain('Enter');
expect(output).toContain('Ctrl+C');
stdoutWriteSpy.mockRestore();
});
it('should highlight selected option', () => {
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(mockOptions, mockPrompt);
(selector as any).selectedIndex = 1;
(selector as any).renderMenu();
const output = stdoutWriteSpy.mock.calls.map((call) => call[0]).join('');
// Selected option should have cyan color code
expect(output).toContain('\x1B[36m');
stdoutWriteSpy.mockRestore();
});
it('should calculate correct total lines', () => {
const selector = new InteractiveSelector(mockOptions, mockPrompt);
// Access private method for testing
(selector as any).calculateTotalLines();
// Expected: 4 (prompt + empty + empty + instructions) + 3 (options) = 7
expect((selector as any).calculateTotalLines()).toBe(7);
});
it('should handle options without descriptions', () => {
const simpleOptions = [
{ value: 'a', label: 'A' },
{ value: 'b', label: 'B' },
];
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(simpleOptions, mockPrompt);
(selector as any).renderMenu();
const output = stdoutWriteSpy.mock.calls.map((call) => call[0]).join('');
expect(output).toContain('A');
expect(output).toContain('B');
stdoutWriteSpy.mockRestore();
});
});
});

View file

@ -0,0 +1,166 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { stdin, stdout } from 'node:process';
import { t } from '../../i18n/index.js';
/**
* Represents an option in the interactive selector
*/
interface Option<T> {
value: T;
label: string;
description?: string;
}
/**
* Interactive selector that allows users to navigate with arrow keys
*/
export class InteractiveSelector<T> {
private selectedIndex = 0;
private isListening = false;
constructor(
private options: Array<Option<T>>,
private prompt: string = t('Select an option:'),
) {}
/**
* Shows the interactive menu and waits for user selection
*/
async select(): Promise<T> {
return new Promise((resolve, reject) => {
this.isListening = true;
// Display initial menu
this.renderMenu();
// Check if stdin supports raw mode
if (!stdin.setRawMode) {
// Fallback to readline if raw mode is not available (e.g., when piped)
reject(
new Error(
t('Raw mode not available. Please run in an interactive terminal.'),
),
);
return;
}
const wasRaw = stdin.isRaw;
stdin.setRawMode(true);
stdin.resume();
stdin.setEncoding('utf8');
const onData = (chunk: string) => {
if (!this.isListening) return;
for (const char of chunk) {
switch (char) {
case '\x03': // Ctrl+C
stdin.removeListener('data', onData);
stdin.setRawMode(wasRaw);
reject(new Error('Interrupted'));
return;
case '\r': // Enter
case '\n': // Newline
stdin.removeListener('data', onData);
stdin.setRawMode(wasRaw);
resolve(this.options[this.selectedIndex].value);
return;
case '\x1B': // ESC sequence
// Next character will be [, then A, B, C, or D
break;
default:
// Handle other characters if needed
break;
}
}
// Handle escape sequences
if (chunk.startsWith('\x1B')) {
if (chunk === '\x1B[A') {
// Arrow up
this.moveUp();
} else if (chunk === '\x1B[B') {
// Arrow down
this.moveDown();
} else if (chunk === '\x1B[C') {
// Arrow right
// Do nothing for now
} else if (chunk === '\x1B[D') {
// Arrow left
// Do nothing for now
}
}
};
stdin.on('data', onData);
});
}
/**
* Renders the menu to stdout
*/
private renderMenu(): void {
// Calculate how many lines we need to clear
const totalLines = this.calculateTotalLines();
// Clear the screen area we'll be using
if (totalLines > 0) {
stdout.write(`\x1B[${totalLines}A\x1B[J`); // Move up and clear from cursor down
}
// Write the prompt
stdout.write(`${this.prompt}\n\n`);
// Write each option - combine label and description on same line
this.options.forEach((option, index) => {
const isSelected = index === this.selectedIndex;
const indicator = isSelected ? '> ' : ' ';
const color = isSelected ? '\x1B[36m' : '\x1B[0m'; // Cyan for selected, default for others
const reset = '\x1B[0m';
// Combine label and description in one line
let line = `${indicator}${color}${option.label}`;
if (option.description) {
line += ` - ${option.description}`;
}
line += `${reset}\n`;
stdout.write(line);
});
// Add instructions
stdout.write(
`\n${t('(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n')}`,
);
}
/**
* Calculates the total number of lines to clear
*/
private calculateTotalLines(): number {
// Lines for: prompt (1) + empty line (1) + options (each option takes 1 line) + empty line (1) + instructions (1)
return 4 + this.options.length;
}
/**
* Moves selection up
*/
private moveUp(): void {
this.selectedIndex =
(this.selectedIndex - 1 + this.options.length) % this.options.length;
this.renderMenu();
}
/**
* Moves selection down
*/
private moveDown(): void {
this.selectedIndex = (this.selectedIndex + 1) % this.options.length;
this.renderMenu();
}
}

View file

@ -0,0 +1,266 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { showAuthStatus } from './handler.js';
import { AuthType } from '@qwen-code/qwen-code-core';
import { CODING_PLAN_ENV_KEY } from '../../constants/codingPlan.js';
import type { LoadedSettings } from '../../config/settings.js';
vi.mock('../../config/settings.js', () => ({
loadSettings: vi.fn(),
}));
vi.mock('../../utils/stdioHelpers.js', () => ({
writeStdoutLine: vi.fn(),
writeStderrLine: vi.fn(),
}));
import { loadSettings } from '../../config/settings.js';
import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js';
describe('showAuthStatus', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never);
delete process.env[CODING_PLAN_ENV_KEY];
});
afterEach(() => {
vi.restoreAllMocks();
delete process.env[CODING_PLAN_ENV_KEY];
});
const createMockSettings = (
merged: Record<string, unknown>,
): LoadedSettings =>
({
merged,
system: { settings: {}, path: '/system.json' },
systemDefaults: { settings: {}, path: '/system-defaults.json' },
user: { settings: {}, path: '/user.json' },
workspace: { settings: {}, path: '/workspace.json' },
forScope: vi.fn(),
setValue: vi.fn(),
isTrusted: true,
}) as unknown as LoadedSettings;
it('should show message when no authentication is configured', async () => {
vi.mocked(loadSettings).mockReturnValue(createMockSettings({}));
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('No authentication method configured'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('qwen auth qwen-oauth'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('qwen auth coding-plan'),
);
expect(process.exit).toHaveBeenCalledWith(0);
});
it('should show Qwen OAuth status when configured', async () => {
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
security: {
auth: {
selectedType: AuthType.QWEN_OAUTH,
},
},
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('Qwen OAuth'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('Free tier'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('1,000 requests/day'),
);
expect(process.exit).toHaveBeenCalledWith(0);
});
it('should show Coding Plan status when configured with API key', async () => {
process.env[CODING_PLAN_ENV_KEY] = 'test-api-key';
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
security: {
auth: {
selectedType: AuthType.USE_OPENAI,
},
},
codingPlan: {
region: 'china',
version: 'abc123def456',
},
model: {
name: 'qwen3.5-plus',
},
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('Alibaba Cloud Coding Plan'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('API key configured'),
);
expect(process.exit).toHaveBeenCalledWith(0);
});
it('should show Coding Plan as incomplete when API key is missing', async () => {
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
security: {
auth: {
selectedType: AuthType.USE_OPENAI,
},
},
codingPlan: {
region: 'global',
},
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('Incomplete'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('API key not found'),
);
});
it('should show Coding Plan region for china', async () => {
process.env[CODING_PLAN_ENV_KEY] = 'test-api-key';
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
security: {
auth: {
selectedType: AuthType.USE_OPENAI,
},
},
codingPlan: {
region: 'china',
},
model: {
name: 'qwen3.5-plus',
},
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('中国 (China)'),
);
});
it('should show Coding Plan region for global', async () => {
process.env[CODING_PLAN_ENV_KEY] = 'test-api-key';
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
security: {
auth: {
selectedType: AuthType.USE_OPENAI,
},
},
codingPlan: {
region: 'global',
},
model: {
name: 'qwen3-coder-plus',
},
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('Global'),
);
});
it('should show current model name', async () => {
process.env[CODING_PLAN_ENV_KEY] = 'test-api-key';
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
security: {
auth: {
selectedType: AuthType.USE_OPENAI,
},
},
codingPlan: {
region: 'china',
},
model: {
name: 'qwen3.5-plus',
},
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('qwen3.5-plus'),
);
});
it('should show config version (truncated)', async () => {
process.env[CODING_PLAN_ENV_KEY] = 'test-api-key';
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
security: {
auth: {
selectedType: AuthType.USE_OPENAI,
},
},
codingPlan: {
region: 'china',
version: 'abc123def456789',
},
model: {
name: 'qwen3.5-plus',
},
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('abc123de...'),
);
});
it('should handle errors and exit with code 1', async () => {
const error = new Error('Settings load failed');
vi.mocked(loadSettings).mockImplementation(() => {
throw error;
});
await showAuthStatus();
expect(writeStderrLine).toHaveBeenCalledWith(
expect.stringContaining('Failed to check authentication status'),
);
expect(process.exit).toHaveBeenCalledWith(1);
});
});

View file

@ -33,6 +33,7 @@ import {
} from '@qwen-code/qwen-code-core';
import { extensionsCommand } from '../commands/extensions.js';
import { hooksCommand } from '../commands/hooks.js';
import { authCommand } from '../commands/auth.js';
import type { Settings } from './settings.js';
import {
resolveCliGenerationConfig,
@ -51,16 +52,16 @@ import { appEvents } from '../utils/events.js';
import { mcpCommand } from '../commands/mcp.js';
// UUID v4 regex pattern for validation
const UUID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const SESSION_ID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}(-agent-[a-zA-Z0-9_.-]+)?$/i;
/**
* Validates if a string is a valid UUID format
* @param value - The string to validate
* @returns True if the string is a valid UUID, false otherwise
* Validates if a string is a valid session ID format.
* Accepts a standard UUID, or a UUID followed by `-agent-{suffix}`
* (used by Arena to give each agent a deterministic session ID).
*/
function isValidUUID(value: string): boolean {
return UUID_REGEX.test(value);
function isValidSessionId(value: string): boolean {
return SESSION_ID_REGEX.test(value);
}
import { isWorkspaceTrusted } from './trustedFolders.js';
@ -568,10 +569,13 @@ export async function parseArguments(): Promise<CliArgs> {
if (argv['sessionId'] && (argv['continue'] || argv['resume'])) {
return 'Cannot use --session-id with --continue or --resume. Use --session-id to start a new session with a specific ID, or use --continue/--resume to resume an existing session.';
}
if (argv['sessionId'] && !isValidUUID(argv['sessionId'] as string)) {
if (
argv['sessionId'] &&
!isValidSessionId(argv['sessionId'] as string)
) {
return `Invalid --session-id: "${argv['sessionId']}". Must be a valid UUID (e.g., "123e4567-e89b-12d3-a456-426614174000").`;
}
if (argv['resume'] && !isValidUUID(argv['resume'] as string)) {
if (argv['resume'] && !isValidSessionId(argv['resume'] as string)) {
return `Invalid --resume: "${argv['resume']}". Must be a valid UUID (e.g., "123e4567-e89b-12d3-a456-426614174000").`;
}
return true;
@ -581,6 +585,8 @@ export async function parseArguments(): Promise<CliArgs> {
.command(mcpCommand)
// Register Extension subcommands
.command(extensionsCommand)
// Register Auth subcommands
.command(authCommand)
// Register Hooks subcommands
.command(hooksCommand);
@ -1058,6 +1064,18 @@ export async function loadCliConfig(
lsp: {
enabled: lspEnabled,
},
agents: settings.agents
? {
displayMode: settings.agents.displayMode,
arena: settings.agents.arena
? {
worktreeBaseDir: settings.agents.arena.worktreeBaseDir,
preserveArtifacts:
settings.agents.arena.preserveArtifacts ?? false,
}
: undefined,
}
: undefined,
});
if (lspEnabled) {

View file

@ -1244,6 +1244,104 @@ const SETTINGS_SCHEMA = {
description: 'Configuration for web search providers.',
showInDialog: false,
},
agents: {
type: 'object',
label: 'Agents',
category: 'Advanced',
requiresRestart: false,
default: {},
description:
'Settings for multi-agent collaboration features (Arena, Team, Swarm).',
showInDialog: false,
properties: {
displayMode: {
type: 'enum',
label: 'Display Mode',
category: 'Advanced',
requiresRestart: false,
default: undefined as string | undefined,
description:
'Display mode for multi-agent sessions. Currently only "in-process" is supported.',
showInDialog: false,
options: [
{ value: 'in-process', label: 'In-process' },
// { value: 'tmux', label: 'tmux' },
// { value: 'iterm2', label: 'iTerm2' },
],
},
arena: {
type: 'object',
label: 'Arena',
category: 'Advanced',
requiresRestart: false,
default: {},
description: 'Settings for Arena (multi-model competitive execution).',
showInDialog: false,
properties: {
worktreeBaseDir: {
type: 'string',
label: 'Worktree Base Directory',
category: 'Advanced',
requiresRestart: true,
default: undefined as string | undefined,
description:
'Custom base directory for Arena worktrees. Defaults to ~/.qwen/arena.',
showInDialog: false,
},
preserveArtifacts: {
type: 'boolean',
label: 'Preserve Arena Artifacts',
category: 'Advanced',
requiresRestart: false,
default: false,
description:
'When enabled, Arena worktrees and session state files are preserved after the session ends or the main agent exits.',
showInDialog: true,
},
maxRoundsPerAgent: {
type: 'number',
label: 'Max Rounds Per Agent',
category: 'Advanced',
requiresRestart: false,
default: undefined as number | undefined,
description:
'Maximum number of rounds (turns) each agent can execute. No limit if unset.',
showInDialog: false,
},
timeoutSeconds: {
type: 'number',
label: 'Timeout (seconds)',
category: 'Advanced',
requiresRestart: false,
default: undefined as number | undefined,
description:
'Total timeout in seconds for the Arena session. No limit if unset.',
showInDialog: false,
},
},
},
team: {
type: 'object',
label: 'Team',
category: 'Advanced',
requiresRestart: false,
default: {},
description:
'Settings for Agent Team (role-based collaborative execution). Reserved for future use.',
showInDialog: false,
},
swarm: {
type: 'object',
label: 'Swarm',
category: 'Advanced',
requiresRestart: false,
default: {},
description:
'Settings for Agent Swarm (parallel sub-agent execution). Reserved for future use.',
showInDialog: false,
},
},
},
hooksConfig: {
type: 'object',
@ -1418,6 +1516,17 @@ const SETTINGS_SCHEMA = {
},
},
},
experimental: {
type: 'object',
label: 'Experimental',
category: 'Experimental',
requiresRestart: true,
default: {},
description: 'Setting to enable experimental features',
showInDialog: false,
properties: {},
},
} as const satisfies SettingsSchema;
export type SettingsSchemaType = typeof SETTINGS_SCHEMA;

View file

@ -97,7 +97,7 @@ export function generateCodingPlanTemplate(
extra_body: {
enable_thinking: true,
},
contextWindowSize: 1000000,
contextWindowSize: 196608,
},
},
{
@ -222,7 +222,7 @@ export function generateCodingPlanTemplate(
extra_body: {
enable_thinking: true,
},
contextWindowSize: 1000000,
contextWindowSize: 196608,
},
},
{

View file

@ -35,6 +35,7 @@ import { KeypressProvider } from './ui/contexts/KeypressContext.js';
import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
import { SettingsContext } from './ui/contexts/SettingsContext.js';
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
import { AgentViewProvider } from './ui/contexts/AgentViewContext.js';
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
import { themeManager } from './ui/themes/theme-manager.js';
import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.js';
@ -162,13 +163,15 @@ export async function startInteractiveUI(
>
<SessionStatsProvider sessionId={config.getSessionId()}>
<VimModeProvider settings={settings}>
<AppContainer
config={config}
settings={settings}
startupWarnings={startupWarnings}
version={version}
initializationResult={initializationResult}
/>
<AgentViewProvider config={config}>
<AppContainer
config={config}
settings={settings}
startupWarnings={startupWarnings}
version={version}
initializationResult={initializationResult}
/>
</AgentViewProvider>
</VimModeProvider>
</SessionStatsProvider>
</KeypressProvider>

View file

@ -1620,6 +1620,36 @@ export default {
'Neue Modellkonfigurationen sind für {{region}} verfügbar. Jetzt aktualisieren?',
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
'{{region}}-Konfiguration erfolgreich aktualisiert. Modell auf "{{model}}" umgeschaltet.',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
'Erfolgreich mit {{region}} authentifiziert. API-Schlüssel und Modellkonfigurationen wurden in settings.json gespeichert (gesichert).',
// ============================================================================
// Context Usage Component
// ============================================================================
'Context Usage': 'Kontextnutzung',
'No API response yet. Send a message to see actual usage.':
'Noch keine API-Antwort. Senden Sie eine Nachricht, um die tatsächliche Nutzung anzuzeigen.',
'Estimated pre-conversation overhead':
'Geschätzte Vorabkosten vor der Unterhaltung',
'Context window': 'Kontextfenster',
tokens: 'Tokens',
Used: 'Verwendet',
Free: 'Frei',
'Autocompact buffer': 'Autokomprimierungs-Puffer',
'Usage by category': 'Verwendung nach Kategorie',
'System prompt': 'System-Prompt',
'Built-in tools': 'Integrierte Tools',
'MCP tools': 'MCP-Tools',
'Memory files': 'Speicherdateien',
Skills: 'Fähigkeiten',
Messages: 'Nachrichten',
'Show context window usage breakdown.':
'Zeigt die Aufschlüsselung der Kontextfenster-Nutzung an.',
'Run /context detail for per-item breakdown.':
'Führen Sie /context detail für eine Aufschlüsselung nach Elementen aus.',
active: 'aktiv',
'body loaded': 'Inhalt geladen',
memory: 'Speicher',
'{{region}} configuration updated successfully.':
'{{region}}-Konfiguration erfolgreich aktualisiert.',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.':
@ -1655,4 +1685,80 @@ export default {
'↑/↓: Navigieren | Space/Enter: Umschalten | Esc: Abbrechen',
'↑/↓: Navigate | Enter: Select | Esc: Cancel':
'↑/↓: Navigieren | Enter: Auswählen | Esc: Abbrechen',
// ============================================================================
// Commands - Auth
// ============================================================================
'Configure Qwen authentication information with Qwen-OAuth or Alibaba Cloud Coding Plan':
'Qwen-Authentifizierung mit Qwen-OAuth oder Alibaba Cloud Coding Plan konfigurieren',
'Authenticate using Qwen OAuth': 'Mit Qwen OAuth authentifizieren',
'Authenticate using Alibaba Cloud Coding Plan':
'Mit Alibaba Cloud Coding Plan authentifizieren',
'Region for Coding Plan (china/global)':
'Region für Coding Plan (china/global)',
'API key for Coding Plan': 'API-Schlüssel für Coding Plan',
'Show current authentication status':
'Aktuellen Authentifizierungsstatus anzeigen',
'Authentication completed successfully.':
'Authentifizierung erfolgreich abgeschlossen.',
'Starting Qwen OAuth authentication...':
'Qwen OAuth-Authentifizierung wird gestartet...',
'Successfully authenticated with Qwen OAuth.':
'Erfolgreich mit Qwen OAuth authentifiziert.',
'Failed to authenticate with Qwen OAuth: {{error}}':
'Authentifizierung mit Qwen OAuth fehlgeschlagen: {{error}}',
'Processing Alibaba Cloud Coding Plan authentication...':
'Alibaba Cloud Coding Plan-Authentifizierung wird verarbeitet...',
'Successfully authenticated with Alibaba Cloud Coding Plan.':
'Erfolgreich mit Alibaba Cloud Coding Plan authentifiziert.',
'Failed to authenticate with Coding Plan: {{error}}':
'Authentifizierung mit Coding Plan fehlgeschlagen: {{error}}',
'中国 (China)': '中国 (China)',
'阿里云百炼 (aliyun.com)': '阿里云百炼 (aliyun.com)',
Global: 'Global',
'Alibaba Cloud (alibabacloud.com)': 'Alibaba Cloud (alibabacloud.com)',
'Select region for Coding Plan:': 'Region für Coding Plan auswählen:',
'Enter your Coding Plan API key: ':
'Geben Sie Ihren Coding Plan API-Schlüssel ein: ',
'Select authentication method:': 'Authentifizierungsmethode auswählen:',
'\n=== Authentication Status ===\n': '\n=== Authentifizierungsstatus ===\n',
'⚠️ No authentication method configured.\n':
'⚠️ Keine Authentifizierungsmethode konfiguriert.\n',
'Run one of the following commands to get started:\n':
'Führen Sie einen der folgenden Befehle aus, um zu beginnen:\n',
' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)':
' qwen auth qwen-oauth - Mit Qwen OAuth authentifizieren (kostenlos)',
' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n':
' qwen auth coding-plan - Mit Alibaba Cloud Coding Plan authentifizieren\n',
'Or simply run:': 'Oder einfach ausführen:',
' qwen auth - Interactive authentication setup\n':
' qwen auth - Interaktive Authentifizierungseinrichtung\n',
'✓ Authentication Method: Qwen OAuth':
'✓ Authentifizierungsmethode: Qwen OAuth',
' Type: Free tier': ' Typ: Kostenlos',
' Limit: Up to 1,000 requests/day': ' Limit: Bis zu 1.000 Anfragen/Tag',
' Models: Qwen latest models\n': ' Modelle: Qwen neueste Modelle\n',
'✓ Authentication Method: Alibaba Cloud Coding Plan':
'✓ Authentifizierungsmethode: Alibaba Cloud Coding Plan',
'中国 (China) - 阿里云百炼': '中国 (China) - 阿里云百炼',
'Global - Alibaba Cloud': 'Global - Alibaba Cloud',
' Region: {{region}}': ' Region: {{region}}',
' Current Model: {{model}}': ' Aktuelles Modell: {{model}}',
' Config Version: {{version}}': ' Konfigurationsversion: {{version}}',
' Status: API key configured\n': ' Status: API-Schlüssel konfiguriert\n',
'⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)':
'⚠️ Authentifizierungsmethode: Alibaba Cloud Coding Plan (Unvollständig)',
' Issue: API key not found in environment or settings\n':
' Problem: API-Schlüssel nicht in Umgebung oder Einstellungen gefunden\n',
' Run `qwen auth coding-plan` to re-configure.\n':
' Führen Sie `qwen auth coding-plan` aus, um neu zu konfigurieren.\n',
'✓ Authentication Method: {{type}}': '✓ Authentifizierungsmethode: {{type}}',
' Status: Configured\n': ' Status: Konfiguriert\n',
'Failed to check authentication status: {{error}}':
'Authentifizierungsstatus konnte nicht überprüft werden: {{error}}',
'Select an option:': 'Option auswählen:',
'Raw mode not available. Please run in an interactive terminal.':
'Raw-Modus nicht verfügbar. Bitte in einem interaktiven Terminal ausführen.',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(↑ ↓ Pfeiltasten zum Navigieren, Enter zum Auswählen, Strg+C zum Beenden)\n',
};

View file

@ -1672,6 +1672,34 @@ export default {
'New model configurations are available for {{region}}. Update now?',
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
'{{region}} configuration updated successfully. Model switched to "{{model}}".',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).',
// ============================================================================
// Context Usage Component
// ============================================================================
'Context Usage': 'Context Usage',
'No API response yet. Send a message to see actual usage.':
'No API response yet. Send a message to see actual usage.',
'Estimated pre-conversation overhead': 'Estimated pre-conversation overhead',
'Context window': 'Context window',
tokens: 'tokens',
Used: 'Used',
Free: 'Free',
'Autocompact buffer': 'Autocompact buffer',
'Usage by category': 'Usage by category',
'System prompt': 'System prompt',
'Built-in tools': 'Built-in tools',
'MCP tools': 'MCP tools',
'Memory files': 'Memory files',
Skills: 'Skills',
Messages: 'Messages',
'Show context window usage breakdown.':
'Show context window usage breakdown.',
'Run /context detail for per-item breakdown.':
'Run /context detail for per-item breakdown.',
'body loaded': 'body loaded',
memory: 'memory',
'{{region}} configuration updated successfully.':
'{{region}} configuration updated successfully.',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.':
@ -1706,4 +1734,77 @@ export default {
'↑/↓: Navigate | Space/Enter: Toggle | Esc: Cancel',
'↑/↓: Navigate | Enter: Select | Esc: Cancel':
'↑/↓: Navigate | Enter: Select | Esc: Cancel',
// ============================================================================
// Commands - Auth
// ============================================================================
'Configure Qwen authentication information with Qwen-OAuth or Alibaba Cloud Coding Plan':
'Configure Qwen authentication information with Qwen-OAuth or Alibaba Cloud Coding Plan',
'Authenticate using Qwen OAuth': 'Authenticate using Qwen OAuth',
'Authenticate using Alibaba Cloud Coding Plan':
'Authenticate using Alibaba Cloud Coding Plan',
'Region for Coding Plan (china/global)':
'Region for Coding Plan (china/global)',
'API key for Coding Plan': 'API key for Coding Plan',
'Show current authentication status': 'Show current authentication status',
'Authentication completed successfully.':
'Authentication completed successfully.',
'Starting Qwen OAuth authentication...':
'Starting Qwen OAuth authentication...',
'Successfully authenticated with Qwen OAuth.':
'Successfully authenticated with Qwen OAuth.',
'Failed to authenticate with Qwen OAuth: {{error}}':
'Failed to authenticate with Qwen OAuth: {{error}}',
'Processing Alibaba Cloud Coding Plan authentication...':
'Processing Alibaba Cloud Coding Plan authentication...',
'Successfully authenticated with Alibaba Cloud Coding Plan.':
'Successfully authenticated with Alibaba Cloud Coding Plan.',
'Failed to authenticate with Coding Plan: {{error}}':
'Failed to authenticate with Coding Plan: {{error}}',
'中国 (China)': '中国 (China)',
'阿里云百炼 (aliyun.com)': '阿里云百炼 (aliyun.com)',
Global: 'Global',
'Alibaba Cloud (alibabacloud.com)': 'Alibaba Cloud (alibabacloud.com)',
'Select region for Coding Plan:': 'Select region for Coding Plan:',
'Enter your Coding Plan API key: ': 'Enter your Coding Plan API key: ',
'Select authentication method:': 'Select authentication method:',
'\n=== Authentication Status ===\n': '\n=== Authentication Status ===\n',
'⚠️ No authentication method configured.\n':
'⚠️ No authentication method configured.\n',
'Run one of the following commands to get started:\n':
'Run one of the following commands to get started:\n',
' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)':
' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)',
' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n':
' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n',
'Or simply run:': 'Or simply run:',
' qwen auth - Interactive authentication setup\n':
' qwen auth - Interactive authentication setup\n',
'✓ Authentication Method: Qwen OAuth': '✓ Authentication Method: Qwen OAuth',
' Type: Free tier': ' Type: Free tier',
' Limit: Up to 1,000 requests/day': ' Limit: Up to 1,000 requests/day',
' Models: Qwen latest models\n': ' Models: Qwen latest models\n',
'✓ Authentication Method: Alibaba Cloud Coding Plan':
'✓ Authentication Method: Alibaba Cloud Coding Plan',
'中国 (China) - 阿里云百炼': '中国 (China) - 阿里云百炼',
'Global - Alibaba Cloud': 'Global - Alibaba Cloud',
' Region: {{region}}': ' Region: {{region}}',
' Current Model: {{model}}': ' Current Model: {{model}}',
' Config Version: {{version}}': ' Config Version: {{version}}',
' Status: API key configured\n': ' Status: API key configured\n',
'⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)':
'⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)',
' Issue: API key not found in environment or settings\n':
' Issue: API key not found in environment or settings\n',
' Run `qwen auth coding-plan` to re-configure.\n':
' Run `qwen auth coding-plan` to re-configure.\n',
'✓ Authentication Method: {{type}}': '✓ Authentication Method: {{type}}',
' Status: Configured\n': ' Status: Configured\n',
'Failed to check authentication status: {{error}}':
'Failed to check authentication status: {{error}}',
'Select an option:': 'Select an option:',
'Raw mode not available. Please run in an interactive terminal.':
'Raw mode not available. Please run in an interactive terminal.',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n',
};

View file

@ -1126,6 +1126,35 @@ export default {
'{{region}} の新しいモデル設定が利用可能です。今すぐ更新しますか?',
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
'{{region}} の設定が正常に更新されました。モデルが "{{model}}" に切り替わりました。',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
'{{region}} での認証に成功しました。API キーとモデル設定が settings.json に保存されました(バックアップ済み)。',
// ============================================================================
// Context Usage Component
// ============================================================================
'Context Usage': 'コンテキスト使用量',
'No API response yet. Send a message to see actual usage.':
'API応答はありません。メッセージを送信して実際の使用量を確認してください。',
'Estimated pre-conversation overhead': '推定事前会話オーバーヘッド',
'Context window': 'コンテキストウィンドウ',
tokens: 'トークン',
Used: '使用済み',
Free: '空き',
'Autocompact buffer': '自動圧縮バッファ',
'Usage by category': 'カテゴリ別の使用量',
'System prompt': 'システムプロンプト',
'Built-in tools': '組み込みツール',
'MCP tools': 'MCPツール',
'Memory files': 'メモリファイル',
Skills: 'スキル',
Messages: 'メッセージ',
'Show context window usage breakdown.':
'コンテキストウィンドウの使用状況を表示します。',
'Run /context detail for per-item breakdown.':
'/context detail を実行すると項目ごとの内訳を表示します。',
active: '有効',
'body loaded': '本文読み込み済み',
memory: 'メモリ',
'{{region}} configuration updated successfully.':
'{{region}} の設定が正常に更新されました。',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.':
@ -1159,4 +1188,76 @@ export default {
'↑/↓: ナビゲート | Space/Enter: 切り替え | Esc: キャンセル',
'↑/↓: Navigate | Enter: Select | Esc: Cancel':
'↑/↓: ナビゲート | Enter: 選択 | Esc: キャンセル',
// ============================================================================
// Commands - Auth
// ============================================================================
'Configure Qwen authentication information with Qwen-OAuth or Alibaba Cloud Coding Plan':
'Qwen-OAuth または Alibaba Cloud Coding Plan で Qwen 認証情報を設定する',
'Authenticate using Qwen OAuth': 'Qwen OAuth で認証する',
'Authenticate using Alibaba Cloud Coding Plan':
'Alibaba Cloud Coding Plan で認証する',
'Region for Coding Plan (china/global)':
'Coding Plan のリージョン (china/global)',
'API key for Coding Plan': 'Coding Plan の API キー',
'Show current authentication status': '現在の認証ステータスを表示',
'Authentication completed successfully.': '認証が正常に完了しました。',
'Starting Qwen OAuth authentication...': 'Qwen OAuth 認証を開始しています...',
'Successfully authenticated with Qwen OAuth.':
'Qwen OAuth での認証に成功しました。',
'Failed to authenticate with Qwen OAuth: {{error}}':
'Qwen OAuth での認証に失敗しました: {{error}}',
'Processing Alibaba Cloud Coding Plan authentication...':
'Alibaba Cloud Coding Plan 認証を処理しています...',
'Successfully authenticated with Alibaba Cloud Coding Plan.':
'Alibaba Cloud Coding Plan での認証に成功しました。',
'Failed to authenticate with Coding Plan: {{error}}':
'Coding Plan での認証に失敗しました: {{error}}',
'中国 (China)': '中国 (China)',
'阿里云百炼 (aliyun.com)': '阿里云百炼 (aliyun.com)',
Global: 'グローバル',
'Alibaba Cloud (alibabacloud.com)': 'Alibaba Cloud (alibabacloud.com)',
'Select region for Coding Plan:': 'Coding Plan のリージョンを選択:',
'Enter your Coding Plan API key: ':
'Coding Plan の API キーを入力してください: ',
'Select authentication method:': '認証方法を選択:',
'\n=== Authentication Status ===\n': '\n=== 認証ステータス ===\n',
'⚠️ No authentication method configured.\n':
'⚠️ 認証方法が設定されていません。\n',
'Run one of the following commands to get started:\n':
'以下のコマンドのいずれかを実行して開始してください:\n',
' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)':
' qwen auth qwen-oauth - Qwen OAuth で認証(無料)',
' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n':
' qwen auth coding-plan - Alibaba Cloud Coding Plan で認証\n',
'Or simply run:': 'または以下を実行:',
' qwen auth - Interactive authentication setup\n':
' qwen auth - インタラクティブ認証セットアップ\n',
'✓ Authentication Method: Qwen OAuth': '✓ 認証方法: Qwen OAuth',
' Type: Free tier': ' タイプ: 無料プラン',
' Limit: Up to 1,000 requests/day': ' 制限: 1日最大1,000リクエスト',
' Models: Qwen latest models\n': ' モデル: Qwen 最新モデル\n',
'✓ Authentication Method: Alibaba Cloud Coding Plan':
'✓ 認証方法: Alibaba Cloud Coding Plan',
'中国 (China) - 阿里云百炼': '中国 (China) - 阿里云百炼',
'Global - Alibaba Cloud': 'グローバル - Alibaba Cloud',
' Region: {{region}}': ' リージョン: {{region}}',
' Current Model: {{model}}': ' 現在のモデル: {{model}}',
' Config Version: {{version}}': ' 設定バージョン: {{version}}',
' Status: API key configured\n': ' ステータス: APIキー設定済み\n',
'⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)':
'⚠️ 認証方法: Alibaba Cloud Coding Plan不完全',
' Issue: API key not found in environment or settings\n':
' 問題: 環境変数または設定にAPIキーが見つかりません\n',
' Run `qwen auth coding-plan` to re-configure.\n':
' `qwen auth coding-plan` を実行して再設定してください。\n',
'✓ Authentication Method: {{type}}': '✓ 認証方法: {{type}}',
' Status: Configured\n': ' ステータス: 設定済み\n',
'Failed to check authentication status: {{error}}':
'認証ステータスの確認に失敗しました: {{error}}',
'Select an option:': 'オプションを選択:',
'Raw mode not available. Please run in an interactive terminal.':
'Rawモードが利用できません。インタラクティブターミナルで実行してください。',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(↑ ↓ 矢印キーで移動、Enter で選択、Ctrl+C で終了)\n',
};

View file

@ -1615,6 +1615,35 @@ export default {
'Novas configurações de modelo estão disponíveis para o {{region}}. Atualizar agora?',
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
'Configuração do {{region}} atualizada com sucesso. Modelo alterado para "{{model}}".',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
'Autenticado com sucesso com {{region}}. Chave de API e configurações de modelo salvas em settings.json (com backup).',
// ============================================================================
// Context Usage Component
// ============================================================================
'Context Usage': 'Uso do Contexto',
'No API response yet. Send a message to see actual usage.':
'Ainda não há resposta da API. Envie uma mensagem para ver o uso real.',
'Estimated pre-conversation overhead': 'Sobrecarga estimada pré-conversa',
'Context window': 'Janela de Contexto',
tokens: 'tokens',
Used: 'Usado',
Free: 'Livre',
'Autocompact buffer': 'Buffer de autocompactação',
'Usage by category': 'Uso por categoria',
'System prompt': 'Prompt do sistema',
'Built-in tools': 'Ferramentas integradas',
'MCP tools': 'Ferramentas MCP',
'Memory files': 'Arquivos de memória',
Skills: 'Habilidades',
Messages: 'Mensagens',
'Show context window usage breakdown.':
'Exibe a divisão de uso da janela de contexto.',
'Run /context detail for per-item breakdown.':
'Execute /context detail para detalhamento por item.',
active: 'ativo',
'body loaded': 'conteúdo carregado',
memory: 'memória',
'{{region}} configuration updated successfully.':
'Configuração do {{region}} atualizada com sucesso.',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.':
@ -1650,4 +1679,78 @@ export default {
'↑/↓: Navegar | Space/Enter: Alternar | Esc: Cancelar',
'↑/↓: Navigate | Enter: Select | Esc: Cancel':
'↑/↓: Navegar | Enter: Selecionar | Esc: Cancelar',
// ============================================================================
// Commands - Auth
// ============================================================================
'Configure Qwen authentication information with Qwen-OAuth or Alibaba Cloud Coding Plan':
'Configurar autenticação Qwen com Qwen-OAuth ou Alibaba Cloud Coding Plan',
'Authenticate using Qwen OAuth': 'Autenticar usando Qwen OAuth',
'Authenticate using Alibaba Cloud Coding Plan':
'Autenticar usando Alibaba Cloud Coding Plan',
'Region for Coding Plan (china/global)':
'Região para Coding Plan (china/global)',
'API key for Coding Plan': 'Chave de API para Coding Plan',
'Show current authentication status': 'Mostrar status atual de autenticação',
'Authentication completed successfully.':
'Autenticação concluída com sucesso.',
'Starting Qwen OAuth authentication...':
'Iniciando autenticação Qwen OAuth...',
'Successfully authenticated with Qwen OAuth.':
'Autenticado com sucesso via Qwen OAuth.',
'Failed to authenticate with Qwen OAuth: {{error}}':
'Falha ao autenticar com Qwen OAuth: {{error}}',
'Processing Alibaba Cloud Coding Plan authentication...':
'Processando autenticação Alibaba Cloud Coding Plan...',
'Successfully authenticated with Alibaba Cloud Coding Plan.':
'Autenticado com sucesso via Alibaba Cloud Coding Plan.',
'Failed to authenticate with Coding Plan: {{error}}':
'Falha ao autenticar com Coding Plan: {{error}}',
'中国 (China)': '中国 (China)',
'阿里云百炼 (aliyun.com)': '阿里云百炼 (aliyun.com)',
Global: 'Global',
'Alibaba Cloud (alibabacloud.com)': 'Alibaba Cloud (alibabacloud.com)',
'Select region for Coding Plan:': 'Selecione a região para Coding Plan:',
'Enter your Coding Plan API key: ':
'Insira sua chave de API do Coding Plan: ',
'Select authentication method:': 'Selecione o método de autenticação:',
'\n=== Authentication Status ===\n': '\n=== Status de Autenticação ===\n',
'⚠️ No authentication method configured.\n':
'⚠️ Nenhum método de autenticação configurado.\n',
'Run one of the following commands to get started:\n':
'Execute um dos seguintes comandos para começar:\n',
' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)':
' qwen auth qwen-oauth - Autenticar com Qwen OAuth (gratuito)',
' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n':
' qwen auth coding-plan - Autenticar com Alibaba Cloud Coding Plan\n',
'Or simply run:': 'Ou simplesmente execute:',
' qwen auth - Interactive authentication setup\n':
' qwen auth - Configuração interativa de autenticação\n',
'✓ Authentication Method: Qwen OAuth': '✓ Método de autenticação: Qwen OAuth',
' Type: Free tier': ' Tipo: Gratuito',
' Limit: Up to 1,000 requests/day': ' Limite: Até 1.000 solicitações/dia',
' Models: Qwen latest models\n': ' Modelos: Modelos Qwen mais recentes\n',
'✓ Authentication Method: Alibaba Cloud Coding Plan':
'✓ Método de autenticação: Alibaba Cloud Coding Plan',
'中国 (China) - 阿里云百炼': '中国 (China) - 阿里云百炼',
'Global - Alibaba Cloud': 'Global - Alibaba Cloud',
' Region: {{region}}': ' Região: {{region}}',
' Current Model: {{model}}': ' Modelo atual: {{model}}',
' Config Version: {{version}}': ' Versão da configuração: {{version}}',
' Status: API key configured\n': ' Status: Chave de API configurada\n',
'⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)':
'⚠️ Método de autenticação: Alibaba Cloud Coding Plan (Incompleto)',
' Issue: API key not found in environment or settings\n':
' Problema: Chave de API não encontrada no ambiente ou configurações\n',
' Run `qwen auth coding-plan` to re-configure.\n':
' Execute `qwen auth coding-plan` para reconfigurar.\n',
'✓ Authentication Method: {{type}}': '✓ Método de autenticação: {{type}}',
' Status: Configured\n': ' Status: Configurado\n',
'Failed to check authentication status: {{error}}':
'Falha ao verificar status de autenticação: {{error}}',
'Select an option:': 'Selecione uma opção:',
'Raw mode not available. Please run in an interactive terminal.':
'Modo raw não disponível. Execute em um terminal interativo.',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(Use ↑ ↓ para navegar, Enter para selecionar, Ctrl+C para sair)\n',
};

View file

@ -1553,6 +1553,32 @@ export default {
'Успешная аутентификация с {{region}}. API-ключ и конфигурации моделей сохранены в settings.json (резервная копия создана).',
// ============================================================================
// Context Usage Component
// ============================================================================
'Context Usage': 'Использование контекста',
'No API response yet. Send a message to see actual usage.':
'Пока нет ответа от API. Отправьте сообщение, чтобы увидеть фактическое использование.',
'Estimated pre-conversation overhead':
'Оценочные накладные расходы перед беседой',
'Context window': 'Контекстное окно',
tokens: 'токенов',
Used: 'Использовано',
Free: 'Свободно',
'Autocompact buffer': 'Буфер автоупаковки',
'Usage by category': 'Использование по категориям',
'System prompt': 'Системная подсказка',
'Built-in tools': 'Встроенные инструменты',
'MCP tools': 'Инструменты MCP',
'Memory files': 'Файлы памяти',
Skills: 'Навыки',
Messages: 'Сообщения',
'Show context window usage breakdown.':
'Показать разбивку использования контекстного окна.',
'Run /context detail for per-item breakdown.':
'Выполните /context detail для детализации по элементам.',
active: 'активно',
'body loaded': 'содержимое загружено',
memory: 'память',
// MCP Management Dialog
// ============================================================================
'MCP Management': 'Управление MCP',
@ -1662,4 +1688,77 @@ export default {
'↑/↓: Навигация | Space/Enter: Переключить | Esc: Отмена',
'↑/↓: Navigate | Enter: Select | Esc: Cancel':
'↑/↓: Навигация | Enter: Выбор | Esc: Отмена',
// ============================================================================
// Commands - Auth
// ============================================================================
'Configure Qwen authentication information with Qwen-OAuth or Alibaba Cloud Coding Plan':
'Настроить аутентификацию Qwen через Qwen-OAuth или Alibaba Cloud Coding Plan',
'Authenticate using Qwen OAuth': 'Аутентификация через Qwen OAuth',
'Authenticate using Alibaba Cloud Coding Plan':
'Аутентификация через Alibaba Cloud Coding Plan',
'Region for Coding Plan (china/global)':
'Регион для Coding Plan (china/global)',
'API key for Coding Plan': 'API-ключ для Coding Plan',
'Show current authentication status':
'Показать текущий статус аутентификации',
'Authentication completed successfully.': 'Аутентификация успешно завершена.',
'Starting Qwen OAuth authentication...':
'Запуск аутентификации Qwen OAuth...',
'Successfully authenticated with Qwen OAuth.':
'Успешная аутентификация через Qwen OAuth.',
'Failed to authenticate with Qwen OAuth: {{error}}':
'Ошибка аутентификации через Qwen OAuth: {{error}}',
'Processing Alibaba Cloud Coding Plan authentication...':
'Обработка аутентификации Alibaba Cloud Coding Plan...',
'Successfully authenticated with Alibaba Cloud Coding Plan.':
'Успешная аутентификация через Alibaba Cloud Coding Plan.',
'Failed to authenticate with Coding Plan: {{error}}':
'Ошибка аутентификации через Coding Plan: {{error}}',
'中国 (China)': '中国 (China)',
'阿里云百炼 (aliyun.com)': '阿里云百炼 (aliyun.com)',
Global: 'Глобальный',
'Alibaba Cloud (alibabacloud.com)': 'Alibaba Cloud (alibabacloud.com)',
'Select region for Coding Plan:': 'Выберите регион для Coding Plan:',
'Enter your Coding Plan API key: ': 'Введите ваш API-ключ Coding Plan: ',
'Select authentication method:': 'Выберите метод аутентификации:',
'\n=== Authentication Status ===\n': '\n=== Статус аутентификации ===\n',
'⚠️ No authentication method configured.\n':
'⚠️ Метод аутентификации не настроен.\n',
'Run one of the following commands to get started:\n':
'Выполните одну из следующих команд для начала:\n',
' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)':
' qwen auth qwen-oauth - Аутентификация через Qwen OAuth (бесплатно)',
' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n':
' qwen auth coding-plan - Аутентификация через Alibaba Cloud Coding Plan\n',
'Or simply run:': 'Или просто выполните:',
' qwen auth - Interactive authentication setup\n':
' qwen auth - Интерактивная настройка аутентификации\n',
'✓ Authentication Method: Qwen OAuth': '✓ Метод аутентификации: Qwen OAuth',
' Type: Free tier': ' Тип: Бесплатный',
' Limit: Up to 1,000 requests/day': ' Лимит: До 1 000 запросов/день',
' Models: Qwen latest models\n': ' Модели: Последние модели Qwen\n',
'✓ Authentication Method: Alibaba Cloud Coding Plan':
'✓ Метод аутентификации: Alibaba Cloud Coding Plan',
'中国 (China) - 阿里云百炼': '中国 (China) - 阿里云百炼',
'Global - Alibaba Cloud': 'Глобальный - Alibaba Cloud',
' Region: {{region}}': ' Регион: {{region}}',
' Current Model: {{model}}': ' Текущая модель: {{model}}',
' Config Version: {{version}}': ' Версия конфигурации: {{version}}',
' Status: API key configured\n': ' Статус: API-ключ настроен\n',
'⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)':
'⚠️ Метод аутентификации: Alibaba Cloud Coding Plan (Не завершён)',
' Issue: API key not found in environment or settings\n':
' Проблема: API-ключ не найден в окружении или настройках\n',
' Run `qwen auth coding-plan` to re-configure.\n':
' Выполните `qwen auth coding-plan` для повторной настройки.\n',
'✓ Authentication Method: {{type}}': '✓ Метод аутентификации: {{type}}',
' Status: Configured\n': ' Статус: Настроено\n',
'Failed to check authentication status: {{error}}':
'Не удалось проверить статус аутентификации: {{error}}',
'Select an option:': 'Выберите вариант:',
'Raw mode not available. Please run in an interactive terminal.':
'Raw-режим недоступен. Пожалуйста, запустите в интерактивном терминале.',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(↑ ↓ стрелки для навигации, Enter для выбора, Ctrl+C для выхода)\n',
};

View file

@ -1496,6 +1496,33 @@ export default {
'{{region}} 有新的模型配置可用。是否立即更新?',
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
'{{region}} 配置更新成功。模型已切换至 "{{model}}"。',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
'成功通过 {{region}} 认证。API Key 和模型配置已保存至 settings.json已备份。',
// ============================================================================
// Context Usage
// ============================================================================
'Context Usage': '上下文使用情况',
'Context window': '上下文窗口',
Used: '已用',
Free: '空闲',
'Autocompact buffer': '自动压缩缓冲区',
'Usage by category': '分类用量',
'System prompt': '系统提示',
'Built-in tools': '内置工具',
'MCP tools': 'MCP 工具',
'Memory files': '记忆文件',
Skills: '技能',
Messages: '消息',
tokens: 'tokens',
'Estimated pre-conversation overhead': '预估对话前开销',
'No API response yet. Send a message to see actual usage.':
'暂无 API 响应。发送消息以查看实际使用情况。',
'Show context window usage breakdown.': '显示上下文窗口使用情况分解。',
'Run /context detail for per-item breakdown.':
'运行 /context detail 查看详细分解。',
'body loaded': '内容已加载',
memory: '记忆',
'{{region}} configuration updated successfully.': '{{region}} 配置更新成功。',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.':
'成功通过 {{region}} 认证。API Key 和模型配置已保存至 settings.json。',
@ -1526,4 +1553,72 @@ export default {
'↑/↓: 导航 | Space/Enter: 切换 | Esc: 取消',
'↑/↓: Navigate | Enter: Select | Esc: Cancel':
'↑/↓: 导航 | Enter: 选择 | Esc: 取消',
// ============================================================================
// Commands - Auth
// ============================================================================
'Configure Qwen authentication information with Qwen-OAuth or Alibaba Cloud Coding Plan':
'使用 Qwen OAuth 或阿里云百炼 Coding Plan 配置 Qwen 认证信息',
'Authenticate using Qwen OAuth': '使用 Qwen OAuth 进行认证',
'Authenticate using Alibaba Cloud Coding Plan':
'使用阿里云百炼 Coding Plan 进行认证',
'Region for Coding Plan (china/global)': 'Coding Plan 区域 (china/global)',
'API key for Coding Plan': 'Coding Plan 的 API 密钥',
'Show current authentication status': '显示当前认证状态',
'Authentication completed successfully.': '认证完成。',
'Starting Qwen OAuth authentication...': '正在启动 Qwen OAuth 认证...',
'Successfully authenticated with Qwen OAuth.': '已成功通过 Qwen OAuth 认证。',
'Failed to authenticate with Qwen OAuth: {{error}}':
'Qwen OAuth 认证失败:{{error}}',
'Processing Alibaba Cloud Coding Plan authentication...':
'正在处理阿里云百炼 Coding Plan 认证...',
'Successfully authenticated with Alibaba Cloud Coding Plan.':
'已成功通过阿里云百炼 Coding Plan 认证。',
'Failed to authenticate with Coding Plan: {{error}}':
'Coding Plan 认证失败:{{error}}',
'中国 (China)': '中国 (China)',
'阿里云百炼 (aliyun.com)': '阿里云百炼 (aliyun.com)',
Global: '全球',
'Alibaba Cloud (alibabacloud.com)': 'Alibaba Cloud (alibabacloud.com)',
'Select region for Coding Plan:': '选择 Coding Plan 区域:',
'Enter your Coding Plan API key: ': '请输入您的 Coding Plan API 密钥:',
'Select authentication method:': '选择认证方式:',
'\n=== Authentication Status ===\n': '\n=== 认证状态 ===\n',
'⚠️ No authentication method configured.\n': '⚠️ 未配置认证方式。\n',
'Run one of the following commands to get started:\n':
'运行以下命令之一开始配置:\n',
' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)':
' qwen auth qwen-oauth - 使用 Qwen OAuth 认证(免费)',
' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n':
' qwen auth coding-plan - 使用阿里云百炼 Coding Plan 认证\n',
'Or simply run:': '或者直接运行:',
' qwen auth - Interactive authentication setup\n':
' qwen auth - 交互式认证配置\n',
'✓ Authentication Method: Qwen OAuth': '✓ 认证方式Qwen OAuth',
' Type: Free tier': ' 类型:免费版',
' Limit: Up to 1,000 requests/day': ' 限额:每天最多 1,000 次请求',
' Models: Qwen latest models\n': ' 模型Qwen 最新模型\n',
'✓ Authentication Method: Alibaba Cloud Coding Plan':
'✓ 认证方式:阿里云百炼 Coding Plan',
'中国 (China) - 阿里云百炼': '中国 (China) - 阿里云百炼',
'Global - Alibaba Cloud': '全球 - Alibaba Cloud',
' Region: {{region}}': ' 区域:{{region}}',
' Current Model: {{model}}': ' 当前模型:{{model}}',
' Config Version: {{version}}': ' 配置版本:{{version}}',
' Status: API key configured\n': ' 状态API 密钥已配置\n',
'⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)':
'⚠️ 认证方式:阿里云百炼 Coding Plan不完整',
' Issue: API key not found in environment or settings\n':
' 问题:在环境变量或设置中未找到 API 密钥\n',
' Run `qwen auth coding-plan` to re-configure.\n':
' 运行 `qwen auth coding-plan` 重新配置。\n',
'✓ Authentication Method: {{type}}': '✓ 认证方式:{{type}}',
' Status: Configured\n': ' 状态:已配置\n',
'Failed to check authentication status: {{error}}':
'检查认证状态失败:{{error}}',
'Select an option:': '请选择:',
'Raw mode not available. Please run in an interactive terminal.':
'原始模式不可用。请在交互式终端中运行。',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(使用 ↑ ↓ 箭头导航Enter 选择Ctrl+C 退出)\n',
};

View file

@ -282,12 +282,12 @@ export abstract class BaseJsonOutputAdapter {
return;
}
if (lastBlock.type === 'text') {
const index = state.blocks.length - 1;
this.onBlockClosed(state, index, actualParentToolUseId);
this.closeBlock(state, index);
} else if (lastBlock.type === 'thinking') {
const index = state.blocks.length - 1;
const index = state.blocks.length - 1;
if (!state.openBlocks.has(index)) {
return;
}
if (lastBlock.type === 'text' || lastBlock.type === 'thinking') {
this.onBlockClosed(state, index, actualParentToolUseId);
this.closeBlock(state, index);
}
@ -392,7 +392,9 @@ export abstract class BaseJsonOutputAdapter {
}
const message = this.buildMessage(parentToolUseId);
this.emitMessageImpl(message);
if (state.messageStarted) {
this.emitMessageImpl(message);
}
return message;
}
@ -656,12 +658,7 @@ export abstract class BaseJsonOutputAdapter {
parentToolUseId: string,
): CLIAssistantMessage {
const state = this.getMessageState(parentToolUseId);
const message = this.finalizeAssistantMessageInternal(
state,
parentToolUseId,
);
this.updateLastAssistantMessage(message);
return message;
return this.finalizeAssistantMessageInternal(state, parentToolUseId);
}
/**

View file

@ -52,12 +52,10 @@ export class JsonOutputAdapter
}
finalizeAssistantMessage(): CLIAssistantMessage {
const message = this.finalizeAssistantMessageInternal(
return this.finalizeAssistantMessageInternal(
this.mainAgentMessageState,
null,
);
this.updateLastAssistantMessage(message);
return message;
}
emitResult(options: ResultOptions): void {

View file

@ -654,6 +654,24 @@ describe('StreamJsonOutputAdapter', () => {
'Message not started',
);
});
it('should not emit empty assistant message when started but no content processed', () => {
stdoutWriteSpy.mockClear();
adapter.finalizeAssistantMessage();
const assistantCalls = stdoutWriteSpy.mock.calls.filter(
(call: unknown[]) => {
try {
const parsed = JSON.parse(call[0] as string);
return parsed.type === 'assistant';
} catch {
return false;
}
},
);
expect(assistantCalls).toHaveLength(0);
});
});
describe('emitResult', () => {
@ -1007,56 +1025,68 @@ describe('StreamJsonOutputAdapter', () => {
});
});
describe('message_id in stream events', () => {
describe('content_block event identification', () => {
beforeEach(() => {
adapter = new StreamJsonOutputAdapter(mockConfig, true);
adapter.startAssistantMessage();
});
it('should include message_id in stream events after message starts', () => {
it('should not include message_id in content_block events', () => {
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Text',
});
// Process another event to ensure messageStarted is true
adapter.processEvent({
type: GeminiEventType.Content,
value: 'More',
});
const calls = stdoutWriteSpy.mock.calls;
// Find all delta events
const deltaCalls = calls.filter((call: unknown[]) => {
const contentBlockCalls = calls.filter((call: unknown[]) => {
try {
const parsed = JSON.parse(call[0] as string);
return (
parsed.type === 'stream_event' &&
parsed.event.type === 'content_block_delta'
(parsed.event.type === 'content_block_start' ||
parsed.event.type === 'content_block_delta' ||
parsed.event.type === 'content_block_stop')
);
} catch {
return false;
}
});
expect(deltaCalls.length).toBeGreaterThan(0);
// The second delta event should have message_id (after messageStarted becomes true)
// message_id is added to the event object, so check parsed.event.message_id
if (deltaCalls.length > 1) {
const secondDelta = JSON.parse(
(deltaCalls[1] as unknown[])[0] as string,
);
// message_id is on the enriched event object
expect(
secondDelta.event.message_id || secondDelta.message_id,
).toBeTruthy();
} else {
// If only one delta, check if message_id exists
const delta = JSON.parse((deltaCalls[0] as unknown[])[0] as string);
// message_id is added when messageStarted is true
// First event may or may not have it, but subsequent ones should
expect(delta.event.message_id || delta.message_id).toBeTruthy();
expect(contentBlockCalls.length).toBeGreaterThan(0);
for (const call of contentBlockCalls) {
const parsed = JSON.parse((call as unknown[])[0] as string);
expect(parsed.event.message_id).toBeUndefined();
}
});
it('should identify content_block events by session_id and index', () => {
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Text',
});
const calls = stdoutWriteSpy.mock.calls;
const blockStartCall = calls.find((call: unknown[]) => {
try {
const parsed = JSON.parse(call[0] as string);
return (
parsed.type === 'stream_event' &&
parsed.event.type === 'content_block_start'
);
} catch {
return false;
}
});
expect(blockStartCall).toBeDefined();
const parsed = JSON.parse((blockStartCall as unknown[])[0] as string);
expect(parsed.session_id).toBe('test-session-id');
expect(typeof parsed.event.index).toBe('number');
});
});
describe('multiple text blocks', () => {

View file

@ -36,6 +36,8 @@ export class StreamJsonOutputAdapter
extends BaseJsonOutputAdapter
implements JsonOutputAdapterInterface
{
private mainTurnMessageStartEmitted = false;
constructor(
config: Config,
private readonly includePartialMessages: boolean,
@ -68,29 +70,27 @@ export class StreamJsonOutputAdapter
return this.includePartialMessages;
}
override startAssistantMessage(): void {
this.mainTurnMessageStartEmitted = false;
super.startAssistantMessage();
}
finalizeAssistantMessage(): CLIAssistantMessage {
const state = this.mainAgentMessageState;
if (state.finalized) {
return this.buildMessage(null);
}
state.finalized = true;
this.finalizePendingBlocks(state, null);
const orderedOpenBlocks = Array.from(state.openBlocks).sort(
(a, b) => a - b,
const message = this.finalizeAssistantMessageInternal(
this.mainAgentMessageState,
null,
);
for (const index of orderedOpenBlocks) {
this.onBlockClosed(state, index, null);
this.closeBlock(state, index);
if (this.mainTurnMessageStartEmitted && this.includePartialMessages) {
const partial: CLIPartialAssistantMessage = {
type: 'stream_event',
uuid: randomUUID(),
session_id: this.getSessionId(),
parent_tool_use_id: null,
event: { type: 'message_stop' },
};
this.emitMessageImpl(partial);
}
if (state.messageStarted && this.includePartialMessages) {
this.emitStreamEventIfEnabled({ type: 'message_stop' }, null);
}
const message = this.buildMessage(null);
this.updateLastAssistantMessage(message);
this.emitMessageImpl(message);
this.mainTurnMessageStartEmitted = false;
return message;
}
@ -249,14 +249,15 @@ export class StreamJsonOutputAdapter
/**
* Overrides base class hook to emit message_start event when message is started.
* Only emits for main agent, not for subagents.
* Only emits once per turn for the main agent (guarded by mainTurnMessageStartEmitted),
* so block-type transitions inside a single turn do not produce spurious message_start events.
*/
protected override onEnsureMessageStarted(
state: MessageState,
parentToolUseId: string | null,
): void {
// Only emit message_start for main agent, not for subagents
if (parentToolUseId === null) {
if (parentToolUseId === null && !this.mainTurnMessageStartEmitted) {
this.mainTurnMessageStartEmitted = true;
this.emitStreamEventIfEnabled(
{
type: 'message_start',
@ -264,6 +265,7 @@ export class StreamJsonOutputAdapter
id: state.messageId!,
role: 'assistant',
model: this.config.getModel(),
content: [],
},
},
null,
@ -311,19 +313,12 @@ export class StreamJsonOutputAdapter
return;
}
const state = this.getMessageState(parentToolUseId);
const enrichedEvent = state.messageStarted
? ({ ...event, message_id: state.messageId } as StreamEvent & {
message_id: string;
})
: event;
const partial: CLIPartialAssistantMessage = {
type: 'stream_event',
uuid: randomUUID(),
session_id: this.getSessionId(),
parent_tool_use_id: parentToolUseId,
event: enrichedEvent,
event,
};
this.emitMessageImpl(partial);
}

View file

@ -201,6 +201,7 @@ export interface MessageStartStreamEvent {
id: string;
role: 'assistant';
model: string;
content: [];
};
}

View file

@ -390,6 +390,16 @@ export async function runNonInteractive(
}
}
} catch (error) {
// Ensure message_start / message_stop (and content_block events) are
// properly paired even when an error aborts the turn mid-stream.
// The call is safe when no message was started (throws → caught) or
// when already finalized (idempotent guard inside the adapter).
try {
adapter.finalizeAssistantMessage();
} catch {
// Expected when no message was started or already finalized
}
// For JSON and STREAM_JSON modes, compute usage from metrics
const message = error instanceof Error ? error.message : String(error);
const metrics = uiTelemetryService.getMetrics();

View file

@ -9,11 +9,13 @@ import type { SlashCommand } from '../ui/commands/types.js';
import type { Config } from '@qwen-code/qwen-code-core';
import { aboutCommand } from '../ui/commands/aboutCommand.js';
import { agentsCommand } from '../ui/commands/agentsCommand.js';
import { arenaCommand } from '../ui/commands/arenaCommand.js';
import { approvalModeCommand } from '../ui/commands/approvalModeCommand.js';
import { authCommand } from '../ui/commands/authCommand.js';
import { bugCommand } from '../ui/commands/bugCommand.js';
import { clearCommand } from '../ui/commands/clearCommand.js';
import { compressCommand } from '../ui/commands/compressCommand.js';
import { contextCommand } from '../ui/commands/contextCommand.js';
import { copyCommand } from '../ui/commands/copyCommand.js';
import { docsCommand } from '../ui/commands/docsCommand.js';
import { directoryCommand } from '../ui/commands/directoryCommand.js';
@ -61,11 +63,13 @@ export class BuiltinCommandLoader implements ICommandLoader {
const allDefinitions: Array<SlashCommand | null> = [
aboutCommand,
agentsCommand,
arenaCommand,
approvalModeCommand,
authCommand,
bugCommand,
clearCommand,
compressCommand,
contextCommand,
copyCommand,
docsCommand,
directoryCommand,

View file

@ -109,10 +109,9 @@ export class ShellProcessor implements IPromptProcessor {
return { ...injection, resolvedCommand: undefined };
}
const resolvedCommand = command.replaceAll(
SHORTHAND_ARGS_PLACEHOLDER,
userArgsEscaped,
);
const resolvedCommand = command
.replaceAll(SHORTHAND_ARGS_PLACEHOLDER, userArgsEscaped) // Replace {{args}}
.replaceAll('$ARGUMENTS', userArgsEscaped); // Replace $ARGUMENTS
return { ...injection, resolvedCommand };
},
);

View file

@ -9,6 +9,11 @@ import { render } from 'ink-testing-library';
import { Text, useIsScreenReaderEnabled } from 'ink';
import { App } from './App.js';
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
import {
UIActionsContext,
type UIActions,
} from './contexts/UIActionsContext.js';
import { AgentViewProvider } from './contexts/AgentViewContext.js';
import { StreamingState } from './types.js';
vi.mock('ink', async (importOriginal) => {
@ -43,6 +48,10 @@ vi.mock('./components/Footer.js', () => ({
Footer: () => <Text>Footer</Text>,
}));
vi.mock('./components/agent-view/AgentTabBar.js', () => ({
AgentTabBar: () => null,
}));
describe('App', () => {
const mockUIState: Partial<UIState> = {
streamingState: StreamingState.Idle,
@ -58,13 +67,24 @@ describe('App', () => {
},
};
it('should render main content and composer when not quitting', () => {
const { lastFrame } = render(
<UIStateContext.Provider value={mockUIState as UIState}>
<App />
</UIStateContext.Provider>,
const mockUIActions = {
refreshStatic: vi.fn(),
} as unknown as UIActions;
const renderWithProviders = (uiState: UIState) =>
render(
<UIActionsContext.Provider value={mockUIActions}>
<AgentViewProvider>
<UIStateContext.Provider value={uiState}>
<App />
</UIStateContext.Provider>
</AgentViewProvider>
</UIActionsContext.Provider>,
);
it('should render main content and composer when not quitting', () => {
const { lastFrame } = renderWithProviders(mockUIState as UIState);
expect(lastFrame()).toContain('MainContent');
expect(lastFrame()).toContain('Composer');
});
@ -75,11 +95,7 @@ describe('App', () => {
quittingMessages: [{ id: 1, type: 'user', text: 'test' }],
} as UIState;
const { lastFrame } = render(
<UIStateContext.Provider value={quittingUIState}>
<App />
</UIStateContext.Provider>,
);
const { lastFrame } = renderWithProviders(quittingUIState);
expect(lastFrame()).toContain('Quitting...');
});
@ -90,11 +106,7 @@ describe('App', () => {
dialogsVisible: true,
} as UIState;
const { lastFrame } = render(
<UIStateContext.Provider value={dialogUIState}>
<App />
</UIStateContext.Provider>,
);
const { lastFrame } = renderWithProviders(dialogUIState);
expect(lastFrame()).toContain('MainContent');
expect(lastFrame()).toContain('DialogManager');
@ -107,11 +119,7 @@ describe('App', () => {
ctrlCPressedOnce: true,
} as UIState;
const { lastFrame } = render(
<UIStateContext.Provider value={ctrlCUIState}>
<App />
</UIStateContext.Provider>,
);
const { lastFrame } = renderWithProviders(ctrlCUIState);
expect(lastFrame()).toContain('Press Ctrl+C again to exit.');
});
@ -123,11 +131,7 @@ describe('App', () => {
ctrlDPressedOnce: true,
} as UIState;
const { lastFrame } = render(
<UIStateContext.Provider value={ctrlDUIState}>
<App />
</UIStateContext.Provider>,
);
const { lastFrame } = renderWithProviders(ctrlDUIState);
expect(lastFrame()).toContain('Press Ctrl+D again to exit.');
});
@ -135,11 +139,7 @@ describe('App', () => {
it('should render ScreenReaderAppLayout when screen reader is enabled', () => {
(useIsScreenReaderEnabled as vi.Mock).mockReturnValue(true);
const { lastFrame } = render(
<UIStateContext.Provider value={mockUIState as UIState}>
<App />
</UIStateContext.Provider>,
);
const { lastFrame } = renderWithProviders(mockUIState as UIState);
expect(lastFrame()).toContain(
'Notifications\nFooter\nMainContent\nComposer',
@ -149,11 +149,7 @@ describe('App', () => {
it('should render DefaultAppLayout when screen reader is not enabled', () => {
(useIsScreenReaderEnabled as vi.Mock).mockReturnValue(false);
const { lastFrame } = render(
<UIStateContext.Provider value={mockUIState as UIState}>
<App />
</UIStateContext.Provider>,
);
const { lastFrame } = renderWithProviders(mockUIState as UIState);
expect(lastFrame()).toContain('MainContent\nComposer');
});

View file

@ -78,6 +78,21 @@ vi.mock('./hooks/useAutoAcceptIndicator.js');
vi.mock('./hooks/useGitBranchName.js');
vi.mock('./contexts/VimModeContext.js');
vi.mock('./contexts/SessionContext.js');
vi.mock('./contexts/AgentViewContext.js', () => ({
useAgentViewState: vi.fn(() => ({
activeView: 'main',
agents: new Map(),
})),
useAgentViewActions: vi.fn(() => ({
switchToMain: vi.fn(),
switchToAgent: vi.fn(),
switchToNext: vi.fn(),
switchToPrevious: vi.fn(),
registerAgent: vi.fn(),
unregisterAgent: vi.fn(),
unregisterAll: vi.fn(),
})),
}));
vi.mock('./components/shared/text-buffer.js');
vi.mock('./hooks/useLogger.js');
@ -268,7 +283,7 @@ describe('AppContainer State Management', () => {
listSubagents: vi.fn().mockResolvedValue([]),
addChangeListener: vi.fn(),
loadSubagent: vi.fn(),
createSubagentScope: vi.fn(),
createSubagent: vi.fn(),
};
vi.spyOn(mockConfig, 'getSubagentManager').mockReturnValue(
mockSubagentManager as SubagentManager,

View file

@ -54,6 +54,7 @@ import { useAuthCommand } from './auth/useAuth.js';
import { useEditorSettings } from './hooks/useEditorSettings.js';
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
import { useModelCommand } from './hooks/useModelCommand.js';
import { useArenaCommand } from './hooks/useArenaCommand.js';
import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js';
import { useResumeCommand } from './hooks/useResumeCommand.js';
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
@ -98,6 +99,7 @@ import {
} from './hooks/useExtensionUpdates.js';
import { useCodingPlanUpdates } from './hooks/useCodingPlanUpdates.js';
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
import { useAgentViewState } from './contexts/AgentViewContext.js';
import { t } from '../i18n/index.js';
import { useWelcomeBack } from './hooks/useWelcomeBack.js';
import { useDialogClose } from './hooks/useDialogClose.js';
@ -507,6 +509,8 @@ export const AppContainer = (props: AppContainerProps) => {
const { isModelDialogOpen, openModelDialog, closeModelDialog } =
useModelCommand();
const { activeArenaDialog, openArenaDialog, closeArenaDialog } =
useArenaCommand();
const {
isResumeDialogOpen,
@ -546,6 +550,7 @@ export const AppContainer = (props: AppContainerProps) => {
openEditorDialog,
openSettingsDialog,
openModelDialog,
openArenaDialog,
openPermissionsDialog,
openApprovalModeDialog,
quit: (messages: HistoryItem[]) => {
@ -570,6 +575,7 @@ export const AppContainer = (props: AppContainerProps) => {
openEditorDialog,
openSettingsDialog,
openModelDialog,
openArenaDialog,
setDebugMessage,
dispatchExtensionStateUpdate,
openPermissionsDialog,
@ -706,12 +712,15 @@ export const AppContainer = (props: AppContainerProps) => {
// Track whether suggestions are visible for Tab key handling
const [hasSuggestionsVisible, setHasSuggestionsVisible] = useState(false);
// Auto-accept indicator
const agentViewState = useAgentViewState();
// Auto-accept indicator — disabled on agent tabs (agents handle their own)
const showAutoAcceptIndicator = useAutoAcceptIndicator({
config,
addItem: historyManager.addItem,
onApprovalModeChange: handleApprovalModeChange,
shouldBlockTab: () => hasSuggestionsVisible,
disabled: agentViewState.activeView !== 'main',
});
const { messageQueue, addMessage, clearQueue, getQueuedMessagesText } =
@ -724,9 +733,26 @@ export const AppContainer = (props: AppContainerProps) => {
// Callback for handling final submit (must be after addMessage from useMessageQueue)
const handleFinalSubmit = useCallback(
(submittedValue: string) => {
// Route to active in-process agent if viewing a sub-agent tab.
if (agentViewState.activeView !== 'main') {
const agent = agentViewState.agents.get(agentViewState.activeView);
if (agent) {
agent.interactiveAgent.enqueueMessage(submittedValue.trim());
return;
}
}
addMessage(submittedValue);
},
[addMessage],
[addMessage, agentViewState],
);
const handleArenaModelsSelected = useCallback(
(models: string[]) => {
const value = models.join(',');
buffer.setText(`/arena start --models ${value} `);
closeArenaDialog();
},
[buffer, closeArenaDialog],
);
// Welcome back functionality (must be after handleFinalSubmit)
@ -802,10 +828,17 @@ export const AppContainer = (props: AppContainerProps) => {
}
}, [buffer, terminalWidth, terminalHeight]);
// Compute available terminal height based on controls measurement
// agentViewState is declared earlier (before handleFinalSubmit) so it
// is available for input routing. Referenced here for layout computation.
// Compute available terminal height based on controls measurement.
// When in-process agents are present the AgentTabBar renders an extra
// row at the top of the layout; subtract it so downstream consumers
// (shell, transcript, etc.) don't overestimate available space.
const tabBarHeight = agentViewState.agents.size > 0 ? 1 : 0;
const availableTerminalHeight = Math.max(
0,
terminalHeight - controlsHeight - staticExtraHeight - 2,
terminalHeight - controlsHeight - staticExtraHeight - 2 - tabBarHeight,
);
config.setShellExecutionConfig({
@ -1059,10 +1092,16 @@ export const AppContainer = (props: AppContainerProps) => {
[historyManager, setShowCommandMigrationNudge, config.storage],
);
const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator(
streamingState,
settings.merged.ui?.customWittyPhrases,
);
const currentCandidatesTokens = Object.values(
sessionStats.metrics?.models ?? {},
).reduce((acc, model) => acc + (model.tokens?.candidates ?? 0), 0);
const { elapsedTime, currentLoadingPhrase, taskStartTokens } =
useLoadingIndicator(
streamingState,
settings.merged.ui?.customWittyPhrases,
currentCandidatesTokens,
);
useAttentionNotifications({
isFocused,
@ -1085,6 +1124,8 @@ export const AppContainer = (props: AppContainerProps) => {
exitEditorDialog,
isSettingsDialogOpen,
closeSettingsDialog,
activeArenaDialog,
closeArenaDialog,
isFolderTrustDialogOpen,
showWelcomeBackDialog,
handleWelcomeBackClose,
@ -1342,6 +1383,7 @@ export const AppContainer = (props: AppContainerProps) => {
isThemeDialogOpen ||
isSettingsDialogOpen ||
isModelDialogOpen ||
activeArenaDialog !== null ||
isPermissionsDialogOpen ||
isAuthDialogOpen ||
isAuthenticating ||
@ -1392,6 +1434,7 @@ export const AppContainer = (props: AppContainerProps) => {
quittingMessages,
isSettingsDialogOpen,
isModelDialogOpen,
activeArenaDialog,
isPermissionsDialogOpen,
isApprovalModeDialogOpen,
isResumeDialogOpen,
@ -1468,6 +1511,8 @@ export const AppContainer = (props: AppContainerProps) => {
isMcpDialogOpen,
// Feedback dialog
isFeedbackDialogOpen,
// Per-task token tracking
taskStartTokens,
}),
[
isThemeDialogOpen,
@ -1485,6 +1530,7 @@ export const AppContainer = (props: AppContainerProps) => {
quittingMessages,
isSettingsDialogOpen,
isModelDialogOpen,
activeArenaDialog,
isPermissionsDialogOpen,
isApprovalModeDialogOpen,
isResumeDialogOpen,
@ -1562,6 +1608,8 @@ export const AppContainer = (props: AppContainerProps) => {
isMcpDialogOpen,
// Feedback dialog
isFeedbackDialogOpen,
// Per-task token tracking
taskStartTokens,
],
);
@ -1581,6 +1629,9 @@ export const AppContainer = (props: AppContainerProps) => {
exitEditorDialog,
closeSettingsDialog,
closeModelDialog,
openArenaDialog,
closeArenaDialog,
handleArenaModelsSelected,
dismissCodingPlanUpdate,
closePermissionsDialog,
setShellModeActive,
@ -1630,6 +1681,9 @@ export const AppContainer = (props: AppContainerProps) => {
exitEditorDialog,
closeSettingsDialog,
closeModelDialog,
openArenaDialog,
closeArenaDialog,
handleArenaModelsSelected,
dismissCodingPlanUpdate,
closePermissionsDialog,
setShellModeActive,

View file

@ -0,0 +1,395 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
type ArenaManager,
AgentStatus,
ArenaSessionStatus,
} from '@qwen-code/qwen-code-core';
import { arenaCommand } from './arenaCommand.js';
import type {
CommandContext,
OpenDialogActionReturn,
SlashCommand,
} from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
function getArenaSubCommand(
name: 'start' | 'stop' | 'status' | 'select',
): SlashCommand {
const command = arenaCommand.subCommands?.find((item) => item.name === name);
if (!command?.action) {
throw new Error(`Arena subcommand "${name}" is missing an action`);
}
return command;
}
describe('arenaCommand stop subcommand', () => {
let mockContext: CommandContext;
let mockConfig: {
getArenaManager: ReturnType<typeof vi.fn>;
setArenaManager: ReturnType<typeof vi.fn>;
cleanupArenaRuntime: ReturnType<typeof vi.fn>;
getAgentsSettings: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
mockConfig = {
getArenaManager: vi.fn(() => null),
setArenaManager: vi.fn(),
cleanupArenaRuntime: vi.fn().mockResolvedValue(undefined),
getAgentsSettings: vi.fn(() => ({})),
};
mockContext = createMockCommandContext({
invocation: {
raw: '/arena stop',
name: 'arena',
args: 'stop',
},
executionMode: 'interactive',
services: {
config: mockConfig as never,
},
});
});
it('returns an error when no arena session is running', async () => {
const stopCommand = getArenaSubCommand('stop');
const result = await stopCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'No running Arena session found.',
});
});
it('opens stop dialog when a running session exists', async () => {
const mockManager = {
getSessionStatus: vi.fn(() => ArenaSessionStatus.RUNNING),
} as unknown as ArenaManager;
mockConfig.getArenaManager = vi.fn(() => mockManager);
const stopCommand = getArenaSubCommand('stop');
const result = (await stopCommand.action!(
mockContext,
'',
)) as OpenDialogActionReturn;
expect(result).toEqual({
type: 'dialog',
dialog: 'arena_stop',
});
});
it('opens stop dialog when a completed session exists', async () => {
const mockManager = {
getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED),
} as unknown as ArenaManager;
mockConfig.getArenaManager = vi.fn(() => mockManager);
const stopCommand = getArenaSubCommand('stop');
const result = (await stopCommand.action!(
mockContext,
'',
)) as OpenDialogActionReturn;
expect(result).toEqual({
type: 'dialog',
dialog: 'arena_stop',
});
});
});
describe('arenaCommand status subcommand', () => {
let mockContext: CommandContext;
let mockConfig: {
getArenaManager: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
mockConfig = {
getArenaManager: vi.fn(() => null),
};
mockContext = createMockCommandContext({
invocation: {
raw: '/arena status',
name: 'arena',
args: 'status',
},
executionMode: 'interactive',
services: {
config: mockConfig as never,
},
});
});
it('returns an error when no arena session exists', async () => {
const statusCommand = getArenaSubCommand('status');
const result = await statusCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'No Arena session found. Start one with /arena start.',
});
});
it('opens status dialog when a session exists', async () => {
const mockManager = {
getSessionStatus: vi.fn(() => ArenaSessionStatus.RUNNING),
} as unknown as ArenaManager;
mockConfig.getArenaManager = vi.fn(() => mockManager);
const statusCommand = getArenaSubCommand('status');
const result = (await statusCommand.action!(
mockContext,
'',
)) as OpenDialogActionReturn;
expect(result).toEqual({
type: 'dialog',
dialog: 'arena_status',
});
});
it('opens status dialog for completed session', async () => {
const mockManager = {
getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED),
} as unknown as ArenaManager;
mockConfig.getArenaManager = vi.fn(() => mockManager);
const statusCommand = getArenaSubCommand('status');
const result = (await statusCommand.action!(
mockContext,
'',
)) as OpenDialogActionReturn;
expect(result).toEqual({
type: 'dialog',
dialog: 'arena_status',
});
});
});
describe('arenaCommand select subcommand', () => {
let mockContext: CommandContext;
let mockConfig: {
getArenaManager: ReturnType<typeof vi.fn>;
setArenaManager: ReturnType<typeof vi.fn>;
cleanupArenaRuntime: ReturnType<typeof vi.fn>;
getAgentsSettings: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
mockConfig = {
getArenaManager: vi.fn(() => null),
setArenaManager: vi.fn(),
cleanupArenaRuntime: vi.fn().mockResolvedValue(undefined),
getAgentsSettings: vi.fn(() => ({})),
};
mockContext = createMockCommandContext({
invocation: {
raw: '/arena select',
name: 'arena',
args: 'select',
},
executionMode: 'interactive',
services: {
config: mockConfig as never,
},
});
});
it('returns error when no arena session exists', async () => {
const selectCommand = getArenaSubCommand('select');
const result = await selectCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'No arena session found. Start one with /arena start.',
});
});
it('returns error when arena is still running', async () => {
const mockManager = {
getSessionStatus: vi.fn(() => ArenaSessionStatus.RUNNING),
} as unknown as ArenaManager;
mockConfig.getArenaManager = vi.fn(() => mockManager);
const selectCommand = getArenaSubCommand('select');
const result = await selectCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content:
'Arena session is still running. Wait for it to complete or use /arena stop first.',
});
});
it('returns error when all agents failed', async () => {
const mockManager = {
getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED),
getAgentStates: vi.fn(() => [
{
agentId: 'agent-1',
status: AgentStatus.FAILED,
model: { modelId: 'model-1' },
},
]),
} as unknown as ArenaManager;
mockConfig.getArenaManager = vi.fn(() => mockManager);
const selectCommand = getArenaSubCommand('select');
const result = await selectCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content:
'No successful agent results to select from. All agents failed or were cancelled.\n' +
'Use /arena stop to end the session.',
});
});
it('opens dialog when no args provided and agents have results', async () => {
const mockManager = {
getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED),
getAgentStates: vi.fn(() => [
{
agentId: 'agent-1',
status: AgentStatus.COMPLETED,
model: { modelId: 'model-1' },
},
{
agentId: 'agent-2',
status: AgentStatus.COMPLETED,
model: { modelId: 'model-2' },
},
]),
} as unknown as ArenaManager;
mockConfig.getArenaManager = vi.fn(() => mockManager);
const selectCommand = getArenaSubCommand('select');
const result = await selectCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'dialog',
dialog: 'arena_select',
});
});
it('applies changes directly when model name is provided', async () => {
const mockManager = {
getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED),
getAgentStates: vi.fn(() => [
{
agentId: 'agent-1',
status: AgentStatus.COMPLETED,
model: { modelId: 'gpt-4o', displayName: 'gpt-4o' },
},
{
agentId: 'agent-2',
status: AgentStatus.COMPLETED,
model: { modelId: 'claude-sonnet', displayName: 'claude-sonnet' },
},
]),
applyAgentResult: vi.fn().mockResolvedValue({ success: true }),
cleanup: vi.fn().mockResolvedValue(undefined),
} as unknown as ArenaManager;
mockConfig.getArenaManager = vi.fn(() => mockManager);
const selectCommand = getArenaSubCommand('select');
const result = await selectCommand.action!(mockContext, 'gpt-4o');
expect(mockManager.applyAgentResult).toHaveBeenCalledWith('agent-1');
expect(mockConfig.cleanupArenaRuntime).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'info',
content:
'Applied changes from gpt-4o to workspace. Arena session complete.',
});
});
it('returns error when specified model not found', async () => {
const mockManager = {
getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED),
getAgentStates: vi.fn(() => [
{
agentId: 'agent-1',
status: AgentStatus.COMPLETED,
model: { modelId: 'gpt-4o', displayName: 'gpt-4o' },
},
]),
} as unknown as ArenaManager;
mockConfig.getArenaManager = vi.fn(() => mockManager);
const selectCommand = getArenaSubCommand('select');
const result = await selectCommand.action!(mockContext, 'nonexistent');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'No idle agent found matching "nonexistent".',
});
});
it('asks for confirmation when --discard flag is used', async () => {
const mockManager = {
getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED),
getAgentStates: vi.fn(() => [
{
agentId: 'agent-1',
status: AgentStatus.COMPLETED,
model: { modelId: 'gpt-4o' },
},
]),
} as unknown as ArenaManager;
mockConfig.getArenaManager = vi.fn(() => mockManager);
const selectCommand = getArenaSubCommand('select');
const result = await selectCommand.action!(mockContext, '--discard');
expect(result).toEqual({
type: 'confirm_action',
prompt: 'Discard all Arena results and clean up worktrees?',
originalInvocation: { raw: '/arena select' },
});
});
it('discards results after --discard confirmation', async () => {
const mockManager = {
getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED),
getAgentStates: vi.fn(() => [
{
agentId: 'agent-1',
status: AgentStatus.COMPLETED,
model: { modelId: 'gpt-4o' },
},
]),
cleanup: vi.fn().mockResolvedValue(undefined),
} as unknown as ArenaManager;
mockConfig.getArenaManager = vi.fn(() => mockManager);
mockContext.overwriteConfirmed = true;
const selectCommand = getArenaSubCommand('select');
const result = await selectCommand.action!(mockContext, '--discard');
expect(mockConfig.cleanupArenaRuntime).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'Arena results discarded. All worktrees cleaned up.',
});
});
});

View file

@ -0,0 +1,659 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type {
SlashCommand,
CommandContext,
ConfirmActionReturn,
MessageActionReturn,
OpenDialogActionReturn,
SlashCommandActionReturn,
} from './types.js';
import { CommandKind } from './types.js';
import {
ArenaManager,
ArenaEventType,
isTerminalStatus,
isSuccessStatus,
ArenaSessionStatus,
AuthType,
createDebugLogger,
stripStartupContext,
type Config,
type ArenaModelConfig,
type ArenaAgentErrorEvent,
type ArenaAgentCompleteEvent,
type ArenaAgentStartEvent,
type ArenaSessionCompleteEvent,
type ArenaSessionErrorEvent,
type ArenaSessionStartEvent,
type ArenaSessionUpdateEvent,
} from '@qwen-code/qwen-code-core';
import {
MessageType,
type ArenaAgentCardData,
type HistoryItemWithoutId,
} from '../types.js';
/**
* Parsed model entry with optional auth type.
*/
interface ParsedModel {
authType?: string;
modelId: string;
}
/**
* Parses arena command arguments.
*
* Supported formats:
* /arena start --models model1,model2 <task>
* /arena start --models authType1:model1,authType2:model2 <task>
*
* Model format: [authType:]modelId
* - "gpt-4o" uses default auth type
* - "openai:gpt-4o" uses "openai" auth type
*/
function parseArenaArgs(args: string): {
models: ParsedModel[];
task: string;
} {
const modelsMatch = args.match(/--models\s+(\S+)/);
let models: ParsedModel[] = [];
let task = args;
if (modelsMatch) {
const modelStrings = modelsMatch[1]!.split(',').filter(Boolean);
models = modelStrings.map((str) => {
// Check for authType:modelId format
const colonIndex = str.indexOf(':');
if (colonIndex > 0) {
return {
authType: str.substring(0, colonIndex),
modelId: str.substring(colonIndex + 1),
};
}
return { modelId: str };
});
task = task.replace(/--models\s+\S+/, '').trim();
}
// Strip surrounding quotes from task
task = task.replace(/^["']|["']$/g, '').trim();
return { models, task };
}
const debugLogger = createDebugLogger('ARENA_COMMAND');
interface ArenaExecutionInput {
task: string;
models: ArenaModelConfig[];
approvalMode?: string;
}
function buildArenaExecutionInput(
parsed: ReturnType<typeof parseArenaArgs>,
config: Config,
): ArenaExecutionInput | MessageActionReturn {
if (!parsed.task) {
return {
type: 'message',
messageType: 'error',
content:
'Usage: /arena start --models model1,model2 <task>\n' +
'\n' +
'Options:\n' +
' --models [authType:]model1,[authType:]model2\n' +
' Models to compete (required, at least 2)\n' +
' Format: authType:modelId or just modelId\n' +
'\n' +
'Examples:\n' +
' /arena start --models openai:gpt-4o,anthropic:claude-3 "implement sorting"\n' +
' /arena start --models qwen-coder-plus,kimi-for-coding "fix the bug"',
};
}
if (parsed.models.length < 2) {
return {
type: 'message',
messageType: 'error',
content:
'Arena requires at least 2 models. Use --models model1,model2 to specify.\n' +
'Format: [authType:]modelId (e.g., openai:gpt-4o or just gpt-4o)',
};
}
// Get the current auth type as default for models without explicit auth type
const contentGeneratorConfig = config.getContentGeneratorConfig();
const defaultAuthType =
contentGeneratorConfig?.authType ?? AuthType.USE_OPENAI;
// Build ArenaModelConfig for each model, resolving display names from
// the model registry when available.
const modelsConfig = config.getModelsConfig();
const models: ArenaModelConfig[] = parsed.models.map((parsedModel) => {
const authType =
(parsedModel.authType as AuthType | undefined) ?? defaultAuthType;
const registryModels = modelsConfig.getAvailableModelsForAuthType(authType);
const resolved = registryModels.find((m) => m.id === parsedModel.modelId);
return {
modelId: parsedModel.modelId,
authType,
displayName: resolved?.label ?? parsedModel.modelId,
};
});
return {
task: parsed.task,
models,
approvalMode: config.getApprovalMode(),
};
}
/**
* Persists a single arena history item to the session JSONL file.
*
* Arena events fire asynchronously (after the slash command's recording
* window has closed), so each item must be recorded individually.
*/
function recordArenaItem(config: Config, item: HistoryItemWithoutId): void {
try {
const chatRecorder = config.getChatRecordingService();
if (!chatRecorder) return;
chatRecorder.recordSlashCommand({
phase: 'result',
rawCommand: '/arena',
outputHistoryItems: [{ ...item } as Record<string, unknown>],
});
} catch {
debugLogger.error('Failed to record arena history item');
}
}
function executeArenaCommand(
config: Config,
ui: CommandContext['ui'],
input: ArenaExecutionInput,
): void {
// Capture the main session's chat history so arena agents start with
// conversational context. Strip the leading startup context (env info
// user message + model ack) because each agent generates its own for
// its worktree directory — keeping the parent's would duplicate it.
let chatHistory;
try {
const fullHistory = config.getGeminiClient().getHistory();
chatHistory = stripStartupContext(fullHistory);
} catch {
debugLogger.debug('Could not retrieve chat history for arena agents');
}
const manager = new ArenaManager(config);
const emitter = manager.getEventEmitter();
const detachListeners: Array<() => void> = [];
const agentLabels = new Map<string, string>();
const addArenaMessage = (
type: 'info' | 'warning' | 'error' | 'success',
text: string,
) => {
ui.addItem({ type, text }, Date.now());
};
const addAndRecordArenaMessage = (
type: 'info' | 'warning' | 'error' | 'success',
text: string,
) => {
const item: HistoryItemWithoutId = { type, text };
ui.addItem(item, Date.now());
recordArenaItem(config, item);
};
const handleSessionStart = (event: ArenaSessionStartEvent) => {
const modelList = event.models
.map((model, index) => ` ${index + 1}. ${model.modelId}`)
.join('\n');
// SESSION_START fires synchronously before the first await in
// ArenaManager.start(), so the slash command processor's finally
// block already captures this item — no extra recording needed.
addArenaMessage(
MessageType.INFO,
`Arena started with ${event.models.length} agents on task: "${event.task}"\nModels:\n${modelList}`,
);
};
const handleAgentStart = (event: ArenaAgentStartEvent) => {
agentLabels.set(event.agentId, event.model.modelId);
debugLogger.debug(
`Arena agent started: ${event.model.modelId} (${event.agentId})`,
);
};
const handleSessionUpdate = (event: ArenaSessionUpdateEvent) => {
const attachHintPrefix = 'To view agent panes, run: ';
if (event.message.startsWith(attachHintPrefix)) {
const command = event.message.slice(attachHintPrefix.length).trim();
addAndRecordArenaMessage(
MessageType.INFO,
`Arena panes are running in tmux. Attach with: \`${command}\``,
);
return;
}
if (event.type === 'success') {
addAndRecordArenaMessage(MessageType.SUCCESS, event.message);
} else if (event.type === 'info') {
addAndRecordArenaMessage(MessageType.INFO, event.message);
} else {
addAndRecordArenaMessage(MessageType.WARNING, event.message);
}
};
const handleAgentError = (event: ArenaAgentErrorEvent) => {
const label = agentLabels.get(event.agentId) || event.agentId;
addAndRecordArenaMessage(
MessageType.ERROR,
`[${label}] failed: ${event.error}`,
);
};
const buildAgentCardData = (
result: ArenaAgentCompleteEvent['result'],
): ArenaAgentCardData => ({
label: result.model.modelId,
status: result.status,
durationMs: result.stats.durationMs,
totalTokens: result.stats.totalTokens,
inputTokens: result.stats.inputTokens,
outputTokens: result.stats.outputTokens,
toolCalls: result.stats.toolCalls,
successfulToolCalls: result.stats.successfulToolCalls,
failedToolCalls: result.stats.failedToolCalls,
rounds: result.stats.rounds,
error: result.error,
diff: result.diff,
});
const handleAgentComplete = (event: ArenaAgentCompleteEvent) => {
if (!isTerminalStatus(event.result.status)) {
return;
}
const agent = buildAgentCardData(event.result);
const item = {
type: 'arena_agent_complete',
agent,
} as HistoryItemWithoutId;
ui.addItem(item, Date.now());
recordArenaItem(config, item);
};
const handleSessionError = (event: ArenaSessionErrorEvent) => {
addAndRecordArenaMessage(MessageType.ERROR, `${event.error}`);
};
const handleSessionComplete = (event: ArenaSessionCompleteEvent) => {
const item = {
type: 'arena_session_complete',
sessionStatus: event.result.status,
task: event.result.task,
totalDurationMs: event.result.totalDurationMs ?? 0,
agents: event.result.agents.map(buildAgentCardData),
} as HistoryItemWithoutId;
ui.addItem(item, Date.now());
recordArenaItem(config, item);
};
emitter.on(ArenaEventType.SESSION_START, handleSessionStart);
detachListeners.push(() =>
emitter.off(ArenaEventType.SESSION_START, handleSessionStart),
);
emitter.on(ArenaEventType.AGENT_START, handleAgentStart);
detachListeners.push(() =>
emitter.off(ArenaEventType.AGENT_START, handleAgentStart),
);
emitter.on(ArenaEventType.SESSION_UPDATE, handleSessionUpdate);
detachListeners.push(() =>
emitter.off(ArenaEventType.SESSION_UPDATE, handleSessionUpdate),
);
emitter.on(ArenaEventType.AGENT_ERROR, handleAgentError);
detachListeners.push(() =>
emitter.off(ArenaEventType.AGENT_ERROR, handleAgentError),
);
emitter.on(ArenaEventType.AGENT_COMPLETE, handleAgentComplete);
detachListeners.push(() =>
emitter.off(ArenaEventType.AGENT_COMPLETE, handleAgentComplete),
);
emitter.on(ArenaEventType.SESSION_ERROR, handleSessionError);
detachListeners.push(() =>
emitter.off(ArenaEventType.SESSION_ERROR, handleSessionError),
);
emitter.on(ArenaEventType.SESSION_COMPLETE, handleSessionComplete);
detachListeners.push(() =>
emitter.off(ArenaEventType.SESSION_COMPLETE, handleSessionComplete),
);
config.setArenaManager(manager);
const cols = process.stdout.columns || 120;
const rows = Math.max((process.stdout.rows || 40) - 2, 1);
const lifecycle = manager
.start({
task: input.task,
models: input.models,
cols,
rows,
approvalMode: input.approvalMode,
chatHistory,
})
.then(
() => {
debugLogger.debug('Arena agents settled');
},
(error) => {
const message = error instanceof Error ? error.message : String(error);
addAndRecordArenaMessage(MessageType.ERROR, `${message}`);
debugLogger.error('Arena session failed:', error);
// Clear the stored manager so subsequent /arena start calls
// are not blocked by the stale reference after a startup failure.
config.setArenaManager(null);
// Detach listeners on failure — session is done for good.
for (const detach of detachListeners) {
detach();
}
},
);
// NOTE: listeners are NOT detached when start() resolves because agents
// may still be alive (IDLE) and accept follow-up tasks. The listeners
// reference this manager's emitter, so they are garbage collected when
// the manager is cleaned up and replaced.
// Store so that stop can wait for start() to fully unwind before cleanup
manager.setLifecyclePromise(lifecycle);
}
export const arenaCommand: SlashCommand = {
name: 'arena',
description: 'Manage Arena sessions',
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: 'start',
description:
'Start an Arena session with multiple models competing on the same task',
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<void | MessageActionReturn | OpenDialogActionReturn> => {
const executionMode = context.executionMode ?? 'interactive';
if (executionMode !== 'interactive') {
return {
type: 'message',
messageType: 'error',
content:
'Arena is not supported in non-interactive mode. Use interactive mode to start an Arena session.',
};
}
const { services, ui } = context;
const { config } = services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Configuration not available.',
};
}
// Refuse to start if a session already exists (regardless of status)
const existingManager = config.getArenaManager();
if (existingManager) {
return {
type: 'message',
messageType: 'error',
content:
'An Arena session exists. Use /arena stop or /arena select to end it before starting a new one.',
};
}
const parsed = parseArenaArgs(args);
if (parsed.models.length === 0) {
return {
type: 'dialog',
dialog: 'arena_start',
};
}
const executionInput = buildArenaExecutionInput(parsed, config);
if ('type' in executionInput) {
return executionInput;
}
executeArenaCommand(config, ui, executionInput);
},
},
{
name: 'stop',
description: 'Stop the current Arena session',
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
): Promise<void | SlashCommandActionReturn> => {
const executionMode = context.executionMode ?? 'interactive';
if (executionMode !== 'interactive') {
return {
type: 'message',
messageType: 'error',
content:
'Arena is not supported in non-interactive mode. Use interactive mode to stop an Arena session.',
};
}
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Configuration not available.',
};
}
const manager = config.getArenaManager();
if (!manager) {
return {
type: 'message',
messageType: 'error',
content: 'No running Arena session found.',
};
}
return {
type: 'dialog',
dialog: 'arena_stop',
};
},
},
{
name: 'status',
description: 'Show the current Arena session status',
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
): Promise<void | SlashCommandActionReturn> => {
const executionMode = context.executionMode ?? 'interactive';
if (executionMode !== 'interactive') {
return {
type: 'message',
messageType: 'error',
content: 'Arena is not supported in non-interactive mode.',
};
}
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Configuration not available.',
};
}
const manager = config.getArenaManager();
if (!manager) {
return {
type: 'message',
messageType: 'error',
content: 'No Arena session found. Start one with /arena start.',
};
}
return {
type: 'dialog',
dialog: 'arena_status',
};
},
},
{
name: 'select',
altNames: ['choose'],
description:
'Select a model result and merge its diff into the current workspace',
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<
| void
| MessageActionReturn
| OpenDialogActionReturn
| ConfirmActionReturn
> => {
const executionMode = context.executionMode ?? 'interactive';
if (executionMode !== 'interactive') {
return {
type: 'message',
messageType: 'error',
content: 'Arena is not supported in non-interactive mode.',
};
}
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Configuration not available.',
};
}
const manager = config.getArenaManager();
if (!manager) {
return {
type: 'message',
messageType: 'error',
content: 'No arena session found. Start one with /arena start.',
};
}
const sessionStatus = manager.getSessionStatus();
if (
sessionStatus === ArenaSessionStatus.RUNNING ||
sessionStatus === ArenaSessionStatus.INITIALIZING
) {
return {
type: 'message',
messageType: 'error',
content:
'Arena session is still running. Wait for it to complete or use /arena stop first.',
};
}
// Handle --discard flag before checking for successful agents,
// so users can clean up worktrees even when all agents failed.
const trimmedArgs = args.trim();
if (trimmedArgs === '--discard') {
if (!context.overwriteConfirmed) {
return {
type: 'confirm_action',
prompt: 'Discard all Arena results and clean up worktrees?',
originalInvocation: {
raw: context.invocation?.raw || '/arena select --discard',
},
};
}
await config.cleanupArenaRuntime(true);
return {
type: 'message',
messageType: 'info',
content: 'Arena results discarded. All worktrees cleaned up.',
};
}
const agents = manager.getAgentStates();
const hasSuccessful = agents.some((a) => isSuccessStatus(a.status));
if (!hasSuccessful) {
return {
type: 'message',
messageType: 'error',
content:
'No successful agent results to select from. All agents failed or were cancelled.\n' +
'Use /arena stop to end the session.',
};
}
// Handle direct model selection via args
if (trimmedArgs) {
const matchingAgent = agents.find(
(a) =>
isSuccessStatus(a.status) &&
a.model.modelId.toLowerCase() === trimmedArgs.toLowerCase(),
);
if (!matchingAgent) {
return {
type: 'message',
messageType: 'error',
content: `No idle agent found matching "${trimmedArgs}".`,
};
}
const label = matchingAgent.model.modelId;
const result = await manager.applyAgentResult(matchingAgent.agentId);
if (!result.success) {
return {
type: 'message',
messageType: 'error',
content: `Failed to apply changes from ${label}: ${result.error}`,
};
}
await config.cleanupArenaRuntime(true);
return {
type: 'message',
messageType: 'info',
content: `Applied changes from ${label} to workspace. Arena session complete.`,
};
}
// No args → open the select dialog
return {
type: 'dialog',
dialog: 'arena_select',
};
},
},
],
};

View file

@ -58,6 +58,7 @@ describe('clearCommand', () => {
warn: vi.fn(),
}),
getModel: () => 'test-model',
getToolRegistry: () => undefined,
},
},
session: {

View file

@ -11,6 +11,8 @@ import {
uiTelemetryService,
SessionEndReason,
SessionStartSource,
ToolNames,
SkillTool,
} from '@qwen-code/qwen-code-core';
export const clearCommand: SlashCommand = {
@ -38,6 +40,15 @@ export const clearCommand: SlashCommand = {
// Reset UI telemetry metrics for the new session
uiTelemetryService.reset();
// Clear loaded-skills tracking so /context doesn't show stale data
const skillTool = config
.getToolRegistry()
?.getAllTools()
.find((tool) => tool.name === ToolNames.SKILL);
if (skillTool instanceof SkillTool) {
skillTool.clearLoadedSkills();
}
if (newSessionId && context.session.startNewSession) {
context.session.startNewSession(newSessionId);
}

View file

@ -0,0 +1,376 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import {
type CommandContext,
type SlashCommand,
CommandKind,
} from './types.js';
import {
MessageType,
type HistoryItemContextUsage,
type ContextCategoryBreakdown,
type ContextToolDetail,
type ContextMemoryDetail,
type ContextSkillDetail,
} from '../types.js';
import {
DiscoveredMCPTool,
uiTelemetryService,
getCoreSystemPrompt,
DEFAULT_TOKEN_LIMIT,
ToolNames,
SkillTool,
buildSkillLlmContent,
} from '@qwen-code/qwen-code-core';
import { t } from '../../i18n/index.js';
/**
* Default compression token threshold (triggers compression at 70% usage).
* The autocompact buffer is (1 - threshold) * contextWindowSize.
*/
const DEFAULT_COMPRESSION_THRESHOLD = 0.7;
/**
* Estimate token count for a string using a character-based heuristic.
* ASCII chars 4 chars/token, CJK/non-ASCII chars 1.5 tokens/char.
*/
function estimateTokens(text: string): number {
if (!text || text.length === 0) return 0;
let asciiChars = 0;
let nonAsciiChars = 0;
for (let i = 0; i < text.length; i++) {
const charCode = text.charCodeAt(i);
if (charCode < 128) {
asciiChars++;
} else {
nonAsciiChars++;
}
}
// CJK and other non-ASCII characters typically produce 1.5-2 tokens each
return Math.ceil(asciiChars / 4 + nonAsciiChars * 1.5);
}
/**
* Parse concatenated memory content into individual file entries.
* Memory content format: "--- Context from: <path> ---\n<content>\n--- End of Context from: <path> ---"
*/
function parseMemoryFiles(memoryContent: string): ContextMemoryDetail[] {
if (!memoryContent || memoryContent.trim().length === 0) return [];
const results: ContextMemoryDetail[] = [];
// Use backreference (\1) to ensure start/end path markers match
const regex =
/--- Context from: (.+?) ---\n([\s\S]*?)--- End of Context from: \1 ---/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(memoryContent)) !== null) {
const filePath = match[1]!;
const content = match[2]!;
results.push({
path: filePath,
tokens: estimateTokens(content),
});
}
// If no structured markers found, treat as a single memory block
if (results.length === 0 && memoryContent.trim().length > 0) {
results.push({
path: t('memory'),
tokens: estimateTokens(memoryContent),
});
}
return results;
}
export const contextCommand: SlashCommand = {
name: 'context',
get description() {
return t(
'Show context window usage breakdown. Use "/context detail" for per-item breakdown.',
);
},
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext, args?: string) => {
const showDetails =
args?.trim().toLowerCase() === 'detail' ||
args?.trim().toLowerCase() === '-d';
const { config } = context.services;
if (!config) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: t('Config not loaded.'),
},
Date.now(),
);
return;
}
// --- Gather data ---
const modelName = config.getModel() || 'unknown';
const contentGeneratorConfig = config.getContentGeneratorConfig();
const contextWindowSize =
contentGeneratorConfig.contextWindowSize ?? DEFAULT_TOKEN_LIMIT;
// Total prompt token count from API (most accurate)
const apiTotalTokens = uiTelemetryService.getLastPromptTokenCount();
// Cached content token count — when available (e.g. DashScope prefix caching),
// represents the cached overhead (system prompt + tools). Using this gives a much
// more accurate "Messages" count: promptTokens - cachedTokens = actual history tokens.
const apiCachedTokens = uiTelemetryService.getLastCachedContentTokenCount();
// 1. System prompt tokens (without memory, as memory is counted separately)
const systemPromptText = getCoreSystemPrompt(undefined, modelName);
const systemPromptTokens = estimateTokens(systemPromptText);
// 2. Tool declarations tokens (includes ALL tools: built-in, MCP, skill tool)
const toolRegistry = config.getToolRegistry();
const allTools = toolRegistry ? toolRegistry.getAllTools() : [];
const toolDeclarations = toolRegistry
? toolRegistry.getFunctionDeclarations()
: [];
const toolsJsonStr = JSON.stringify(toolDeclarations);
const allToolsTokens = estimateTokens(toolsJsonStr);
// 3. Per-tool details (for breakdown display)
const builtinTools: ContextToolDetail[] = [];
const mcpTools: ContextToolDetail[] = [];
for (const tool of allTools) {
const toolJsonStr = JSON.stringify(tool.schema);
const tokens = estimateTokens(toolJsonStr);
if (tool instanceof DiscoveredMCPTool) {
mcpTools.push({
name: `${tool.serverName}__${tool.serverToolName || tool.name}`,
tokens,
});
} else if (tool.name !== ToolNames.SKILL) {
// Built-in tool (exclude SkillTool, which is shown under Skills)
builtinTools.push({
name: tool.name,
tokens,
});
}
}
// 4. Memory files
const memoryContent = config.getUserMemory();
const memoryFiles = parseMemoryFiles(memoryContent);
const memoryFilesTokens = memoryFiles.reduce((sum, f) => sum + f.tokens, 0);
// 5. Skills (progressive disclosure)
// Two cost components:
// a) Tool definition: SkillTool's description embeds all skill
// name+description listings plus instruction text — always in context.
// b) Loaded bodies: When the model invokes a skill, the full SKILL.md
// body is injected into the conversation as a tool result. We track
// which skills have been loaded and attribute their body tokens here
// so the "Skills" category accurately reflects the total cost.
const skillTool = allTools.find((tool) => tool.name === ToolNames.SKILL);
const skillToolDefinitionTokens = skillTool
? estimateTokens(JSON.stringify(skillTool.schema))
: 0;
// Determine which skills have been loaded in this session
const loadedSkillNames: ReadonlySet<string> =
skillTool instanceof SkillTool
? skillTool.getLoadedSkillNames()
: new Set();
// Per-skill breakdown: listing cost + body cost for loaded skills
const skillManager = config.getSkillManager();
const skillConfigs = skillManager ? await skillManager.listSkills() : [];
let loadedBodiesTokens = 0;
const skills: ContextSkillDetail[] = skillConfigs.map((skill) => {
const listingTokens = estimateTokens(
`<skill>\n<name>\n${skill.name}\n</name>\n<description>\n${skill.description} (${skill.level})\n</description>\n<location>\n${skill.level}\n</location>\n</skill>`,
);
const isLoaded = loadedSkillNames.has(skill.name);
let bodyTokens: number | undefined;
if (isLoaded && skill.body) {
const baseDir = skill.filePath
? skill.filePath.replace(/\/[^/]+$/, '')
: '';
bodyTokens = estimateTokens(buildSkillLlmContent(baseDir, skill.body));
loadedBodiesTokens += bodyTokens;
}
return {
name: skill.name,
tokens: listingTokens,
loaded: isLoaded,
bodyTokens,
};
});
// Total skills cost = tool definition + loaded bodies
const skillsTokens = skillToolDefinitionTokens + loadedBodiesTokens;
// 6. Autocompact buffer
const compressionThreshold =
config.getChatCompression()?.contextPercentageThreshold ??
DEFAULT_COMPRESSION_THRESHOLD;
const autocompactBuffer =
compressionThreshold > 0
? Math.round((1 - compressionThreshold) * contextWindowSize)
: 0;
// 7. Calculate raw overhead
// allToolsTokens includes the skill tool definition; loadedBodiesTokens
// covers the on-demand skill bodies now attributed to Skills.
const rawOverhead =
systemPromptTokens +
allToolsTokens +
memoryFilesTokens +
loadedBodiesTokens;
// 8. Determine total tokens and build breakdown
const isEstimated = apiTotalTokens === 0;
// Sum of MCP tool tokens for category-level display
const mcpToolsTotalTokens = mcpTools.reduce(
(sum, tool) => sum + tool.tokens,
0,
);
let totalTokens: number;
let displaySystemPrompt: number;
let displayBuiltinTools: number;
let displayMcpTools: number;
let displayMemoryFiles: number;
let displaySkills: number;
let messagesTokens: number;
let freeSpace: number;
let detailBuiltinTools: ContextToolDetail[];
let detailMcpTools: ContextToolDetail[];
let detailMemoryFiles: ContextMemoryDetail[];
let detailSkills: ContextSkillDetail[];
if (isEstimated) {
// No API data yet: show raw overhead estimates only.
// Use 0 as totalTokens so the progress bar stays empty —
// avoids showing an inflated estimate that would "decrease"
// once real API data arrives.
totalTokens = 0;
displaySystemPrompt = systemPromptTokens;
// Skills = tool definition + loaded bodies
displaySkills = skillsTokens;
// builtinTools = allTools minus skills-definition minus mcpTools
displayBuiltinTools = Math.max(
0,
allToolsTokens - skillToolDefinitionTokens - mcpToolsTotalTokens,
);
displayMcpTools = mcpToolsTotalTokens;
displayMemoryFiles = memoryFilesTokens;
messagesTokens = 0;
// Free space accounts for the estimated overhead
freeSpace = Math.max(
0,
contextWindowSize - rawOverhead - autocompactBuffer,
);
detailBuiltinTools = builtinTools;
detailMcpTools = mcpTools;
detailMemoryFiles = memoryFiles;
detailSkills = skills;
} else {
// API data available: use actual total with proportional scaling
totalTokens = apiTotalTokens;
// When estimates overshoot API total, scale down proportionally
// so the breakdown categories add up to totalTokens.
const overheadScale =
rawOverhead > totalTokens ? totalTokens / rawOverhead : 1;
displaySystemPrompt = Math.round(systemPromptTokens * overheadScale);
const scaledAllTools = Math.round(allToolsTokens * overheadScale);
displayMemoryFiles = Math.round(memoryFilesTokens * overheadScale);
// Skills = tool definition + loaded bodies (scaled together)
displaySkills = Math.round(skillsTokens * overheadScale);
const scaledMcpTotal = Math.round(mcpToolsTotalTokens * overheadScale);
displayMcpTools = scaledMcpTotal;
// builtinTools = allTools minus skill-definition minus mcpTools
const scaledSkillDefinition = Math.round(
skillToolDefinitionTokens * overheadScale,
);
displayBuiltinTools = Math.max(
0,
scaledAllTools - scaledSkillDefinition - scaledMcpTotal,
);
const scaledOverhead =
displaySystemPrompt +
scaledAllTools +
displayMemoryFiles +
Math.round(loadedBodiesTokens * overheadScale);
// When the API reports cached content tokens (e.g. DashScope prefix caching),
// use them as the actual overhead indicator for a more accurate messages count.
// cachedTokens ≈ system prompt + tools tokens actually served from cache.
// This avoids the "messages = 0" problem caused by estimation overshoot.
if (apiCachedTokens > 0) {
messagesTokens = Math.max(0, totalTokens - apiCachedTokens);
} else {
messagesTokens = Math.max(0, totalTokens - scaledOverhead);
}
freeSpace = Math.max(
0,
contextWindowSize - totalTokens - autocompactBuffer,
);
// Scale detail items to match their parent categories
const scaleDetail = <T extends { tokens: number }>(items: T[]): T[] =>
overheadScale < 1
? items.map((item) => ({
...item,
tokens: Math.round(item.tokens * overheadScale),
}))
: items;
detailBuiltinTools = scaleDetail(builtinTools);
detailMcpTools = scaleDetail(mcpTools);
detailMemoryFiles = scaleDetail(memoryFiles);
detailSkills =
overheadScale < 1
? skills.map((item) => ({
...item,
tokens: Math.round(item.tokens * overheadScale),
bodyTokens: item.bodyTokens
? Math.round(item.bodyTokens * overheadScale)
: undefined,
}))
: skills;
}
const breakdown: ContextCategoryBreakdown = {
systemPrompt: displaySystemPrompt,
builtinTools: displayBuiltinTools,
mcpTools: displayMcpTools,
memoryFiles: displayMemoryFiles,
skills: displaySkills,
messages: messagesTokens,
freeSpace,
autocompactBuffer,
};
const contextUsageItem: HistoryItemContextUsage = {
type: MessageType.CONTEXT_USAGE,
modelName,
totalTokens,
contextWindowSize,
breakdown,
builtinTools: detailBuiltinTools,
mcpTools: detailMcpTools,
memoryFiles: detailMemoryFiles,
skills: detailSkills,
isEstimated,
showDetails,
};
context.ui.addItem(contextUsageItem, Date.now());
},
};

View file

@ -139,6 +139,10 @@ export interface OpenDialogActionReturn {
dialog:
| 'help'
| 'arena_start'
| 'arena_select'
| 'arena_stop'
| 'arena_status'
| 'auth'
| 'theme'
| 'editor'

View file

@ -0,0 +1,287 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview BaseTextInput shared text input component with rendering
* and common readline keyboard handling.
*
* Provides:
* - Viewport line rendering from a TextBuffer with cursor display
* - Placeholder support when buffer is empty
* - Configurable border/prefix styling
* - Standard readline shortcuts (Ctrl+A/E/K/U/W, Escape, etc.)
* - An `onKeypress` interceptor so consumers can layer custom behavior
*
* Used by both InputPrompt (with syntax highlighting + complex key handling)
* and AgentComposer (with minimal customization).
*/
import type React from 'react';
import { useCallback } from 'react';
import { Box, Text } from 'ink';
import chalk from 'chalk';
import type { TextBuffer } from './shared/text-buffer.js';
import type { Key } from '../hooks/useKeypress.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import { cpSlice, cpLen } from '../utils/textUtils.js';
import { theme } from '../semantic-colors.js';
// ─── Types ──────────────────────────────────────────────────
export interface RenderLineOptions {
/** The text content of this visual line. */
lineText: string;
/** Whether the cursor is on this visual line. */
isOnCursorLine: boolean;
/** The cursor column within this visual line (visual col, not logical). */
cursorCol: number;
/** Whether the cursor should be rendered. */
showCursor: boolean;
/** Index of this line within the rendered viewport (0-based). */
visualLineIndex: number;
/** Absolute visual line index (scrollVisualRow + visualLineIndex). */
absoluteVisualIndex: number;
/** The underlying text buffer. */
buffer: TextBuffer;
/** The first visible visual row (scroll offset). */
scrollVisualRow: number;
}
export interface BaseTextInputProps {
/** The text buffer driving this input. */
buffer: TextBuffer;
/** Called when the user submits (Enter). Buffer is cleared automatically. */
onSubmit: (text: string) => void;
/**
* Optional key interceptor. Called before default readline handling.
* Return `true` if the key was handled (skips default processing).
*/
onKeypress?: (key: Key) => boolean;
/** Whether to show the blinking block cursor. Defaults to true. */
showCursor?: boolean;
/** Placeholder text shown when the buffer is empty. */
placeholder?: string;
/** Custom prefix node (defaults to `> `). */
prefix?: React.ReactNode;
/** Border color for the input box. */
borderColor?: string;
/** Whether keyboard handling is active. Defaults to true. */
isActive?: boolean;
/**
* Custom line renderer for advanced rendering (e.g. syntax highlighting).
* When not provided, lines are rendered as plain text with cursor overlay.
*/
renderLine?: (opts: RenderLineOptions) => React.ReactNode;
}
// ─── Default line renderer ──────────────────────────────────
/**
* Renders a single visual line with an inverse-video block cursor.
* Uses codepoint-aware string operations for Unicode/emoji safety.
*/
export function defaultRenderLine({
lineText,
isOnCursorLine,
cursorCol,
showCursor,
}: RenderLineOptions): React.ReactNode {
if (!isOnCursorLine || !showCursor) {
return <Text>{lineText || ' '}</Text>;
}
const len = cpLen(lineText);
// Cursor past end of line — append inverse space
if (cursorCol >= len) {
return (
<Text>
{lineText}
{chalk.inverse(' ') + '\u200B'}
</Text>
);
}
const before = cpSlice(lineText, 0, cursorCol);
const cursorChar = cpSlice(lineText, cursorCol, cursorCol + 1);
const after = cpSlice(lineText, cursorCol + 1);
return (
<Text>
{before}
{chalk.inverse(cursorChar)}
{after}
</Text>
);
}
// ─── Component ──────────────────────────────────────────────
export const BaseTextInput: React.FC<BaseTextInputProps> = ({
buffer,
onSubmit,
onKeypress,
showCursor = true,
placeholder,
prefix,
borderColor,
isActive = true,
renderLine = defaultRenderLine,
}) => {
// ── Keyboard handling ──
const handleKey = useCallback(
(key: Key) => {
// Let the consumer intercept first
if (onKeypress?.(key)) {
return;
}
// ── Standard readline shortcuts ──
// Submit (Enter, no modifiers)
if (keyMatchers[Command.SUBMIT](key)) {
if (buffer.text.trim()) {
const text = buffer.text;
buffer.setText('');
onSubmit(text);
}
return;
}
// Newline (Shift+Enter, Ctrl+Enter, Ctrl+J)
if (keyMatchers[Command.NEWLINE](key)) {
buffer.newline();
return;
}
// Escape → clear input
if (keyMatchers[Command.ESCAPE](key)) {
if (buffer.text.length > 0) {
buffer.setText('');
}
return;
}
// Ctrl+C → clear input
if (keyMatchers[Command.CLEAR_INPUT](key)) {
if (buffer.text.length > 0) {
buffer.setText('');
}
return;
}
// Ctrl+A → home
if (keyMatchers[Command.HOME](key)) {
buffer.move('home');
return;
}
// Ctrl+E → end
if (keyMatchers[Command.END](key)) {
buffer.move('end');
return;
}
// Ctrl+K → kill to end of line
if (keyMatchers[Command.KILL_LINE_RIGHT](key)) {
buffer.killLineRight();
return;
}
// Ctrl+U → kill to start of line
if (keyMatchers[Command.KILL_LINE_LEFT](key)) {
buffer.killLineLeft();
return;
}
// Ctrl+W / Alt+Backspace → delete word backward
if (keyMatchers[Command.DELETE_WORD_BACKWARD](key)) {
buffer.deleteWordLeft();
return;
}
// Ctrl+X Ctrl+E → open in external editor
if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) {
buffer.openInExternalEditor();
return;
}
// Backspace
if (
key.name === 'backspace' ||
key.sequence === '\x7f' ||
(key.ctrl && key.name === 'h')
) {
buffer.backspace();
return;
}
// Fallthrough — delegate to buffer's built-in input handler
buffer.handleInput(key);
},
[buffer, onSubmit, onKeypress],
);
useKeypress(handleKey, { isActive });
// ── Rendering ──
const linesToRender = buffer.viewportVisualLines;
const [cursorVisualRow, cursorVisualCol] = buffer.visualCursor;
const scrollVisualRow = buffer.visualScrollRow;
const resolvedBorderColor = borderColor ?? theme.border.focused;
const resolvedPrefix = prefix ?? (
<Text color={theme.text.accent}>{'> '}</Text>
);
return (
<Box
borderStyle="single"
borderTop={true}
borderBottom={true}
borderLeft={false}
borderRight={false}
borderColor={resolvedBorderColor}
>
{resolvedPrefix}
<Box flexGrow={1} flexDirection="column">
{buffer.text.length === 0 && placeholder ? (
showCursor ? (
<Text>
{chalk.inverse(placeholder.slice(0, 1))}
<Text color={theme.text.secondary}>{placeholder.slice(1)}</Text>
</Text>
) : (
<Text color={theme.text.secondary}>{placeholder}</Text>
)
) : (
linesToRender.map((lineText, idx) => {
const absoluteVisualIndex = scrollVisualRow + idx;
const isOnCursorLine = absoluteVisualIndex === cursorVisualRow;
return (
<Box key={idx} height={1}>
{renderLine({
lineText,
isOnCursorLine,
cursorCol: cursorVisualCol,
showCursor,
visualLineIndex: idx,
absoluteVisualIndex,
buffer,
scrollVisualRow,
})}
</Box>
);
})
)}
</Box>
</Box>
);
};

View file

@ -111,6 +111,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
debugMessage: '',
nightly: false,
isTrustedFolder: true,
taskStartTokens: 0,
...overrides,
}) as UIState;

View file

@ -27,7 +27,17 @@ export const Composer = () => {
const uiActions = useUIActions();
const { vimEnabled } = useVimMode();
const { showAutoAcceptIndicator } = uiState;
const { showAutoAcceptIndicator, sessionStats, taskStartTokens } = uiState;
const tokens = Object.values(sessionStats.metrics?.models ?? {}).reduce(
(acc, model) => ({
prompt: acc.prompt + (model.tokens?.prompt ?? 0),
candidates: acc.candidates + (model.tokens?.candidates ?? 0),
}),
{ prompt: 0, candidates: 0 },
);
const taskTokens = tokens.candidates - taskStartTokens;
// State for keyboard shortcuts display toggle
const [showShortcuts, setShowShortcuts] = useState(false);
@ -64,6 +74,7 @@ export const Composer = () => {
: uiState.currentLoadingPhrase
}
elapsedTime={uiState.elapsedTime}
candidatesTokens={taskTokens}
/>
)}
@ -104,8 +115,8 @@ export const Composer = () => {
{/* Exclusive area: only one component visible at a time */}
{/* Hide footer when a confirmation dialog (e.g. ask_user_question) is active */}
{!showSuggestions &&
uiState.streamingState !== StreamingState.WaitingForConfirmation &&
{uiState.isInputActive &&
!showSuggestions &&
(showShortcuts ? (
<KeyboardShortcuts />
) : (

View file

@ -20,6 +20,10 @@ import { AuthDialog } from '../auth/AuthDialog.js';
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
import { ModelDialog } from './ModelDialog.js';
import { ArenaStartDialog } from './arena/ArenaStartDialog.js';
import { ArenaSelectDialog } from './arena/ArenaSelectDialog.js';
import { ArenaStopDialog } from './arena/ArenaStopDialog.js';
import { ArenaStatusDialog } from './arena/ArenaStatusDialog.js';
import { ApprovalModeDialog } from './ApprovalModeDialog.js';
import { theme } from '../semantic-colors.js';
import { useUIState } from '../contexts/UIStateContext.js';
@ -237,6 +241,49 @@ export const DialogManager = ({
if (uiState.isModelDialogOpen) {
return <ModelDialog onClose={uiActions.closeModelDialog} />;
}
if (uiState.activeArenaDialog === 'start') {
return (
<ArenaStartDialog
onClose={() => uiActions.closeArenaDialog()}
onConfirm={(models) => uiActions.handleArenaModelsSelected?.(models)}
/>
);
}
if (uiState.activeArenaDialog === 'status') {
const arenaManager = config.getArenaManager();
if (arenaManager) {
return (
<ArenaStatusDialog
manager={arenaManager}
closeArenaDialog={uiActions.closeArenaDialog}
width={mainAreaWidth}
/>
);
}
}
if (uiState.activeArenaDialog === 'stop') {
return (
<ArenaStopDialog
config={config}
addItem={addItem}
closeArenaDialog={uiActions.closeArenaDialog}
/>
);
}
if (uiState.activeArenaDialog === 'select') {
const arenaManager = config.getArenaManager();
if (arenaManager) {
return (
<ArenaSelectDialog
manager={arenaManager}
config={config}
addItem={addItem}
closeArenaDialog={uiActions.closeArenaDialog}
/>
);
}
}
if (uiState.isAuthDialogOpen || uiState.authError) {
return (
<Box flexDirection="column">

View file

@ -24,6 +24,7 @@ import {
WarningMessage,
ErrorMessage,
RetryCountdownMessage,
SuccessMessage,
} from './messages/StatusMessages.js';
import { Box } from 'ink';
import { AboutBox } from './AboutBox.js';
@ -38,6 +39,8 @@ import { getMCPServerStatus } from '@qwen-code/qwen-code-core';
import { SkillsList } from './views/SkillsList.js';
import { ToolsList } from './views/ToolsList.js';
import { McpStatus } from './views/McpStatus.js';
import { ContextUsage } from './views/ContextUsage.js';
import { ArenaAgentCard, ArenaSessionCard } from './arena/ArenaCards.js';
import { InsightProgressMessage } from './messages/InsightProgressMessage.js';
interface HistoryItemDisplayProps {
@ -132,6 +135,9 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'info' && (
<InfoMessage text={itemForDisplay.text} />
)}
{itemForDisplay.type === 'success' && (
<SuccessMessage text={itemForDisplay.text} />
)}
{itemForDisplay.type === 'warning' && (
<WarningMessage text={itemForDisplay.text} />
)}
@ -191,6 +197,32 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'mcp_status' && (
<McpStatus {...itemForDisplay} serverStatus={getMCPServerStatus} />
)}
{itemForDisplay.type === 'context_usage' && (
<ContextUsage
modelName={itemForDisplay.modelName}
totalTokens={itemForDisplay.totalTokens}
contextWindowSize={itemForDisplay.contextWindowSize}
breakdown={itemForDisplay.breakdown}
builtinTools={itemForDisplay.builtinTools}
mcpTools={itemForDisplay.mcpTools}
memoryFiles={itemForDisplay.memoryFiles}
skills={itemForDisplay.skills}
isEstimated={itemForDisplay.isEstimated}
showDetails={itemForDisplay.showDetails}
/>
)}
{itemForDisplay.type === 'arena_agent_complete' && (
<ArenaAgentCard agent={itemForDisplay.agent} width={boxWidth} />
)}
{itemForDisplay.type === 'arena_session_complete' && (
<ArenaSessionCard
sessionStatus={itemForDisplay.sessionStatus}
task={itemForDisplay.task}
totalDurationMs={itemForDisplay.totalDurationMs}
agents={itemForDisplay.agents}
width={boxWidth}
/>
)}
{itemForDisplay.type === 'insight_progress' && (
<InsightProgressMessage progress={itemForDisplay.progress} />
)}

View file

@ -1957,6 +1957,25 @@ describe('InputPrompt', () => {
});
describe('command search (Ctrl+R when not in shell)', () => {
it('passes newest-first user history to command search', async () => {
props.shellModeActive = false;
props.userMessages = ['oldest', 'middle', 'newest'];
const { unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
const commandSearchCall =
mockedUseReverseSearchCompletion.mock.calls.find(
([, history]) =>
Array.isArray(history) &&
history.length === 3 &&
history.includes('newest'),
);
expect(commandSearchCall?.[1]).toEqual(['newest', 'middle', 'oldest']);
unmount();
});
it('enters command search on Ctrl+R and shows suggestions', async () => {
props.shellModeActive = false;

View file

@ -5,7 +5,7 @@
*/
import type React from 'react';
import { useCallback, useEffect, useState, useRef } from 'react';
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import { Box, Text } from 'ink';
import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
import { theme } from '../semantic-colors.js';
@ -18,7 +18,6 @@ import { useShellHistory } from '../hooks/useShellHistory.js';
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
import { useCommandCompletion } from '../hooks/useCommandCompletion.js';
import type { Key } from '../hooks/useKeypress.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import type { Config } from '@qwen-code/qwen-code-core';
@ -43,7 +42,13 @@ import { useShellFocusState } from '../contexts/ShellFocusContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useKeypressContext } from '../contexts/KeypressContext.js';
import {
useAgentViewState,
useAgentViewActions,
} from '../contexts/AgentViewContext.js';
import { FEEDBACK_DIALOG_KEYS } from '../FeedbackDialog.js';
import { BaseTextInput } from './BaseTextInput.js';
import type { RenderLineOptions } from './BaseTextInput.js';
/**
* Represents an attachment (e.g., pasted image) displayed above the input prompt
@ -78,30 +83,8 @@ export interface InputPromptProps {
isEmbeddedShellFocused?: boolean;
}
// The input content, input container, and input suggestions list may have different widths
export const calculatePromptWidths = (terminalWidth: number) => {
const widthFraction = 0.9;
const FRAME_PADDING_AND_BORDER = 4; // Border (2) + padding (2)
const PROMPT_PREFIX_WIDTH = 2; // '> ' or '! '
const MIN_CONTENT_WIDTH = 2;
const innerContentWidth =
Math.floor(terminalWidth * widthFraction) -
FRAME_PADDING_AND_BORDER -
PROMPT_PREFIX_WIDTH;
const inputWidth = Math.max(MIN_CONTENT_WIDTH, innerContentWidth);
const FRAME_OVERHEAD = FRAME_PADDING_AND_BORDER + PROMPT_PREFIX_WIDTH;
const containerWidth = inputWidth + FRAME_OVERHEAD;
const suggestionsWidth = Math.max(20, Math.floor(terminalWidth * 1.0));
return {
inputWidth,
containerWidth,
suggestionsWidth,
frameOverhead: FRAME_OVERHEAD,
} as const;
};
// Re-export from shared utils for backwards compatibility
export { calculatePromptWidths } from '../utils/layoutUtils.js';
// Large paste placeholder thresholds
const LARGE_PASTE_CHAR_THRESHOLD = 1000;
@ -132,6 +115,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const uiState = useUIState();
const uiActions = useUIActions();
const { pasteWorkaround } = useKeypressContext();
const { agents, agentTabBarFocused } = useAgentViewState();
const { setAgentTabBarFocused } = useAgentViewActions();
const hasAgents = agents.size > 0;
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
const [escPressCount, setEscPressCount] = useState(0);
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
@ -213,9 +199,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
reverseSearchActive,
);
const commandSearchHistory = useMemo(
() => [...userMessages].reverse(),
[userMessages],
);
const commandSearchCompletion = useReverseSearchCompletion(
buffer,
userMessages,
commandSearchHistory,
commandSearchActive,
);
@ -225,7 +216,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const resetCommandSearchCompletionState =
commandSearchCompletion.resetCompletionState;
const showCursor = focus && isShellFocused && !isEmbeddedShellFocused;
const showCursor =
focus && isShellFocused && !isEmbeddedShellFocused && !agentTabBarFocused;
const resetEscapeState = useCallback(() => {
if (escapeTimerRef.current) {
@ -351,6 +343,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
onChange: customSetTextAndResetCompletionSignal,
});
// When an arena session starts (agents appear), reset history position so
// that pressing down-arrow immediately focuses the agent tab bar instead
// of cycling through input history.
const prevHasAgentsRef = useRef(hasAgents);
useEffect(() => {
if (hasAgents && !prevHasAgentsRef.current) {
inputHistory.resetHistoryNav();
}
prevHasAgentsRef.current = hasAgents;
}, [hasAgents, inputHistory]);
// Effect to reset completion if history navigation just occurred and set the text
useEffect(() => {
if (justNavigatedHistory) {
@ -411,13 +414,30 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}, []);
const handleInput = useCallback(
(key: Key) => {
(key: Key): boolean => {
// When the tab bar has focus, block all non-printable keys so arrow
// keys and shortcuts don't interfere. Printable characters fall
// through to BaseTextInput's default handler so the first keystroke
// appears in the input immediately (the tab bar handler releases
// focus on the same event).
if (agentTabBarFocused) {
if (
key.sequence &&
key.sequence.length === 1 &&
!key.ctrl &&
!key.meta
) {
return false; // let BaseTextInput type the character
}
return true; // consume non-printable keys
}
// TODO(jacobr): this special case is likely not needed anymore.
// We should probably stop supporting paste if the InputPrompt is not
// focused.
/// We want to handle paste even when not focused to support drag and drop.
if (!focus && !key.paste) {
return;
return true;
}
if (key.paste) {
@ -459,18 +479,18 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// Normal paste handling for small content
buffer.handleInput(key);
}
return;
return true;
}
if (vimHandleInput && vimHandleInput(key)) {
return;
return true;
}
// Handle feedback dialog keyboard interactions when dialog is open
if (uiState.isFeedbackDialogOpen) {
// If it's one of the feedback option keys (1-4), let FeedbackDialog handle it
if ((FEEDBACK_DIALOG_KEYS as readonly string[]).includes(key.name)) {
return;
return true;
} else {
// For any other key, close feedback dialog temporarily and continue with normal processing
uiActions.temporaryCloseFeedbackDialog();
@ -496,7 +516,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
setShellModeActive(!shellModeActive);
buffer.setText(''); // Clear the '!' from input
return;
return true;
}
// Toggle keyboard shortcuts display with "?" when buffer is empty
@ -507,7 +527,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
onToggleShortcuts
) {
onToggleShortcuts();
return;
return true;
}
// Hide shortcuts on any other key press
@ -537,33 +557,33 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setReverseSearchActive,
reverseSearchCompletion.resetCompletionState,
);
return;
return true;
}
if (commandSearchActive) {
cancelSearch(
setCommandSearchActive,
commandSearchCompletion.resetCompletionState,
);
return;
return true;
}
if (shellModeActive) {
setShellModeActive(false);
resetEscapeState();
return;
return true;
}
if (completion.showSuggestions) {
completion.resetCompletionState();
setExpandedSuggestionIndex(-1);
resetEscapeState();
return;
return true;
}
// Handle double ESC for clearing input
if (escPressCount === 0) {
if (buffer.text === '') {
return;
return true;
}
setEscPressCount(1);
setShowEscapePrompt(true);
@ -579,7 +599,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
resetCompletionState();
resetEscapeState();
}
return;
return true;
}
// Ctrl+Y: Retry the last failed request.
@ -589,19 +609,19 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// If no failed request exists, a message will be shown to the user.
if (keyMatchers[Command.RETRY_LAST](key)) {
uiActions.handleRetryLastPrompt();
return;
return true;
}
if (shellModeActive && keyMatchers[Command.REVERSE_SEARCH](key)) {
setReverseSearchActive(true);
setTextBeforeReverseSearch(buffer.text);
setCursorPosition(buffer.cursor);
return;
return true;
}
if (keyMatchers[Command.CLEAR_SCREEN](key)) {
onClearScreen();
return;
return true;
}
if (reverseSearchActive || commandSearchActive) {
@ -626,29 +646,29 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (showSuggestions) {
if (keyMatchers[Command.NAVIGATION_UP](key)) {
navigateUp();
return;
return true;
}
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
navigateDown();
return;
return true;
}
if (keyMatchers[Command.COLLAPSE_SUGGESTION](key)) {
if (suggestions[activeSuggestionIndex].value.length >= MAX_WIDTH) {
setExpandedSuggestionIndex(-1);
return;
return true;
}
}
if (keyMatchers[Command.EXPAND_SUGGESTION](key)) {
if (suggestions[activeSuggestionIndex].value.length >= MAX_WIDTH) {
setExpandedSuggestionIndex(activeSuggestionIndex);
return;
return true;
}
}
if (keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH](key)) {
sc.handleAutocomplete(activeSuggestionIndex);
resetState();
setActive(false);
return;
return true;
}
}
@ -660,7 +680,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
handleSubmitAndClear(textToSubmit);
resetState();
setActive(false);
return;
return true;
}
// Prevent up/down from falling through to regular history navigation
@ -668,14 +688,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
keyMatchers[Command.NAVIGATION_UP](key) ||
keyMatchers[Command.NAVIGATION_DOWN](key)
) {
return;
return true;
}
}
// If the command is a perfect match, pressing enter should execute it.
if (completion.isPerfectMatch && keyMatchers[Command.RETURN](key)) {
handleSubmitAndClear(buffer.text);
return;
return true;
}
if (completion.showSuggestions) {
@ -683,12 +703,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (keyMatchers[Command.COMPLETION_UP](key)) {
completion.navigateUp();
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
return;
return true;
}
if (keyMatchers[Command.COMPLETION_DOWN](key)) {
completion.navigateDown();
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
return;
return true;
}
}
@ -703,7 +723,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setExpandedSuggestionIndex(-1); // Reset expansion after selection
}
}
return;
return true;
}
}
@ -711,28 +731,28 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (isAttachmentMode && attachments.length > 0) {
if (key.name === 'left') {
setSelectedAttachmentIndex((i) => Math.max(0, i - 1));
return;
return true;
}
if (key.name === 'right') {
setSelectedAttachmentIndex((i) =>
Math.min(attachments.length - 1, i + 1),
);
return;
return true;
}
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
// Exit attachment mode and return to input
setIsAttachmentMode(false);
setSelectedAttachmentIndex(-1);
return;
return true;
}
if (key.name === 'backspace' || key.name === 'delete') {
handleAttachmentDelete(selectedAttachmentIndex);
return;
return true;
}
if (key.name === 'return' || key.name === 'escape') {
setIsAttachmentMode(false);
setSelectedAttachmentIndex(-1);
return;
return true;
}
// For other keys, exit attachment mode and let input handle them
setIsAttachmentMode(false);
@ -753,7 +773,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
) {
setIsAttachmentMode(true);
setSelectedAttachmentIndex(attachments.length - 1);
return;
return true;
}
if (!shellModeActive) {
@ -761,16 +781,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setCommandSearchActive(true);
setTextBeforeReverseSearch(buffer.text);
setCursorPosition(buffer.cursor);
return;
return true;
}
if (keyMatchers[Command.HISTORY_UP](key)) {
inputHistory.navigateUp();
return;
return true;
}
if (keyMatchers[Command.HISTORY_DOWN](key)) {
inputHistory.navigateDown();
return;
return true;
}
// Handle arrow-up/down for history on single-line or at edges
if (
@ -779,27 +799,33 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
(buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0))
) {
inputHistory.navigateUp();
return;
return true;
}
if (
keyMatchers[Command.NAVIGATION_DOWN](key) &&
(buffer.allVisualLines.length === 1 ||
buffer.visualCursor[0] === buffer.allVisualLines.length - 1)
) {
inputHistory.navigateDown();
return;
if (inputHistory.navigateDown()) {
return true;
}
if (hasAgents) {
setAgentTabBarFocused(true);
return true;
}
return true;
}
} else {
// Shell History Navigation
if (keyMatchers[Command.NAVIGATION_UP](key)) {
const prevCommand = shellHistory.getPreviousCommand();
if (prevCommand !== null) buffer.setText(prevCommand);
return;
return true;
}
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
const nextCommand = shellHistory.getNextCommand();
if (nextCommand !== null) buffer.setText(nextCommand);
return;
return true;
}
}
@ -810,7 +836,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// paste markers may not work reliably and Enter key events can leak from pasted text.
if (pasteWorkaround && recentPasteTime !== null) {
// Paste occurred recently, ignore this submit to prevent auto-execution
return;
return true;
}
const [row, col] = buffer.cursor;
@ -823,65 +849,21 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
handleSubmitAndClear(buffer.text);
}
}
return;
}
// Newline insertion
if (keyMatchers[Command.NEWLINE](key)) {
buffer.newline();
return;
}
// Ctrl+A (Home) / Ctrl+E (End)
if (keyMatchers[Command.HOME](key)) {
buffer.move('home');
return;
}
if (keyMatchers[Command.END](key)) {
buffer.move('end');
return;
}
// Ctrl+C (Clear input)
if (keyMatchers[Command.CLEAR_INPUT](key)) {
if (buffer.text.length > 0) {
buffer.setText('');
resetCompletionState();
}
return;
}
// Kill line commands
if (keyMatchers[Command.KILL_LINE_RIGHT](key)) {
buffer.killLineRight();
return;
}
if (keyMatchers[Command.KILL_LINE_LEFT](key)) {
buffer.killLineLeft();
return;
}
if (keyMatchers[Command.DELETE_WORD_BACKWARD](key)) {
buffer.deleteWordLeft();
return;
}
// External editor
if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) {
buffer.openInExternalEditor();
return;
return true;
}
// Ctrl+V for clipboard image paste
if (keyMatchers[Command.PASTE_CLIPBOARD_IMAGE](key)) {
handleClipboardImage();
return;
return true;
}
// Handle backspace with placeholder-aware deletion
if (
key.name === 'backspace' ||
key.sequence === '\x7f' ||
(key.ctrl && key.name === 'h')
pendingPastes.size > 0 &&
(key.name === 'backspace' ||
key.sequence === '\x7f' ||
(key.ctrl && key.name === 'h'))
) {
const text = buffer.text;
const [row, col] = buffer.cursor;
@ -894,7 +876,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
offset += col;
// Check if we're at the end of any placeholder
let placeholderDeleted = false;
for (const placeholder of pendingPastes.keys()) {
const placeholderStart = offset - placeholder.length;
if (
@ -913,20 +894,22 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (parsed) {
freePlaceholderId(parsed.charCount, parsed.id);
}
placeholderDeleted = true;
break;
return true;
}
}
if (!placeholderDeleted) {
// Normal backspace behavior
buffer.backspace();
}
return;
// No placeholder matched — fall through to BaseTextInput's default backspace
}
// Fall back to the text buffer's default input handling for all other keys
buffer.handleInput(key);
// Ctrl+C with completion active — also reset completion state
if (keyMatchers[Command.CLEAR_INPUT](key)) {
if (buffer.text.length > 0) {
resetCompletionState();
}
// Fall through to BaseTextInput's default CLEAR_INPUT handler
}
// All remaining keys (readline shortcuts, text input) handled by BaseTextInput
return false;
},
[
focus,
@ -964,15 +947,89 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
pendingPastes,
parsePlaceholder,
freePlaceholderId,
agentTabBarFocused,
hasAgents,
setAgentTabBarFocused,
],
);
useKeypress(handleInput, { isActive: !isEmbeddedShellFocused });
const renderLineWithHighlighting = useCallback(
(opts: RenderLineOptions): React.ReactNode => {
const {
lineText,
isOnCursorLine,
cursorCol: cursorVisualColAbsolute,
showCursor: showCursorOpt,
absoluteVisualIndex,
buffer: buf,
} = opts;
const mapEntry = buf.visualToLogicalMap[absoluteVisualIndex];
const [logicalLineIdx, logicalStartCol] = mapEntry;
const logicalLine = buf.lines[logicalLineIdx] || '';
const tokens = parseInputForHighlighting(logicalLine, logicalLineIdx);
const linesToRender = buffer.viewportVisualLines;
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =
buffer.visualCursor;
const scrollVisualRow = buffer.visualScrollRow;
const visualStart = logicalStartCol;
const visualEnd = logicalStartCol + cpLen(lineText);
const segments = buildSegmentsForVisualSlice(
tokens,
visualStart,
visualEnd,
);
const renderedLine: React.ReactNode[] = [];
let charCount = 0;
segments.forEach((seg, segIdx) => {
const segLen = cpLen(seg.text);
let display = seg.text;
if (isOnCursorLine) {
const segStart = charCount;
const segEnd = segStart + segLen;
if (
cursorVisualColAbsolute >= segStart &&
cursorVisualColAbsolute < segEnd
) {
const charToHighlight = cpSlice(
seg.text,
cursorVisualColAbsolute - segStart,
cursorVisualColAbsolute - segStart + 1,
);
const highlighted = showCursorOpt
? chalk.inverse(charToHighlight)
: charToHighlight;
display =
cpSlice(seg.text, 0, cursorVisualColAbsolute - segStart) +
highlighted +
cpSlice(seg.text, cursorVisualColAbsolute - segStart + 1);
}
charCount = segEnd;
}
const color =
seg.type === 'command' || seg.type === 'file'
? theme.text.accent
: theme.text.primary;
renderedLine.push(
<Text key={`token-${segIdx}`} color={color}>
{display}
</Text>,
);
});
if (isOnCursorLine && cursorVisualColAbsolute === cpLen(lineText)) {
// Add zero-width space after cursor to prevent Ink from trimming trailing whitespace
renderedLine.push(
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
{showCursorOpt ? chalk.inverse(' ') + '\u200B' : ' \u200B'}
</Text>,
);
}
return <Text>{renderedLine}</Text>;
},
[],
);
const getActiveCompletion = () => {
if (commandSearchActive) return commandSearchCompletion;
@ -1009,10 +1066,33 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
const borderColor =
isShellFocused && !isEmbeddedShellFocused
isShellFocused && !isEmbeddedShellFocused && !agentTabBarFocused
? (statusColor ?? theme.border.focused)
: theme.border.default;
const prefixNode = (
<Text
color={statusColor ?? theme.text.accent}
aria-label={statusText || undefined}
>
{shellModeActive ? (
reverseSearchActive ? (
<Text color={theme.text.link} aria-label={SCREEN_READER_USER_PREFIX}>
(r:){' '}
</Text>
) : (
'!'
)
) : commandSearchActive ? (
<Text color={theme.text.accent}>(r:) </Text>
) : showYoloStyling ? (
'*'
) : (
'>'
)}{' '}
</Text>
);
return (
<>
{attachments.length > 0 && (
@ -1032,142 +1112,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
))}
</Box>
)}
<Box
borderStyle="single"
borderTop={true}
borderBottom={true}
borderLeft={false}
borderRight={false}
<BaseTextInput
buffer={buffer}
onSubmit={handleSubmitAndClear}
onKeypress={handleInput}
showCursor={showCursor}
placeholder={placeholder}
prefix={prefixNode}
borderColor={borderColor}
>
<Text
color={statusColor ?? theme.text.accent}
aria-label={statusText || undefined}
>
{shellModeActive ? (
reverseSearchActive ? (
<Text
color={theme.text.link}
aria-label={SCREEN_READER_USER_PREFIX}
>
(r:){' '}
</Text>
) : (
'!'
)
) : commandSearchActive ? (
<Text color={theme.text.accent}>(r:) </Text>
) : showYoloStyling ? (
'*'
) : (
'>'
)}{' '}
</Text>
<Box flexGrow={1} flexDirection="column">
{buffer.text.length === 0 && placeholder ? (
showCursor ? (
<Text>
{chalk.inverse(placeholder.slice(0, 1))}
<Text color={theme.text.secondary}>{placeholder.slice(1)}</Text>
</Text>
) : (
<Text color={theme.text.secondary}>{placeholder}</Text>
)
) : (
linesToRender.map((lineText, visualIdxInRenderedSet) => {
const absoluteVisualIdx =
scrollVisualRow + visualIdxInRenderedSet;
const mapEntry = buffer.visualToLogicalMap[absoluteVisualIdx];
const cursorVisualRow = cursorVisualRowAbsolute - scrollVisualRow;
const isOnCursorLine =
focus && visualIdxInRenderedSet === cursorVisualRow;
const renderedLine: React.ReactNode[] = [];
const [logicalLineIdx, logicalStartCol] = mapEntry;
const logicalLine = buffer.lines[logicalLineIdx] || '';
const tokens = parseInputForHighlighting(
logicalLine,
logicalLineIdx,
);
const visualStart = logicalStartCol;
const visualEnd = logicalStartCol + cpLen(lineText);
const segments = buildSegmentsForVisualSlice(
tokens,
visualStart,
visualEnd,
);
let charCount = 0;
segments.forEach((seg, segIdx) => {
const segLen = cpLen(seg.text);
let display = seg.text;
if (isOnCursorLine) {
const relativeVisualColForHighlight = cursorVisualColAbsolute;
const segStart = charCount;
const segEnd = segStart + segLen;
if (
relativeVisualColForHighlight >= segStart &&
relativeVisualColForHighlight < segEnd
) {
const charToHighlight = cpSlice(
seg.text,
relativeVisualColForHighlight - segStart,
relativeVisualColForHighlight - segStart + 1,
);
const highlighted = showCursor
? chalk.inverse(charToHighlight)
: charToHighlight;
display =
cpSlice(
seg.text,
0,
relativeVisualColForHighlight - segStart,
) +
highlighted +
cpSlice(
seg.text,
relativeVisualColForHighlight - segStart + 1,
);
}
charCount = segEnd;
}
const color =
seg.type === 'command' || seg.type === 'file'
? theme.text.accent
: theme.text.primary;
renderedLine.push(
<Text key={`token-${segIdx}`} color={color}>
{display}
</Text>,
);
});
if (
isOnCursorLine &&
cursorVisualColAbsolute === cpLen(lineText)
) {
// Add zero-width space after cursor to prevent Ink from trimming trailing whitespace
renderedLine.push(
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
{showCursor ? chalk.inverse(' ') + '\u200B' : ' \u200B'}
</Text>,
);
}
return (
<Box key={`line-${visualIdxInRenderedSet}`} height={1}>
<Text>{renderedLine}</Text>
</Box>
);
})
)}
</Box>
</Box>
isActive={!isEmbeddedShellFocused}
renderLine={renderLineWithHighlighting}
/>
{shouldShowSuggestions && (
<Box marginLeft={2} marginRight={2}>
<SuggestionsDisplay

View file

@ -72,7 +72,8 @@ describe('<LoadingIndicator />', () => {
const output = lastFrame();
expect(output).toContain('MockRespondingSpinner');
expect(output).toContain('Loading...');
expect(output).toContain('(esc to cancel, 5s)');
expect(output).toContain('5s');
expect(output).toContain('esc to cancel');
});
it('should render spinner (static), phrase but no time/cancel when streamingState is WaitingForConfirmation', () => {
@ -88,7 +89,7 @@ describe('<LoadingIndicator />', () => {
expect(output).toContain('⠏'); // Static char for WaitingForConfirmation
expect(output).toContain('Confirm action');
expect(output).not.toContain('(esc to cancel)');
expect(output).not.toContain(', 10s');
expect(output).not.toContain('10s');
});
it('should display the currentLoadingPhrase correctly', () => {
@ -112,7 +113,7 @@ describe('<LoadingIndicator />', () => {
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
expect(lastFrame()).toContain('(esc to cancel, 1m)');
expect(lastFrame()).toContain('(1m · esc to cancel)');
});
it('should display the elapsedTime correctly in human-readable format', () => {
@ -124,7 +125,7 @@ describe('<LoadingIndicator />', () => {
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
expect(lastFrame()).toContain('(esc to cancel, 2m 5s)');
expect(lastFrame()).toContain('(2m 5s · esc to cancel)');
});
it('should render rightContent when provided', () => {
@ -155,7 +156,7 @@ describe('<LoadingIndicator />', () => {
let output = lastFrame();
expect(output).toContain('MockRespondingSpinner');
expect(output).toContain('Now Responding');
expect(output).toContain('(esc to cancel, 2s)');
expect(output).toContain('(2s · esc to cancel)');
// Transition to WaitingForConfirmation
rerender(
@ -170,7 +171,7 @@ describe('<LoadingIndicator />', () => {
expect(output).toContain('⠏');
expect(output).toContain('Please Confirm');
expect(output).not.toContain('(esc to cancel)');
expect(output).not.toContain(', 15s');
expect(output).not.toContain('15s');
// Transition back to Idle
rerender(
@ -262,7 +263,7 @@ describe('<LoadingIndicator />', () => {
// Check for single line output
expect(output?.includes('\n')).toBe(false);
expect(output).toContain('Loading...');
expect(output).toContain('(esc to cancel, 5s)');
expect(output).toContain('(5s · esc to cancel)');
expect(output).toContain('Right');
});
@ -284,8 +285,8 @@ describe('<LoadingIndicator />', () => {
expect(lines).toHaveLength(3);
if (lines) {
expect(lines[0]).toContain('Loading...');
expect(lines[0]).not.toContain('(esc to cancel, 5s)');
expect(lines[1]).toContain('(esc to cancel, 5s)');
expect(lines[0]).not.toContain('5s');
expect(lines[1]).toContain('5s');
expect(lines[2]).toContain('Right');
}
});
@ -308,4 +309,70 @@ describe('<LoadingIndicator />', () => {
expect(lastFrame()?.includes('\n')).toBe(true);
});
});
describe('token display', () => {
it('should display output tokens inline with arrow notation', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} candidatesTokens={847} />,
StreamingState.Responding,
);
const output = lastFrame();
expect(output).toContain('↓ 847 tokens');
expect(output).not.toContain('↑');
expect(output).toContain('5s');
expect(output).toContain('esc to cancel');
});
it('should not display tokens when output tokens is 0', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} candidatesTokens={0} />,
StreamingState.Responding,
);
const output = lastFrame();
expect(output).not.toContain('↓');
expect(output).not.toContain('tokens');
});
it('should not display tokens when props are undefined', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} />,
StreamingState.Responding,
);
const output = lastFrame();
expect(output).not.toContain('↓');
expect(output).not.toContain('tokens');
});
it('should hide tokens in narrow terminal', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} candidatesTokens={500} />,
StreamingState.Responding,
79,
);
const output = lastFrame();
expect(output).not.toContain('↓');
expect(output).not.toContain('tokens');
expect(output).toContain('esc to cancel');
});
it('should show tokens in wide terminal with inline format', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} candidatesTokens={5400} />,
StreamingState.Responding,
80,
);
const output = lastFrame();
expect(output).toContain('↓ 5.4k tokens');
});
it('should format tokens inline with time and cancel', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} candidatesTokens={5400} />,
StreamingState.Responding,
120,
);
const output = lastFrame();
expect(output).toContain('(5s · ↓ 5.4k tokens · esc to cancel)');
});
});
});

View file

@ -11,7 +11,7 @@ import { theme } from '../semantic-colors.js';
import { useStreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js';
import { formatDuration } from '../utils/formatters.js';
import { formatDuration, formatTokenCount } from '../utils/formatters.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { t } from '../../i18n/index.js';
@ -21,6 +21,7 @@ interface LoadingIndicatorProps {
elapsedTime: number;
rightContent?: React.ReactNode;
thought?: ThoughtSummary | null;
candidatesTokens?: number;
}
export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
@ -28,6 +29,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
elapsedTime,
rightContent,
thought,
candidatesTokens,
}) => {
const streamingState = useStreamingContext();
const { columns: terminalWidth } = useTerminalSize();
@ -39,18 +41,26 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
const primaryText = thought?.subject || currentLoadingPhrase;
const outputTokens = candidatesTokens ?? 0;
const showTokens = !isNarrow && outputTokens > 0;
const timeStr =
elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000);
const tokenStr = showTokens
? ` · ↓ ${formatTokenCount(outputTokens)} tokens`
: '';
const cancelAndTimerContent =
streamingState !== StreamingState.WaitingForConfirmation
? t('(esc to cancel, {{time}})', {
time:
elapsedTime < 60
? `${elapsedTime}s`
: formatDuration(elapsedTime * 1000),
? t('({{time}}{{tokens}} · esc to cancel)', {
time: timeStr,
tokens: tokenStr,
})
: null;
return (
<Box paddingLeft={0} flexDirection="column">
<Box paddingLeft={2} flexDirection="column">
{/* Main loading line */}
<Box
width="100%"

View file

@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<LoadingIndicator /> > should truncate long primary text instead of wrapping 1`] = `
"MockResponding This is an extremely long loading phrase that should be truncated in t (esc to
Spinner cancel, 5s)"
" MockResponding This is an extremely long loading phrase that should be truncated in (5s · esc to
Spinner cancel)"
`;

View file

@ -0,0 +1,272 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview AgentChatView displays a single in-process agent's conversation.
*
* Renders the agent's message history using HistoryItemDisplay the same
* component used by the main agent view. AgentMessage[] is converted to
* HistoryItem[] by agentMessagesToHistoryItems() so all 27 HistoryItem types
* are available without duplicating rendering logic.
*
* Layout:
* - Static area: finalized messages (efficient Ink <Static>)
* - Live area: tool groups still executing / awaiting confirmation
* - Status line: spinner while the agent is running
*
* Model text output is shown only after each round completes (no live
* streaming), which avoids per-chunk re-renders and keeps the display simple.
*/
import { Box, Text, Static } from 'ink';
import { useMemo, useState, useEffect, useCallback, useRef } from 'react';
import {
AgentStatus,
AgentEventType,
getGitBranch,
type AgentStatusChangeEvent,
} from '@qwen-code/qwen-code-core';
import {
useAgentViewState,
useAgentViewActions,
} from '../../contexts/AgentViewContext.js';
import { useUIState } from '../../contexts/UIStateContext.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { HistoryItemDisplay } from '../HistoryItemDisplay.js';
import { ToolCallStatus } from '../../types.js';
import { theme } from '../../semantic-colors.js';
import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { agentMessagesToHistoryItems } from './agentHistoryAdapter.js';
import { AgentHeader } from './AgentHeader.js';
// ─── Main Component ─────────────────────────────────────────
interface AgentChatViewProps {
agentId: string;
}
export const AgentChatView = ({ agentId }: AgentChatViewProps) => {
const { agents } = useAgentViewState();
const { setAgentShellFocused } = useAgentViewActions();
const uiState = useUIState();
const { historyRemountKey, availableTerminalHeight, constrainHeight } =
uiState;
const { columns: terminalWidth } = useTerminalSize();
const agent = agents.get(agentId);
const contentWidth = terminalWidth - 4;
// Force re-render on message updates and status changes.
// STREAM_TEXT is deliberately excluded — model text is shown only after
// each round completes (via committed messages), avoiding per-chunk re-renders.
const [, setRenderTick] = useState(0);
const tickRef = useRef(0);
const forceRender = useCallback(() => {
tickRef.current += 1;
setRenderTick(tickRef.current);
}, []);
useEffect(() => {
if (!agent) return;
const emitter = agent.interactiveAgent.getEventEmitter();
if (!emitter) return;
const onStatusChange = (_event: AgentStatusChangeEvent) => forceRender();
const onToolCall = () => forceRender();
const onToolResult = () => forceRender();
const onRoundEnd = () => forceRender();
const onApproval = () => forceRender();
const onOutputUpdate = () => forceRender();
emitter.on(AgentEventType.STATUS_CHANGE, onStatusChange);
emitter.on(AgentEventType.TOOL_CALL, onToolCall);
emitter.on(AgentEventType.TOOL_RESULT, onToolResult);
emitter.on(AgentEventType.ROUND_END, onRoundEnd);
emitter.on(AgentEventType.TOOL_WAITING_APPROVAL, onApproval);
emitter.on(AgentEventType.TOOL_OUTPUT_UPDATE, onOutputUpdate);
return () => {
emitter.off(AgentEventType.STATUS_CHANGE, onStatusChange);
emitter.off(AgentEventType.TOOL_CALL, onToolCall);
emitter.off(AgentEventType.TOOL_RESULT, onToolResult);
emitter.off(AgentEventType.ROUND_END, onRoundEnd);
emitter.off(AgentEventType.TOOL_WAITING_APPROVAL, onApproval);
emitter.off(AgentEventType.TOOL_OUTPUT_UPDATE, onOutputUpdate);
};
}, [agent, forceRender]);
const interactiveAgent = agent?.interactiveAgent;
const messages = interactiveAgent?.getMessages() ?? [];
const pendingApprovals = interactiveAgent?.getPendingApprovals();
const liveOutputs = interactiveAgent?.getLiveOutputs();
const shellPids = interactiveAgent?.getShellPids();
const status = interactiveAgent?.getStatus();
const isRunning =
status === AgentStatus.RUNNING || status === AgentStatus.INITIALIZING;
// Derive the active PTY PID: first shell PID among currently-executing tools.
// Resets naturally to undefined when the tool finishes (shellPids cleared).
const activePtyId =
shellPids && shellPids.size > 0
? shellPids.values().next().value
: undefined;
// Track whether the user has toggled input focus into the embedded shell.
// Mirrors the main agent's embeddedShellFocused in AppContainer.
const [embeddedShellFocused, setEmbeddedShellFocusedLocal] = useState(false);
// Sync to AgentViewContext so AgentTabBar can suppress arrow-key navigation
// when an agent's embedded shell is focused.
useEffect(() => {
setAgentShellFocused(embeddedShellFocused);
return () => setAgentShellFocused(false);
}, [embeddedShellFocused, setAgentShellFocused]);
// Reset focus when the shell exits (activePtyId disappears).
useEffect(() => {
if (!activePtyId) setEmbeddedShellFocusedLocal(false);
}, [activePtyId]);
// Ctrl+F: toggle shell input focus when a PTY is active.
useKeypress(
(key) => {
if (key.ctrl && key.name === 'f') {
if (activePtyId || embeddedShellFocused) {
setEmbeddedShellFocusedLocal((prev) => !prev);
}
}
},
{ isActive: true },
);
// Convert AgentMessage[] → HistoryItem[] via adapter.
// tickRef.current in deps ensures we rebuild when events fire even if
// messages.length and pendingApprovals.size haven't changed (e.g. a
// tool result updates an existing entry in place).
const allItems = useMemo(
() =>
agentMessagesToHistoryItems(
messages,
pendingApprovals ?? new Map(),
liveOutputs,
shellPids,
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
agentId,
messages.length,
pendingApprovals?.size,
liveOutputs?.size,
shellPids?.size,
tickRef.current,
],
);
// Split into committed (Static) and pending (live area).
// Any tool_group with an Executing or Confirming tool — plus everything
// after it — stays in the live area so confirmation dialogs remain
// interactive (Ink's <Static> cannot receive input).
const splitIndex = useMemo(() => {
for (let idx = allItems.length - 1; idx >= 0; idx--) {
const item = allItems[idx]!;
if (
item.type === 'tool_group' &&
item.tools.some(
(t) =>
t.status === ToolCallStatus.Executing ||
t.status === ToolCallStatus.Confirming,
)
) {
return idx;
}
}
return allItems.length; // all committed
}, [allItems]);
const committedItems = allItems.slice(0, splitIndex);
const pendingItems = allItems.slice(splitIndex);
const core = interactiveAgent?.getCore();
const agentWorkingDir = core?.runtimeContext.getTargetDir() ?? '';
// Cache the branch — it won't change during the agent's lifetime and
// getGitBranch uses synchronous execSync which blocks the render loop.
const agentGitBranch = useMemo(
() => (agentWorkingDir ? getGitBranch(agentWorkingDir) : ''),
// eslint-disable-next-line react-hooks/exhaustive-deps
[agentId],
);
if (!agent || !interactiveAgent || !core) {
return (
<Box marginX={2}>
<Text color={theme.status.error}>
Agent &quot;{agentId}&quot; not found.
</Text>
</Box>
);
}
const agentModelId = core.modelConfig.model ?? '';
return (
<Box flexDirection="column">
{/* Committed message history.
key includes historyRemountKey: when refreshStatic() clears the
terminal it bumps the key, forcing Static to remount and re-emit
all items on the cleared screen. */}
<Static
key={`agent-${agentId}-${historyRemountKey}`}
items={[
<AgentHeader
key="agent-header"
modelId={agentModelId}
modelName={agent.modelName}
workingDirectory={agentWorkingDir}
gitBranch={agentGitBranch}
/>,
...committedItems.map((item) => (
<HistoryItemDisplay
key={item.id}
item={item}
isPending={false}
terminalWidth={terminalWidth}
mainAreaWidth={contentWidth}
/>
)),
]}
>
{(item) => item}
</Static>
{/* Live area tool groups awaiting confirmation or still executing.
Must remain outside Static so confirmation dialogs are interactive.
Pass PTY state so ShellInputPrompt is reachable via Ctrl+F. */}
{pendingItems.map((item) => (
<HistoryItemDisplay
key={item.id}
item={item}
isPending={true}
terminalWidth={terminalWidth}
mainAreaWidth={contentWidth}
availableTerminalHeight={
constrainHeight ? availableTerminalHeight : undefined
}
isFocused={true}
activeShellPtyId={activePtyId ?? null}
embeddedShellFocused={embeddedShellFocused}
/>
))}
{/* Spinner */}
{isRunning && (
<Box marginX={2} marginTop={1}>
<GeminiRespondingSpinner />
</Box>
)}
</Box>
);
};

View file

@ -0,0 +1,308 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview AgentComposer footer area for in-process agent tabs.
*
* Replaces the main Composer when an agent tab is active so that:
* - The loading indicator reflects the agent's status (not the main agent)
* - The input prompt sends messages to the agent (via enqueueMessage)
* - Keyboard events are scoped no conflict with the main InputPrompt
*
* Wraps its content in a local StreamingContext.Provider so reusable
* components like LoadingIndicator and GeminiRespondingSpinner read the
* agent's derived streaming state instead of the main agent's.
*/
import { Box, Text, useStdin } from 'ink';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
AgentStatus,
isTerminalStatus,
ApprovalMode,
APPROVAL_MODES,
} from '@qwen-code/qwen-code-core';
import {
useAgentViewState,
useAgentViewActions,
} from '../../contexts/AgentViewContext.js';
import { useConfig } from '../../contexts/ConfigContext.js';
import { StreamingContext } from '../../contexts/StreamingContext.js';
import { StreamingState } from '../../types.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { useAgentStreamingState } from '../../hooks/useAgentStreamingState.js';
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
import { useTextBuffer } from '../shared/text-buffer.js';
import { calculatePromptWidths } from '../../utils/layoutUtils.js';
import { BaseTextInput } from '../BaseTextInput.js';
import { LoadingIndicator } from '../LoadingIndicator.js';
import { QueuedMessageDisplay } from '../QueuedMessageDisplay.js';
import { AgentFooter } from './AgentFooter.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
import { theme } from '../../semantic-colors.js';
import { t } from '../../../i18n/index.js';
// ─── Types ──────────────────────────────────────────────────
interface AgentComposerProps {
agentId: string;
}
// ─── Component ──────────────────────────────────────────────
export const AgentComposer: React.FC<AgentComposerProps> = ({ agentId }) => {
const { agents, agentTabBarFocused, agentShellFocused, agentApprovalModes } =
useAgentViewState();
const {
setAgentInputBufferText,
setAgentTabBarFocused,
setAgentApprovalMode,
} = useAgentViewActions();
const agent = agents.get(agentId);
const interactiveAgent = agent?.interactiveAgent;
const config = useConfig();
const { columns: terminalWidth } = useTerminalSize();
const { inputWidth } = calculatePromptWidths(terminalWidth);
const { stdin, setRawMode } = useStdin();
const {
status,
streamingState,
isInputActive,
elapsedTime,
lastPromptTokenCount,
} = useAgentStreamingState(interactiveAgent);
// ── Escape to cancel the active agent round ──
useKeypress(
(key) => {
if (
key.name === 'escape' &&
streamingState === StreamingState.Responding
) {
interactiveAgent?.cancelCurrentRound();
}
},
{
isActive:
streamingState === StreamingState.Responding && !agentShellFocused,
},
);
// ── Shift+Tab to cycle this agent's approval mode ──
const agentApprovalMode =
agentApprovalModes.get(agentId) ?? ApprovalMode.DEFAULT;
useKeypress(
(key) => {
const isShiftTab = key.shift && key.name === 'tab';
const isWindowsTab =
process.platform === 'win32' &&
key.name === 'tab' &&
!key.ctrl &&
!key.meta;
if (isShiftTab || isWindowsTab) {
const currentIndex = APPROVAL_MODES.indexOf(agentApprovalMode);
const nextIndex =
currentIndex === -1 ? 0 : (currentIndex + 1) % APPROVAL_MODES.length;
setAgentApprovalMode(agentId, APPROVAL_MODES[nextIndex]!);
}
},
{ isActive: !agentShellFocused },
);
// ── Input buffer (independent from main agent) ──
const isValidPath = useCallback((): boolean => false, []);
const buffer = useTextBuffer({
initialText: '',
viewport: { height: 3, width: inputWidth },
stdin,
setRawMode,
isValidPath,
});
// Sync agent buffer text to context so AgentTabBar can guard tab switching
useEffect(() => {
setAgentInputBufferText(buffer.text);
return () => setAgentInputBufferText('');
}, [buffer.text, setAgentInputBufferText]);
// When agent input is not active (agent running, completed, etc.),
// auto-focus the tab bar so arrow keys switch tabs directly.
// We also depend on streamingState so that transitions like
// WaitingForConfirmation → Responding re-trigger the effect — the
// approval keypress releases tab-bar focus (printable char handler),
// but isInputActive stays false throughout, so without this extra
// dependency the focus would never be restored.
useEffect(() => {
if (!isInputActive) {
setAgentTabBarFocused(true);
}
}, [isInputActive, streamingState, setAgentTabBarFocused]);
// ── Focus management between input and tab bar ──
const handleKeypress = useCallback(
(key: Key): boolean => {
// When tab bar has focus, block all non-printable keys so they don't
// act on the hidden buffer. Printable characters fall through to
// BaseTextInput naturally; the tab bar handler releases focus on the
// same event so the keystroke appears in the input immediately.
if (agentTabBarFocused) {
if (
key.sequence &&
key.sequence.length === 1 &&
!key.ctrl &&
!key.meta
) {
return false; // let BaseTextInput type the character
}
return true; // consume non-printable keys
}
// Down arrow at the bottom edge (or empty buffer) → focus the tab bar
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
if (
buffer.text === '' ||
buffer.allVisualLines.length === 1 ||
buffer.visualCursor[0] === buffer.allVisualLines.length - 1
) {
setAgentTabBarFocused(true);
return true;
}
}
return false;
},
[buffer, agentTabBarFocused, setAgentTabBarFocused],
);
// ── Message queue (accumulate while streaming, flush as one prompt on idle) ──
const [messageQueue, setMessageQueue] = useState<string[]>([]);
// When agent becomes idle (and not terminal), flush queued messages.
useEffect(() => {
if (
streamingState === StreamingState.Idle &&
messageQueue.length > 0 &&
status !== undefined &&
!isTerminalStatus(status)
) {
const combined = messageQueue.join('\n');
setMessageQueue([]);
interactiveAgent?.enqueueMessage(combined);
}
}, [streamingState, messageQueue, interactiveAgent, status]);
const handleSubmit = useCallback(
(text: string) => {
const trimmed = text.trim();
if (!trimmed || !interactiveAgent) return;
if (streamingState === StreamingState.Idle) {
interactiveAgent.enqueueMessage(trimmed);
} else {
setMessageQueue((prev) => [...prev, trimmed]);
}
},
[interactiveAgent, streamingState],
);
// ── Render ──
const statusLabel = useMemo(() => {
switch (status) {
case AgentStatus.COMPLETED:
return { text: t('Completed'), color: theme.status.success };
case AgentStatus.FAILED:
return {
text: t('Failed: {{error}}', {
error:
interactiveAgent?.getError() ??
interactiveAgent?.getLastRoundError() ??
'unknown',
}),
color: theme.status.error,
};
case AgentStatus.CANCELLED:
return { text: t('Cancelled'), color: theme.text.secondary };
default:
return null;
}
}, [status, interactiveAgent]);
// ── Approval-mode styling (mirrors main InputPrompt) ──
const isYolo = agentApprovalMode === ApprovalMode.YOLO;
const isAutoAccept = agentApprovalMode !== ApprovalMode.DEFAULT;
const statusColor = isYolo
? theme.status.errorDim
: isAutoAccept
? theme.status.warningDim
: undefined;
const inputBorderColor =
!isInputActive || agentTabBarFocused
? theme.border.default
: (statusColor ?? theme.border.focused);
const prefixNode = (
<Text color={statusColor ?? theme.text.accent}>{isYolo ? '*' : '>'} </Text>
);
return (
<StreamingContext.Provider value={streamingState}>
<Box flexDirection="column" marginTop={1}>
{/* Loading indicator mirrors main Composer but reads agent's
streaming state via the overridden StreamingContext. */}
<LoadingIndicator
currentLoadingPhrase={
streamingState === StreamingState.Responding
? t('Thinking…')
: undefined
}
elapsedTime={elapsedTime}
/>
{/* Terminal status for completed/failed agents */}
{statusLabel && (
<Box marginLeft={2}>
<Text color={statusLabel.color}>{statusLabel.text}</Text>
</Box>
)}
<QueuedMessageDisplay messageQueue={messageQueue} />
{/* Input prompt — always visible, like the main Composer */}
<BaseTextInput
buffer={buffer}
onSubmit={handleSubmit}
onKeypress={handleKeypress}
showCursor={isInputActive && !agentTabBarFocused}
placeholder={' ' + t('Send a message to this agent')}
prefix={prefixNode}
borderColor={inputBorderColor}
isActive={isInputActive && !agentShellFocused}
/>
{/* Footer: approval mode + context usage */}
<AgentFooter
approvalMode={agentApprovalMode}
promptTokenCount={lastPromptTokenCount}
contextWindowSize={
config.getContentGeneratorConfig()?.contextWindowSize
}
terminalWidth={terminalWidth}
/>
</Box>
</StreamingContext.Provider>
);
};

View file

@ -0,0 +1,66 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Lightweight footer for agent tabs showing approval mode
* and context usage. Mirrors the main Footer layout but without
* main-agent-specific concerns (vim mode, shell mode, exit prompts, etc.).
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { ApprovalMode } from '@qwen-code/qwen-code-core';
import { AutoAcceptIndicator } from '../AutoAcceptIndicator.js';
import { ContextUsageDisplay } from '../ContextUsageDisplay.js';
import { theme } from '../../semantic-colors.js';
interface AgentFooterProps {
approvalMode: ApprovalMode | undefined;
promptTokenCount: number;
contextWindowSize: number | undefined;
terminalWidth: number;
}
export const AgentFooter: React.FC<AgentFooterProps> = ({
approvalMode,
promptTokenCount,
contextWindowSize,
terminalWidth,
}) => {
const showApproval =
approvalMode !== undefined && approvalMode !== ApprovalMode.DEFAULT;
const showContext = promptTokenCount > 0 && contextWindowSize !== undefined;
if (!showApproval && !showContext) {
return null;
}
return (
<Box
justifyContent="space-between"
width="100%"
flexDirection="row"
alignItems="center"
>
<Box marginLeft={2}>
{showApproval ? (
<AutoAcceptIndicator approvalMode={approvalMode} />
) : null}
</Box>
<Box marginRight={2}>
{showContext && (
<Text color={theme.text.accent}>
<ContextUsageDisplay
promptTokenCount={promptTokenCount}
terminalWidth={terminalWidth}
contextWindowSize={contextWindowSize!}
/>
</Text>
)}
</Box>
</Box>
);
};

View file

@ -0,0 +1,64 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Compact header for agent tabs, visually distinct from the
* main view's boxed logo header. Shows model, working directory, and git
* branch in a bordered info panel.
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { shortenPath, tildeifyPath } from '@qwen-code/qwen-code-core';
import { theme } from '../../semantic-colors.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
interface AgentHeaderProps {
modelId: string;
modelName?: string;
workingDirectory: string;
gitBranch?: string;
}
export const AgentHeader: React.FC<AgentHeaderProps> = ({
modelId,
modelName,
workingDirectory,
gitBranch,
}) => {
const { columns: terminalWidth } = useTerminalSize();
const maxPathLen = Math.max(20, terminalWidth - 12);
const displayPath = shortenPath(tildeifyPath(workingDirectory), maxPathLen);
const modelText =
modelName && modelName !== modelId ? `${modelId} (${modelName})` : modelId;
return (
<Box
flexDirection="column"
marginX={2}
marginTop={1}
borderStyle="round"
borderColor={theme.border.default}
paddingX={1}
>
<Text>
<Text color={theme.text.secondary}>{'Model: '}</Text>
<Text color={theme.text.primary}>{modelText}</Text>
</Text>
<Text>
<Text color={theme.text.secondary}>{'Path: '}</Text>
<Text color={theme.text.primary}>{displayPath}</Text>
</Text>
{gitBranch && (
<Text>
<Text color={theme.text.secondary}>{'Branch: '}</Text>
<Text color={theme.text.primary}>{gitBranch}</Text>
</Text>
)}
</Box>
);
};

View file

@ -0,0 +1,167 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview AgentTabBar horizontal tab strip for in-process agent views.
*
* Rendered at the top of the terminal whenever in-process agents are registered.
*
* On the main tab, Left/Right switch tabs when the input buffer is empty.
* On agent tabs, the tab bar uses an exclusive-focus model:
* - Down arrow at the input's bottom edge focuses the tab bar
* - Left/Right switch tabs only when the tab bar is focused
* - Up arrow or typing returns focus to the input
*
* Tab indicators: running, idle/completed, failed, cancelled
*/
import { Box, Text } from 'ink';
import { useState, useEffect, useCallback } from 'react';
import { AgentStatus, AgentEventType } from '@qwen-code/qwen-code-core';
import {
useAgentViewState,
useAgentViewActions,
type RegisteredAgent,
} from '../../contexts/AgentViewContext.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { useUIState } from '../../contexts/UIStateContext.js';
import { theme } from '../../semantic-colors.js';
// ─── Status Indicators ──────────────────────────────────────
function statusIndicator(agent: RegisteredAgent): {
symbol: string;
color: string;
} {
const status = agent.interactiveAgent.getStatus();
switch (status) {
case AgentStatus.RUNNING:
case AgentStatus.INITIALIZING:
return { symbol: '\u25CF', color: theme.status.warning }; // ● running
case AgentStatus.IDLE:
return { symbol: '\u25CF', color: theme.status.success }; // ● idle (ready)
case AgentStatus.COMPLETED:
return { symbol: '\u2713', color: theme.status.success }; // ✓ completed
case AgentStatus.FAILED:
return { symbol: '\u2717', color: theme.status.error }; // ✗ failed
case AgentStatus.CANCELLED:
return { symbol: '\u25CB', color: theme.text.secondary }; // ○ cancelled
default:
return { symbol: '\u25CB', color: theme.text.secondary }; // ○ fallback
}
}
// ─── Component ──────────────────────────────────────────────
export const AgentTabBar: React.FC = () => {
const { activeView, agents, agentShellFocused, agentTabBarFocused } =
useAgentViewState();
const { switchToNext, switchToPrevious, setAgentTabBarFocused } =
useAgentViewActions();
const { embeddedShellFocused } = useUIState();
useKeypress(
(key) => {
if (embeddedShellFocused || agentShellFocused) return;
if (!agentTabBarFocused) return;
if (key.name === 'left') {
switchToPrevious();
} else if (key.name === 'right') {
switchToNext();
} else if (key.name === 'up') {
setAgentTabBarFocused(false);
} else if (
key.sequence &&
key.sequence.length === 1 &&
!key.ctrl &&
!key.meta
) {
// Printable character → return focus to input (key falls through
// to BaseTextInput's useKeypress and gets typed normally)
setAgentTabBarFocused(false);
}
},
{ isActive: true },
);
// Subscribe to STATUS_CHANGE events from all agents so the tab bar
// re-renders when an agent's status transitions (e.g. RUNNING → COMPLETED).
// Without this, status indicators would be stale until the next unrelated render.
const [, setTick] = useState(0);
const forceRender = useCallback(() => setTick((t) => t + 1), []);
useEffect(() => {
const cleanups: Array<() => void> = [];
for (const [, agent] of agents) {
const emitter = agent.interactiveAgent.getEventEmitter();
if (emitter) {
emitter.on(AgentEventType.STATUS_CHANGE, forceRender);
cleanups.push(() =>
emitter.off(AgentEventType.STATUS_CHANGE, forceRender),
);
}
}
return () => cleanups.forEach((fn) => fn());
}, [agents, forceRender]);
const isFocused = agentTabBarFocused;
// Navigation hint varies by context
const hint = isFocused ? '\u2190/\u2192 switch \u2191 input' : '\u2193 tabs';
return (
<Box flexDirection="row" paddingX={1}>
{/* Main tab */}
<Box marginRight={1}>
<Text
bold={activeView === 'main'}
dimColor={!isFocused}
backgroundColor={
activeView === 'main' ? theme.border.default : undefined
}
color={
activeView === 'main' ? theme.text.primary : theme.text.secondary
}
>
{' Main '}
</Text>
</Box>
{/* Separator */}
<Text dimColor={!isFocused} color={theme.border.default}>
{'\u2502'}
</Text>
{/* Agent tabs */}
{[...agents.entries()].map(([agentId, agent]) => {
const isActive = activeView === agentId;
const { symbol, color: indicatorColor } = statusIndicator(agent);
return (
<Box key={agentId} marginLeft={1}>
<Text
bold={isActive}
dimColor={!isFocused}
backgroundColor={isActive ? theme.border.default : undefined}
color={isActive ? undefined : agent.color || theme.text.secondary}
>
{` ${agent.modelId} `}
</Text>
<Text dimColor={!isFocused} color={indicatorColor}>
{` ${symbol}`}
</Text>
</Box>
);
})}
{/* Navigation hint */}
<Box marginLeft={2}>
<Text color={theme.text.secondary}>{hint}</Text>
</Box>
</Box>
);
};

View file

@ -0,0 +1,510 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { agentMessagesToHistoryItems } from './agentHistoryAdapter.js';
import type {
AgentMessage,
ToolCallConfirmationDetails,
} from '@qwen-code/qwen-code-core';
import { ToolCallStatus } from '../../types.js';
// ─── Helpers ────────────────────────────────────────────────
function msg(
role: AgentMessage['role'],
content: string,
extra?: Partial<AgentMessage>,
): AgentMessage {
return { role, content, timestamp: 0, ...extra };
}
const noApprovals = new Map<string, ToolCallConfirmationDetails>();
function toolCallMsg(
callId: string,
toolName: string,
opts?: { description?: string; renderOutputAsMarkdown?: boolean },
): AgentMessage {
return msg('tool_call', `Tool call: ${toolName}`, {
metadata: {
callId,
toolName,
description: opts?.description ?? '',
renderOutputAsMarkdown: opts?.renderOutputAsMarkdown,
},
});
}
function toolResultMsg(
callId: string,
toolName: string,
opts?: {
success?: boolean;
resultDisplay?: string;
outputFile?: string;
},
): AgentMessage {
return msg('tool_result', `Tool ${toolName}`, {
metadata: {
callId,
toolName,
success: opts?.success ?? true,
resultDisplay: opts?.resultDisplay,
outputFile: opts?.outputFile,
},
});
}
// ─── Role mapping ────────────────────────────────────────────
describe('agentMessagesToHistoryItems — role mapping', () => {
it('maps user message', () => {
const items = agentMessagesToHistoryItems(
[msg('user', 'hello')],
noApprovals,
);
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({ type: 'user', text: 'hello' });
});
it('maps plain assistant message', () => {
const items = agentMessagesToHistoryItems(
[msg('assistant', 'response')],
noApprovals,
);
expect(items[0]).toMatchObject({ type: 'gemini', text: 'response' });
});
it('maps thought assistant message', () => {
const items = agentMessagesToHistoryItems(
[msg('assistant', 'thinking...', { thought: true })],
noApprovals,
);
expect(items[0]).toMatchObject({
type: 'gemini_thought',
text: 'thinking...',
});
});
it('maps assistant message with error metadata', () => {
const items = agentMessagesToHistoryItems(
[msg('assistant', 'oops', { metadata: { error: true } })],
noApprovals,
);
expect(items[0]).toMatchObject({ type: 'error', text: 'oops' });
});
it('maps info message with no level → type info', () => {
const items = agentMessagesToHistoryItems(
[msg('info', 'note')],
noApprovals,
);
expect(items[0]).toMatchObject({ type: 'info', text: 'note' });
});
it.each([
['warning', 'warning'],
['success', 'success'],
['error', 'error'],
] as const)('maps info message with level=%s', (level, expectedType) => {
const items = agentMessagesToHistoryItems(
[msg('info', 'text', { metadata: { level } })],
noApprovals,
);
expect(items[0]).toMatchObject({ type: expectedType });
});
it('maps unknown info level → type info', () => {
const items = agentMessagesToHistoryItems(
[msg('info', 'x', { metadata: { level: 'verbose' } })],
noApprovals,
);
expect(items[0]).toMatchObject({ type: 'info' });
});
it('skips unknown roles without crashing', () => {
const items = agentMessagesToHistoryItems(
[
msg('user', 'before'),
// force an unknown role
{ role: 'unknown' as AgentMessage['role'], content: 'x', timestamp: 0 },
msg('user', 'after'),
],
noApprovals,
);
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({ type: 'user', text: 'before' });
expect(items[1]).toMatchObject({ type: 'user', text: 'after' });
});
});
// ─── Tool grouping ───────────────────────────────────────────
describe('agentMessagesToHistoryItems — tool grouping', () => {
it('merges a tool_call + tool_result pair into one tool_group', () => {
const items = agentMessagesToHistoryItems(
[toolCallMsg('c1', 'read_file'), toolResultMsg('c1', 'read_file')],
noApprovals,
);
expect(items).toHaveLength(1);
expect(items[0]!.type).toBe('tool_group');
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools).toHaveLength(1);
expect(group.tools[0]!.name).toBe('read_file');
});
it('merges multiple parallel tool calls into one tool_group', () => {
const items = agentMessagesToHistoryItems(
[
toolCallMsg('c1', 'read_file'),
toolCallMsg('c2', 'write_file'),
toolResultMsg('c1', 'read_file'),
toolResultMsg('c2', 'write_file'),
],
noApprovals,
);
expect(items).toHaveLength(1);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools).toHaveLength(2);
expect(group.tools[0]!.name).toBe('read_file');
expect(group.tools[1]!.name).toBe('write_file');
});
it('preserves tool call order by first appearance', () => {
const items = agentMessagesToHistoryItems(
[
toolCallMsg('c2', 'second'),
toolCallMsg('c1', 'first'),
toolResultMsg('c1', 'first'),
toolResultMsg('c2', 'second'),
],
noApprovals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.name).toBe('second');
expect(group.tools[1]!.name).toBe('first');
});
it('breaks tool groups at non-tool messages', () => {
const items = agentMessagesToHistoryItems(
[
toolCallMsg('c1', 'tool_a'),
toolResultMsg('c1', 'tool_a'),
msg('assistant', 'between'),
toolCallMsg('c2', 'tool_b'),
toolResultMsg('c2', 'tool_b'),
],
noApprovals,
);
expect(items).toHaveLength(3);
expect(items[0]!.type).toBe('tool_group');
expect(items[1]!.type).toBe('gemini');
expect(items[2]!.type).toBe('tool_group');
});
it('handles tool_result arriving without a prior tool_call gracefully', () => {
const items = agentMessagesToHistoryItems(
[
toolResultMsg('c1', 'orphan', {
success: true,
resultDisplay: 'output',
}),
],
noApprovals,
);
expect(items).toHaveLength(1);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.callId).toBe('c1');
expect(group.tools[0]!.status).toBe(ToolCallStatus.Success);
});
});
// ─── Tool status ─────────────────────────────────────────────
describe('agentMessagesToHistoryItems — tool status', () => {
it('Executing: tool_call with no result yet', () => {
const items = agentMessagesToHistoryItems(
[toolCallMsg('c1', 'shell')],
noApprovals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.status).toBe(ToolCallStatus.Executing);
});
it('Success: tool_result with success=true', () => {
const items = agentMessagesToHistoryItems(
[
toolCallMsg('c1', 'read'),
toolResultMsg('c1', 'read', { success: true }),
],
noApprovals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.status).toBe(ToolCallStatus.Success);
});
it('Error: tool_result with success=false', () => {
const items = agentMessagesToHistoryItems(
[
toolCallMsg('c1', 'write'),
toolResultMsg('c1', 'write', { success: false }),
],
noApprovals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.status).toBe(ToolCallStatus.Error);
});
it('Confirming: tool_call present in pendingApprovals', () => {
const fakeApproval = {} as ToolCallConfirmationDetails;
const approvals = new Map([['c1', fakeApproval]]);
const items = agentMessagesToHistoryItems(
[toolCallMsg('c1', 'shell')],
approvals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.status).toBe(ToolCallStatus.Confirming);
expect(group.tools[0]!.confirmationDetails).toBe(fakeApproval);
});
it('Confirming takes priority over Executing', () => {
// pending approval AND no result yet → Confirming, not Executing
const approvals = new Map([['c1', {} as ToolCallConfirmationDetails]]);
const items = agentMessagesToHistoryItems(
[toolCallMsg('c1', 'shell')],
approvals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.status).toBe(ToolCallStatus.Confirming);
});
});
// ─── Tool metadata ───────────────────────────────────────────
describe('agentMessagesToHistoryItems — tool metadata', () => {
it('forwards resultDisplay from tool_result', () => {
const items = agentMessagesToHistoryItems(
[
toolCallMsg('c1', 'read'),
toolResultMsg('c1', 'read', {
success: true,
resultDisplay: 'file contents',
}),
],
noApprovals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.resultDisplay).toBe('file contents');
});
it('forwards renderOutputAsMarkdown from tool_call', () => {
const items = agentMessagesToHistoryItems(
[
toolCallMsg('c1', 'web_fetch', { renderOutputAsMarkdown: true }),
toolResultMsg('c1', 'web_fetch', { success: true }),
],
noApprovals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.renderOutputAsMarkdown).toBe(true);
});
it('forwards description from tool_call', () => {
const items = agentMessagesToHistoryItems(
[toolCallMsg('c1', 'read', { description: 'reading src/index.ts' })],
noApprovals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.description).toBe('reading src/index.ts');
});
});
// ─── liveOutputs overlay ─────────────────────────────────────
describe('agentMessagesToHistoryItems — liveOutputs', () => {
it('uses liveOutput as resultDisplay for Executing tools', () => {
const liveOutputs = new Map([['c1', 'live stdout so far']]);
const items = agentMessagesToHistoryItems(
[toolCallMsg('c1', 'shell')],
noApprovals,
liveOutputs,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.resultDisplay).toBe('live stdout so far');
});
it('ignores liveOutput for completed tools', () => {
const liveOutputs = new Map([['c1', 'stale live output']]);
const items = agentMessagesToHistoryItems(
[
toolCallMsg('c1', 'shell'),
toolResultMsg('c1', 'shell', {
success: true,
resultDisplay: 'final output',
}),
],
noApprovals,
liveOutputs,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.resultDisplay).toBe('final output');
});
it('falls back to entry resultDisplay when no liveOutput for callId', () => {
const liveOutputs = new Map([['other-id', 'unrelated']]);
const items = agentMessagesToHistoryItems(
[toolCallMsg('c1', 'shell')],
noApprovals,
liveOutputs,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.resultDisplay).toBeUndefined();
});
});
// ─── shellPids overlay ───────────────────────────────────────
describe('agentMessagesToHistoryItems — shellPids', () => {
it('sets ptyId for Executing tools with a known PID', () => {
const shellPids = new Map([['c1', 12345]]);
const items = agentMessagesToHistoryItems(
[toolCallMsg('c1', 'shell')],
noApprovals,
undefined,
shellPids,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.ptyId).toBe(12345);
});
it('does not set ptyId for completed tools', () => {
const shellPids = new Map([['c1', 12345]]);
const items = agentMessagesToHistoryItems(
[
toolCallMsg('c1', 'shell'),
toolResultMsg('c1', 'shell', { success: true }),
],
noApprovals,
undefined,
shellPids,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.ptyId).toBeUndefined();
});
it('does not set ptyId when shellPids is not provided', () => {
const items = agentMessagesToHistoryItems(
[toolCallMsg('c1', 'shell')],
noApprovals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.ptyId).toBeUndefined();
});
});
// ─── ID stability ────────────────────────────────────────────
describe('agentMessagesToHistoryItems — ID stability', () => {
it('assigns monotonically increasing IDs', () => {
const items = agentMessagesToHistoryItems(
[
msg('user', 'u1'),
msg('assistant', 'a1'),
msg('info', 'i1'),
toolCallMsg('c1', 'tool'),
toolResultMsg('c1', 'tool'),
],
noApprovals,
);
const ids = items.map((i) => i.id);
expect(ids).toEqual([0, 1, 2, 3]);
});
it('tool_group consumes one ID regardless of how many calls it contains', () => {
const items = agentMessagesToHistoryItems(
[
msg('user', 'go'),
toolCallMsg('c1', 'tool_a'),
toolCallMsg('c2', 'tool_b'),
toolResultMsg('c1', 'tool_a'),
toolResultMsg('c2', 'tool_b'),
msg('assistant', 'done'),
],
noApprovals,
);
// user=0, tool_group=1, assistant=2
expect(items.map((i) => i.id)).toEqual([0, 1, 2]);
});
it('IDs from a prefix of messages are stable when more messages are appended', () => {
const base: AgentMessage[] = [msg('user', 'u'), msg('assistant', 'a')];
const before = agentMessagesToHistoryItems(base, noApprovals);
const after = agentMessagesToHistoryItems(
[...base, msg('info', 'i')],
noApprovals,
);
expect(after[0]!.id).toBe(before[0]!.id);
expect(after[1]!.id).toBe(before[1]!.id);
expect(after[2]!.id).toBe(2);
});
});

View file

@ -0,0 +1,194 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview agentHistoryAdapter converts AgentMessage[] to HistoryItem[].
*
* This adapter bridges the sub-agent data model (AgentMessage[] from
* AgentInteractive) to the shared rendering model (HistoryItem[] consumed by
* HistoryItemDisplay). It lives in the CLI package so that packages/core types
* are never coupled to CLI rendering types.
*
* ID stability: AgentMessage[] is append-only, so the resulting HistoryItem[]
* only ever grows. Index-based IDs are therefore stable Ink's <Static>
* requires items never shift or be removed, which this guarantees.
*/
import type {
AgentMessage,
ToolCallConfirmationDetails,
ToolResultDisplay,
} from '@qwen-code/qwen-code-core';
import type { HistoryItem, IndividualToolCallDisplay } from '../../types.js';
import { ToolCallStatus } from '../../types.js';
/**
* Convert AgentMessage[] + pendingApprovals into HistoryItem[].
*
* Consecutive tool_call / tool_result messages are merged into a single
* tool_group HistoryItem. pendingApprovals overlays confirmation state so
* ToolGroupMessage can render confirmation dialogs.
*
* liveOutputs (optional) provides real-time display data for executing tools.
* shellPids (optional) provides PTY PIDs for interactive shell tools so
* HistoryItemDisplay can render ShellInputPrompt on the active shell.
*/
export function agentMessagesToHistoryItems(
messages: readonly AgentMessage[],
pendingApprovals: ReadonlyMap<string, ToolCallConfirmationDetails>,
liveOutputs?: ReadonlyMap<string, ToolResultDisplay>,
shellPids?: ReadonlyMap<string, number>,
): HistoryItem[] {
const items: HistoryItem[] = [];
let nextId = 0;
let i = 0;
while (i < messages.length) {
const msg = messages[i]!;
// ── user ──────────────────────────────────────────────────
if (msg.role === 'user') {
items.push({ type: 'user', text: msg.content, id: nextId++ });
i++;
// ── assistant ─────────────────────────────────────────────
} else if (msg.role === 'assistant') {
if (msg.metadata?.['error']) {
items.push({ type: 'error', text: msg.content, id: nextId++ });
} else if (msg.thought) {
items.push({ type: 'gemini_thought', text: msg.content, id: nextId++ });
} else {
items.push({ type: 'gemini', text: msg.content, id: nextId++ });
}
i++;
// ── info / warning / success / error ──────────────────────
} else if (msg.role === 'info') {
const level = msg.metadata?.['level'] as string | undefined;
const type =
level === 'warning' || level === 'success' || level === 'error'
? level
: 'info';
items.push({ type, text: msg.content, id: nextId++ });
i++;
// ── tool_call / tool_result → tool_group ──────────────────
} else if (msg.role === 'tool_call' || msg.role === 'tool_result') {
const groupId = nextId++;
const callMap = new Map<
string,
{
callId: string;
name: string;
description: string;
resultDisplay: ToolResultDisplay | string | undefined;
outputFile: string | undefined;
renderOutputAsMarkdown: boolean | undefined;
success: boolean | undefined;
}
>();
const callOrder: string[] = [];
while (
i < messages.length &&
(messages[i]!.role === 'tool_call' ||
messages[i]!.role === 'tool_result')
) {
const m = messages[i]!;
const callId = (m.metadata?.['callId'] as string) ?? `unknown-${i}`;
if (m.role === 'tool_call') {
if (!callMap.has(callId)) callOrder.push(callId);
callMap.set(callId, {
callId,
name: (m.metadata?.['toolName'] as string) ?? 'unknown',
description: (m.metadata?.['description'] as string) ?? '',
resultDisplay: undefined,
outputFile: undefined,
renderOutputAsMarkdown: m.metadata?.['renderOutputAsMarkdown'] as
| boolean
| undefined,
success: undefined,
});
} else {
// tool_result — attach to existing call entry
const entry = callMap.get(callId);
const resultDisplay = m.metadata?.['resultDisplay'] as
| ToolResultDisplay
| string
| undefined;
const outputFile = m.metadata?.['outputFile'] as string | undefined;
const success = m.metadata?.['success'] as boolean;
if (entry) {
entry.success = success;
entry.resultDisplay = resultDisplay;
entry.outputFile = outputFile;
} else {
// Result arrived without a prior tool_call message (shouldn't
// normally happen, but handle gracefully)
callOrder.push(callId);
callMap.set(callId, {
callId,
name: (m.metadata?.['toolName'] as string) ?? 'unknown',
description: '',
resultDisplay,
outputFile,
renderOutputAsMarkdown: undefined,
success,
});
}
}
i++;
}
const tools: IndividualToolCallDisplay[] = callOrder.map((callId) => {
const entry = callMap.get(callId)!;
const approval = pendingApprovals.get(callId);
let status: ToolCallStatus;
if (approval) {
status = ToolCallStatus.Confirming;
} else if (entry.success === undefined) {
status = ToolCallStatus.Executing;
} else if (entry.success) {
status = ToolCallStatus.Success;
} else {
status = ToolCallStatus.Error;
}
// For executing tools, use live output if available (Gap 4)
const resultDisplay =
status === ToolCallStatus.Executing && liveOutputs?.has(callId)
? liveOutputs.get(callId)
: entry.resultDisplay;
return {
callId: entry.callId,
name: entry.name,
description: entry.description,
resultDisplay,
outputFile: entry.outputFile,
renderOutputAsMarkdown: entry.renderOutputAsMarkdown,
status,
confirmationDetails: approval,
ptyId:
status === ToolCallStatus.Executing
? shellPids?.get(callId)
: undefined,
};
});
items.push({ type: 'tool_group', tools, id: groupId });
} else {
// Skip unknown roles
i++;
}
}
return items;
}

View file

@ -0,0 +1,12 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export { AgentTabBar } from './AgentTabBar.js';
export { AgentChatView } from './AgentChatView.js';
export { AgentHeader } from './AgentHeader.js';
export { AgentComposer } from './AgentComposer.js';
export { AgentFooter } from './AgentFooter.js';
export { agentMessagesToHistoryItems } from './agentHistoryAdapter.js';

View file

@ -0,0 +1,290 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import { formatDuration } from '../../utils/formatters.js';
import { getArenaStatusLabel } from '../../utils/displayUtils.js';
import type { ArenaAgentCardData } from '../../types.js';
// ─── Helpers ────────────────────────────────────────────────
// ─── Agent Complete Card ────────────────────────────────────
interface ArenaAgentCardProps {
agent: ArenaAgentCardData;
width?: number;
}
export const ArenaAgentCard: React.FC<ArenaAgentCardProps> = ({
agent,
width,
}) => {
const { icon, text, color } = getArenaStatusLabel(agent.status);
const duration = formatDuration(agent.durationMs);
const tokens = agent.totalTokens.toLocaleString();
const inTokens = agent.inputTokens.toLocaleString();
const outTokens = agent.outputTokens.toLocaleString();
return (
<Box flexDirection="column" width={width}>
{/* Line 1: Status icon + text + label + duration */}
<Box>
<Text color={color}>
{icon} {agent.label} · {text} · {duration}
</Text>
</Box>
{/* Line 2: Tokens */}
<Box marginLeft={2}>
<Text color={theme.text.secondary}>
Tokens: {tokens} (in {inTokens}, out {outTokens})
</Text>
</Box>
{/* Line 3: Tool Calls with colored success/error counts */}
<Box marginLeft={2}>
<Text color={theme.text.secondary}>
Tool Calls: {agent.toolCalls}
{agent.failedToolCalls > 0 && (
<>
{' '}
(
<Text color={theme.status.success}>
{agent.successfulToolCalls}
</Text>
<Text color={theme.text.secondary}> </Text>
<Text color={theme.status.error}> {agent.failedToolCalls}</Text>)
</>
)}
</Text>
</Box>
{/* Error line (if terminated with error) */}
{agent.error && (
<Box marginLeft={2}>
<Text color={theme.status.error}>{agent.error}</Text>
</Box>
)}
</Box>
);
};
// ─── Session Complete Card ──────────────────────────────────
interface ArenaSessionCardProps {
sessionStatus: string;
task: string;
totalDurationMs: number;
agents: ArenaAgentCardData[];
width?: number;
}
/**
* Pad or truncate a string to a fixed visual width.
*/
function pad(
str: string,
len: number,
align: 'left' | 'right' = 'left',
): string {
if (str.length >= len) return str.slice(0, len);
const padding = ' '.repeat(len - str.length);
return align === 'right' ? padding + str : str + padding;
}
/**
* Truncate a string to a maximum length, adding ellipsis if truncated.
*/
function truncate(str: string, maxLen: number): string {
if (str.length <= maxLen) return str;
return str.slice(0, maxLen - 1) + '…';
}
/**
* Calculate diff stats from a unified diff string.
* Returns the stats string and individual counts for colored rendering.
*/
function getDiffStats(diff: string | undefined): {
text: string;
additions: number;
deletions: number;
} {
if (!diff) return { text: '', additions: 0, deletions: 0 };
const lines = diff.split('\n');
let additions = 0;
let deletions = 0;
for (const line of lines) {
if (line.startsWith('+') && !line.startsWith('+++')) {
additions++;
} else if (line.startsWith('-') && !line.startsWith('---')) {
deletions++;
}
}
return { text: `+${additions}/-${deletions}`, additions, deletions };
}
const MAX_MODEL_NAME_LENGTH = 35;
export const ArenaSessionCard: React.FC<ArenaSessionCardProps> = ({
sessionStatus,
task,
agents,
width,
}) => {
// Truncate task for display
const maxTaskLen = 60;
const displayTask =
task.length > maxTaskLen ? task.slice(0, maxTaskLen - 1) + '…' : task;
// Column widths for the agent table (unified with Arena Results)
const colStatus = 14;
const colTime = 8;
const colTokens = 10;
const colChanges = 10;
const titleLabel =
sessionStatus === 'idle'
? 'Agents Status · Idle'
: sessionStatus === 'completed'
? 'Arena Complete'
: sessionStatus === 'cancelled'
? 'Arena Cancelled'
: 'Arena Failed';
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
paddingX={2}
paddingY={1}
width={width}
>
{/* Title - neutral color (not green) */}
<Box>
<Text bold color={theme.text.primary}>
{titleLabel}
</Text>
</Box>
<Box height={1} />
{/* Task */}
<Box>
<Text>
<Text color={theme.text.secondary}>Task: </Text>
<Text color={theme.text.primary}>&quot;{displayTask}&quot;</Text>
</Text>
</Box>
<Box height={1} />
{/* Table header - unified columns: Agent, Status, Time, Tokens, Changes */}
<Box>
<Box flexGrow={1}>
<Text bold color={theme.text.secondary}>
Agent
</Text>
</Box>
<Box width={colStatus} justifyContent="flex-end">
<Text bold color={theme.text.secondary}>
Status
</Text>
</Box>
<Box width={colTime} justifyContent="flex-end">
<Text bold color={theme.text.secondary}>
Time
</Text>
</Box>
<Box width={colTokens} justifyContent="flex-end">
<Text bold color={theme.text.secondary}>
Tokens
</Text>
</Box>
<Box width={colChanges} justifyContent="flex-end">
<Text bold color={theme.text.secondary}>
Changes
</Text>
</Box>
</Box>
{/* Table separator */}
<Box>
<Text color={theme.border.default}>
{'─'.repeat((width ?? 60) - 8)}
</Text>
</Box>
{/* Agent rows */}
{agents.map((agent) => {
const { text: statusText, color } = getArenaStatusLabel(agent.status);
const diffStats = getDiffStats(agent.diff);
return (
<Box key={agent.label}>
<Box flexGrow={1}>
<Text color={theme.text.primary}>
{truncate(agent.label, MAX_MODEL_NAME_LENGTH)}
</Text>
</Box>
<Box width={colStatus} justifyContent="flex-end">
<Text color={color}>{statusText}</Text>
</Box>
<Box width={colTime} justifyContent="flex-end">
<Text color={theme.text.primary}>
{pad(formatDuration(agent.durationMs), colTime - 1, 'right')}
</Text>
</Box>
<Box width={colTokens} justifyContent="flex-end">
<Text color={theme.text.primary}>
{pad(
agent.totalTokens.toLocaleString(),
colTokens - 1,
'right',
)}
</Text>
</Box>
<Box width={colChanges} justifyContent="flex-end">
{diffStats.additions > 0 || diffStats.deletions > 0 ? (
<Text>
<Text color={theme.status.success}>
+{diffStats.additions}
</Text>
<Text color={theme.text.secondary}>/</Text>
<Text color={theme.status.error}>-{diffStats.deletions}</Text>
</Text>
) : (
<Text color={theme.text.secondary}>-</Text>
)}
</Box>
</Box>
);
})}
<Box height={1} />
{/* Hint */}
{sessionStatus === 'idle' && (
<Box flexDirection="column">
<Text color={theme.text.secondary}>
Switch to an agent tab to continue, or{' '}
<Text color={theme.text.accent}>/arena select</Text> to pick a
winner.
</Text>
</Box>
)}
{sessionStatus === 'completed' && (
<Box>
<Text color={theme.text.secondary}>
Run <Text color={theme.text.accent}>/arena select</Text> to pick a
winner.
</Text>
</Box>
)}
</Box>
);
};

View file

@ -0,0 +1,260 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useCallback, useMemo } from 'react';
import { Box, Text } from 'ink';
import {
type ArenaManager,
isSuccessStatus,
type Config,
} from '@qwen-code/qwen-code-core';
import { theme } from '../../semantic-colors.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { MessageType, type HistoryItemWithoutId } from '../../types.js';
import type { UseHistoryManagerReturn } from '../../hooks/useHistoryManager.js';
import { formatDuration } from '../../utils/formatters.js';
import { getArenaStatusLabel } from '../../utils/displayUtils.js';
import { DescriptiveRadioButtonSelect } from '../shared/DescriptiveRadioButtonSelect.js';
import type { DescriptiveRadioSelectItem } from '../shared/DescriptiveRadioButtonSelect.js';
interface ArenaSelectDialogProps {
manager: ArenaManager;
config: Config;
addItem: UseHistoryManagerReturn['addItem'];
closeArenaDialog: () => void;
}
export function ArenaSelectDialog({
manager,
config,
addItem,
closeArenaDialog,
}: ArenaSelectDialogProps): React.JSX.Element {
const pushMessage = useCallback(
(result: { messageType: 'info' | 'error'; content: string }) => {
const item: HistoryItemWithoutId = {
type:
result.messageType === 'info' ? MessageType.INFO : MessageType.ERROR,
text: result.content,
};
addItem(item, Date.now());
try {
const chatRecorder = config.getChatRecordingService();
chatRecorder?.recordSlashCommand({
phase: 'result',
rawCommand: '/arena select',
outputHistoryItems: [{ ...item } as Record<string, unknown>],
});
} catch {
// Best-effort recording
}
},
[addItem, config],
);
const onSelect = useCallback(
async (agentId: string) => {
closeArenaDialog();
const mgr = config.getArenaManager();
if (!mgr) {
pushMessage({
messageType: 'error',
content: 'No arena session found. Start one with /arena start.',
});
return;
}
const agent =
mgr.getAgentState(agentId) ??
mgr.getAgentStates().find((item) => item.agentId === agentId);
const label = agent?.model.modelId || agentId;
pushMessage({
messageType: 'info',
content: `Applying changes from ${label}`,
});
const result = await mgr.applyAgentResult(agentId);
if (!result.success) {
pushMessage({
messageType: 'error',
content: `Failed to apply changes from ${label}: ${result.error}`,
});
return;
}
try {
await config.cleanupArenaRuntime(true);
} catch (err) {
pushMessage({
messageType: 'error',
content: `Warning: failed to clean up arena resources: ${err instanceof Error ? err.message : String(err)}`,
});
}
pushMessage({
messageType: 'info',
content: `Applied changes from ${label} to workspace. Arena session complete.`,
});
},
[closeArenaDialog, config, pushMessage],
);
const onDiscard = useCallback(async () => {
closeArenaDialog();
const mgr = config.getArenaManager();
if (!mgr) {
pushMessage({
messageType: 'error',
content: 'No arena session found. Start one with /arena start.',
});
return;
}
try {
pushMessage({
messageType: 'info',
content: 'Discarding Arena results and cleaning up…',
});
await config.cleanupArenaRuntime(true);
pushMessage({
messageType: 'info',
content: 'Arena results discarded. All worktrees cleaned up.',
});
} catch (err) {
pushMessage({
messageType: 'error',
content: `Failed to clean up arena worktrees: ${err instanceof Error ? err.message : String(err)}`,
});
}
}, [closeArenaDialog, config, pushMessage]);
const result = manager.getResult();
const agents = manager.getAgentStates();
const items: Array<DescriptiveRadioSelectItem<string>> = useMemo(
() =>
agents.map((agent) => {
const label = agent.model.modelId;
const statusInfo = getArenaStatusLabel(agent.status);
const duration = formatDuration(agent.stats.durationMs);
const tokens = agent.stats.totalTokens.toLocaleString();
// Build diff summary from cached result if available
let diffAdditions = 0;
let diffDeletions = 0;
if (isSuccessStatus(agent.status) && result) {
const agentResult = result.agents.find(
(a) => a.agentId === agent.agentId,
);
if (agentResult?.diff) {
const lines = agentResult.diff.split('\n');
for (const line of lines) {
if (line.startsWith('+') && !line.startsWith('+++')) {
diffAdditions++;
} else if (line.startsWith('-') && !line.startsWith('---')) {
diffDeletions++;
}
}
}
}
// Title: full model name (not truncated)
const title = <Text>{label}</Text>;
// Description: status, time, tokens, changes (unified with Arena Complete columns)
const description = (
<Text>
<Text color={statusInfo.color}>{statusInfo.text}</Text>
<Text color={theme.text.secondary}> · </Text>
<Text color={theme.text.secondary}>{duration}</Text>
<Text color={theme.text.secondary}> · </Text>
<Text color={theme.text.secondary}>{tokens} tokens</Text>
{(diffAdditions > 0 || diffDeletions > 0) && (
<>
<Text color={theme.text.secondary}> · </Text>
<Text color={theme.status.success}>+{diffAdditions}</Text>
<Text color={theme.text.secondary}>/</Text>
<Text color={theme.status.error}>-{diffDeletions}</Text>
<Text color={theme.text.secondary}> lines</Text>
</>
)}
</Text>
);
return {
key: agent.agentId,
value: agent.agentId,
title,
description,
disabled: !isSuccessStatus(agent.status),
};
}),
[agents, result],
);
useKeypress(
(key) => {
if (key.name === 'escape') {
closeArenaDialog();
}
if (key.name === 'd' && !key.ctrl && !key.meta) {
onDiscard();
}
},
{ isActive: true },
);
const task = result?.task || '';
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
width="100%"
>
{/* Neutral title color (not green) */}
<Text bold color={theme.text.primary}>
Arena Results
</Text>
<Box marginTop={1} flexDirection="column">
<Text>
<Text color={theme.text.secondary}>Task: </Text>
<Text
color={theme.text.primary}
>{`"${task.length > 60 ? task.slice(0, 59) + '…' : task}"`}</Text>
</Text>
</Box>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
Select a winner to apply changes:
</Text>
</Box>
<Box marginTop={1} flexDirection="column">
<DescriptiveRadioButtonSelect
items={items}
initialIndex={items.findIndex((item) => !item.disabled)}
onSelect={(agentId: string) => {
onSelect(agentId);
}}
isFocused={true}
showNumbers={false}
/>
</Box>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
Enter to select, d to discard all, Esc to cancel
</Text>
</Box>
</Box>
);
}

View file

@ -0,0 +1,161 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useMemo, useState } from 'react';
import { Box, Text } from 'ink';
import Link from 'ink-link';
import { AuthType } from '@qwen-code/qwen-code-core';
import { useConfig } from '../../contexts/ConfigContext.js';
import { theme } from '../../semantic-colors.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { MultiSelect } from '../shared/MultiSelect.js';
import { t } from '../../../i18n/index.js';
interface ArenaStartDialogProps {
onClose: () => void;
onConfirm: (selectedModels: string[]) => void;
}
const MODEL_PROVIDERS_DOCUMENTATION_URL =
'https://qwenlm.github.io/qwen-code-docs/en/users/configuration/settings/#modelproviders';
export function ArenaStartDialog({
onClose,
onConfirm,
}: ArenaStartDialogProps): React.JSX.Element {
const config = useConfig();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const modelItems = useMemo(() => {
const allModels = config.getAllConfiguredModels();
const selectableModels = allModels.filter((model) => !model.isRuntimeModel);
return selectableModels.map((model) => {
const token = `${model.authType}:${model.id}`;
const isQwenOauth = model.authType === AuthType.QWEN_OAUTH;
return {
key: token,
value: token,
label: `[${model.authType}] ${model.label}`,
disabled: isQwenOauth,
};
});
}, [config]);
const hasDisabledQwenOauth = modelItems.some((item) => item.disabled);
const selectableModelCount = modelItems.filter(
(item) => !item.disabled,
).length;
const needsMoreModels = selectableModelCount < 2;
const shouldShowMoreModelsHint =
selectableModelCount >= 2 && selectableModelCount < 3;
useKeypress(
(key) => {
if (key.name === 'escape') {
onClose();
}
},
{ isActive: true },
);
const handleConfirm = (values: string[]) => {
if (values.length < 2) {
setErrorMessage(
t('Please select at least 2 models to start an Arena session.'),
);
return;
}
setErrorMessage(null);
onConfirm(values);
};
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold>{t('Select Models')}</Text>
{modelItems.length === 0 ? (
<Box marginTop={1} flexDirection="column">
<Text color={theme.status.warning}>
{t('No models available. Please configure models first.')}
</Text>
</Box>
) : (
<Box marginTop={1}>
<MultiSelect
items={modelItems}
initialIndex={0}
onConfirm={handleConfirm}
showNumbers
showScrollArrows
maxItemsToShow={10}
/>
</Box>
)}
{errorMessage && (
<Box marginTop={1}>
<Text color={theme.status.error}>{errorMessage}</Text>
</Box>
)}
{(hasDisabledQwenOauth || needsMoreModels) && (
<Box marginTop={1} flexDirection="column">
{hasDisabledQwenOauth && (
<Text color={theme.status.warning}>
{t('Note: qwen-oauth models are not supported in Arena.')}
</Text>
)}
{needsMoreModels && (
<>
<Text color={theme.status.warning}>
{t('Arena requires at least 2 models. To add more:')}
</Text>
<Text color={theme.status.warning}>
{t(
' - Run /auth to set up a Coding Plan (includes multiple models)',
)}
</Text>
<Text color={theme.status.warning}>
{t(' - Or configure modelProviders in settings.json')}
</Text>
</>
)}
</Box>
)}
{shouldShowMoreModelsHint && (
<>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('Configure more models with the modelProviders guide:')}
</Text>
</Box>
<Box marginTop={0}>
<Link url={MODEL_PROVIDERS_DOCUMENTATION_URL} fallback={false}>
<Text color={theme.text.secondary} underline>
{MODEL_PROVIDERS_DOCUMENTATION_URL}
</Text>
</Link>
</Box>
</>
)}
<Box marginTop={1} flexDirection="column">
<Text color={theme.text.secondary}>
{t('Space to toggle, Enter to confirm, Esc to cancel')}
</Text>
</Box>
</Box>
);
}

View file

@ -0,0 +1,288 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Box, Text } from 'ink';
import {
type ArenaManager,
type ArenaAgentState,
type InProcessBackend,
type AgentStatsSummary,
isSettledStatus,
ArenaSessionStatus,
DISPLAY_MODE,
} from '@qwen-code/qwen-code-core';
import { theme } from '../../semantic-colors.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { formatDuration } from '../../utils/formatters.js';
import { getArenaStatusLabel } from '../../utils/displayUtils.js';
const STATUS_REFRESH_INTERVAL_MS = 2000;
const IN_PROCESS_REFRESH_INTERVAL_MS = 1000;
interface ArenaStatusDialogProps {
manager: ArenaManager;
closeArenaDialog: () => void;
width?: number;
}
function truncate(str: string, maxLen: number): string {
if (str.length <= maxLen) return str;
return str.slice(0, maxLen - 1) + '…';
}
function pad(
str: string,
len: number,
align: 'left' | 'right' = 'left',
): string {
if (str.length >= len) return str.slice(0, len);
const padding = ' '.repeat(len - str.length);
return align === 'right' ? padding + str : str + padding;
}
function getElapsedMs(agent: ArenaAgentState): number {
if (isSettledStatus(agent.status)) {
return agent.stats.durationMs;
}
return Date.now() - agent.startedAt;
}
function getSessionStatusLabel(status: ArenaSessionStatus): {
text: string;
color: string;
} {
switch (status) {
case ArenaSessionStatus.RUNNING:
return { text: 'Running', color: theme.status.success };
case ArenaSessionStatus.INITIALIZING:
return { text: 'Initializing', color: theme.status.warning };
case ArenaSessionStatus.IDLE:
return { text: 'Idle', color: theme.status.success };
case ArenaSessionStatus.COMPLETED:
return { text: 'Completed', color: theme.status.success };
case ArenaSessionStatus.CANCELLED:
return { text: 'Cancelled', color: theme.status.warning };
case ArenaSessionStatus.FAILED:
return { text: 'Failed', color: theme.status.error };
default:
return { text: String(status), color: theme.text.secondary };
}
}
const MAX_MODEL_NAME_LENGTH = 35;
export function ArenaStatusDialog({
manager,
closeArenaDialog,
width,
}: ArenaStatusDialogProps): React.JSX.Element {
const [tick, setTick] = useState(0);
// Detect in-process backend for live stats reading
const backend = manager.getBackend();
const isInProcess = backend?.type === DISPLAY_MODE.IN_PROCESS;
const inProcessBackend = isInProcess ? (backend as InProcessBackend) : null;
useEffect(() => {
const interval = isInProcess
? IN_PROCESS_REFRESH_INTERVAL_MS
: STATUS_REFRESH_INTERVAL_MS;
const timer = setInterval(() => {
setTick((prev) => prev + 1);
}, interval);
return () => clearInterval(timer);
}, [isInProcess]);
// Force re-read on every tick
void tick;
const sessionStatus = manager.getSessionStatus();
const sessionLabel = getSessionStatusLabel(sessionStatus);
const agents = manager.getAgentStates();
const task = manager.getTask() ?? '';
// For in-process mode, read live stats directly from AgentInteractive
const liveStats = useMemo(() => {
if (!inProcessBackend) return null;
const statsMap = new Map<string, AgentStatsSummary>();
for (const agent of agents) {
const interactive = inProcessBackend.getAgent(agent.agentId);
if (interactive) {
statsMap.set(agent.agentId, interactive.getStats());
}
}
return statsMap;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inProcessBackend, agents, tick]);
const maxTaskLen = 60;
const displayTask =
task.length > maxTaskLen ? task.slice(0, maxTaskLen - 1) + '…' : task;
const colStatus = 14;
const colTime = 8;
const colTokens = 10;
const colRounds = 8;
const colTools = 8;
useKeypress(
(key) => {
if (key.name === 'escape' || key.name === 'q' || key.name === 'return') {
closeArenaDialog();
}
},
{ isActive: true },
);
// Inner content width: total width minus border (2) and paddingX (2*2)
const innerWidth = (width ?? 80) - 6;
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
paddingX={2}
paddingY={1}
width="100%"
>
{/* Title */}
<Box>
<Text bold color={theme.text.primary}>
Arena Status
</Text>
<Text color={theme.text.secondary}> · </Text>
<Text color={sessionLabel.color}>{sessionLabel.text}</Text>
</Box>
<Box height={1} />
{/* Task */}
<Box>
<Text>
<Text color={theme.text.secondary}>Task: </Text>
<Text color={theme.text.primary}>&quot;{displayTask}&quot;</Text>
</Text>
</Box>
<Box height={1} />
{/* Table header */}
<Box>
<Box flexGrow={1}>
<Text bold color={theme.text.secondary}>
Agent
</Text>
</Box>
<Box width={colStatus} justifyContent="flex-end">
<Text bold color={theme.text.secondary}>
Status
</Text>
</Box>
<Box width={colTime} justifyContent="flex-end">
<Text bold color={theme.text.secondary}>
Time
</Text>
</Box>
<Box width={colTokens} justifyContent="flex-end">
<Text bold color={theme.text.secondary}>
Tokens
</Text>
</Box>
<Box width={colRounds} justifyContent="flex-end">
<Text bold color={theme.text.secondary}>
Rounds
</Text>
</Box>
<Box width={colTools} justifyContent="flex-end">
<Text bold color={theme.text.secondary}>
Tools
</Text>
</Box>
</Box>
{/* Separator */}
<Box>
<Text color={theme.border.default}>{'─'.repeat(innerWidth)}</Text>
</Box>
{/* Agent rows */}
{agents.map((agent) => {
const label = agent.model.modelId;
const { text: statusText, color } = getArenaStatusLabel(agent.status);
const elapsed = getElapsedMs(agent);
// Use live stats from AgentInteractive when in-process, otherwise
// fall back to the cached ArenaAgentState.stats (file-polled).
const live = liveStats?.get(agent.agentId);
const totalTokens = live?.totalTokens ?? agent.stats.totalTokens;
const rounds = live?.rounds ?? agent.stats.rounds;
const toolCalls = live?.totalToolCalls ?? agent.stats.toolCalls;
const successfulToolCalls =
live?.successfulToolCalls ?? agent.stats.successfulToolCalls;
const failedToolCalls =
live?.failedToolCalls ?? agent.stats.failedToolCalls;
return (
<Box key={agent.agentId} flexDirection="column">
<Box>
<Box flexGrow={1}>
<Text color={theme.text.primary}>
{truncate(label, MAX_MODEL_NAME_LENGTH)}
</Text>
</Box>
<Box width={colStatus} justifyContent="flex-end">
<Text color={color}>{statusText}</Text>
</Box>
<Box width={colTime} justifyContent="flex-end">
<Text color={theme.text.primary}>
{pad(formatDuration(elapsed), colTime - 1, 'right')}
</Text>
</Box>
<Box width={colTokens} justifyContent="flex-end">
<Text color={theme.text.primary}>
{pad(totalTokens.toLocaleString(), colTokens - 1, 'right')}
</Text>
</Box>
<Box width={colRounds} justifyContent="flex-end">
<Text color={theme.text.primary}>
{pad(String(rounds), colRounds - 1, 'right')}
</Text>
</Box>
<Box width={colTools} justifyContent="flex-end">
{failedToolCalls > 0 ? (
<Text>
<Text color={theme.status.success}>
{successfulToolCalls}
</Text>
<Text color={theme.text.secondary}>/</Text>
<Text color={theme.status.error}>{failedToolCalls}</Text>
</Text>
) : (
<Text
color={
toolCalls > 0 ? theme.status.success : theme.text.primary
}
>
{pad(String(toolCalls), colTools - 1, 'right')}
</Text>
)}
</Box>
</Box>
</Box>
);
})}
{agents.length === 0 && (
<Box>
<Text color={theme.text.secondary}>No agents registered yet.</Text>
</Box>
)}
</Box>
);
}

View file

@ -0,0 +1,213 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useCallback, useMemo, useState } from 'react';
import { Box, Text } from 'ink';
import {
ArenaSessionStatus,
createDebugLogger,
type Config,
} from '@qwen-code/qwen-code-core';
import { theme } from '../../semantic-colors.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { MessageType, type HistoryItemWithoutId } from '../../types.js';
import type { UseHistoryManagerReturn } from '../../hooks/useHistoryManager.js';
import { DescriptiveRadioButtonSelect } from '../shared/DescriptiveRadioButtonSelect.js';
import type { DescriptiveRadioSelectItem } from '../shared/DescriptiveRadioButtonSelect.js';
const debugLogger = createDebugLogger('ARENA_STOP_DIALOG');
type StopAction = 'cleanup' | 'preserve';
interface ArenaStopDialogProps {
config: Config;
addItem: UseHistoryManagerReturn['addItem'];
closeArenaDialog: () => void;
}
export function ArenaStopDialog({
config,
addItem,
closeArenaDialog,
}: ArenaStopDialogProps): React.JSX.Element {
const [isProcessing, setIsProcessing] = useState(false);
const pushMessage = useCallback(
(result: { messageType: 'info' | 'error'; content: string }) => {
const item: HistoryItemWithoutId = {
type:
result.messageType === 'info' ? MessageType.INFO : MessageType.ERROR,
text: result.content,
};
addItem(item, Date.now());
try {
const chatRecorder = config.getChatRecordingService();
chatRecorder?.recordSlashCommand({
phase: 'result',
rawCommand: '/arena stop',
outputHistoryItems: [{ ...item } as Record<string, unknown>],
});
} catch {
// Best-effort recording
}
},
[addItem, config],
);
const onStop = useCallback(
async (action: StopAction) => {
if (isProcessing) return;
setIsProcessing(true);
closeArenaDialog();
const mgr = config.getArenaManager();
if (!mgr) {
pushMessage({
messageType: 'error',
content: 'No running Arena session found.',
});
return;
}
try {
const sessionStatus = mgr.getSessionStatus();
if (
sessionStatus === ArenaSessionStatus.RUNNING ||
sessionStatus === ArenaSessionStatus.INITIALIZING
) {
pushMessage({
messageType: 'info',
content: 'Stopping Arena agents…',
});
await mgr.cancel();
}
await mgr.waitForSettled();
pushMessage({
messageType: 'info',
content: 'Cleaning up Arena resources…',
});
if (action === 'preserve') {
await mgr.cleanupRuntime();
} else {
await mgr.cleanup();
}
config.setArenaManager(null);
if (action === 'preserve') {
pushMessage({
messageType: 'info',
content:
'Arena session stopped. Worktrees and session files were preserved. ' +
'Use /arena select --discard to manually clean up later.',
});
} else {
pushMessage({
messageType: 'info',
content:
'Arena session stopped. All Arena resources (including Git worktrees) were cleaned up.',
});
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
debugLogger.error('Failed to stop Arena session:', error);
pushMessage({
messageType: 'error',
content: `Failed to stop Arena session: ${message}`,
});
}
},
[isProcessing, closeArenaDialog, config, pushMessage],
);
const configPreserve =
config.getAgentsSettings().arena?.preserveArtifacts ?? false;
const items: Array<DescriptiveRadioSelectItem<StopAction>> = useMemo(
() => [
{
key: 'cleanup',
value: 'cleanup' as StopAction,
title: <Text>Stop and clean up</Text>,
description: (
<Text color={theme.text.secondary}>
Remove all worktrees and session files
</Text>
),
},
{
key: 'preserve',
value: 'preserve' as StopAction,
title: <Text>Stop and preserve artifacts</Text>,
description: (
<Text color={theme.text.secondary}>
Keep worktrees and session files for later inspection
</Text>
),
},
],
[],
);
const defaultIndex = configPreserve ? 1 : 0;
useKeypress(
(key) => {
if (key.name === 'escape') {
closeArenaDialog();
}
},
{ isActive: !isProcessing },
);
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold color={theme.text.primary}>
Stop Arena Session
</Text>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
Choose what to do with Arena artifacts:
</Text>
</Box>
<Box marginTop={1} flexDirection="column">
<DescriptiveRadioButtonSelect
items={items}
initialIndex={defaultIndex}
onSelect={(action: StopAction) => {
onStop(action);
}}
isFocused={!isProcessing}
showNumbers={false}
/>
</Box>
{configPreserve && (
<Box marginTop={1}>
<Text color={theme.text.secondary} dimColor>
Default: preserve (agents.arena.preserveArtifacts is enabled)
</Text>
</Box>
)}
<Box marginTop={1}>
<Text color={theme.text.secondary}>
Enter to confirm, Esc to cancel
</Text>
</Box>
</Box>
);
}

View file

@ -75,7 +75,7 @@ export const SuccessMessage: React.FC<StatusTextProps> = ({ text }) => (
export const WarningMessage: React.FC<StatusTextProps> = ({ text }) => (
<StatusMessage
text={text}
prefix=""
prefix=""
prefixColor={theme.status.warning}
textColor={theme.status.warning}
/>

View file

@ -66,7 +66,11 @@ export function DescriptiveRadioButtonSelect<T>({
renderItem={(item, { titleColor }) => (
<Box flexDirection="column" key={item.key}>
<Text color={titleColor}>{item.title}</Text>
<Text color={theme.text.secondary}>{item.description}</Text>
{typeof item.description === 'string' ? (
<Text color={theme.text.secondary}>{item.description}</Text>
) : (
item.description
)}
</Box>
)}
/>

View file

@ -0,0 +1,193 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import { useSelectionList } from '../../hooks/useSelectionList.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import type { SelectionListItem } from '../../hooks/useSelectionList.js';
export interface MultiSelectItem<T> extends SelectionListItem<T> {
label: string;
}
export interface MultiSelectProps<T> {
items: Array<MultiSelectItem<T>>;
initialIndex?: number;
initialSelectedKeys?: string[];
onConfirm: (selectedValues: T[]) => void;
onChange?: (selectedValues: T[]) => void;
onHighlight?: (value: T) => void;
isFocused?: boolean;
showNumbers?: boolean;
showScrollArrows?: boolean;
maxItemsToShow?: number;
}
const EMPTY_SELECTED_KEYS: string[] = [];
function getSelectedValues<T>(
items: Array<MultiSelectItem<T>>,
selectedKeys: Set<string>,
): T[] {
return items
.filter((item) => selectedKeys.has(item.key))
.map((item) => item.value);
}
export function MultiSelect<T>({
items,
initialIndex = 0,
initialSelectedKeys = EMPTY_SELECTED_KEYS,
onConfirm,
onChange,
onHighlight,
isFocused = true,
showNumbers = true,
showScrollArrows = false,
maxItemsToShow = 10,
}: MultiSelectProps<T>): React.JSX.Element {
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(
() => new Set(initialSelectedKeys),
);
const [scrollOffset, setScrollOffset] = useState(0);
useEffect(() => {
setSelectedKeys((prev) => {
const next = new Set(initialSelectedKeys);
if (
prev.size === next.size &&
Array.from(next).every((key) => prev.has(key))
) {
return prev;
}
return next;
});
}, [initialSelectedKeys]);
const { activeIndex } = useSelectionList({
items,
initialIndex,
isFocused,
// Disable numeric quick-select in useSelectionList — in a multi-select
// context, onSelect triggers onConfirm (submit), so numeric keys would
// accidentally submit the dialog instead of toggling checkboxes.
// Numbers are still rendered visually via the showNumbers prop below.
showNumbers: false,
onHighlight,
onSelect: () => {
onConfirm(getSelectedValues(items, selectedKeys));
},
});
const toggleSelectionAtIndex = useCallback(
(index: number) => {
const item = items[index];
if (!item || item.disabled) {
return;
}
setSelectedKeys((prev) => {
const next = new Set(prev);
if (next.has(item.key)) {
next.delete(item.key);
} else {
next.add(item.key);
}
return next;
});
},
[items],
);
useEffect(() => {
onChange?.(getSelectedValues(items, selectedKeys));
}, [items, selectedKeys, onChange]);
useKeypress(
(key) => {
if (key.name === 'space' || key.sequence === ' ') {
toggleSelectionAtIndex(activeIndex);
}
},
{ isActive: isFocused },
);
useEffect(() => {
const newScrollOffset = Math.max(
0,
Math.min(activeIndex - maxItemsToShow + 1, items.length - maxItemsToShow),
);
if (activeIndex < scrollOffset) {
setScrollOffset(activeIndex);
} else if (activeIndex >= scrollOffset + maxItemsToShow) {
setScrollOffset(newScrollOffset);
}
}, [activeIndex, items.length, scrollOffset, maxItemsToShow]);
const visibleItems = useMemo(
() => items.slice(scrollOffset, scrollOffset + maxItemsToShow),
[items, scrollOffset, maxItemsToShow],
);
const numberColumnWidth = String(items.length).length;
const hasMoreAbove = scrollOffset > 0;
const hasMoreBelow = scrollOffset + maxItemsToShow < items.length;
const moreAboveCount = scrollOffset;
const moreBelowCount = Math.max(
0,
items.length - (scrollOffset + maxItemsToShow),
);
return (
<Box flexDirection="column">
{showScrollArrows && hasMoreAbove && (
<Text color={theme.text.secondary}> {moreAboveCount} more above</Text>
)}
{visibleItems.map((item, index) => {
const itemIndex = scrollOffset + index;
const isActive = activeIndex === itemIndex;
const isChecked = selectedKeys.has(item.key);
const itemNumberText = `${String(itemIndex + 1).padStart(
numberColumnWidth,
)}.`;
const checkboxText = item.disabled ? '[x]' : isChecked ? '[✓]' : '[ ]';
let textColor = theme.text.primary;
if (item.disabled) {
textColor = theme.text.secondary;
} else if (isActive) {
textColor = theme.status.success;
} else if (isChecked) {
textColor = theme.text.accent;
}
return (
<Box key={item.key} alignItems="flex-start">
<Box minWidth={4} flexShrink={0}>
<Text color={textColor}>{checkboxText}</Text>
</Box>
{showNumbers && (
<Box marginRight={1} minWidth={itemNumberText.length}>
<Text color={textColor}>{itemNumberText}</Text>
</Box>
)}
<Box flexGrow={1}>
<Text color={textColor}>{item.label}</Text>
</Box>
</Box>
);
})}
{showScrollArrows && hasMoreBelow && (
<Text color={theme.text.secondary}> {moreBelowCount} more below</Text>
)}
</Box>
);
}

View file

@ -1907,8 +1907,8 @@ export function useTextBuffer({
else if (key.ctrl && key.name === 'b') move('left');
else if (key.name === 'right' && !key.meta && !key.ctrl) move('right');
else if (key.ctrl && key.name === 'f') move('right');
else if (key.name === 'up') move('up');
else if (key.name === 'down') move('down');
else if (key.name === 'up' && !key.shift) move('up');
else if (key.name === 'down' && !key.shift) move('down');
else if ((key.ctrl || key.meta) && key.name === 'left') move('wordLeft');
else if (key.meta && key.name === 'b') move('wordLeft');
else if ((key.ctrl || key.meta) && key.name === 'right')

View file

@ -8,7 +8,7 @@ import React, { useMemo } from 'react';
import { Box, Text } from 'ink';
import type {
TaskResultDisplay,
SubagentStatsSummary,
AgentStatsSummary,
Config,
} from '@qwen-code/qwen-code-core';
import { theme } from '../../../semantic-colors.js';
@ -467,7 +467,7 @@ const ExecutionSummaryDetails: React.FC<{
* Tool usage statistics component
*/
const ToolUsageStats: React.FC<{
executionSummary?: SubagentStatsSummary;
executionSummary?: AgentStatsSummary;
}> = ({ executionSummary }) => {
if (!executionSummary) {
return (

View file

@ -0,0 +1,424 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import type {
ContextCategoryBreakdown,
ContextToolDetail,
ContextMemoryDetail,
ContextSkillDetail,
} from '../../types.js';
import { t } from '../../../i18n/index.js';
// Progress bar characters
const FILLED = '\u2588'; // █ - filled block
const BUFFER = '\u2592'; // ▒ - medium shade (autocompact buffer)
const EMPTY = '\u2591'; // ░ - light shade (free space)
const CONTENT_WIDTH = 56;
interface ContextUsageProps {
modelName: string;
totalTokens: number;
contextWindowSize: number;
breakdown: ContextCategoryBreakdown;
builtinTools: ContextToolDetail[];
mcpTools: ContextToolDetail[];
memoryFiles: ContextMemoryDetail[];
skills: ContextSkillDetail[];
/** True when totalTokens is estimated (no API call yet) */
isEstimated?: boolean;
/** When true, show per-item detail breakdowns. Default: false (compact). */
showDetails?: boolean;
}
/**
* Truncate a string to maxLen, appending '…' if truncated.
*/
function truncateName(name: string, maxLen: number): string {
if (name.length <= maxLen) return name;
return name.slice(0, maxLen - 1) + '\u2026';
}
/**
* Format token count for display (e.g. 1234 -> "1.2k", 123456 -> "123.5k")
*/
function formatTokens(tokens: number): string {
if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(1)}k`;
}
return `${tokens}`;
}
/**
* Render a three-segment progress bar: used | autocompact buffer | free space.
*/
const ProgressBar: React.FC<{
usedPercentage: number;
bufferPercentage: number;
width: number;
}> = ({ usedPercentage, bufferPercentage, width }) => {
const usedCount = Math.round((Math.min(usedPercentage, 100) / 100) * width);
const bufferCount = Math.round(
(Math.min(bufferPercentage, 100 - usedPercentage) / 100) * width,
);
const freeCount = Math.max(0, width - usedCount - bufferCount);
const usedStr = FILLED.repeat(Math.max(0, usedCount));
const freeStr = EMPTY.repeat(Math.max(0, freeCount));
const bufferStr = BUFFER.repeat(Math.max(0, bufferCount));
// Used color: accent by default, warning/error at high usage.
let usedColor = theme.text.accent;
if (usedPercentage > 80) {
usedColor = theme.status.error;
} else if (usedPercentage > 60) {
usedColor = theme.status.warning;
}
return (
<Text>
<Text color={usedColor}>{usedStr}</Text>
<Text color={theme.text.secondary}>{freeStr}</Text>
<Text color={theme.status.warning}>{bufferStr}</Text>
</Text>
);
};
/**
* A row showing a category with its token count and percentage.
*/
const CategoryRow: React.FC<{
symbol: string;
label: string;
tokens: number;
contextWindowSize: number;
symbolColor?: string;
}> = ({ symbol, label, tokens, contextWindowSize, symbolColor }) => {
const percentage = ((tokens / contextWindowSize) * 100).toFixed(1);
const tokenStr = `${formatTokens(tokens)} ${t('tokens')} (${percentage}%)`;
return (
<Box width={CONTENT_WIDTH}>
<Box width={2}>
<Text color={symbolColor || theme.text.secondary}>{symbol}</Text>
</Box>
<Box width={24}>
<Text color={theme.text.primary}>{label}</Text>
</Box>
<Box flexGrow={1} justifyContent="flex-end">
<Text color={theme.text.secondary}>{tokenStr}</Text>
</Box>
</Box>
);
};
/**
* A detail row for individual items (MCP tools, memory files, skills).
*/
const DETAIL_NAME_MAX_LEN = 30;
const DetailRow: React.FC<{
name: string;
tokens: number;
}> = ({ name, tokens }) => {
const tokenStr =
tokens > 0 ? `${formatTokens(tokens)} ${t('tokens')}` : `0 ${t('tokens')}`;
return (
<Box width={CONTENT_WIDTH} paddingLeft={2}>
<Text color={theme.text.secondary}>{'\u2514'} </Text>
<Box width={32}>
<Text color={theme.text.link}>
{truncateName(name, DETAIL_NAME_MAX_LEN)}
</Text>
</Box>
<Box flexGrow={1} justifyContent="flex-end">
<Text color={theme.text.secondary}>{tokenStr}</Text>
</Box>
</Box>
);
};
export const ContextUsage: React.FC<ContextUsageProps> = ({
modelName,
totalTokens,
contextWindowSize,
breakdown,
builtinTools,
mcpTools,
memoryFiles,
skills,
isEstimated,
showDetails = false,
}) => {
const percentage =
contextWindowSize > 0 ? (totalTokens / contextWindowSize) * 100 : 0;
// Sort detail items by token count (descending) for better readability
const sortedBuiltinTools = [...builtinTools].sort(
(a, b) => b.tokens - a.tokens,
);
const sortedMcpTools = [...mcpTools].sort((a, b) => b.tokens - a.tokens);
const sortedMemoryFiles = [...memoryFiles].sort(
(a, b) => b.tokens - a.tokens,
);
// Sort skills: loaded first, then by total token cost descending
const sortedSkills = [...skills].sort((a, b) => {
if (a.loaded !== b.loaded) return a.loaded ? -1 : 1;
const aTotal = a.tokens + (a.bodyTokens ?? 0);
const bTotal = b.tokens + (b.bodyTokens ?? 0);
return bTotal - aTotal;
});
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
paddingY={1}
paddingX={2}
>
{/* Title */}
<Text bold color={theme.text.accent}>
{t('Context Usage')}
</Text>
<Box height={1} />
{isEstimated ? (
<>
{/* No API data yet — show hint instead of progress bar */}
<Box marginBottom={1}>
<Text color={theme.status.warning} italic>
{t('No API response yet. Send a message to see actual usage.')}
</Text>
</Box>
{/* Estimated overhead categories */}
<Text bold color={theme.text.primary}>
{t('Estimated pre-conversation overhead')}
</Text>
<Text color={theme.text.secondary}>
{t('Model')}: {modelName}
{' '}
{t('Context window')}: {formatTokens(contextWindowSize)}{' '}
{t('tokens')}
</Text>
<Box height={1} />
</>
) : (
<>
{/* Model name + context window info */}
<Box width={CONTENT_WIDTH} marginBottom={1}>
<Text color={theme.text.secondary}>
{t('Model')}: {modelName}
</Text>
<Box flexGrow={1} justifyContent="flex-end">
<Text color={theme.text.secondary}>
{t('Context window')}: {formatTokens(contextWindowSize)}{' '}
{t('tokens')}
</Text>
</Box>
</Box>
{/* Progress bar — three segments: used | free | buffer */}
<Box width={CONTENT_WIDTH}>
<ProgressBar
usedPercentage={Math.min(percentage, 100)}
bufferPercentage={
contextWindowSize > 0
? (breakdown.autocompactBuffer / contextWindowSize) * 100
: 0
}
width={CONTENT_WIDTH}
/>
</Box>
<Box height={1} />
{/* Legend — same layout as CategoryRow for alignment */}
<CategoryRow
symbol={FILLED}
label={t('Used')}
tokens={totalTokens}
contextWindowSize={contextWindowSize}
symbolColor={theme.text.accent}
/>
<CategoryRow
symbol={EMPTY}
label={t('Free')}
tokens={breakdown.freeSpace}
contextWindowSize={contextWindowSize}
symbolColor={theme.text.secondary}
/>
<CategoryRow
symbol={BUFFER}
label={t('Autocompact buffer')}
tokens={breakdown.autocompactBuffer}
contextWindowSize={contextWindowSize}
symbolColor={theme.status.warning}
/>
<Box height={1} />
{/* Breakdown header */}
<Text bold color={theme.text.primary}>
{t('Usage by category')}
</Text>
</>
)}
<CategoryRow
symbol={FILLED}
label={t('System prompt')}
tokens={breakdown.systemPrompt}
contextWindowSize={contextWindowSize}
symbolColor={theme.text.accent}
/>
<CategoryRow
symbol={FILLED}
label={t('Built-in tools')}
tokens={breakdown.builtinTools}
contextWindowSize={contextWindowSize}
symbolColor={theme.text.accent}
/>
{breakdown.mcpTools > 0 && (
<CategoryRow
symbol={FILLED}
label={t('MCP tools')}
tokens={breakdown.mcpTools}
contextWindowSize={contextWindowSize}
symbolColor={theme.text.accent}
/>
)}
<CategoryRow
symbol={FILLED}
label={t('Memory files')}
tokens={breakdown.memoryFiles}
contextWindowSize={contextWindowSize}
symbolColor={theme.text.accent}
/>
<CategoryRow
symbol={FILLED}
label={t('Skills')}
tokens={breakdown.skills}
contextWindowSize={contextWindowSize}
symbolColor={theme.text.accent}
/>
{/* Only show Messages when we have real API data */}
{!isEstimated && (
<CategoryRow
symbol={FILLED}
label={t('Messages')}
tokens={breakdown.messages}
contextWindowSize={contextWindowSize}
symbolColor={theme.text.accent}
/>
)}
{showDetails ? (
<>
{/* Built-in tools detail */}
{sortedBuiltinTools.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text bold color={theme.text.primary}>
{t('Built-in tools')}
</Text>
{sortedBuiltinTools.map((tool) => (
<DetailRow
key={tool.name}
name={tool.name}
tokens={tool.tokens}
/>
))}
</Box>
)}
{/* MCP Tools detail */}
{sortedMcpTools.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text bold color={theme.text.primary}>
{t('MCP tools')}
</Text>
{sortedMcpTools.map((tool) => (
<DetailRow
key={tool.name}
name={tool.name}
tokens={tool.tokens}
/>
))}
</Box>
)}
{/* Memory files detail */}
{sortedMemoryFiles.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text bold color={theme.text.primary}>
{t('Memory files')}
</Text>
{sortedMemoryFiles.map((file) => (
<DetailRow
key={file.path}
name={file.path}
tokens={file.tokens}
/>
))}
</Box>
)}
{/* Skills detail */}
{sortedSkills.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text bold color={theme.text.primary}>
{t('Skills')}
</Text>
{sortedSkills.map((skill) => (
<Box key={skill.name} flexDirection="column">
<Box width={CONTENT_WIDTH} paddingLeft={2}>
<Text color={theme.text.secondary}>{'\u2514'} </Text>
<Box width={32}>
<Text color={theme.text.link}>
{truncateName(skill.name, DETAIL_NAME_MAX_LEN)}
</Text>
{skill.loaded && (
<Text color={theme.status.success}> {t('active')}</Text>
)}
</Box>
<Box flexGrow={1} justifyContent="flex-end">
<Text color={theme.text.secondary}>
{formatTokens(skill.tokens)} {t('tokens')}
</Text>
</Box>
</Box>
{skill.loaded &&
skill.bodyTokens != null &&
skill.bodyTokens > 0 && (
<Box width={CONTENT_WIDTH} paddingLeft={4}>
<Text color={theme.text.secondary}>{' \u2514'} </Text>
<Box width={30}>
<Text color={theme.text.secondary} italic>
{t('body loaded')}
</Text>
</Box>
<Box flexGrow={1} justifyContent="flex-end">
<Text color={theme.status.success}>
+{formatTokens(skill.bodyTokens)} {t('tokens')}
</Text>
</Box>
</Box>
)}
</Box>
))}
</Box>
)}
</>
) : (
<Box marginTop={1}>
<Text color={theme.text.secondary} italic>
{t('Run /context detail for per-item breakdown.')}
</Text>
</Box>
)}
</Box>
);
};

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

@ -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,6 +55,9 @@ export interface UIActions {
exitEditorDialog: () => void;
closeSettingsDialog: () => void;
closeModelDialog: () => void;
openArenaDialog: (type: Exclude<ArenaDialogType, null>) => void;
closeArenaDialog: () => void;
handleArenaModelsSelected?: (models: string[]) => void;
dismissCodingPlanUpdate: () => void;
closePermissionsDialog: () => void;
setShellModeActive: (value: boolean) => void;

View file

@ -33,6 +33,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[];
@ -52,6 +53,7 @@ export interface UIState {
quittingMessages: HistoryItem[] | null;
isSettingsDialogOpen: boolean;
isModelDialogOpen: boolean;
activeArenaDialog: ArenaDialogType;
isPermissionsDialogOpen: boolean;
isApprovalModeDialogOpen: boolean;
isResumeDialogOpen: boolean;
@ -131,6 +133,8 @@ export interface UIState {
isMcpDialogOpen: boolean;
// Feedback dialog
isFeedbackDialogOpen: boolean;
// Per-task token tracking
taskStartTokens: number;
}
export const UIStateContext = createContext<UIState | null>(null);

View file

@ -7,6 +7,7 @@
import { useCallback, useMemo, useEffect, useRef, useState } from 'react';
import { type PartListUnion } from '@google/genai';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import type { ArenaDialogType } from './useArenaCommand.js';
import {
type Logger,
type Config,
@ -66,6 +67,7 @@ const SLASH_COMMANDS_SKIP_RECORDING = new Set([
interface SlashCommandProcessorActions {
openAuthDialog: () => void;
openArenaDialog?: (type: Exclude<ArenaDialogType, null>) => void;
openThemeDialog: () => void;
openEditorDialog: () => void;
openSettingsDialog: () => void;
@ -456,6 +458,18 @@ export const useSlashCommandProcessor = (
return { type: 'handled' };
case 'dialog':
switch (result.dialog) {
case 'arena_start':
actions.openArenaDialog?.('start');
return { type: 'handled' };
case 'arena_select':
actions.openArenaDialog?.('select');
return { type: 'handled' };
case 'arena_stop':
actions.openArenaDialog?.('stop');
return { type: 'handled' };
case 'arena_status':
actions.openArenaDialog?.('status');
return { type: 'handled' };
case 'auth':
actions.openAuthDialog();
return { type: 'handled' };

View file

@ -0,0 +1,166 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Hook that subscribes to an AgentInteractive's events and
* derives streaming state, elapsed time, input-active flag, and status.
*
* Extracts the common reactivity + derived-state pattern shared by
* AgentComposer and AgentChatView so each component only deals with
* layout and interaction.
*/
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import {
AgentStatus,
AgentEventType,
isTerminalStatus,
type AgentInteractive,
type AgentEventEmitter,
} from '@qwen-code/qwen-code-core';
import { StreamingState } from '../types.js';
import { useTimer } from './useTimer.js';
// ─── Types ──────────────────────────────────────────────────
export interface AgentStreamingInfo {
/** The agent's current lifecycle status. */
status: AgentStatus | undefined;
/** Derived streaming state for StreamingContext / LoadingIndicator. */
streamingState: StreamingState;
/** Whether the agent can accept user input right now. */
isInputActive: boolean;
/** Seconds elapsed while in Responding state (resets each cycle). */
elapsedTime: number;
/** Prompt token count from the most recent round (for context usage). */
lastPromptTokenCount: number;
}
// ─── Hook ───────────────────────────────────────────────────
/**
* Subscribe to an AgentInteractive's events and derive UI streaming state.
*
* @param interactiveAgent - The agent instance, or undefined if not yet registered.
* @param events - Which event types trigger a re-render. Defaults to
* STATUS_CHANGE, TOOL_WAITING_APPROVAL, and TOOL_RESULT sufficient for
* composer / footer use. Callers like AgentChatView can pass a broader set
* (e.g. include TOOL_CALL, ROUND_END, TOOL_OUTPUT_UPDATE) for richer updates.
*/
export function useAgentStreamingState(
interactiveAgent: AgentInteractive | undefined,
events?: ReadonlyArray<(typeof AgentEventType)[keyof typeof AgentEventType]>,
): AgentStreamingInfo {
// ── Force-render on agent events ──
const [, setTick] = useState(0);
const tickRef = useRef(0);
const forceRender = useCallback(() => {
tickRef.current += 1;
setTick(tickRef.current);
}, []);
// ── Track last prompt token count from USAGE_METADATA events ──
const [lastPromptTokenCount, setLastPromptTokenCount] = useState(
() => interactiveAgent?.getLastPromptTokenCount() ?? 0,
);
const subscribedEvents = events ?? DEFAULT_EVENTS;
useEffect(() => {
if (!interactiveAgent) return;
const emitter: AgentEventEmitter | undefined =
interactiveAgent.getEventEmitter();
if (!emitter) return;
const handler = () => forceRender();
for (const evt of subscribedEvents) {
emitter.on(evt, handler);
}
// Dedicated listener for usage metadata — updates React state directly
// so the token count is available immediately (even if no other event
// triggers a re-render). Prefers totalTokenCount (prompt + output)
// because output becomes history for the next round, matching
// geminiChat.ts.
const usageHandler = (event: {
usage?: { totalTokenCount?: number; promptTokenCount?: number };
}) => {
const count =
event?.usage?.totalTokenCount ?? event?.usage?.promptTokenCount;
if (typeof count === 'number' && count > 0) {
setLastPromptTokenCount(count);
}
};
emitter.on(AgentEventType.USAGE_METADATA, usageHandler);
return () => {
for (const evt of subscribedEvents) {
emitter.off(evt, handler);
}
emitter.off(AgentEventType.USAGE_METADATA, usageHandler);
};
}, [interactiveAgent, forceRender, subscribedEvents]);
// ── Derived state ──
const status = interactiveAgent?.getStatus();
const pendingApprovals = interactiveAgent?.getPendingApprovals();
const hasPendingApprovals =
pendingApprovals !== undefined && pendingApprovals.size > 0;
const streamingState = useMemo(() => {
if (hasPendingApprovals) {
return StreamingState.WaitingForConfirmation;
}
if (status === AgentStatus.RUNNING || status === AgentStatus.INITIALIZING) {
return StreamingState.Responding;
}
return StreamingState.Idle;
}, [status, hasPendingApprovals]);
const isInputActive =
(streamingState === StreamingState.Idle ||
streamingState === StreamingState.Responding) &&
status !== undefined &&
!isTerminalStatus(status);
// ── Timer (resets each time we enter Responding) ──
const [timerResetKey, setTimerResetKey] = useState(0);
const prevStreamingRef = useRef(streamingState);
useEffect(() => {
if (
streamingState === StreamingState.Responding &&
prevStreamingRef.current !== StreamingState.Responding
) {
setTimerResetKey((k) => k + 1);
}
prevStreamingRef.current = streamingState;
}, [streamingState]);
const elapsedTime = useTimer(
streamingState === StreamingState.Responding,
timerResetKey,
);
return {
status,
streamingState,
isInputActive,
elapsedTime,
lastPromptTokenCount,
};
}
// ─── Defaults ───────────────────────────────────────────────
const DEFAULT_EVENTS = [
AgentEventType.STATUS_CHANGE,
AgentEventType.TOOL_WAITING_APPROVAL,
AgentEventType.TOOL_RESULT,
] as const;

View file

@ -0,0 +1,37 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { useCallback, useState } from 'react';
export type ArenaDialogType = 'start' | 'select' | 'stop' | 'status' | null;
interface UseArenaCommandReturn {
activeArenaDialog: ArenaDialogType;
openArenaDialog: (type: Exclude<ArenaDialogType, null>) => void;
closeArenaDialog: () => void;
}
export function useArenaCommand(): UseArenaCommandReturn {
const [activeArenaDialog, setActiveArenaDialog] =
useState<ArenaDialogType>(null);
const openArenaDialog = useCallback(
(type: Exclude<ArenaDialogType, null>) => {
setActiveArenaDialog(type);
},
[],
);
const closeArenaDialog = useCallback(() => {
setActiveArenaDialog(null);
}, []);
return {
activeArenaDialog,
openArenaDialog,
closeArenaDialog,
};
}

View file

@ -0,0 +1,177 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview useArenaInProcess bridges ArenaManager in-process events
* to AgentViewContext agent registration.
*
* Subscribes to `config.onArenaManagerChange()` to react immediately when
* the arena manager is set or cleared. Event listeners are attached to the
* manager's emitter as soon as it appears the backend is resolved lazily
* inside the AGENT_START handler, which only fires after the backend is
* initialized.
*/
import { useEffect, useRef } from 'react';
import {
ArenaEventType,
ArenaSessionStatus,
DISPLAY_MODE,
type ArenaAgentStartEvent,
type ArenaManager,
type ArenaSessionCompleteEvent,
type Config,
type InProcessBackend,
} from '@qwen-code/qwen-code-core';
import type { AgentViewActions } from '../contexts/AgentViewContext.js';
import { theme } from '../semantic-colors.js';
const AGENT_COLORS = [
theme.text.accent,
theme.text.link,
theme.status.success,
theme.status.warning,
theme.text.code,
theme.status.error,
];
/**
* Bridge arena in-process events to agent tab registration/unregistration.
*
* Called by AgentViewProvider accepts config and actions directly so the
* hook has no dependency on AgentViewContext (avoiding a circular import).
*/
export function useArenaInProcess(
config: Config | null,
actions: AgentViewActions,
): void {
const actionsRef = useRef(actions);
actionsRef.current = actions;
useEffect(() => {
if (!config) return;
let detachArenaListeners: (() => void) | null = null;
const retryTimeouts = new Set<ReturnType<typeof setTimeout>>();
/** Remove agent tabs, cancel pending retries, and detach arena events. */
const detachSession = () => {
actionsRef.current.unregisterAll();
for (const t of retryTimeouts) clearTimeout(t);
retryTimeouts.clear();
detachArenaListeners?.();
detachArenaListeners = null;
};
/** Attach to an arena manager's event emitter. The backend is resolved
* lazily we only need it when registering agents, not at subscribe
* time. This avoids the race where setArenaManager fires before
* manager.start() initializes the backend. */
const attachSession = (manager: ArenaManager) => {
const emitter = manager.getEventEmitter();
let colorIndex = 0;
const nextColor = () => AGENT_COLORS[colorIndex++ % AGENT_COLORS.length]!;
/** Resolve the InProcessBackend, or null if not applicable. */
const getInProcessBackend = (): InProcessBackend | null => {
const backend = manager.getBackend();
if (!backend || backend.type !== DISPLAY_MODE.IN_PROCESS) return null;
return backend as InProcessBackend;
};
// Register agents that already started (events may have fired before
// the callback was attached).
const inProcessBackend = getInProcessBackend();
if (inProcessBackend) {
for (const agentState of manager.getAgentStates()) {
const interactive = inProcessBackend.getAgent(agentState.agentId);
if (interactive) {
actionsRef.current.registerAgent(
agentState.agentId,
interactive,
agentState.model.modelId,
nextColor(),
agentState.model.displayName,
);
}
}
}
// AGENT_START fires *before* backend.spawnAgent() creates the
// AgentInteractive, so getAgent() may return undefined. Retry briefly.
const MAX_RETRIES = 20;
const RETRY_MS = 50;
const onAgentStart = (event: ArenaAgentStartEvent) => {
const tryRegister = (retriesLeft: number) => {
const backend = getInProcessBackend();
if (!backend) return; // not an in-process session
const interactive = backend.getAgent(event.agentId);
if (interactive) {
actionsRef.current.registerAgent(
event.agentId,
interactive,
event.model.modelId,
nextColor(),
event.model.displayName,
);
return;
}
if (retriesLeft > 0) {
const timeout = setTimeout(() => {
retryTimeouts.delete(timeout);
tryRegister(retriesLeft - 1);
}, RETRY_MS);
retryTimeouts.add(timeout);
}
};
tryRegister(MAX_RETRIES);
};
const onSessionComplete = (event: ArenaSessionCompleteEvent) => {
// IDLE means agents finished but the session is still alive for
// follow-up interaction — keep the tab bar.
if (event.result.status === ArenaSessionStatus.IDLE) return;
detachSession();
};
const onSessionError = () => detachSession();
emitter.on(ArenaEventType.AGENT_START, onAgentStart);
emitter.on(ArenaEventType.SESSION_COMPLETE, onSessionComplete);
emitter.on(ArenaEventType.SESSION_ERROR, onSessionError);
detachArenaListeners = () => {
emitter.off(ArenaEventType.AGENT_START, onAgentStart);
emitter.off(ArenaEventType.SESSION_COMPLETE, onSessionComplete);
emitter.off(ArenaEventType.SESSION_ERROR, onSessionError);
};
};
const handleManagerChange = (manager: ArenaManager | null) => {
detachSession();
if (manager) {
attachSession(manager);
}
};
// Subscribe to future changes.
config.onArenaManagerChange(handleManagerChange);
// Handle the case where a manager already exists when we mount.
const current = config.getArenaManager();
if (current) {
attachSession(current);
}
return () => {
config.onArenaManagerChange(null);
detachSession();
};
}, [config]);
}

View file

@ -19,6 +19,8 @@ export interface UseAutoAcceptIndicatorArgs {
addItem?: (item: HistoryItemWithoutId, timestamp: number) => void;
onApprovalModeChange?: (mode: ApprovalMode) => void;
shouldBlockTab?: () => boolean;
/** When true, the keyboard handler is disabled (e.g. agent tab is active). */
disabled?: boolean;
}
export function useAutoAcceptIndicator({
@ -26,6 +28,7 @@ export function useAutoAcceptIndicator({
addItem,
onApprovalModeChange,
shouldBlockTab,
disabled,
}: UseAutoAcceptIndicatorArgs): ApprovalMode {
const currentConfigValue = config.getApprovalMode();
const [showAutoAcceptIndicator, setShowAutoAcceptIndicator] =
@ -78,7 +81,7 @@ export function useAutoAcceptIndicator({
}
}
},
{ isActive: true },
{ isActive: !disabled },
);
return showAutoAcceptIndicator;

View file

@ -7,6 +7,7 @@
import { useCallback } from 'react';
import { SettingScope } from '../../config/settings.js';
import type { AuthType, ApprovalMode } from '@qwen-code/qwen-code-core';
import type { ArenaDialogType } from './useArenaCommand.js';
// OpenAICredentials type (previously imported from OpenAIKeyPrompt)
interface OpenAICredentials {
apiKey: string;
@ -42,6 +43,10 @@ export interface DialogCloseOptions {
isSettingsDialogOpen: boolean;
closeSettingsDialog: () => void;
// Arena dialogs
activeArenaDialog: ArenaDialogType;
closeArenaDialog: () => void;
// Folder trust dialog
isFolderTrustDialogOpen: boolean;
@ -83,6 +88,11 @@ export function useDialogClose(options: DialogCloseOptions) {
return true;
}
if (options.activeArenaDialog !== null) {
options.closeArenaDialog();
return true;
}
if (options.isFolderTrustDialogOpen) {
// FolderTrustDialog doesn't expose close function, but ESC would prevent exit
// We follow the same pattern - prevent exit behavior

View file

@ -203,6 +203,7 @@ describe('useGeminiStream', () => {
.fn()
.mockReturnValue(contentGeneratorConfig),
getMaxSessionTurns: vi.fn(() => 50),
getArenaAgentClient: vi.fn(() => null),
} as unknown as Config;
mockOnDebugMessage = vi.fn();
mockHandleSlashCommand = vi.fn().mockResolvedValue(false);

View file

@ -430,6 +430,12 @@ export const useGeminiStream = (
isSubmittingQueryRef.current = false;
abortControllerRef.current?.abort();
// Report cancellation to arena status reporter (if in arena mode).
// This is needed because cancellation during tool execution won't
// flow through sendMessageStream where the inline reportCancelled()
// lives — tools get cancelled and handleCompletedTools returns early.
config.getArenaAgentClient()?.reportCancelled();
// Log API cancellation
const prompt_id = config.getSessionId() + '########' + getPromptCount();
const cancellationEvent = new ApiCancelEvent(
@ -1433,6 +1439,9 @@ export const useGeminiStream = (
role: 'user',
parts: combinedParts,
});
// Report cancellation to arena (safety net — cancelOngoingRequest
config.getArenaAgentClient()?.reportCancelled();
}
const callIdsToMarkAsSubmitted = geminiTools.map(
@ -1469,6 +1478,7 @@ export const useGeminiStream = (
geminiClient,
performMemoryRefresh,
modelSwitchedFromQuotaError,
config,
],
);

View file

@ -18,6 +18,7 @@ export interface UseInputHistoryReturn {
handleSubmit: (value: string) => void;
navigateUp: () => boolean;
navigateDown: () => boolean;
resetHistoryNav: () => void;
}
export function useInputHistory({
@ -107,5 +108,6 @@ export function useInputHistory({
handleSubmit,
navigateUp,
navigateDown,
resetHistoryNav,
};
}

View file

@ -133,4 +133,119 @@ describe('useLoadingIndicator', () => {
});
expect(result.current.elapsedTime).toBe(0);
});
describe('token tracking', () => {
it('should capture token snapshot when task starts', () => {
const { result, rerender } = renderHook(
({ streamingState, currentCandidatesTokens }) =>
useLoadingIndicator(
streamingState,
undefined,
currentCandidatesTokens,
),
{
initialProps: {
streamingState: StreamingState.Idle,
currentCandidatesTokens: 100,
},
},
);
expect(result.current.taskStartTokens).toBe(0);
act(() => {
rerender({
streamingState: StreamingState.Responding,
currentCandidatesTokens: 100,
});
});
expect(result.current.taskStartTokens).toBe(100);
});
it('should reset token snapshot when transitioning from Responding to Idle', async () => {
const { result, rerender } = renderHook(
({ streamingState, currentCandidatesTokens }) =>
useLoadingIndicator(
streamingState,
undefined,
currentCandidatesTokens,
),
{
initialProps: {
streamingState: StreamingState.Idle,
currentCandidatesTokens: 0,
},
},
);
act(() => {
rerender({
streamingState: StreamingState.Responding,
currentCandidatesTokens: 0,
});
});
expect(result.current.taskStartTokens).toBe(0);
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
rerender({
streamingState: StreamingState.Responding,
currentCandidatesTokens: 500,
});
});
act(() => {
rerender({
streamingState: StreamingState.Idle,
currentCandidatesTokens: 500,
});
});
expect(result.current.taskStartTokens).toBe(0);
});
it('should reset token snapshot when transitioning from WaitingForConfirmation to Responding', async () => {
const { result, rerender } = renderHook(
({ streamingState, currentCandidatesTokens }) =>
useLoadingIndicator(
streamingState,
undefined,
currentCandidatesTokens,
),
{
initialProps: {
streamingState: StreamingState.Responding,
currentCandidatesTokens: 100,
},
},
);
expect(result.current.taskStartTokens).toBe(100);
await act(async () => {
await vi.advanceTimersByTimeAsync(5000);
rerender({
streamingState: StreamingState.Responding,
currentCandidatesTokens: 500,
});
});
act(() => {
rerender({
streamingState: StreamingState.WaitingForConfirmation,
currentCandidatesTokens: 500,
});
});
act(() => {
rerender({
streamingState: StreamingState.Responding,
currentCandidatesTokens: 500,
});
});
expect(result.current.taskStartTokens).toBe(500);
});
});
});

View file

@ -7,11 +7,12 @@
import { StreamingState } from '../types.js';
import { useTimer } from './useTimer.js';
import { usePhraseCycler } from './usePhraseCycler.js';
import { useState, useEffect, useRef } from 'react'; // Added useRef
import { useState, useEffect, useRef } from 'react';
export const useLoadingIndicator = (
streamingState: StreamingState,
customWittyPhrases?: string[],
currentCandidatesTokens?: number,
) => {
const [timerResetKey, setTimerResetKey] = useState(0);
const isTimerActive = streamingState === StreamingState.Responding;
@ -27,6 +28,7 @@ export const useLoadingIndicator = (
);
const [retainedElapsedTime, setRetainedElapsedTime] = useState(0);
const [taskStartTokens, setTaskStartTokens] = useState(0);
const prevStreamingStateRef = useRef<StreamingState | null>(null);
useEffect(() => {
@ -35,21 +37,26 @@ export const useLoadingIndicator = (
streamingState === StreamingState.Responding
) {
setTimerResetKey((prevKey) => prevKey + 1);
setRetainedElapsedTime(0); // Clear retained time when going back to responding
setRetainedElapsedTime(0);
setTaskStartTokens(currentCandidatesTokens ?? 0);
} else if (
streamingState === StreamingState.Idle &&
prevStreamingStateRef.current === StreamingState.Responding
) {
setTimerResetKey((prevKey) => prevKey + 1); // Reset timer when becoming idle from responding
setTimerResetKey((prevKey) => prevKey + 1);
setRetainedElapsedTime(0);
setTaskStartTokens(0);
} else if (
streamingState === StreamingState.Responding &&
prevStreamingStateRef.current !== StreamingState.Responding
) {
setTaskStartTokens(currentCandidatesTokens ?? 0);
} else if (streamingState === StreamingState.WaitingForConfirmation) {
// Capture the time when entering WaitingForConfirmation
// elapsedTimeFromTimer will hold the last value from when isTimerActive was true.
setRetainedElapsedTime(elapsedTimeFromTimer);
}
prevStreamingStateRef.current = streamingState;
}, [streamingState, elapsedTimeFromTimer]);
}, [streamingState, elapsedTimeFromTimer, currentCandidatesTokens]);
return {
elapsedTime:
@ -57,5 +64,6 @@ export const useLoadingIndicator = (
? retainedElapsedTime
: elapsedTimeFromTimer,
currentLoadingPhrase,
taskStartTokens,
};
};

View file

@ -5,6 +5,7 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useEffect, useState } from 'react';
import { renderHook, act } from '@testing-library/react';
import {
useSelectionList,
@ -915,6 +916,37 @@ describe('useSelectionList', () => {
expect(result.current.activeIndex).toBe(2);
});
it('should handle equivalent items regenerated on each render', () => {
const { result } = renderHook(() => {
const [tick, setTick] = useState(0);
const regeneratedItems = [
{ value: 'A', key: 'A' },
{ value: 'B', disabled: true, key: 'B' },
{ value: 'C', key: 'C' },
];
const selection = useSelectionList({
items: regeneratedItems,
onSelect: mockOnSelect,
initialIndex: 0,
});
useEffect(() => {
if (tick === 0) {
setTick(1);
}
}, [tick]);
return {
tick,
activeIndex: selection.activeIndex,
};
});
expect(result.current.tick).toBe(1);
expect(result.current.activeIndex).toBe(0);
});
});
describe('Manual Control', () => {

View file

@ -133,6 +133,27 @@ const computeInitialIndex = <T>(
return targetIndex;
};
const areItemsStructurallyEqual = <T>(
a: Array<SelectionListItem<T>>,
b: Array<SelectionListItem<T>>,
): boolean => {
if (a === b) {
return true;
}
if (a.length !== b.length) {
return false;
}
for (let i = 0; i < a.length; i++) {
if (a[i]?.key !== b[i]?.key || a[i]?.disabled !== b[i]?.disabled) {
return false;
}
}
return true;
};
function selectionListReducer<T>(
state: SelectionListState<T>,
action: SelectionListAction<T>,
@ -176,22 +197,30 @@ function selectionListReducer<T>(
case 'INITIALIZE': {
const { initialIndex, items } = action.payload;
const initialIndexChanged = initialIndex !== state.initialIndex;
const activeKey =
initialIndex === state.initialIndex &&
state.activeIndex !== state.initialIndex
!initialIndexChanged && state.activeIndex !== state.initialIndex
? state.items[state.activeIndex]?.key
: undefined;
const targetIndex = computeInitialIndex(initialIndex, items, activeKey);
const itemsStructurallyEqual = areItemsStructurallyEqual(
items,
state.items,
);
if (items === state.items && initialIndex === state.initialIndex) {
if (
!initialIndexChanged &&
targetIndex === state.activeIndex &&
itemsStructurallyEqual
) {
return state;
}
const targetIndex = computeInitialIndex(initialIndex, items, activeKey);
return {
...state,
items,
items: itemsStructurallyEqual ? state.items : items,
activeIndex: targetIndex,
initialIndex,
pendingHighlight: false,
};
}

View file

@ -5,36 +5,77 @@
*/
import type React from 'react';
import { useEffect, useRef } from 'react';
import { Box } from 'ink';
import { MainContent } from '../components/MainContent.js';
import { DialogManager } from '../components/DialogManager.js';
import { Composer } from '../components/Composer.js';
import { ExitWarning } from '../components/ExitWarning.js';
import { AgentTabBar } from '../components/agent-view/AgentTabBar.js';
import { AgentChatView } from '../components/agent-view/AgentChatView.js';
import { AgentComposer } from '../components/agent-view/AgentComposer.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useAgentViewState } from '../contexts/AgentViewContext.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
export const DefaultAppLayout: React.FC = () => {
const uiState = useUIState();
const { refreshStatic } = useUIActions();
const { activeView, agents } = useAgentViewState();
const { columns: terminalWidth } = useTerminalSize();
const hasAgents = agents.size > 0;
const isAgentTab = activeView !== 'main' && agents.has(activeView);
// Clear terminal on view switch so previous view's <Static> output
// is removed. refreshStatic clears the terminal and bumps the
// historyRemountKey so MainContent's <Static> re-renders all items
// when switching back.
const prevViewRef = useRef(activeView);
useEffect(() => {
if (prevViewRef.current !== activeView) {
prevViewRef.current = activeView;
refreshStatic();
}
}, [activeView, refreshStatic]);
return (
<Box flexDirection="column" width={terminalWidth}>
<MainContent />
<Box flexDirection="column" ref={uiState.mainControlsRef}>
{uiState.dialogsVisible ? (
<Box marginX={2} flexDirection="column" width={uiState.mainAreaWidth}>
<DialogManager
terminalWidth={uiState.terminalWidth}
addItem={uiState.historyManager.addItem}
/>
{isAgentTab ? (
<>
{/* Agent view: chat history + agent-specific composer */}
<AgentChatView agentId={activeView} />
<Box flexDirection="column" ref={uiState.mainControlsRef}>
<AgentComposer key={activeView} agentId={activeView} />
<ExitWarning />
</Box>
) : (
<Composer />
)}
</>
) : (
<>
{/* Main view: conversation history + main composer / dialogs */}
<MainContent />
<Box flexDirection="column" ref={uiState.mainControlsRef}>
{uiState.dialogsVisible ? (
<Box
marginX={2}
flexDirection="column"
width={uiState.mainAreaWidth}
>
<DialogManager
terminalWidth={uiState.terminalWidth}
addItem={uiState.historyManager.addItem}
/>
</Box>
) : (
<Composer />
)}
<ExitWarning />
</Box>
</>
)}
<ExitWarning />
</Box>
{/* Tab bar: visible whenever in-process agents exist and input is active */}
{hasAgents && !uiState.dialogsVisible && <AgentTabBar />}
</Box>
);
};

View file

@ -11,6 +11,7 @@ import type {
ToolCallConfirmationDetails,
ToolConfirmationOutcome,
ToolResultDisplay,
AgentStatus,
} from '@qwen-code/qwen-code-core';
import type { PartListUnion } from '@google/genai';
import { type ReactNode } from 'react';
@ -128,6 +129,11 @@ export type HistoryItemWarning = HistoryItemBase & {
text: string;
};
export type HistoryItemSuccess = HistoryItemBase & {
type: 'success';
text: string;
};
export type HistoryItemRetryCountdown = HistoryItemBase & {
type: 'retry_countdown';
text: string;
@ -256,6 +262,89 @@ export type HistoryItemMcpStatus = HistoryItemBase & {
showTips: boolean;
};
// --- Context Usage types ---
export interface ContextCategoryBreakdown {
systemPrompt: number;
builtinTools: number;
mcpTools: number;
memoryFiles: number;
skills: number;
messages: number;
freeSpace: number;
autocompactBuffer: number;
}
export interface ContextToolDetail {
name: string;
tokens: number;
}
export interface ContextMemoryDetail {
path: string;
tokens: number;
}
export interface ContextSkillDetail {
name: string;
/** Token cost of the skill listing (name+description) in the tool definition */
tokens: number;
/** Whether this skill has been invoked and its full body loaded into context */
loaded?: boolean;
/** Token cost of the loaded SKILL.md body (only set when loaded is true) */
bodyTokens?: number;
}
export type HistoryItemContextUsage = HistoryItemBase & {
type: 'context_usage';
modelName: string;
totalTokens: number;
contextWindowSize: number;
breakdown: ContextCategoryBreakdown;
builtinTools: ContextToolDetail[];
mcpTools: ContextToolDetail[];
memoryFiles: ContextMemoryDetail[];
skills: ContextSkillDetail[];
/** True when totalTokens is estimated (no API call yet) rather than from API response */
isEstimated?: boolean;
/** When true, show per-item detail sections (tools, memory, skills). Default: false (compact). */
showDetails?: boolean;
};
/**
* Arena agent completion card data.
*/
export interface ArenaAgentCardData {
label: string;
status: AgentStatus;
durationMs: number;
totalTokens: number;
inputTokens: number;
outputTokens: number;
toolCalls: number;
successfulToolCalls: number;
failedToolCalls: number;
rounds: number;
error?: string;
diff?: string;
}
export type HistoryItemArenaAgentComplete = HistoryItemBase & {
type: 'arena_agent_complete';
agent: ArenaAgentCardData;
};
export type HistoryItemArenaSessionComplete = HistoryItemBase & {
type: 'arena_session_complete';
sessionStatus: string;
task: string;
totalDurationMs: number;
agents: ArenaAgentCardData[];
};
/**
* Insight progress message.
*/
export type HistoryItemInsightProgress = HistoryItemBase & {
type: 'insight_progress';
progress: InsightProgressProps;
@ -275,6 +364,7 @@ export type HistoryItemWithoutId =
| HistoryItemInfo
| HistoryItemError
| HistoryItemWarning
| HistoryItemSuccess
| HistoryItemRetryCountdown
| HistoryItemAbout
| HistoryItemHelp
@ -290,6 +380,9 @@ export type HistoryItemWithoutId =
| HistoryItemToolsList
| HistoryItemSkillsList
| HistoryItemMcpStatus
| HistoryItemContextUsage
| HistoryItemArenaAgentComplete
| HistoryItemArenaSessionComplete
| HistoryItemInsightProgress;
export type HistoryItem = HistoryItemWithoutId & { id: number };
@ -297,6 +390,7 @@ export type HistoryItem = HistoryItemWithoutId & { id: number };
// Message types used by internal command feedback (subset of HistoryItem types)
export enum MessageType {
INFO = 'info',
SUCCESS = 'success',
ERROR = 'error',
WARNING = 'warning',
USER = 'user',
@ -313,6 +407,9 @@ export enum MessageType {
TOOLS_LIST = 'tools_list',
SKILLS_LIST = 'skills_list',
MCP_STATUS = 'mcp_status',
CONTEXT_USAGE = 'context_usage',
ARENA_AGENT_COMPLETE = 'arena_agent_complete',
ARENA_SESSION_COMPLETE = 'arena_session_complete',
INSIGHT_PROGRESS = 'insight_progress',
}

View file

@ -103,7 +103,7 @@ const RenderInlineInternal: React.FC<RenderInlineProps> = ({
const codeMatch = fullMatch.match(/^(`+)(.+?)\1$/s);
if (codeMatch && codeMatch[2]) {
renderedNode = (
<Text key={key} color={theme.text.accent}>
<Text key={key} color={theme.text.code}>
{codeMatch[2]}
</Text>
);

View file

@ -5,6 +5,34 @@
*/
import { theme } from '../semantic-colors.js';
import { AgentStatus } from '@qwen-code/qwen-code-core';
// --- Status Labels ---
export interface StatusLabel {
icon: string;
text: string;
color: string;
}
export function getArenaStatusLabel(status: AgentStatus): StatusLabel {
switch (status) {
case AgentStatus.IDLE:
return { icon: '✓', text: 'Idle', color: theme.status.success };
case AgentStatus.COMPLETED:
return { icon: '✓', text: 'Done', color: theme.status.success };
case AgentStatus.CANCELLED:
return { icon: '⊘', text: 'Cancelled', color: theme.status.warning };
case AgentStatus.FAILED:
return { icon: '✗', text: 'Failed', color: theme.status.error };
case AgentStatus.RUNNING:
return { icon: '○', text: 'Running', color: theme.text.secondary };
case AgentStatus.INITIALIZING:
return { icon: '○', text: 'Initializing', color: theme.text.secondary };
default:
return { icon: '○', text: status, color: theme.text.secondary };
}
}
// --- Thresholds ---
export const TOOL_SUCCESS_RATE_HIGH = 95;

View file

@ -6,10 +6,395 @@
import { randomUUID } from 'node:crypto';
import type { Config, ChatRecord } from '@qwen-code/qwen-code-core';
import type { GenerateContentResponseUsageMetadata } from '@google/genai';
import type { SessionContext } from '../../../acp-integration/session/types.js';
import type { SessionUpdate, ToolCall } from '@agentclientprotocol/sdk';
import { HistoryReplayer } from '../../../acp-integration/session/HistoryReplayer.js';
import type { ExportMessage, ExportSessionData } from './types.js';
import type {
ExportMessage,
ExportSessionData,
ExportMetadata,
} from './types.js';
/**
* File operation statistics extracted from tool calls.
*/
interface FileOperationStats {
filesWritten: number;
linesAdded: number;
linesRemoved: number;
writtenFilePaths: Set<string>;
}
/**
* Tool call arguments index for matching tool_result records.
*/
interface ToolCallArgsIndex {
byId: Map<string, Record<string, unknown>>;
byName: Map<string, Array<Record<string, unknown>>>;
}
/**
* Extracts tool name from a ChatRecord's function response.
*/
function extractToolNameFromRecord(record: ChatRecord): string | undefined {
if (!record.message?.parts) {
return undefined;
}
for (const part of record.message.parts) {
if ('functionResponse' in part && part.functionResponse?.name) {
return part.functionResponse.name;
}
}
return undefined;
}
/**
* Extracts call ID from a ChatRecord's function response.
*/
function extractFunctionResponseId(record: ChatRecord): string | undefined {
if (!record.message?.parts) {
return undefined;
}
for (const part of record.message.parts) {
if ('functionResponse' in part && part.functionResponse?.id) {
return part.functionResponse.id;
}
}
return undefined;
}
/**
* Normalizes function call args into a plain object.
*/
function normalizeFunctionCallArgs(
args: unknown,
): Record<string, unknown> | undefined {
if (args && typeof args === 'object') {
return args as Record<string, unknown>;
}
if (typeof args === 'string') {
try {
const parsed = JSON.parse(args) as unknown;
if (parsed && typeof parsed === 'object') {
return parsed as Record<string, unknown>;
}
} catch {
// Ignore parse errors and treat as unavailable args
}
}
return undefined;
}
/**
* Builds an index of assistant tool calls for later tool_result arg resolution.
*/
function buildToolCallArgsIndex(records: ChatRecord[]): ToolCallArgsIndex {
const byId = new Map<string, Record<string, unknown>>();
const byName = new Map<string, Array<Record<string, unknown>>>();
for (const record of records) {
if (record.type !== 'assistant' || !record.message?.parts) continue;
for (const part of record.message.parts) {
if (!('functionCall' in part) || !part.functionCall?.name) continue;
const normalizedArgs = normalizeFunctionCallArgs(part.functionCall.args);
if (!normalizedArgs) continue;
const toolName = part.functionCall.name;
const callId =
typeof part.functionCall.id === 'string' ? part.functionCall.id : null;
if (callId) {
byId.set(callId, normalizedArgs);
}
const queue = byName.get(toolName) ?? [];
queue.push(normalizedArgs);
byName.set(toolName, queue);
}
}
return { byId, byName };
}
/**
* Calculate file operation statistics from ChatRecords.
* Uses toolCallResult from tool_result records for accurate statistics.
*/
function calculateFileStats(records: ChatRecord[]): FileOperationStats {
const argsIndex = buildToolCallArgsIndex(records);
const byNameCursor = new Map<string, number>();
const stats: FileOperationStats = {
filesWritten: 0,
linesAdded: 0,
linesRemoved: 0,
writtenFilePaths: new Set(),
};
for (const record of records) {
if (record.type !== 'tool_result' || !record.toolCallResult) continue;
const toolName = extractToolNameFromRecord(record);
const callId =
record.toolCallResult.callId ?? extractFunctionResponseId(record);
const argsFromId =
callId && argsIndex.byId.has(callId)
? argsIndex.byId.get(callId)
: undefined;
let args = argsFromId;
if (!args && toolName) {
const queue = argsIndex.byName.get(toolName);
if (queue && queue.length > 0) {
const cursor = byNameCursor.get(toolName) ?? 0;
args = queue[cursor];
byNameCursor.set(toolName, cursor + 1);
}
}
const { resultDisplay } = record.toolCallResult;
// Track file locations from resultDisplay
if (
resultDisplay &&
typeof resultDisplay === 'object' &&
'fileName' in resultDisplay
) {
const display = resultDisplay as {
fileName: string;
fileDiff?: string;
originalContent?: string | null;
newContent?: string;
diffStat?: { model_added_lines?: number; model_removed_lines?: number };
};
// Determine operation type based on content fields
const hasOriginalContent = 'originalContent' in display;
const hasNewContent = 'newContent' in display;
// For write/edit operations, use full path from args if available
let filePath: string;
if (typeof display.fileName === 'string') {
// Prefer args.file_path for full path, fallback to fileName (which may be basename)
filePath =
(args?.['file_path'] as string) ||
(args?.['absolute_path'] as string) ||
display.fileName;
} else {
// Fallback if fileName is not a string
filePath = 'unknown';
}
if (hasOriginalContent || hasNewContent) {
// This is a write/edit operation
stats.filesWritten++;
stats.writtenFilePaths.add(filePath);
// Calculate line changes
if (display.diffStat) {
// Use diffStat if available for accurate counts
stats.linesAdded += display.diffStat.model_added_lines ?? 0;
stats.linesRemoved += display.diffStat.model_removed_lines ?? 0;
} else {
// Fallback: count lines in content
const oldText = String(display.originalContent ?? '');
const newText = String(display.newContent ?? '');
// Count non-empty lines
const oldLines = oldText
.split('\n')
.filter((line) => line.length > 0).length;
const newLines = newText
.split('\n')
.filter((line) => line.length > 0).length;
stats.linesAdded += newLines;
stats.linesRemoved += oldLines;
}
}
}
}
return stats;
}
/**
* Extracts token usage from TaskResultDisplay executionSummary.
*/
function extractTaskToolTokens(record: ChatRecord): number {
if (record.type !== 'tool_result' || !record.toolCallResult?.resultDisplay) {
return 0;
}
const { resultDisplay } = record.toolCallResult;
if (
typeof resultDisplay === 'object' &&
'type' in resultDisplay &&
resultDisplay.type === 'task_execution' &&
'executionSummary' in resultDisplay
) {
const summary = resultDisplay.executionSummary as {
totalTokens?: number;
inputTokens?: number;
outputTokens?: number;
thoughtTokens?: number;
cachedTokens?: number;
};
// Use totalTokens if available, otherwise sum individual token counts
if (typeof summary.totalTokens === 'number') {
return summary.totalTokens;
}
// Fallback: sum available token counts
return (
(summary.inputTokens ?? 0) +
(summary.outputTokens ?? 0) +
(summary.thoughtTokens ?? 0) +
(summary.cachedTokens ?? 0)
);
}
return 0;
}
/**
* Calculate token statistics from ChatRecords.
* Aggregates usageMetadata from assistant records and TaskTool executionSummary to get total token usage.
* Uses the last assistant record that has both totalTokenCount and contextWindowSize for calculating context usage percent.
*/
function calculateTokenStats(records: ChatRecord[]): {
totalTokens: number;
contextUsagePercent?: number;
contextWindowSize?: number;
} {
let totalTokens = 0;
// Track the last assistant record that has BOTH totalTokenCount and contextWindowSize
// to ensure the percentage calculation uses values from the same record
let lastValidRecord: {
totalTokenCount: number;
contextWindowSize: number;
} | null = null;
// Aggregate usageMetadata from all assistant records
for (const record of records) {
if (record.type === 'assistant') {
if (record.usageMetadata) {
totalTokens += record.usageMetadata.totalTokenCount ?? 0;
}
// Only update lastValidRecord when BOTH values are present in the same record
if (
record.usageMetadata?.totalTokenCount !== undefined &&
record.contextWindowSize !== undefined
) {
lastValidRecord = {
totalTokenCount: record.usageMetadata.totalTokenCount,
contextWindowSize: record.contextWindowSize,
};
}
}
// Include TaskTool token usage from executionSummary
const taskTokens = extractTaskToolTokens(record);
if (taskTokens > 0) {
totalTokens += taskTokens;
}
}
// Use last valid record's values for context usage calculation
// This represents how much of the context window is being used by the total tokens
if (lastValidRecord) {
const percent =
(lastValidRecord.totalTokenCount / lastValidRecord.contextWindowSize) *
100;
return {
totalTokens,
contextUsagePercent: Math.round(percent * 10) / 10,
contextWindowSize: lastValidRecord.contextWindowSize,
};
}
// Fallback: return the contextWindowSize from the last assistant record even if no valid pair found
// (for display purposes only, without percentage)
const lastAssistantRecord = [...records]
.reverse()
.find((r) => r.type === 'assistant' && r.contextWindowSize !== undefined);
return {
totalTokens,
contextWindowSize: lastAssistantRecord?.contextWindowSize,
};
}
/**
* Extract session metadata from ChatRecords.
*/
async function extractMetadata(
conversation: {
sessionId: string;
startTime: string;
messages: ChatRecord[];
},
config: Config,
): Promise<ExportMetadata> {
const { sessionId, startTime, messages } = conversation;
// Extract basic info from the first record
const firstRecord = messages[0];
const cwd = firstRecord?.cwd ?? '';
const gitBranch = firstRecord?.gitBranch;
// Get git repository name
let gitRepo: string | undefined;
if (cwd) {
const { getGitRepoName } = await import('@qwen-code/qwen-code-core');
gitRepo = getGitRepoName(cwd);
}
// Try to get model from assistant messages
let model: string | undefined;
for (const record of messages) {
if (record.type === 'assistant' && record.model) {
model = record.model;
break;
}
}
// Get channel from config
const channel = config.getChannel?.();
// Count user prompts
const promptCount = messages.filter((m) => m.type === 'user').length;
// Calculate file stats from original ChatRecords
const fileStats = calculateFileStats(messages);
// Calculate token stats from original ChatRecords
// contextWindowSize is retrieved from the last assistant record for accuracy
const tokenStats = calculateTokenStats(messages);
return {
sessionId,
startTime,
exportTime: new Date().toISOString(),
cwd,
gitRepo,
gitBranch,
model,
channel,
promptCount,
contextUsagePercent: tokenStats.contextUsagePercent,
contextWindowSize: tokenStats.contextWindowSize,
totalTokens: tokenStats.totalTokens,
filesWritten: fileStats.writtenFilePaths.size,
linesAdded: fileStats.linesAdded,
linesRemoved: fileStats.linesRemoved,
uniqueFiles: Array.from(fileStats.writtenFilePaths),
};
}
/**
* Export session context that captures session updates into export messages.
@ -24,6 +409,7 @@ class ExportSessionContext implements SessionContext {
role: 'user' | 'assistant' | 'thinking';
parts: Array<{ text: string }>;
timestamp: number;
usageMetadata?: GenerateContentResponseUsageMetadata;
} | null = null;
private activeRecordId: string | null = null;
private activeRecordTimestamp: string | null = null;
@ -39,9 +425,37 @@ class ExportSessionContext implements SessionContext {
case 'user_message_chunk':
this.handleMessageChunk('user', update.content);
break;
case 'agent_message_chunk':
this.handleMessageChunk('assistant', update.content);
case 'agent_message_chunk': {
// Extract usageMetadata from _meta if available
const usageMeta = update._meta as
| {
usage?: {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
thoughtTokens?: number;
cachedReadTokens?: number;
};
}
| undefined;
const usageMetadata: GenerateContentResponseUsageMetadata | undefined =
usageMeta?.usage
? {
promptTokenCount: usageMeta.usage.inputTokens,
candidatesTokenCount: usageMeta.usage.outputTokens,
totalTokenCount: usageMeta.usage.totalTokens,
thoughtsTokenCount: usageMeta.usage.thoughtTokens,
cachedContentTokenCount: usageMeta.usage.cachedReadTokens,
}
: undefined;
this.handleMessageChunk(
'assistant',
update.content,
'assistant',
usageMetadata,
);
break;
}
case 'agent_thought_chunk':
this.handleMessageChunk('assistant', update.content, 'thinking');
break;
@ -79,6 +493,7 @@ class ExportSessionContext implements SessionContext {
role: 'user' | 'assistant',
content: { type: string; text?: string },
messageRole: 'user' | 'assistant' | 'thinking' = role,
usageMetadata?: GenerateContentResponseUsageMetadata,
): void {
if (content.type !== 'text' || !content.text) return;
@ -98,12 +513,17 @@ class ExportSessionContext implements SessionContext {
this.currentMessage.role === messageRole
) {
this.currentMessage.parts.push({ text: content.text });
// Merge usageMetadata if provided (for assistant messages)
if (usageMetadata && role === 'assistant') {
this.currentMessage.usageMetadata = usageMetadata;
}
} else {
this.currentMessage = {
type: role,
role: messageRole,
parts: [{ text: content.text }],
timestamp: Date.now(),
...(usageMetadata && role === 'assistant' ? { usageMetadata } : {}),
};
}
}
@ -205,7 +625,7 @@ class ExportSessionContext implements SessionContext {
if (!this.currentMessage) return;
const uuid = this.getMessageUuid();
this.messages.push({
const exportMessage: ExportMessage = {
uuid,
sessionId: this.sessionId,
timestamp: this.getMessageTimestamp(),
@ -214,7 +634,17 @@ class ExportSessionContext implements SessionContext {
role: this.currentMessage.role,
parts: this.currentMessage.parts,
},
});
};
// Add usageMetadata for assistant messages
if (
this.currentMessage.type === 'assistant' &&
this.currentMessage.usageMetadata
) {
exportMessage.usageMetadata = this.currentMessage.usageMetadata;
}
this.messages.push(exportMessage);
this.currentMessage = null;
}
@ -258,9 +688,13 @@ export async function collectSessionData(
// Get the export messages
const messages = exportContext.getMessages();
// Extract metadata from conversation
const metadata = await extractMetadata(conversation, config);
return {
sessionId: conversation.sessionId,
startTime: conversation.startTime,
messages,
metadata,
};
}

View file

@ -36,6 +36,7 @@ export function injectDataIntoHtmlTemplate(
sessionId: string;
startTime: string;
messages: unknown[];
metadata?: unknown;
},
): string {
const jsonData = JSON.stringify(data, null, 2);

View file

@ -12,15 +12,60 @@ import type { ExportSessionData } from '../types.js';
*/
export function toJsonl(sessionData: ExportSessionData): string {
const lines: string[] = [];
const sourceMetadata = sessionData.metadata;
// Add session metadata as the first line
lines.push(
JSON.stringify({
type: 'session_metadata',
sessionId: sessionData.sessionId,
startTime: sessionData.startTime,
}),
);
const metadata: Record<string, unknown> = {
type: 'session_metadata',
sessionId: sessionData.sessionId,
startTime: sessionData.startTime,
};
// Add all metadata fields if available
if (sourceMetadata?.exportTime) {
metadata['exportTime'] = sourceMetadata.exportTime;
}
if (sourceMetadata?.cwd) {
metadata['cwd'] = sourceMetadata.cwd;
}
if (sourceMetadata?.gitRepo) {
metadata['gitRepo'] = sourceMetadata.gitRepo;
}
if (sourceMetadata?.gitBranch) {
metadata['gitBranch'] = sourceMetadata.gitBranch;
}
if (sourceMetadata?.model) {
metadata['model'] = sourceMetadata.model;
}
if (sourceMetadata?.channel) {
metadata['channel'] = sourceMetadata.channel;
}
if (sourceMetadata?.promptCount !== undefined) {
metadata['promptCount'] = sourceMetadata.promptCount;
}
if (sourceMetadata?.contextUsagePercent !== undefined) {
metadata['contextUsagePercent'] = sourceMetadata.contextUsagePercent;
}
if (sourceMetadata?.contextWindowSize !== undefined) {
metadata['contextWindowSize'] = sourceMetadata.contextWindowSize;
}
if (sourceMetadata?.totalTokens !== undefined) {
metadata['totalTokens'] = sourceMetadata.totalTokens;
}
if (sourceMetadata?.filesWritten !== undefined) {
metadata['filesWritten'] = sourceMetadata.filesWritten;
}
if (sourceMetadata?.linesAdded !== undefined) {
metadata['linesAdded'] = sourceMetadata.linesAdded;
}
if (sourceMetadata?.linesRemoved !== undefined) {
metadata['linesRemoved'] = sourceMetadata.linesRemoved;
}
if (sourceMetadata?.uniqueFiles && sourceMetadata.uniqueFiles.length > 0) {
metadata['uniqueFiles'] = sourceMetadata.uniqueFiles;
}
lines.push(JSON.stringify(metadata));
// Add each message as a separate line
for (const message of sessionData.messages) {

View file

@ -11,12 +11,82 @@ import type { ExportSessionData, ExportMessage } from '../types.js';
*/
export function toMarkdown(sessionData: ExportSessionData): string {
const lines: string[] = [];
const metadata = sessionData.metadata;
// Add header with metadata
lines.push('# Chat Session Export\n');
lines.push(`- **Session ID**: \`${sanitizeText(sessionData.sessionId)}\``);
lines.push(`- **Start Time**: ${sanitizeText(sessionData.startTime)}`);
lines.push(`- **Exported**: ${new Date().toISOString()}`);
lines.push(
`- **Exported**: ${sanitizeText(metadata?.exportTime ?? new Date().toISOString())}`,
);
lines.push('');
// Add context info
if (metadata?.cwd) {
lines.push(`- **Working Directory**: \`${sanitizeText(metadata.cwd)}\``);
}
if (metadata?.gitRepo) {
lines.push(`- **Git Repository**: ${sanitizeText(metadata.gitRepo)}`);
}
if (metadata?.gitBranch) {
lines.push(`- **Git Branch**: \`${sanitizeText(metadata.gitBranch)}\``);
}
lines.push('');
// Add model info
if (metadata?.model) {
lines.push(`- **Model**: ${sanitizeText(metadata.model)}`);
}
if (metadata?.channel) {
lines.push(`- **Channel**: ${sanitizeText(metadata.channel)}`);
}
if (metadata?.promptCount !== undefined) {
lines.push(`- **Prompt Count**: ${metadata.promptCount}`);
}
lines.push('');
// Add token stats
if (metadata?.totalTokens !== undefined) {
lines.push(`- **Total Tokens**: ${metadata.totalTokens}`);
}
if (metadata?.contextWindowSize !== undefined) {
lines.push(`- **Context Window Size**: ${metadata.contextWindowSize}`);
}
if (metadata?.contextUsagePercent !== undefined) {
lines.push(`- **Context Usage**: ${metadata.contextUsagePercent}%`);
}
lines.push('');
// Add file operation stats
if (metadata?.filesWritten !== undefined) {
lines.push(`- **Files Written**: ${metadata.filesWritten}`);
}
if (metadata?.linesAdded !== undefined) {
lines.push(`- **Lines Added**: ${metadata.linesAdded}`);
}
if (metadata?.linesRemoved !== undefined) {
lines.push(`- **Lines Removed**: ${metadata.linesRemoved}`);
}
// Add unique files list if available
if (metadata?.uniqueFiles && metadata.uniqueFiles.length > 0) {
lines.push('');
lines.push('<details>');
lines.push(
`<summary><strong>Unique Files Referenced (${metadata.uniqueFiles.length})</strong></summary>`,
);
lines.push('');
for (const file of metadata.uniqueFiles) {
lines.push(`- \`${sanitizeText(file)}\``);
}
lines.push('</details>');
}
lines.push('\n---\n');
// Process each message

View file

@ -28,6 +28,14 @@ export function normalizeSessionData(
}
});
// Build index of assistant messages by uuid for usageMetadata merging
const assistantMessageIndexByUuid = new Map<string, number>();
normalized.forEach((message, index) => {
if (message.type === 'assistant') {
assistantMessageIndexByUuid.set(message.uuid, index);
}
});
// Merge tool result information into tool call messages
for (const record of originalRecords) {
if (record.type !== 'tool_result') continue;
@ -58,6 +66,20 @@ export function normalizeSessionData(
mergeToolCallData(existingMessage.toolCall, toolCallMessage.toolCall);
}
// Merge usageMetadata from assistant records
for (const record of originalRecords) {
if (record.type !== 'assistant') continue;
if (!record.usageMetadata) continue;
const existingIndex = assistantMessageIndexByUuid.get(record.uuid);
if (existingIndex !== undefined) {
// Only set if not already present from collect phase
if (!normalized[existingIndex].usageMetadata) {
normalized[existingIndex].usageMetadata = record.usageMetadata;
}
}
}
return {
...sessionData,
messages: normalized,

View file

@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { GenerateContentResponseUsageMetadata } from '@google/genai';
/**
* Universal export message format - SSOT for all export formats.
* This is format-agnostic and contains all information needed for any export type.
@ -25,6 +27,9 @@ export interface ExportMessage {
/** Model used for assistant messages */
model?: string;
/** Token usage for this message (mainly for assistant messages) */
usageMetadata?: GenerateContentResponseUsageMetadata;
/** For tool_call messages */
toolCall?: {
toolCallId: string;
@ -44,6 +49,44 @@ export interface ExportMessage {
};
}
/**
* Metadata for export session - contains aggregated statistics and session context.
*/
export interface ExportMetadata {
/** Session ID */
sessionId: string;
/** ISO timestamp when session started */
startTime: string;
/** Export timestamp */
exportTime: string;
/** Current working directory */
cwd: string;
/** Git repository name, if available */
gitRepo?: string;
/** Git branch name, if available */
gitBranch?: string;
/** Model used in the session */
model?: string;
/** Channel/source identifier */
channel?: string;
/** Number of user prompts in the session */
promptCount: number;
/** Context window utilization percentage (0-100) */
contextUsagePercent?: number;
/** Context window size in tokens (used for calculating percentage) */
contextWindowSize?: number;
/** Total tokens used (prompt + completion) */
totalTokens?: number;
/** Number of files written/edited */
filesWritten?: number;
/** Lines of code added */
linesAdded?: number;
/** Lines of code removed */
linesRemoved?: number;
/** Unique files referenced in the session (written files only) */
uniqueFiles: string[];
}
/**
* Complete export session data - the single source of truth.
*/
@ -51,4 +94,6 @@ export interface ExportSessionData {
sessionId: string;
startTime: string;
messages: ExportMessage[];
/** Session metadata and statistics */
metadata?: ExportMetadata;
}

View file

@ -9,6 +9,7 @@ import {
formatDuration,
formatMemoryUsage,
formatRelativeTime,
formatTokenCount,
} from './formatters.js';
describe('formatters', () => {
@ -154,4 +155,25 @@ describe('formatters', () => {
expect(formatDuration(-100)).toBe('0s');
});
});
describe('formatTokenCount', () => {
it('should display exact number for counts less than 1000', () => {
expect(formatTokenCount(0)).toBe('0');
expect(formatTokenCount(100)).toBe('100');
expect(formatTokenCount(847)).toBe('847');
expect(formatTokenCount(999)).toBe('999');
});
it('should display with k suffix and one decimal for counts 1000-9999', () => {
expect(formatTokenCount(1000)).toBe('1.0k');
expect(formatTokenCount(5400)).toBe('5.4k');
expect(formatTokenCount(9999)).toBe('10.0k');
});
it('should display with k suffix without decimal for counts 10000 and above', () => {
expect(formatTokenCount(10000)).toBe('10k');
expect(formatTokenCount(15000)).toBe('15k');
expect(formatTokenCount(100000)).toBe('100k');
});
});
});

View file

@ -55,6 +55,16 @@ export const formatRelativeTime = (timestamp: number): string => {
return 'just now';
};
export const formatTokenCount = (count: number): string => {
if (count < 1000) {
return `${count}`;
}
if (count < 10000) {
return `${(count / 1000).toFixed(1)}k`;
}
return `${Math.floor(count / 1000)}k`;
};
export const formatDuration = (milliseconds: number): string => {
if (milliseconds <= 0) {
return '0s';

View file

@ -0,0 +1,40 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Shared layout calculation utilities for the terminal UI.
*/
/**
* Calculate the widths for the input prompt area based on terminal width.
*
* Returns the content width (for the text buffer), the total container width
* (including border + padding + prefix), the suggestions dropdown width,
* and the frame overhead constant.
*/
export const calculatePromptWidths = (terminalWidth: number) => {
const widthFraction = 0.9;
const FRAME_PADDING_AND_BORDER = 4; // Border (2) + padding (2)
const PROMPT_PREFIX_WIDTH = 2; // '> ' or '! '
const MIN_CONTENT_WIDTH = 2;
const innerContentWidth =
Math.floor(terminalWidth * widthFraction) -
FRAME_PADDING_AND_BORDER -
PROMPT_PREFIX_WIDTH;
const inputWidth = Math.max(MIN_CONTENT_WIDTH, innerContentWidth);
const FRAME_OVERHEAD = FRAME_PADDING_AND_BORDER + PROMPT_PREFIX_WIDTH;
const containerWidth = inputWidth + FRAME_OVERHEAD;
const suggestionsWidth = Math.max(20, Math.floor(terminalWidth * 1.0));
return {
inputWidth,
containerWidth,
suggestionsWidth,
frameOverhead: FRAME_OVERHEAD,
} as const;
};