diff --git a/packages/cli/src/ui/commands/arenaCommand.ts b/packages/cli/src/ui/commands/arenaCommand.ts index f17c2ce2e..bf9f44387 100644 --- a/packages/cli/src/ui/commands/arenaCommand.ts +++ b/packages/cli/src/ui/commands/arenaCommand.ts @@ -133,12 +133,20 @@ function buildArenaExecutionInput( const defaultAuthType = contentGeneratorConfig?.authType ?? AuthType.USE_OPENAI; - // Build ArenaModelConfig for each model - const models: ArenaModelConfig[] = parsed.models.map((parsedModel) => ({ - modelId: parsedModel.modelId, - authType: parsedModel.authType ?? defaultAuthType, - displayName: parsedModel.modelId, - })); + // 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, diff --git a/packages/cli/src/ui/components/agent-view/AgentChatView.tsx b/packages/cli/src/ui/components/agent-view/AgentChatView.tsx index 371c8bb27..485316436 100644 --- a/packages/cli/src/ui/components/agent-view/AgentChatView.tsx +++ b/packages/cli/src/ui/components/agent-view/AgentChatView.tsx @@ -26,6 +26,7 @@ import { useMemo, useState, useEffect, useCallback, useRef } from 'react'; import { AgentStatus, AgentEventType, + getGitBranch, type AgentStatusChangeEvent, } from '@qwen-code/qwen-code-core'; import { @@ -40,6 +41,7 @@ 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 ───────────────────────────────────────── @@ -188,7 +190,17 @@ export const AgentChatView = ({ agentId }: AgentChatViewProps) => { const committedItems = allItems.slice(0, splitIndex); const pendingItems = allItems.slice(splitIndex); - if (!agent || !interactiveAgent) { + 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 ( @@ -198,6 +210,8 @@ export const AgentChatView = ({ agentId }: AgentChatViewProps) => { ); } + const agentModelId = core.modelConfig.model ?? ''; + return ( {/* Committed message history. @@ -206,15 +220,24 @@ export const AgentChatView = ({ agentId }: AgentChatViewProps) => { all items on the cleared screen. */} ( - - ))} + items={[ + , + ...committedItems.map((item) => ( + + )), + ]} > {(item) => item} diff --git a/packages/cli/src/ui/components/agent-view/AgentComposer.tsx b/packages/cli/src/ui/components/agent-view/AgentComposer.tsx index 8c4d18b82..3d8062bfa 100644 --- a/packages/cli/src/ui/components/agent-view/AgentComposer.tsx +++ b/packages/cli/src/ui/components/agent-view/AgentComposer.tsx @@ -242,7 +242,7 @@ export const AgentComposer: React.FC = ({ agentId }) => { = ({ agentId }) => { /> {/* Footer: approval mode + context usage */} - {isInputActive && ( - - )} + ); diff --git a/packages/cli/src/ui/components/agent-view/AgentHeader.tsx b/packages/cli/src/ui/components/agent-view/AgentHeader.tsx new file mode 100644 index 000000000..1bf9d4c34 --- /dev/null +++ b/packages/cli/src/ui/components/agent-view/AgentHeader.tsx @@ -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 = ({ + 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 ( + + + {'Model: '} + {modelText} + + + {'Path: '} + {displayPath} + + {gitBranch && ( + + {'Branch: '} + {gitBranch} + + )} + + ); +}; diff --git a/packages/cli/src/ui/components/agent-view/AgentTabBar.tsx b/packages/cli/src/ui/components/agent-view/AgentTabBar.tsx index a502363b4..c7b0b113c 100644 --- a/packages/cli/src/ui/components/agent-view/AgentTabBar.tsx +++ b/packages/cli/src/ui/components/agent-view/AgentTabBar.tsx @@ -149,7 +149,7 @@ export const AgentTabBar: React.FC = () => { backgroundColor={isActive ? theme.border.default : undefined} color={isActive ? undefined : agent.color || theme.text.secondary} > - {` ${agent.displayName} `} + {` ${agent.modelId} `} {` ${symbol}`} diff --git a/packages/cli/src/ui/components/agent-view/index.ts b/packages/cli/src/ui/components/agent-view/index.ts index caa00a18a..c1e595c22 100644 --- a/packages/cli/src/ui/components/agent-view/index.ts +++ b/packages/cli/src/ui/components/agent-view/index.ts @@ -6,6 +6,7 @@ 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'; diff --git a/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx b/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx index 1a126c102..a6409b793 100644 --- a/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx +++ b/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx @@ -213,7 +213,7 @@ export function ArenaStatusDialog({ {/* Agent rows */} {agents.map((agent) => { - const label = agent.model.displayName || agent.model.modelId; + const label = agent.model.modelId; const { text: statusText, color } = getArenaStatusLabel(agent.status); const elapsed = getElapsedMs(agent); @@ -270,18 +270,6 @@ export function ArenaStatusDialog({ )} - {/* In-process mode: show extra detail row with thought/cached tokens */} - {live && (live.thoughtTokens > 0 || live.cachedTokens > 0) && ( - - - {live.thoughtTokens > 0 && - `Thinking: ${live.thoughtTokens.toLocaleString()} tok`} - {live.thoughtTokens > 0 && live.cachedTokens > 0 && ' · '} - {live.cachedTokens > 0 && - `Cached: ${live.cachedTokens.toLocaleString()} tok`} - - - )} ); })} diff --git a/packages/cli/src/ui/contexts/AgentViewContext.tsx b/packages/cli/src/ui/contexts/AgentViewContext.tsx index cb85ab4f2..b2c35e6d3 100644 --- a/packages/cli/src/ui/contexts/AgentViewContext.tsx +++ b/packages/cli/src/ui/contexts/AgentViewContext.tsx @@ -33,7 +33,10 @@ import { useArenaInProcess } from '../hooks/useArenaInProcess.js'; export interface RegisteredAgent { interactiveAgent: AgentInteractive; - displayName: string; + /** 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; } @@ -60,8 +63,9 @@ export interface AgentViewActions { registerAgent( agentId: string, interactiveAgent: AgentInteractive, - displayName: string, + modelId: string, color: string, + modelName?: string, ): void; unregisterAgent(agentId: string): void; unregisterAll(): void; @@ -173,12 +177,18 @@ export function AgentViewProvider({ ( agentId: string, interactiveAgent: AgentInteractive, - displayName: string, + modelId: string, color: string, + modelName?: string, ) => { setAgents((prev) => { const next = new Map(prev); - next.set(agentId, { interactiveAgent, displayName, color }); + next.set(agentId, { + interactiveAgent, + modelId, + color, + modelName, + }); return next; }); // Seed approval mode from the agent's own config diff --git a/packages/cli/src/ui/hooks/useArenaInProcess.ts b/packages/cli/src/ui/hooks/useArenaInProcess.ts index c5793490b..c75634a2a 100644 --- a/packages/cli/src/ui/hooks/useArenaInProcess.ts +++ b/packages/cli/src/ui/hooks/useArenaInProcess.ts @@ -93,8 +93,9 @@ export function useArenaInProcess( actionsRef.current.registerAgent( agentState.agentId, interactive, - agentState.model.displayName || agentState.model.modelId, + agentState.model.modelId, nextColor(), + agentState.model.displayName, ); } } @@ -115,8 +116,9 @@ export function useArenaInProcess( actionsRef.current.registerAgent( event.agentId, interactive, - event.model.displayName || event.model.modelId, + event.model.modelId, nextColor(), + event.model.displayName, ); return; } diff --git a/packages/core/src/agents/arena/ArenaManager.test.ts b/packages/core/src/agents/arena/ArenaManager.test.ts index 3ffcaa3b3..a21f15d63 100644 --- a/packages/core/src/agents/arena/ArenaManager.test.ts +++ b/packages/core/src/agents/arena/ArenaManager.test.ts @@ -411,10 +411,7 @@ describe('ArenaManager', () => { expect(mockBackend.cleanup).toHaveBeenCalledTimes(1); // cleanupSession is called with worktreeDirName (short ID), not the full sessionId. // For 'test-session', the short ID is 'testsess' (first 8 chars with dashes removed). - expect(hoistedMockCleanupSession).toHaveBeenCalledWith( - 'testsess', - 'arena', - ); + expect(hoistedMockCleanupSession).toHaveBeenCalledWith('testsess'); expect(manager.getBackend()).toBeNull(); expect(manager.getSessionId()).toBeUndefined(); }); diff --git a/packages/core/src/agents/arena/ArenaManager.ts b/packages/core/src/agents/arena/ArenaManager.ts index a14dd3e06..e271de7d2 100644 --- a/packages/core/src/agents/arena/ArenaManager.ts +++ b/packages/core/src/agents/arena/ArenaManager.ts @@ -508,7 +508,7 @@ export class ArenaManager { } // Clean up worktrees - await this.worktreeService.cleanupSession(this.worktreeDirName!, 'arena'); + await this.worktreeService.cleanupSession(this.worktreeDirName!); this.agents.clear(); this.cachedResult = null; @@ -758,15 +758,12 @@ export class ArenaManager { debugLogger.info('Setting up worktrees for Arena agents'); - const worktreeNames = this.arenaConfig.models.map( - (m) => m.displayName || m.modelId, - ); + const worktreeNames = this.arenaConfig.models.map((m) => m.modelId); const result = await this.worktreeService.setupWorktrees({ sessionId: this.worktreeDirName!, sourceRepoPath: this.arenaConfig.sourceRepoPath, worktreeNames, - branchPrefix: 'arena', metadata: { arenaSessionId: this.arenaConfig.sessionId }, }); diff --git a/packages/core/src/services/gitWorktreeService.test.ts b/packages/core/src/services/gitWorktreeService.test.ts index 2eb028d98..f34eb1ca2 100644 --- a/packages/core/src/services/gitWorktreeService.test.ts +++ b/packages/core/src/services/gitWorktreeService.test.ts @@ -148,13 +148,13 @@ describe('GitWorktreeService', () => { 'model-a', ); expect(result.success).toBe(true); - expect(result.worktree?.branch).toBe('worktrees/s1/model-a'); + expect(result.worktree?.branch).toBe('main-s1-model-a'); expect(result.worktree?.path).toBe(expectedPath); expect(hoistedMockRaw).toHaveBeenCalledWith([ 'worktree', 'add', '-b', - 'worktrees/s1/model-a', + 'main-s1-model-a', expectedPath, 'main', ]); @@ -228,7 +228,7 @@ describe('GitWorktreeService', () => { expect(result.success).toBe(false); expect(result.errors).toContainEqual({ name: 'b', error: 'boom' }); - expect(cleanupSpy).toHaveBeenCalledWith('s1', 'worktrees'); + expect(cleanupSpy).toHaveBeenCalledWith('s1'); }); it('listWorktrees should return empty array when session dir does not exist', async () => { @@ -256,31 +256,34 @@ describe('GitWorktreeService', () => { expect(hoistedMockRaw).toHaveBeenNthCalledWith(2, ['worktree', 'prune']); }); - it('cleanupSession should remove prefixed branches only', async () => { + it('cleanupSession should remove branches from listed worktrees', async () => { const service = new GitWorktreeService('/repo'); - vi.spyOn(service, 'listWorktrees').mockResolvedValue([]); - hoistedMockBranch.mockImplementation((args?: string[]) => { - if (args?.[0] === '-a') { - return Promise.resolve({ - branches: { - main: {}, - 'worktrees/s1/a': {}, - 'worktrees/s1/b': {}, - }, - }); - } - return Promise.resolve({ branches: {} }); - }); + vi.spyOn(service, 'listWorktrees').mockResolvedValue([ + { + id: 's1/a', + name: 'a', + path: '/w/a', + branch: 'main-s1-a', + isActive: true, + createdAt: Date.now(), + }, + { + id: 's1/b', + name: 'b', + path: '/w/b', + branch: 'main-s1-b', + isActive: true, + createdAt: Date.now(), + }, + ]); + vi.spyOn(service, 'removeWorktree').mockResolvedValue({ success: true }); const result = await service.cleanupSession('s1'); expect(result.success).toBe(true); - expect(result.removedBranches).toEqual([ - 'worktrees/s1/a', - 'worktrees/s1/b', - ]); - expect(hoistedMockBranch).toHaveBeenCalledWith(['-D', 'worktrees/s1/a']); - expect(hoistedMockBranch).toHaveBeenCalledWith(['-D', 'worktrees/s1/b']); + expect(result.removedBranches).toEqual(['main-s1-a', 'main-s1-b']); + expect(hoistedMockBranch).toHaveBeenCalledWith(['-D', 'main-s1-a']); + expect(hoistedMockBranch).toHaveBeenCalledWith(['-D', 'main-s1-b']); expect(hoistedMockRaw).toHaveBeenCalledWith(['worktree', 'prune']); }); diff --git a/packages/core/src/services/gitWorktreeService.ts b/packages/core/src/services/gitWorktreeService.ts index 5683fcdf0..6ceebf11e 100644 --- a/packages/core/src/services/gitWorktreeService.ts +++ b/packages/core/src/services/gitWorktreeService.ts @@ -6,6 +6,7 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; +import { execSync } from 'node:child_process'; import { simpleGit, CheckRepoActions } from 'simple-git'; import type { SimpleGit } from 'simple-git'; import { Storage } from '../config/storage.js'; @@ -51,8 +52,6 @@ export interface WorktreeSetupConfig { worktreeNames: string[]; /** Base branch to create worktrees from (defaults to current branch) */ baseBranch?: string; - /** Branch prefix for worktree branches (default: 'worktrees') */ - branchPrefix?: string; /** Extra metadata to persist alongside the session config */ metadata?: Record; } @@ -226,7 +225,6 @@ export class GitWorktreeService { sessionId: string, name: string, baseBranch?: string, - branchPrefix: string = WORKTREES_DIR, ): Promise { try { const worktreesDir = GitWorktreeService.getWorktreesDir( @@ -238,7 +236,6 @@ export class GitWorktreeService { // Sanitize name for use as branch and directory name const sanitizedName = this.sanitizeName(name); const worktreePath = path.join(worktreesDir, sanitizedName); - const branchName = `${branchPrefix}/${sessionId}/${sanitizedName}`; // Check if worktree already exists const exists = await this.pathExists(worktreePath); @@ -251,6 +248,8 @@ export class GitWorktreeService { // Determine base branch const base = baseBranch || (await this.getCurrentBranch()); + const shortSession = sessionId.slice(0, 6); + const branchName = `${base}-${shortSession}-${sanitizedName}`; // Create the worktree with a new branch await this.git.raw([ @@ -381,15 +380,12 @@ export class GitWorktreeService { // Non-fatal: proceed without untracked files } - const branchPrefix = config.branchPrefix ?? WORKTREES_DIR; - // Create worktrees for each entry for (const name of config.worktreeNames) { const createResult = await this.createWorktree( config.sessionId, name, config.baseBranch, - branchPrefix, ); if (createResult.success && createResult.worktree) { @@ -406,7 +402,7 @@ export class GitWorktreeService { // If any worktree failed, clean up all created resources and fail if (result.errors.length > 0) { try { - await this.cleanupSession(config.sessionId, branchPrefix); + await this.cleanupSession(config.sessionId); } catch (error) { result.errors.push({ name: 'cleanup', @@ -468,10 +464,7 @@ export class GitWorktreeService { /** * Lists all worktrees for a session. */ - async listWorktrees( - sessionId: string, - branchPrefix: string = WORKTREES_DIR, - ): Promise { + async listWorktrees(sessionId: string): Promise { const worktreesDir = GitWorktreeService.getWorktreesDir( sessionId, this.customBaseDir, @@ -484,7 +477,18 @@ export class GitWorktreeService { for (const entry of entries) { if (entry.isDirectory()) { const worktreePath = path.join(worktreesDir, entry.name); - const branchName = `${branchPrefix}/${sessionId}/${entry.name}`; + + // Read the actual branch from the worktree + let branchName = ''; + try { + branchName = execSync('git rev-parse --abbrev-ref HEAD', { + cwd: worktreePath, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + } catch { + // Fallback if git command fails + } // Try to get stats for creation time let createdAt = Date.now(); @@ -544,10 +548,7 @@ export class GitWorktreeService { /** * Cleans up all worktrees and branches for a session. */ - async cleanupSession( - sessionId: string, - branchPrefix: string = WORKTREES_DIR, - ): Promise<{ + async cleanupSession(sessionId: string): Promise<{ success: boolean; removedWorktrees: string[]; removedBranches: string[]; @@ -560,7 +561,11 @@ export class GitWorktreeService { errors: [] as string[], }; - const worktrees = await this.listWorktrees(sessionId, branchPrefix); + // Collect actual branch names from worktrees before removing them + const worktrees = await this.listWorktrees(sessionId); + const worktreeBranches = new Set( + worktrees.map((w) => w.branch).filter(Boolean), + ); // Remove all worktrees for (const worktree of worktrees) { @@ -588,18 +593,14 @@ export class GitWorktreeService { ); } - // Clean up branches - const prefix = `${branchPrefix}/${sessionId}/`; + // Clean up branches that belonged to the worktrees try { - const branches = await this.git.branch(['-a']); - for (const branchName of Object.keys(branches.branches)) { - if (branchName.startsWith(prefix)) { - try { - await this.git.branch(['-D', branchName]); - result.removedBranches.push(branchName); - } catch { - // Branch might already be deleted, ignore - } + for (const branchName of worktreeBranches) { + try { + await this.git.branch(['-D', branchName]); + result.removedBranches.push(branchName); + } catch { + // Branch might already be deleted, ignore } } } catch {