From e1eefffcf19b8b3b902afa3b01018df2b9dca048 Mon Sep 17 00:00:00 2001 From: Sakshi semalti <57029133+sakshisemalti@users.noreply.github.com> Date: Wed, 18 Mar 2026 04:35:49 +0530 Subject: [PATCH 1/7] fix(cli): automatically add all VSCode workspace folders to Gemini context (#21380) Co-authored-by: Spencer --- packages/cli/src/config/config.test.ts | 44 ++++++++++++++++++++++++++ packages/cli/src/config/config.ts | 22 +++++++++++++ 2 files changed, 66 insertions(+) diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 57d1a150f8..a94d1f0a28 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -763,6 +763,48 @@ describe('loadCliConfig', () => { }); }); + it('should add IDE workspace folders from GEMINI_CLI_IDE_WORKSPACE_PATH to include directories', async () => { + vi.stubEnv( + 'GEMINI_CLI_IDE_WORKSPACE_PATH', + ['/project/folderA', '/project/folderB'].join(path.delimiter), + ); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); + const config = await loadCliConfig(settings, 'test-session', argv); + const dirs = config.getPendingIncludeDirectories(); + expect(dirs).toContain('/project/folderA'); + expect(dirs).toContain('/project/folderB'); + }); + + it('should skip inaccessible workspace folders from GEMINI_CLI_IDE_WORKSPACE_PATH', async () => { + const resolveToRealPathSpy = vi + .spyOn(ServerConfig, 'resolveToRealPath') + .mockImplementation((p) => { + if (p.toString().includes('restricted')) { + const err = new Error('EACCES: permission denied'); + (err as NodeJS.ErrnoException).code = 'EACCES'; + throw err; + } + return p.toString(); + }); + vi.stubEnv( + 'GEMINI_CLI_IDE_WORKSPACE_PATH', + ['/project/folderA', '/nonexistent/restricted/folder'].join( + path.delimiter, + ), + ); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); + const config = await loadCliConfig(settings, 'test-session', argv); + const dirs = config.getPendingIncludeDirectories(); + expect(dirs).toContain('/project/folderA'); + expect(dirs).not.toContain('/nonexistent/restricted/folder'); + + resolveToRealPathSpy.mockRestore(); + }); + it('should use default fileFilter options when unconfigured', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); @@ -798,6 +840,7 @@ describe('loadCliConfig', () => { describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { beforeEach(() => { vi.resetAllMocks(); + vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', ''); // Restore ExtensionManager mocks that were reset ExtensionManager.prototype.getExtensions = vi.fn().mockReturnValue([]); ExtensionManager.prototype.loadExtensions = vi @@ -809,6 +852,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { }); afterEach(() => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index b4c8c9ca2e..010e6d8d99 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -475,10 +475,32 @@ export async function loadCliConfig( ...settings.context?.fileFiltering, }; + //changes the includeDirectories to be absolute paths based on the cwd, and also include any additional directories specified via CLI args const includeDirectories = (settings.context?.includeDirectories || []) .map(resolvePath) .concat((argv.includeDirectories || []).map(resolvePath)); + // When running inside VSCode with multiple workspace folders, + // automatically add the other folders as include directories + // so Gemini has context of all open folders, not just the cwd. + const ideWorkspacePath = process.env['GEMINI_CLI_IDE_WORKSPACE_PATH']; + if (ideWorkspacePath) { + const realCwd = resolveToRealPath(cwd); + const ideFolders = ideWorkspacePath.split(path.delimiter).filter((p) => { + const trimmedPath = p.trim(); + if (!trimmedPath) return false; + try { + return resolveToRealPath(trimmedPath) !== realCwd; + } catch (e) { + debugLogger.debug( + `[IDE] Skipping inaccessible workspace folder: ${trimmedPath} (${e instanceof Error ? e.message : String(e)})`, + ); + return false; + } + }); + includeDirectories.push(...ideFolders); + } + const extensionManager = new ExtensionManager({ settings, requestConsent: requestConsentNonInteractive, From b8719bcd47d01a488a9e12695851b43d30d36db3 Mon Sep 17 00:00:00 2001 From: anj-s <32556631+anj-s@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:24:26 -0700 Subject: [PATCH 2/7] feat: add 'blocked' status to tasks and todos (#22735) --- docs/tools/todos.md | 3 ++- packages/cli/src/ui/components/ChecklistItem.test.tsx | 1 + packages/cli/src/ui/components/ChecklistItem.tsx | 10 +++++++++- .../__snapshots__/ChecklistItem.test.tsx.snap | 5 +++++ packages/core/src/services/trackerTypes.ts | 1 + .../__snapshots__/coreToolsModelSnapshots.test.ts.snap | 4 ++++ .../definitions/model-family-sets/default-legacy.ts | 9 ++++++++- .../tools/definitions/model-family-sets/gemini-3.ts | 9 ++++++++- packages/core/src/tools/tools.ts | 7 ++++++- packages/core/src/tools/trackerTools.test.ts | 10 +++++++++- packages/core/src/tools/trackerTools.ts | 8 ++++++-- packages/core/src/tools/write-todos.test.ts | 5 ++++- packages/core/src/tools/write-todos.ts | 1 + 13 files changed, 64 insertions(+), 9 deletions(-) diff --git a/docs/tools/todos.md b/docs/tools/todos.md index abb44c0927..d198b872ea 100644 --- a/docs/tools/todos.md +++ b/docs/tools/todos.md @@ -13,7 +13,8 @@ updates to the CLI interface. - `todos` (array of objects, required): The complete list of tasks. Each object includes: - `description` (string): Technical description of the task. - - `status` (enum): `pending`, `in_progress`, `completed`, or `cancelled`. + - `status` (enum): `pending`, `in_progress`, `completed`, `cancelled`, or + `blocked`. ## Technical behavior diff --git a/packages/cli/src/ui/components/ChecklistItem.test.tsx b/packages/cli/src/ui/components/ChecklistItem.test.tsx index 0f6c0eb0b0..4176f7914b 100644 --- a/packages/cli/src/ui/components/ChecklistItem.test.tsx +++ b/packages/cli/src/ui/components/ChecklistItem.test.tsx @@ -15,6 +15,7 @@ describe('', () => { { status: 'in_progress', label: 'Doing this' }, { status: 'completed', label: 'Done this' }, { status: 'cancelled', label: 'Skipped this' }, + { status: 'blocked', label: 'Blocked this' }, ] as ChecklistItemData[])('renders %s item correctly', async (item) => { const { lastFrame, waitUntilReady } = render(); await waitUntilReady(); diff --git a/packages/cli/src/ui/components/ChecklistItem.tsx b/packages/cli/src/ui/components/ChecklistItem.tsx index 6e08e0af6b..065c79d516 100644 --- a/packages/cli/src/ui/components/ChecklistItem.tsx +++ b/packages/cli/src/ui/components/ChecklistItem.tsx @@ -13,7 +13,8 @@ export type ChecklistStatus = | 'pending' | 'in_progress' | 'completed' - | 'cancelled'; + | 'cancelled' + | 'blocked'; export interface ChecklistItemData { status: ChecklistStatus; @@ -48,6 +49,12 @@ const ChecklistStatusDisplay: React.FC<{ status: ChecklistStatus }> = ({ ✗ ); + case 'blocked': + return ( + + ⛔ + + ); default: checkExhaustive(status); } @@ -70,6 +77,7 @@ export const ChecklistItem: React.FC = ({ return theme.text.accent; case 'completed': case 'cancelled': + case 'blocked': return theme.text.secondary; case 'pending': return theme.text.primary; diff --git a/packages/cli/src/ui/components/__snapshots__/ChecklistItem.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ChecklistItem.test.tsx.snap index 9cd5fbb64c..80599ae878 100644 --- a/packages/cli/src/ui/components/__snapshots__/ChecklistItem.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/ChecklistItem.test.tsx.snap @@ -1,5 +1,10 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[` > renders { status: 'blocked', label: 'Blocked this' } item correctly 1`] = ` +"⛔ Blocked this +" +`; + exports[` > renders { status: 'cancelled', label: 'Skipped this' } item correctly 1`] = ` "✗ Skipped this " diff --git a/packages/core/src/services/trackerTypes.ts b/packages/core/src/services/trackerTypes.ts index d0e94bb986..6c21456fe1 100644 --- a/packages/core/src/services/trackerTypes.ts +++ b/packages/core/src/services/trackerTypes.ts @@ -22,6 +22,7 @@ export const TASK_TYPE_LABELS: Record = { export enum TaskStatus { OPEN = 'open', IN_PROGRESS = 'in_progress', + BLOCKED = 'blocked', CLOSED = 'closed', } export const TaskStatusSchema = z.nativeEnum(TaskStatus); diff --git a/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap b/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap index e3a80eddd7..e2bab4d050 100644 --- a/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap +++ b/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap @@ -697,6 +697,7 @@ DO NOT use this tool for simple tasks that can be completed in less than 2 steps - in_progress: Marked just prior to beginning work on a given subtask. You should only have one subtask as in_progress at a time. - completed: Subtask was successfully completed with no errors or issues. If the subtask required more steps to complete, update the todo list with the subtasks. All steps should be identified as completed only when they are completed. - cancelled: As you update the todo list, some tasks are not required anymore due to the dynamic nature of the task. In this case, mark the subtasks as cancelled. +- blocked: Subtask is blocked and cannot be completed at this time. ## Methodology for using this tool @@ -766,6 +767,7 @@ The agent did not use the todo list because this task could be completed by a ti "in_progress", "completed", "cancelled", + "blocked", ], "type": "string", }, @@ -1451,6 +1453,7 @@ DO NOT use this tool for simple tasks that can be completed in less than 2 steps - in_progress: Marked just prior to beginning work on a given subtask. You should only have one subtask as in_progress at a time. - completed: Subtask was successfully completed with no errors or issues. If the subtask required more steps to complete, update the todo list with the subtasks. All steps should be identified as completed only when they are completed. - cancelled: As you update the todo list, some tasks are not required anymore due to the dynamic nature of the task. In this case, mark the subtasks as cancelled. +- blocked: Subtask is blocked and cannot be completed at this time. ## Methodology for using this tool @@ -1520,6 +1523,7 @@ The agent did not use the todo list because this task could be completed by a ti "in_progress", "completed", "cancelled", + "blocked", ], "type": "string", }, diff --git a/packages/core/src/tools/definitions/model-family-sets/default-legacy.ts b/packages/core/src/tools/definitions/model-family-sets/default-legacy.ts index 3309fcc5ba..5c219f4685 100644 --- a/packages/core/src/tools/definitions/model-family-sets/default-legacy.ts +++ b/packages/core/src/tools/definitions/model-family-sets/default-legacy.ts @@ -543,6 +543,7 @@ DO NOT use this tool for simple tasks that can be completed in less than 2 steps - in_progress: Marked just prior to beginning work on a given subtask. You should only have one subtask as in_progress at a time. - completed: Subtask was successfully completed with no errors or issues. If the subtask required more steps to complete, update the todo list with the subtasks. All steps should be identified as completed only when they are completed. - cancelled: As you update the todo list, some tasks are not required anymore due to the dynamic nature of the task. In this case, mark the subtasks as cancelled. +- blocked: Subtask is blocked and cannot be completed at this time. ## Methodology for using this tool @@ -609,7 +610,13 @@ The agent did not use the todo list because this task could be completed by a ti [TODOS_ITEM_PARAM_STATUS]: { type: 'string', description: 'The current status of the task.', - enum: ['pending', 'in_progress', 'completed', 'cancelled'], + enum: [ + 'pending', + 'in_progress', + 'completed', + 'cancelled', + 'blocked', + ], }, }, required: [TODOS_ITEM_PARAM_DESCRIPTION, TODOS_ITEM_PARAM_STATUS], diff --git a/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts b/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts index 2c0375baa3..cac98a90b3 100644 --- a/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts +++ b/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts @@ -518,6 +518,7 @@ DO NOT use this tool for simple tasks that can be completed in less than 2 steps - in_progress: Marked just prior to beginning work on a given subtask. You should only have one subtask as in_progress at a time. - completed: Subtask was successfully completed with no errors or issues. If the subtask required more steps to complete, update the todo list with the subtasks. All steps should be identified as completed only when they are completed. - cancelled: As you update the todo list, some tasks are not required anymore due to the dynamic nature of the task. In this case, mark the subtasks as cancelled. +- blocked: Subtask is blocked and cannot be completed at this time. ## Methodology for using this tool @@ -584,7 +585,13 @@ The agent did not use the todo list because this task could be completed by a ti [TODOS_ITEM_PARAM_STATUS]: { type: 'string', description: 'The current status of the task.', - enum: ['pending', 'in_progress', 'completed', 'cancelled'], + enum: [ + 'pending', + 'in_progress', + 'completed', + 'cancelled', + 'blocked', + ], }, }, required: [TODOS_ITEM_PARAM_DESCRIPTION, TODOS_ITEM_PARAM_STATUS], diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index c94cef4a92..3865aaf357 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -823,7 +823,12 @@ export type ToolResultDisplay = | TodoList | SubagentProgress; -export type TodoStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled'; +export type TodoStatus = + | 'pending' + | 'in_progress' + | 'completed' + | 'cancelled' + | 'blocked'; export interface Todo { description: string; diff --git a/packages/core/src/tools/trackerTools.test.ts b/packages/core/src/tools/trackerTools.test.ts index 8236dba3a1..6513a71dd5 100644 --- a/packages/core/src/tools/trackerTools.test.ts +++ b/packages/core/src/tools/trackerTools.test.ts @@ -222,15 +222,23 @@ describe('Tracker Tools Integration', () => { status: TaskStatus.IN_PROGRESS, dependencies: [], }; + const t4 = { + id: 't4', + title: 'T4', + type: TaskType.TASK, + status: TaskStatus.BLOCKED, + dependencies: [], + }; const mockService = { - listTasks: async () => [t1, t2, t3], + listTasks: async () => [t1, t2, t3, t4], } as unknown as TrackerService; const display = await buildTodosReturnDisplay(mockService); expect(display.todos).toEqual([ { description: `task: T3 (t3)`, status: 'in_progress' }, { description: `task: T2 (t2)`, status: 'pending' }, + { description: `task: T4 (t4)`, status: 'blocked' }, { description: `task: T1 (t1)`, status: 'completed' }, ]); }); diff --git a/packages/core/src/tools/trackerTools.ts b/packages/core/src/tools/trackerTools.ts index 18f3ccc3cc..1594cceca8 100644 --- a/packages/core/src/tools/trackerTools.ts +++ b/packages/core/src/tools/trackerTools.ts @@ -48,10 +48,11 @@ export async function buildTodosReturnDisplay( } } - const statusOrder = { + const statusOrder: Record = { [TaskStatus.IN_PROGRESS]: 0, [TaskStatus.OPEN]: 1, - [TaskStatus.CLOSED]: 2, + [TaskStatus.BLOCKED]: 2, + [TaskStatus.CLOSED]: 3, }; const sortTasks = (a: TrackerTask, b: TrackerTask) => { @@ -80,6 +81,8 @@ export async function buildTodosReturnDisplay( status = 'in_progress'; } else if (task.status === TaskStatus.CLOSED) { status = 'completed'; + } else if (task.status === TaskStatus.BLOCKED) { + status = 'blocked'; } const indent = ' '.repeat(depth); @@ -585,6 +588,7 @@ class TrackerVisualizeInvocation extends BaseToolInvocation< const statusEmojis: Record = { open: '⭕', in_progress: '🚧', + blocked: '⛔', closed: '✅', }; diff --git a/packages/core/src/tools/write-todos.test.ts b/packages/core/src/tools/write-todos.test.ts index 117a3d2681..47ce8c2b6e 100644 --- a/packages/core/src/tools/write-todos.test.ts +++ b/packages/core/src/tools/write-todos.test.ts @@ -19,6 +19,7 @@ describe('WriteTodosTool', () => { { description: 'Task 1', status: 'pending' }, { description: 'Task 2', status: 'in_progress' }, { description: 'Task 3', status: 'completed' }, + { description: 'Task 4', status: 'blocked' }, ], }; await expect(tool.buildAndExecute(params, signal)).resolves.toBeDefined(); @@ -96,13 +97,15 @@ describe('WriteTodosTool', () => { { description: 'First task', status: 'completed' }, { description: 'Second task', status: 'in_progress' }, { description: 'Third task', status: 'pending' }, + { description: 'Fourth task', status: 'blocked' }, ], }; const result = await tool.buildAndExecute(params, signal); const expectedOutput = `Successfully updated the todo list. The current list is now: 1. [completed] First task 2. [in_progress] Second task -3. [pending] Third task`; +3. [pending] Third task +4. [blocked] Fourth task`; expect(result.llmContent).toBe(expectedOutput); expect(result.returnDisplay).toEqual(params); }); diff --git a/packages/core/src/tools/write-todos.ts b/packages/core/src/tools/write-todos.ts index dd7ab780e6..746219ecd7 100644 --- a/packages/core/src/tools/write-todos.ts +++ b/packages/core/src/tools/write-todos.ts @@ -22,6 +22,7 @@ const TODO_STATUSES = [ 'in_progress', 'completed', 'cancelled', + 'blocked', ] as const; export interface WriteTodosToolParams { From e2658ccda8610f5054cc446ca5b3046e904afe88 Mon Sep 17 00:00:00 2001 From: "N. Taylor Mullen" Date: Tue, 17 Mar 2026 16:48:16 -0700 Subject: [PATCH 3/7] refactor(cli): remove extra newlines in ShellToolMessage.tsx (#22868) Co-authored-by: Spencer --- .../ui/components/messages/ShellToolMessage.tsx | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx index f34aa08bfb..f3694f3490 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx @@ -42,33 +42,19 @@ export interface ShellToolMessageProps extends ToolMessageProps { export const ShellToolMessage: React.FC = ({ name, - description, - resultDisplay, - status, - availableTerminalHeight, - terminalWidth, - emphasis = 'medium', - renderOutputAsMarkdown = true, - ptyId, - config, - isFirst, - borderColor, - borderDimColor, - isExpandable, - originalRequestName, }) => { const { @@ -142,11 +128,9 @@ export const ShellToolMessage: React.FC = ({ }, [isThisShellFocused, embeddedShellFocused, setEmbeddedShellFocused]); const headerRef = React.useRef(null); - const contentRef = React.useRef(null); // The shell is focusable if it's the shell command, it's executing, and the interactive shell is enabled. - const isThisShellFocusable = checkIsShellFocusable(name, status, config); const handleFocus = () => { @@ -156,7 +140,6 @@ export const ShellToolMessage: React.FC = ({ }; useMouseClick(headerRef, handleFocus, { isActive: !!isThisShellFocusable }); - useMouseClick(contentRef, handleFocus, { isActive: !!isThisShellFocusable }); const { shouldShowFocusHint } = useFocusHint( From bd34a42ec3520f1964c7b9a5a0fd3418c57e7462 Mon Sep 17 00:00:00 2001 From: adithya32 <163162210+KumarADITHYA123@users.noreply.github.com> Date: Wed, 18 Mar 2026 06:10:38 +0530 Subject: [PATCH 4/7] fix(cli): lazily load settings in onModelChange to prevent stale closure data loss (#20403) Co-authored-by: Spencer --- packages/cli/src/config/config.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 010e6d8d99..80c1e19443 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -430,8 +430,6 @@ export async function loadCliConfig( const { cwd = process.cwd(), projectHooks } = options; const debugMode = isDebugMode(argv); - const loadedSettings = loadSettings(cwd); - if (argv.sandbox) { process.env['GEMINI_SANDBOX'] = 'true'; } @@ -886,7 +884,7 @@ export async function loadCliConfig( hooks: settings.hooks || {}, disabledHooks: settings.hooksConfig?.disabled || [], projectHooks: projectHooks || {}, - onModelChange: (model: string) => saveModelChange(loadedSettings, model), + onModelChange: (model: string) => saveModelChange(loadSettings(cwd), model), onReload: async () => { const refreshedSettings = loadSettings(cwd); return { From 7bfe6ac418f6f0b0e7b6fc15d70bce8cb2cc3e84 Mon Sep 17 00:00:00 2001 From: AK Date: Tue, 17 Mar 2026 19:34:44 -0700 Subject: [PATCH 5/7] feat(core): subagent local execution and tool isolation (#22718) --- packages/cli/src/test-utils/AppRig.tsx | 10 +- .../core/src/agents/agent-scheduler.test.ts | 6 + packages/core/src/agents/agent-scheduler.ts | 11 +- .../core/src/agents/local-executor.test.ts | 108 +++++++++++++++--- packages/core/src/agents/local-executor.ts | 107 ++++++++++++----- .../core/src/config/agent-loop-context.ts | 8 ++ packages/core/src/config/config.ts | 28 ++++- 7 files changed, 222 insertions(+), 56 deletions(-) diff --git a/packages/cli/src/test-utils/AppRig.tsx b/packages/cli/src/test-utils/AppRig.tsx index 8c62592bc6..6043c7f8cc 100644 --- a/packages/cli/src/test-utils/AppRig.tsx +++ b/packages/cli/src/test-utils/AppRig.tsx @@ -280,14 +280,14 @@ export class AppRig { } private stubRefreshAuth() { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment + // eslint-disable-next-line @typescript-eslint/no-explicit-any const gcConfig = this.config as any; gcConfig.refreshAuth = async (authMethod: AuthType) => { gcConfig.modelAvailabilityService.reset(); const newContentGeneratorConfig = { authType: authMethod, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + proxy: gcConfig.getProxy(), apiKey: process.env['GEMINI_API_KEY'] || 'test-api-key', }; @@ -456,7 +456,7 @@ export class AppRig { const actualToolName = toolName === '*' ? undefined : toolName; this.config .getPolicyEngine() - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + .removeRulesForTool(actualToolName as string, source); this.breakpointTools.delete(toolName); } @@ -729,7 +729,7 @@ export class AppRig { .getGeminiClient() ?.getChatRecordingService(); if (recordingService) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion + // eslint-disable-next-line @typescript-eslint/no-explicit-any (recordingService as any).conversationFile = null; } } @@ -749,7 +749,7 @@ export class AppRig { MockShellExecutionService.reset(); ideContextStore.clear(); // Forcefully clear IdeClient singleton promise - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion + // eslint-disable-next-line @typescript-eslint/no-explicit-any (IdeClient as any).instancePromise = null; vi.clearAllMocks(); diff --git a/packages/core/src/agents/agent-scheduler.test.ts b/packages/core/src/agents/agent-scheduler.test.ts index 2be2f033d9..5d5b6569af 100644 --- a/packages/core/src/agents/agent-scheduler.test.ts +++ b/packages/core/src/agents/agent-scheduler.test.ts @@ -42,6 +42,8 @@ describe('agent-scheduler', () => { it('should create a scheduler with agent-specific config', async () => { const mockConfig = { + getPromptRegistry: vi.fn(), + getResourceRegistry: vi.fn(), messageBus: mockMessageBus, toolRegistry: mockToolRegistry, } as unknown as Mocked; @@ -91,6 +93,8 @@ describe('agent-scheduler', () => { } as unknown as Mocked; const config = { + getPromptRegistry: vi.fn(), + getResourceRegistry: vi.fn(), messageBus: mockMessageBus, } as unknown as Mocked; Object.defineProperty(config, 'toolRegistry', { @@ -123,6 +127,8 @@ describe('agent-scheduler', () => { it('should create an AgentLoopContext that has a defined .config property', async () => { const mockConfig = { + getPromptRegistry: vi.fn(), + getResourceRegistry: vi.fn(), messageBus: mockMessageBus, toolRegistry: mockToolRegistry, promptId: 'test-prompt', diff --git a/packages/core/src/agents/agent-scheduler.ts b/packages/core/src/agents/agent-scheduler.ts index 852e25b4c1..8bed1de00b 100644 --- a/packages/core/src/agents/agent-scheduler.ts +++ b/packages/core/src/agents/agent-scheduler.ts @@ -11,6 +11,8 @@ import type { CompletedToolCall, } from '../scheduler/types.js'; import type { ToolRegistry } from '../tools/tool-registry.js'; +import type { PromptRegistry } from '../prompts/prompt-registry.js'; +import type { ResourceRegistry } from '../resources/resource-registry.js'; import type { EditorType } from '../utils/editor.js'; /** @@ -25,6 +27,10 @@ export interface AgentSchedulingOptions { parentCallId?: string; /** The tool registry specific to this agent. */ toolRegistry: ToolRegistry; + /** The prompt registry specific to this agent. */ + promptRegistry?: PromptRegistry; + /** The resource registry specific to this agent. */ + resourceRegistry?: ResourceRegistry; /** AbortSignal for cancellation. */ signal: AbortSignal; /** Optional function to get the preferred editor for tool modifications. */ @@ -51,16 +57,19 @@ export async function scheduleAgentTools( subagent, parentCallId, toolRegistry, + promptRegistry, + resourceRegistry, signal, getPreferredEditor, onWaitingForConfirmation, } = options; - // Create a proxy/override of the config to provide the agent-specific tool registry. const schedulerContext = { config, promptId: config.promptId, toolRegistry, + promptRegistry: promptRegistry ?? config.getPromptRegistry(), + resourceRegistry: resourceRegistry ?? config.getResourceRegistry(), messageBus: toolRegistry.messageBus, geminiClient: config.geminiClient, sandboxManager: config.sandboxManager, diff --git a/packages/core/src/agents/local-executor.test.ts b/packages/core/src/agents/local-executor.test.ts index 3ae273cf2f..f0afa73e6a 100644 --- a/packages/core/src/agents/local-executor.test.ts +++ b/packages/core/src/agents/local-executor.test.ts @@ -13,10 +13,43 @@ import { afterEach, type Mock, } from 'vitest'; + +const { + mockSendMessageStream, + mockScheduleAgentTools, + mockSetSystemInstruction, + mockCompress, + mockMaybeDiscoverMcpServer, + mockStopMcp, +} = vi.hoisted(() => ({ + mockSendMessageStream: vi.fn().mockResolvedValue({ + async *[Symbol.asyncIterator]() { + yield { + type: 'chunk', + value: { candidates: [] }, + }; + }, + }), + mockScheduleAgentTools: vi.fn(), + mockSetSystemInstruction: vi.fn(), + mockCompress: vi.fn(), + mockMaybeDiscoverMcpServer: vi.fn().mockResolvedValue(undefined), + mockStopMcp: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../tools/mcp-client-manager.js', () => ({ + McpClientManager: class { + maybeDiscoverMcpServer = mockMaybeDiscoverMcpServer; + stop = mockStopMcp; + }, +})); + import { debugLogger } from '../utils/debugLogger.js'; import { LocalAgentExecutor, type ActivityCallback } from './local-executor.js'; import { makeFakeConfig } from '../test-utils/config.js'; import { ToolRegistry } from '../tools/tool-registry.js'; +import { PromptRegistry } from '../prompts/prompt-registry.js'; +import { ResourceRegistry } from '../resources/resource-registry.js'; import { DiscoveredMCPTool } from '../tools/mcp-tool.js'; import { LSTool } from '../tools/ls.js'; import { LS_TOOL_NAME, READ_FILE_TOOL_NAME } from '../tools/tool-names.js'; @@ -70,18 +103,6 @@ import type { import { getModelConfigAlias, type AgentRegistry } from './registry.js'; import type { ModelRouterService } from '../routing/modelRouterService.js'; -const { - mockSendMessageStream, - mockScheduleAgentTools, - mockSetSystemInstruction, - mockCompress, -} = vi.hoisted(() => ({ - mockSendMessageStream: vi.fn(), - mockScheduleAgentTools: vi.fn(), - mockSetSystemInstruction: vi.fn(), - mockCompress: vi.fn(), -})); - let mockChatHistory: Content[] = []; const mockSetHistory = vi.fn((newHistory: Content[]) => { mockChatHistory = newHistory; @@ -2722,6 +2743,67 @@ describe('LocalAgentExecutor', () => { }); }); + describe('MCP Isolation', () => { + it('should initialize McpClientManager when mcpServers are defined', async () => { + const { MCPServerConfig } = await import('../config/config.js'); + const mcpServers = { + 'test-server': new MCPServerConfig('node', ['server.js']), + }; + + const definition = { + ...createTestDefinition(), + mcpServers, + }; + + vi.spyOn(mockConfig, 'getMcpClientManager').mockReturnValue({ + maybeDiscoverMcpServer: mockMaybeDiscoverMcpServer, + } as unknown as ReturnType); + + await LocalAgentExecutor.create(definition, mockConfig); + + const mcpManager = mockConfig.getMcpClientManager(); + expect(mcpManager?.maybeDiscoverMcpServer).toHaveBeenCalledWith( + 'test-server', + mcpServers['test-server'], + expect.objectContaining({ + toolRegistry: expect.any(ToolRegistry), + promptRegistry: expect.any(PromptRegistry), + resourceRegistry: expect.any(ResourceRegistry), + }), + ); + }); + + it('should inherit main registry tools', async () => { + const parentMcpTool = new DiscoveredMCPTool( + {} as unknown as CallableTool, + 'main-server', + 'tool1', + 'desc1', + {}, + mockConfig.getMessageBus(), + ); + + parentToolRegistry.registerTool(parentMcpTool); + + const definition = createTestDefinition(); + definition.toolConfig = undefined; // trigger inheritance + + vi.spyOn(mockConfig, 'getMcpClientManager').mockReturnValue({ + maybeDiscoverMcpServer: vi.fn(), + } as unknown as ReturnType); + const executor = await LocalAgentExecutor.create( + definition, + mockConfig, + onActivity, + ); + const agentTools = ( + executor as unknown as { toolRegistry: ToolRegistry } + ).toolRegistry.getAllToolNames(); + + expect(agentTools).toContain(parentMcpTool.name); + }); + }); + describe('DeclarativeTool instance tools (browser agent pattern)', () => { /** * The browser agent passes DeclarativeTool instances (not string names) in @@ -2827,13 +2909,11 @@ describe('LocalAgentExecutor', () => { const navTool = new MockTool({ name: 'navigate_page' }); const definition = createInstanceToolDefinition([clickTool, navTool]); - const executor = await LocalAgentExecutor.create( definition, mockConfig, onActivity, ); - const registry = executor['toolRegistry']; expect(registry.getTool('click')).toBeDefined(); expect(registry.getTool('navigate_page')).toBeDefined(); diff --git a/packages/core/src/agents/local-executor.ts b/packages/core/src/agents/local-executor.ts index a177012850..a9adeb2e2d 100644 --- a/packages/core/src/agents/local-executor.ts +++ b/packages/core/src/agents/local-executor.ts @@ -4,7 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Config } from '../config/config.js'; import { type AgentLoopContext } from '../config/agent-loop-context.js'; import { reportError } from '../utils/errorReporting.js'; import { GeminiChat, StreamEventType } from '../core/geminiChat.js'; @@ -17,6 +16,8 @@ import { type Schema, } from '@google/genai'; import { ToolRegistry } from '../tools/tool-registry.js'; +import { PromptRegistry } from '../prompts/prompt-registry.js'; +import { ResourceRegistry } from '../resources/resource-registry.js'; import { type AnyDeclarativeTool } from '../tools/tools.js'; import { DiscoveredMCPTool, @@ -102,14 +103,22 @@ export class LocalAgentExecutor { private readonly agentId: string; private readonly toolRegistry: ToolRegistry; + private readonly promptRegistry: PromptRegistry; + private readonly resourceRegistry: ResourceRegistry; private readonly context: AgentLoopContext; private readonly onActivity?: ActivityCallback; private readonly compressionService: ChatCompressionService; private readonly parentCallId?: string; private hasFailedCompressionAttempt = false; - private get config(): Config { - return this.context.config; + private get executionContext(): AgentLoopContext { + return { + ...this.context, + toolRegistry: this.toolRegistry, + promptRegistry: this.promptRegistry, + resourceRegistry: this.resourceRegistry, + messageBus: this.toolRegistry.getMessageBus(), + }; } /** @@ -133,11 +142,27 @@ export class LocalAgentExecutor { // Create an override object to inject the subagent name into tool confirmation requests const subagentMessageBus = parentMessageBus.derive(definition.name); - // Create an isolated tool registry for this agent instance. + // Create isolated registries for this agent instance. const agentToolRegistry = new ToolRegistry( context.config, subagentMessageBus, ); + const agentPromptRegistry = new PromptRegistry(); + const agentResourceRegistry = new ResourceRegistry(); + + if (definition.mcpServers) { + const globalMcpManager = context.config.getMcpClientManager(); + if (globalMcpManager) { + for (const [name, config] of Object.entries(definition.mcpServers)) { + await globalMcpManager.maybeDiscoverMcpServer(name, config, { + toolRegistry: agentToolRegistry, + promptRegistry: agentPromptRegistry, + resourceRegistry: agentResourceRegistry, + }); + } + } + } + const parentToolRegistry = context.toolRegistry; const allAgentNames = new Set( context.config.getAgentRegistry().getAllAgentNames(), @@ -153,7 +178,9 @@ export class LocalAgentExecutor { return; } - agentToolRegistry.registerTool(tool); + // Clone the tool, so it gets its own state and subagent messageBus + const clonedTool = tool.clone(subagentMessageBus); + agentToolRegistry.registerTool(clonedTool); }; const registerToolByName = (toolName: string) => { @@ -228,10 +255,12 @@ export class LocalAgentExecutor { return new LocalAgentExecutor( definition, context, - agentToolRegistry, parentPromptId, - parentCallId, + agentToolRegistry, + agentPromptRegistry, + agentResourceRegistry, onActivity, + parentCallId, ); } @@ -244,14 +273,18 @@ export class LocalAgentExecutor { private constructor( definition: LocalAgentDefinition, context: AgentLoopContext, - toolRegistry: ToolRegistry, parentPromptId: string | undefined, - parentCallId: string | undefined, + toolRegistry: ToolRegistry, + promptRegistry: PromptRegistry, + resourceRegistry: ResourceRegistry, onActivity?: ActivityCallback, + parentCallId?: string, ) { this.definition = definition; this.context = context; this.toolRegistry = toolRegistry; + this.promptRegistry = promptRegistry; + this.resourceRegistry = resourceRegistry; this.onActivity = onActivity; this.compressionService = new ChatCompressionService(); this.parentCallId = parentCallId; @@ -447,7 +480,7 @@ export class LocalAgentExecutor { } finally { clearTimeout(graceTimeoutId); logRecoveryAttempt( - this.config, + this.context.config, new RecoveryAttemptEvent( this.agentId, this.definition.name, @@ -495,7 +528,7 @@ export class LocalAgentExecutor { const combinedSignal = AbortSignal.any([signal, deadlineTimer.signal]); logAgentStart( - this.config, + this.context.config, new AgentStartEvent(this.agentId, this.definition.name), ); @@ -506,7 +539,7 @@ export class LocalAgentExecutor { const augmentedInputs = { ...inputs, cliVersion: await getVersion(), - activeModel: this.config.getActiveModel(), + activeModel: this.context.config.getActiveModel(), today: new Date().toLocaleDateString(), }; @@ -528,14 +561,16 @@ export class LocalAgentExecutor { // Capture the index of the last hint before starting to avoid re-injecting old hints. // NOTE: Hints added AFTER this point will be broadcast to all currently running // local agents via the listener below. - const startIndex = this.config.injectionService.getLatestInjectionIndex(); - this.config.injectionService.onInjection(injectionListener); + const startIndex = + this.context.config.injectionService.getLatestInjectionIndex(); + this.context.config.injectionService.onInjection(injectionListener); try { - const initialHints = this.config.injectionService.getInjectionsAfter( - startIndex, - 'user_steering', - ); + const initialHints = + this.context.config.injectionService.getInjectionsAfter( + startIndex, + 'user_steering', + ); const formattedInitialHints = formatUserHintsForModel(initialHints); let currentMessage: Content = formattedInitialHints @@ -606,7 +641,16 @@ export class LocalAgentExecutor { } } } finally { - this.config.injectionService.offInjection(injectionListener); + this.context.config.injectionService.offInjection(injectionListener); + + const globalMcpManager = this.context.config.getMcpClientManager(); + if (globalMcpManager) { + globalMcpManager.removeRegistries({ + toolRegistry: this.toolRegistry, + promptRegistry: this.promptRegistry, + resourceRegistry: this.resourceRegistry, + }); + } } // === UNIFIED RECOVERY BLOCK === @@ -719,7 +763,7 @@ export class LocalAgentExecutor { } finally { deadlineTimer.abort(); logAgentFinish( - this.config, + this.context.config, new AgentFinishEvent( this.agentId, this.definition.name, @@ -742,7 +786,7 @@ export class LocalAgentExecutor { prompt_id, false, model, - this.config, + this.context.config, this.hasFailedCompressionAttempt, ); @@ -780,10 +824,11 @@ export class LocalAgentExecutor { const modelConfigAlias = getModelConfigAlias(this.definition); // Resolve the model config early to get the concrete model string (which may be `auto`). - const resolvedConfig = this.config.modelConfigService.getResolvedConfig({ - model: modelConfigAlias, - overrideScope: this.definition.name, - }); + const resolvedConfig = + this.context.config.modelConfigService.getResolvedConfig({ + model: modelConfigAlias, + overrideScope: this.definition.name, + }); const requestedModel = resolvedConfig.model; let modelToUse: string; @@ -800,7 +845,7 @@ export class LocalAgentExecutor { signal, requestedModel, }; - const router = this.config.getModelRouterService(); + const router = this.context.config.getModelRouterService(); const decision = await router.route(routingContext); modelToUse = decision.model; } catch (error) { @@ -888,7 +933,7 @@ export class LocalAgentExecutor { try { return new GeminiChat( - this.config, + this.executionContext, systemInstruction, [{ functionDeclarations: tools }], startHistory, @@ -1136,13 +1181,15 @@ export class LocalAgentExecutor { // Execute standard tool calls using the new scheduler if (toolRequests.length > 0) { const completedCalls = await scheduleAgentTools( - this.config, + this.context.config, toolRequests, { - schedulerId: this.agentId, + schedulerId: promptId, subagent: this.definition.name, parentCallId: this.parentCallId, toolRegistry: this.toolRegistry, + promptRegistry: this.promptRegistry, + resourceRegistry: this.resourceRegistry, signal, onWaitingForConfirmation, }, @@ -1277,7 +1324,7 @@ export class LocalAgentExecutor { let finalPrompt = templateString(promptConfig.systemPrompt, inputs); // Append environment context (CWD and folder structure). - const dirContext = await getDirectoryContextString(this.config); + const dirContext = await getDirectoryContextString(this.context.config); finalPrompt += `\n\n# Environment Context\n${dirContext}`; // Append standard rules for non-interactive execution. diff --git a/packages/core/src/config/agent-loop-context.ts b/packages/core/src/config/agent-loop-context.ts index 0a879d9c93..b16326a7ce 100644 --- a/packages/core/src/config/agent-loop-context.ts +++ b/packages/core/src/config/agent-loop-context.ts @@ -7,6 +7,8 @@ import type { GeminiClient } from '../core/client.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; import type { ToolRegistry } from '../tools/tool-registry.js'; +import type { PromptRegistry } from '../prompts/prompt-registry.js'; +import type { ResourceRegistry } from '../resources/resource-registry.js'; import type { SandboxManager } from '../services/sandboxManager.js'; import type { Config } from './config.js'; @@ -24,6 +26,12 @@ export interface AgentLoopContext { /** The registry of tools available to the agent in this context. */ readonly toolRegistry: ToolRegistry; + /** The registry of prompts available to the agent in this context. */ + readonly promptRegistry: PromptRegistry; + + /** The registry of resources available to the agent in this context. */ + readonly resourceRegistry: ResourceRegistry; + /** The bus for user confirmations and messages in this context. */ readonly messageBus: MessageBus; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index fcb6613756..aa3e9aa5b6 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -660,8 +660,8 @@ export class Config implements McpContext, AgentLoopContext { private allowedEnvironmentVariables: string[]; private blockedEnvironmentVariables: string[]; private readonly enableEnvironmentVariableRedaction: boolean; - private promptRegistry!: PromptRegistry; - private resourceRegistry!: ResourceRegistry; + private _promptRegistry!: PromptRegistry; + private _resourceRegistry!: ResourceRegistry; private agentRegistry!: AgentRegistry; private readonly acknowledgedAgentsService: AcknowledgedAgentsService; private skillManager!: SkillManager; @@ -1245,8 +1245,8 @@ export class Config implements McpContext, AgentLoopContext { if (this.getCheckpointingEnabled()) { await this.getGitService(); } - this.promptRegistry = new PromptRegistry(); - this.resourceRegistry = new ResourceRegistry(); + this._promptRegistry = new PromptRegistry(); + this._resourceRegistry = new ResourceRegistry(); this.agentRegistry = new AgentRegistry(this); await this.agentRegistry.initialize(); @@ -1482,6 +1482,22 @@ export class Config implements McpContext, AgentLoopContext { return this._toolRegistry; } + /** + * @deprecated Do not access directly on Config. + * Use the injected AgentLoopContext instead. + */ + get promptRegistry(): PromptRegistry { + return this._promptRegistry; + } + + /** + * @deprecated Do not access directly on Config. + * Use the injected AgentLoopContext instead. + */ + get resourceRegistry(): ResourceRegistry { + return this._resourceRegistry; + } + /** * @deprecated Do not access directly on Config. * Use the injected AgentLoopContext instead. @@ -1794,7 +1810,7 @@ export class Config implements McpContext, AgentLoopContext { } getPromptRegistry(): PromptRegistry { - return this.promptRegistry; + return this._promptRegistry; } getSkillManager(): SkillManager { @@ -1802,7 +1818,7 @@ export class Config implements McpContext, AgentLoopContext { } getResourceRegistry(): ResourceRegistry { - return this.resourceRegistry; + return this._resourceRegistry; } getDebugMode(): boolean { From be7c7bb83d73a88cf3c5213f62fd063fa36d8631 Mon Sep 17 00:00:00 2001 From: Abhi <43648792+abhipatel12@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:11:20 -0400 Subject: [PATCH 6/7] fix(cli): resolve subagent grouping and UI state persistence (#22252) --- .../messages/SubagentGroupDisplay.test.tsx | 120 ++++++++ .../messages/SubagentGroupDisplay.tsx | 269 ++++++++++++++++++ .../messages/SubagentProgressDisplay.test.tsx | 16 +- .../messages/SubagentProgressDisplay.tsx | 27 +- .../components/messages/ToolGroupMessage.tsx | 58 +++- .../components/messages/ToolResultDisplay.tsx | 7 +- .../SubagentGroupDisplay.test.tsx.snap | 9 + .../SubagentProgressDisplay.test.tsx.snap | 28 +- packages/cli/src/ui/hooks/useGeminiStream.ts | 70 +++-- .../core/src/agents/local-invocation.test.ts | 30 +- packages/core/src/agents/local-invocation.ts | 28 +- packages/core/src/agents/types.ts | 2 + packages/core/src/index.ts | 1 + 13 files changed, 596 insertions(+), 69 deletions(-) create mode 100644 packages/cli/src/ui/components/messages/SubagentGroupDisplay.test.tsx create mode 100644 packages/cli/src/ui/components/messages/SubagentGroupDisplay.tsx create mode 100644 packages/cli/src/ui/components/messages/__snapshots__/SubagentGroupDisplay.test.tsx.snap diff --git a/packages/cli/src/ui/components/messages/SubagentGroupDisplay.test.tsx b/packages/cli/src/ui/components/messages/SubagentGroupDisplay.test.tsx new file mode 100644 index 0000000000..197b78e356 --- /dev/null +++ b/packages/cli/src/ui/components/messages/SubagentGroupDisplay.test.tsx @@ -0,0 +1,120 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { waitFor } from '../../../test-utils/async.js'; +import { render } from '../../../test-utils/render.js'; +import { SubagentGroupDisplay } from './SubagentGroupDisplay.js'; +import { Kind, CoreToolCallStatus } from '@google/gemini-cli-core'; +import type { IndividualToolCallDisplay } from '../../types.js'; +import { KeypressProvider } from '../../contexts/KeypressContext.js'; +import { OverflowProvider } from '../../contexts/OverflowContext.js'; +import { vi } from 'vitest'; +import { Text } from 'ink'; + +vi.mock('../../utils/MarkdownDisplay.js', () => ({ + MarkdownDisplay: ({ text }: { text: string }) => {text}, +})); + +describe('', () => { + const mockToolCalls: IndividualToolCallDisplay[] = [ + { + callId: 'call-1', + name: 'agent_1', + description: 'Test agent 1', + confirmationDetails: undefined, + status: CoreToolCallStatus.Executing, + kind: Kind.Agent, + resultDisplay: { + isSubagentProgress: true, + agentName: 'api-monitor', + state: 'running', + recentActivity: [ + { + id: 'act-1', + type: 'tool_call', + status: 'running', + content: '', + displayName: 'Action Required', + description: 'Verify server is running', + }, + ], + }, + }, + { + callId: 'call-2', + name: 'agent_2', + description: 'Test agent 2', + confirmationDetails: undefined, + status: CoreToolCallStatus.Success, + kind: Kind.Agent, + resultDisplay: { + isSubagentProgress: true, + agentName: 'db-manager', + state: 'completed', + result: 'Database schema validated', + recentActivity: [ + { + id: 'act-2', + type: 'thought', + status: 'completed', + content: 'Database schema validated', + }, + ], + }, + }, + ]; + + const renderSubagentGroup = ( + toolCallsToRender: IndividualToolCallDisplay[], + height?: number, + ) => ( + + + + + + ); + + it('renders nothing if there are no agent tool calls', async () => { + const { lastFrame } = render(renderSubagentGroup([], 40)); + expect(lastFrame({ allowEmpty: true })).toBe(''); + }); + + it('renders collapsed view by default with correct agent counts and states', async () => { + const { lastFrame, waitUntilReady } = render( + renderSubagentGroup(mockToolCalls, 40), + ); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('expands when availableTerminalHeight is undefined', async () => { + const { lastFrame, rerender } = render( + renderSubagentGroup(mockToolCalls, 40), + ); + + // Default collapsed view + await waitFor(() => { + expect(lastFrame()).toContain('(ctrl+o to expand)'); + }); + + // Expand view + rerender(renderSubagentGroup(mockToolCalls, undefined)); + await waitFor(() => { + expect(lastFrame()).toContain('(ctrl+o to collapse)'); + }); + + // Collapse view + rerender(renderSubagentGroup(mockToolCalls, 40)); + await waitFor(() => { + expect(lastFrame()).toContain('(ctrl+o to expand)'); + }); + }); +}); diff --git a/packages/cli/src/ui/components/messages/SubagentGroupDisplay.tsx b/packages/cli/src/ui/components/messages/SubagentGroupDisplay.tsx new file mode 100644 index 0000000000..2d3f8a44c8 --- /dev/null +++ b/packages/cli/src/ui/components/messages/SubagentGroupDisplay.tsx @@ -0,0 +1,269 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useEffect, useId } from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../../semantic-colors.js'; +import type { IndividualToolCallDisplay } from '../../types.js'; +import { + isSubagentProgress, + checkExhaustive, + type SubagentActivityItem, +} from '@google/gemini-cli-core'; +import { + SubagentProgressDisplay, + formatToolArgs, +} from './SubagentProgressDisplay.js'; +import { useOverflowActions } from '../../contexts/OverflowContext.js'; + +export interface SubagentGroupDisplayProps { + toolCalls: IndividualToolCallDisplay[]; + availableTerminalHeight?: number; + terminalWidth: number; + borderColor?: string; + borderDimColor?: boolean; + isFirst?: boolean; + isExpandable?: boolean; +} + +export const SubagentGroupDisplay: React.FC = ({ + toolCalls, + availableTerminalHeight, + terminalWidth, + borderColor, + borderDimColor, + isFirst, + isExpandable = true, +}) => { + const isExpanded = availableTerminalHeight === undefined; + const overflowActions = useOverflowActions(); + const uniqueId = useId(); + const overflowId = `subagent-${uniqueId}`; + + useEffect(() => { + if (isExpandable && overflowActions) { + // Register with the global overflow system so "ctrl+o to expand" shows in the sticky footer + // and AppContainer passes the shortcut through. + overflowActions.addOverflowingId(overflowId); + } + return () => { + if (overflowActions) { + overflowActions.removeOverflowingId(overflowId); + } + }; + }, [isExpandable, overflowActions, overflowId]); + + if (toolCalls.length === 0) { + return null; + } + + let headerText = ''; + if (toolCalls.length === 1) { + const singleAgent = toolCalls[0].resultDisplay; + if (isSubagentProgress(singleAgent)) { + switch (singleAgent.state) { + case 'completed': + headerText = 'Agent Completed'; + break; + case 'cancelled': + headerText = 'Agent Cancelled'; + break; + case 'error': + headerText = 'Agent Error'; + break; + default: + headerText = 'Running Agent...'; + break; + } + } else { + headerText = 'Running Agent...'; + } + } else { + let completedCount = 0; + let runningCount = 0; + for (const tc of toolCalls) { + const progress = tc.resultDisplay; + if (isSubagentProgress(progress)) { + if (progress.state === 'completed') completedCount++; + else if (progress.state === 'running') runningCount++; + } else { + // It hasn't emitted progress yet, but it is "running" + runningCount++; + } + } + + if (completedCount === toolCalls.length) { + headerText = `${toolCalls.length} Agents Completed`; + } else if (completedCount > 0) { + headerText = `${toolCalls.length} Agents (${runningCount} running, ${completedCount} completed)...`; + } else { + headerText = `Running ${toolCalls.length} Agents...`; + } + } + const toggleText = `(ctrl+o to ${isExpanded ? 'collapse' : 'expand'})`; + + const renderCollapsedRow = ( + key: string, + agentName: string, + icon: React.ReactNode, + content: string, + displayArgs?: string, + ) => ( + + + {icon} + + + + {agentName} + + + + · + + + + {content} + {displayArgs && ` ${displayArgs}`} + + + + ); + + return ( + + + + + {headerText} + + {isExpandable && {toggleText}} + + + {toolCalls.map((toolCall) => { + const progress = toolCall.resultDisplay; + + if (!isSubagentProgress(progress)) { + const agentName = toolCall.name || 'agent'; + if (!isExpanded) { + return renderCollapsedRow( + toolCall.callId, + agentName, + !, + 'Starting...', + ); + } else { + return ( + + + ! + + {agentName} + + + + Starting... + + + ); + } + } + + const lastActivity: SubagentActivityItem | undefined = + progress.recentActivity[progress.recentActivity.length - 1]; + + // Collapsed View: Show single compact line per agent + if (!isExpanded) { + let content = 'Starting...'; + let formattedArgs: string | undefined; + + if (progress.state === 'completed') { + if ( + progress.terminateReason && + progress.terminateReason !== 'GOAL' + ) { + content = `Finished Early (${progress.terminateReason})`; + } else { + content = 'Completed successfully'; + } + } else if (lastActivity) { + // Match expanded view logic exactly: + // Primary text: displayName || content + content = lastActivity.displayName || lastActivity.content; + + // Secondary text: description || formatToolArgs(args) + if (lastActivity.description) { + formattedArgs = lastActivity.description; + } else if (lastActivity.type === 'tool_call' && lastActivity.args) { + formattedArgs = formatToolArgs(lastActivity.args); + } + } + + const displayArgs = + progress.state === 'completed' ? '' : formattedArgs; + + const renderStatusIcon = () => { + const state = progress.state ?? 'running'; + switch (state) { + case 'running': + return !; + case 'completed': + return ; + case 'cancelled': + return ; + case 'error': + return ; + default: + return checkExhaustive(state); + } + }; + + return renderCollapsedRow( + toolCall.callId, + progress.agentName, + renderStatusIcon(), + lastActivity?.type === 'thought' ? `💭 ${content}` : content, + displayArgs, + ); + } + + // Expanded View: Render full history + return ( + + + + ); + })} + + ); +}; diff --git a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx index e8b67301ad..f2c57f9662 100644 --- a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx +++ b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx @@ -36,7 +36,7 @@ describe('', () => { }; const { lastFrame, waitUntilReady } = render( - , + , ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); @@ -60,7 +60,7 @@ describe('', () => { }; const { lastFrame, waitUntilReady } = render( - , + , ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); @@ -82,7 +82,7 @@ describe('', () => { }; const { lastFrame, waitUntilReady } = render( - , + , ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); @@ -104,7 +104,7 @@ describe('', () => { }; const { lastFrame, waitUntilReady } = render( - , + , ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); @@ -128,7 +128,7 @@ describe('', () => { }; const { lastFrame, waitUntilReady } = render( - , + , ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); @@ -149,7 +149,7 @@ describe('', () => { }; const { lastFrame, waitUntilReady } = render( - , + , ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); @@ -164,7 +164,7 @@ describe('', () => { }; const { lastFrame, waitUntilReady } = render( - , + , ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); @@ -185,7 +185,7 @@ describe('', () => { }; const { lastFrame, waitUntilReady } = render( - , + , ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); diff --git a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx index b34a904b3e..5d1086c759 100644 --- a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx +++ b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx @@ -8,18 +8,21 @@ import type React from 'react'; import { Box, Text } from 'ink'; import { theme } from '../../semantic-colors.js'; import Spinner from 'ink-spinner'; +import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; import type { SubagentProgress, SubagentActivityItem, } from '@google/gemini-cli-core'; import { TOOL_STATUS } from '../../constants.js'; import { STATUS_INDICATOR_WIDTH } from './ToolShared.js'; +import { safeJsonToMarkdown } from '@google/gemini-cli-core'; export interface SubagentProgressDisplayProps { progress: SubagentProgress; + terminalWidth: number; } -const formatToolArgs = (args?: string): string => { +export const formatToolArgs = (args?: string): string => { if (!args) return ''; try { const parsed: unknown = JSON.parse(args); @@ -54,7 +57,7 @@ const formatToolArgs = (args?: string): string => { export const SubagentProgressDisplay: React.FC< SubagentProgressDisplayProps -> = ({ progress }) => { +> = ({ progress, terminalWidth }) => { let headerText: string | undefined; let headerColor = theme.text.secondary; @@ -67,6 +70,9 @@ export const SubagentProgressDisplay: React.FC< } else if (progress.state === 'completed') { headerText = `Subagent ${progress.agentName} completed.`; headerColor = theme.status.success; + } else { + headerText = `Running subagent ${progress.agentName}...`; + headerColor = theme.text.primary; } return ( @@ -146,6 +152,23 @@ export const SubagentProgressDisplay: React.FC< return null; })} + + {progress.state === 'completed' && progress.result && ( + + {progress.terminateReason && progress.terminateReason !== 'GOAL' && ( + + + Agent Finished Early ({progress.terminateReason}) + + + )} + + + )} ); }; diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index ee3a98930f..69da3a1029 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -15,12 +15,14 @@ import type { import { ToolCallStatus, mapCoreStatusToDisplayStatus } from '../../types.js'; import { ToolMessage } from './ToolMessage.js'; import { ShellToolMessage } from './ShellToolMessage.js'; +import { SubagentGroupDisplay } from './SubagentGroupDisplay.js'; import { theme } from '../../semantic-colors.js'; import { useConfig } from '../../contexts/ConfigContext.js'; import { isShellTool } from './ToolShared.js'; import { shouldHideToolCall, CoreToolCallStatus, + Kind, } from '@google/gemini-cli-core'; import { useUIState } from '../../contexts/UIStateContext.js'; import { getToolGroupBorderAppearance } from '../../utils/borderStyles.js'; @@ -125,12 +127,36 @@ export const ToolGroupMessage: React.FC = ({ let countToolCallsWithResults = 0; for (const tool of visibleToolCalls) { - if (tool.resultDisplay !== undefined && tool.resultDisplay !== '') { + if ( + tool.kind !== Kind.Agent && + tool.resultDisplay !== undefined && + tool.resultDisplay !== '' + ) { countToolCallsWithResults++; } } const countOneLineToolCalls = - visibleToolCalls.length - countToolCallsWithResults; + visibleToolCalls.filter((t) => t.kind !== Kind.Agent).length - + countToolCallsWithResults; + const groupedTools = useMemo(() => { + const groups: Array< + IndividualToolCallDisplay | IndividualToolCallDisplay[] + > = []; + for (const tool of visibleToolCalls) { + if (tool.kind === Kind.Agent) { + const lastGroup = groups[groups.length - 1]; + if (Array.isArray(lastGroup)) { + lastGroup.push(tool); + } else { + groups.push([tool]); + } + } else { + groups.push(tool); + } + } + return groups; + }, [visibleToolCalls]); + const availableTerminalHeightPerToolMessage = availableTerminalHeight ? Math.max( Math.floor( @@ -167,8 +193,29 @@ export const ToolGroupMessage: React.FC = ({ width={terminalWidth} paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN} > - {visibleToolCalls.map((tool, index) => { + {groupedTools.map((group, index) => { const isFirst = index === 0; + const resolvedIsFirst = + borderTopOverride !== undefined + ? borderTopOverride && isFirst + : isFirst; + + if (Array.isArray(group)) { + return ( + + ); + } + + const tool = group; const isShellToolCall = isShellTool(tool.name); const commonProps = { @@ -176,10 +223,7 @@ export const ToolGroupMessage: React.FC = ({ availableTerminalHeight: availableTerminalHeightPerToolMessage, terminalWidth: contentWidth, emphasis: 'medium' as const, - isFirst: - borderTopOverride !== undefined - ? borderTopOverride && isFirst - : isFirst, + isFirst: resolvedIsFirst, borderColor, borderDimColor, isExpandable, diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx index 0bbe3446e0..3b7cfaa8da 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx @@ -102,7 +102,12 @@ export const ToolResultDisplay: React.FC = ({ ); } else if (isSubagentProgress(contentData)) { - content = ; + content = ( + + ); } else if (typeof contentData === 'string' && renderOutputAsMarkdown) { content = ( > renders collapsed view by default with correct agent counts and states 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ ≡ 2 Agents (1 running, 1 completed)... (ctrl+o to expand) │ +│ ! api-monitor · Action Required Verify server is running │ +│ ✓ db-manager · 💭 Completed successfully │ +" +`; diff --git a/packages/cli/src/ui/components/messages/__snapshots__/SubagentProgressDisplay.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/SubagentProgressDisplay.test.tsx.snap index 8a4c5bd4c4..2d31c9c652 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/SubagentProgressDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/SubagentProgressDisplay.test.tsx.snap @@ -1,7 +1,9 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[` > renders "Request cancelled." with the info icon 1`] = ` -"ℹ Request cancelled. +"Running subagent TestAgent... + +ℹ Request cancelled. " `; @@ -11,31 +13,43 @@ exports[` > renders cancelled state correctly 1`] = ` `; exports[` > renders correctly with command fallback 1`] = ` -"⠋ run_shell_command echo hello +"Running subagent TestAgent... + +⠋ run_shell_command echo hello " `; exports[` > renders correctly with description in args 1`] = ` -"⠋ run_shell_command Say hello +"Running subagent TestAgent... + +⠋ run_shell_command Say hello " `; exports[` > renders correctly with displayName and description from item 1`] = ` -"⠋ RunShellCommand Executing echo hello +"Running subagent TestAgent... + +⠋ RunShellCommand Executing echo hello " `; exports[` > renders correctly with file_path 1`] = ` -"✓ write_file /tmp/test.txt +"Running subagent TestAgent... + +✓ write_file /tmp/test.txt " `; exports[` > renders thought bubbles correctly 1`] = ` -"💭 Thinking about life +"Running subagent TestAgent... + +💭 Thinking about life " `; exports[` > truncates long args 1`] = ` -"⠋ run_shell_command This is a very long description that should definitely be tr... +"Running subagent TestAgent... + +⠋ run_shell_command This is a very long description that should definitely be tr... " `; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index c394b866ad..2034e14b87 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -38,6 +38,7 @@ import { GeminiCliOperation, getPlanModeExitMessage, isBackgroundExecutionData, + Kind, } from '@google/gemini-cli-core'; import type { Config, @@ -408,7 +409,8 @@ export const useGeminiStream = ( // Push completed tools to history as they finish useEffect(() => { const toolsToPush: TrackedToolCall[] = []; - for (const tc of toolCalls) { + for (let i = 0; i < toolCalls.length; i++) { + const tc = toolCalls[i]; if (pushedToolCallIdsRef.current.has(tc.request.callId)) continue; if ( @@ -416,6 +418,40 @@ export const useGeminiStream = ( tc.status === 'error' || tc.status === 'cancelled' ) { + // TODO(#22883): This lookahead logic is a tactical UI fix to prevent parallel agents + // from tearing visually when they finish at slightly different times. + // Architecturally, `useGeminiStream` should not be responsible for stitching + // together semantic batches using timing/refs. `packages/core` should be + // refactored to emit structured `ToolBatch` or `Turn` objects, and this layer + // should simply render those semantic boundaries. + // If this is an agent tool, look ahead to ensure all subsequent + // contiguous agents in the same batch are also finished before pushing. + const isAgent = tc.tool?.kind === Kind.Agent; + if (isAgent) { + let contigAgentsComplete = true; + for (let j = i + 1; j < toolCalls.length; j++) { + const nextTc = toolCalls[j]; + if (nextTc.tool?.kind === Kind.Agent) { + if ( + nextTc.status !== 'success' && + nextTc.status !== 'error' && + nextTc.status !== 'cancelled' + ) { + contigAgentsComplete = false; + break; + } + } else { + // End of the contiguous agent block + break; + } + } + + if (!contigAgentsComplete) { + // Wait for the entire contiguous block of agents to finish + break; + } + } + toolsToPush.push(tc); } else { // Stop at first non-terminal tool to preserve order @@ -425,27 +461,27 @@ export const useGeminiStream = ( if (toolsToPush.length > 0) { const newPushed = new Set(pushedToolCallIdsRef.current); - let isFirst = isFirstToolInGroupRef.current; for (const tc of toolsToPush) { newPushed.add(tc.request.callId); - const isLastInBatch = tc === toolCalls[toolCalls.length - 1]; - - const historyItem = mapTrackedToolCallsToDisplay(tc, { - borderTop: isFirst, - borderBottom: isLastInBatch, - ...getToolGroupBorderAppearance( - { type: 'tool_group', tools: toolCalls }, - activeShellPtyId, - !!isShellFocused, - [], - backgroundShells, - ), - }); - addItem(historyItem); - isFirst = false; } + const isLastInBatch = + toolsToPush[toolsToPush.length - 1] === toolCalls[toolCalls.length - 1]; + + const historyItem = mapTrackedToolCallsToDisplay(toolsToPush, { + borderTop: isFirstToolInGroupRef.current, + borderBottom: isLastInBatch, + ...getToolGroupBorderAppearance( + { type: 'tool_group', tools: toolCalls }, + activeShellPtyId, + !!isShellFocused, + [], + backgroundShells, + ), + }); + addItem(historyItem); + setPushedToolCallIds(newPushed); setIsFirstToolInGroup(false); } diff --git a/packages/core/src/agents/local-invocation.test.ts b/packages/core/src/agents/local-invocation.test.ts index b56fea54b6..0cd77176ba 100644 --- a/packages/core/src/agents/local-invocation.test.ts +++ b/packages/core/src/agents/local-invocation.test.ts @@ -207,8 +207,11 @@ describe('LocalSubagentInvocation', () => { ), }, ]); - expect(result.returnDisplay).toBe('Analysis complete.'); - expect(result.returnDisplay).not.toContain('Termination Reason'); + const display = result.returnDisplay as SubagentProgress; + expect(display.isSubagentProgress).toBe(true); + expect(display.state).toBe('completed'); + expect(display.result).toBe('Analysis complete.'); + expect(display.terminateReason).toBe(AgentTerminateMode.GOAL); }); it('should show detailed UI for non-goal terminations (e.g., TIMEOUT)', async () => { @@ -220,11 +223,11 @@ describe('LocalSubagentInvocation', () => { const result = await invocation.execute(signal, updateOutput); - expect(result.returnDisplay).toContain( - '### Subagent MockAgent Finished Early', - ); - expect(result.returnDisplay).toContain('**Termination Reason:** TIMEOUT'); - expect(result.returnDisplay).toContain('Partial progress...'); + const display = result.returnDisplay as SubagentProgress; + expect(display.isSubagentProgress).toBe(true); + expect(display.state).toBe('completed'); + expect(display.result).toBe('Partial progress...'); + expect(display.terminateReason).toBe(AgentTerminateMode.TIMEOUT); }); it('should stream THOUGHT_CHUNK activities from the executor', async () => { @@ -250,8 +253,8 @@ describe('LocalSubagentInvocation', () => { await invocation.execute(signal, updateOutput); - expect(updateOutput).toHaveBeenCalledTimes(3); // Initial + 2 updates - const lastCall = updateOutput.mock.calls[2][0] as SubagentProgress; + expect(updateOutput).toHaveBeenCalledTimes(4); // Initial + 2 updates + Final completion + const lastCall = updateOutput.mock.calls[3][0] as SubagentProgress; expect(lastCall.recentActivity).toContainEqual( expect.objectContaining({ type: 'thought', @@ -283,8 +286,8 @@ describe('LocalSubagentInvocation', () => { await invocation.execute(signal, updateOutput); - expect(updateOutput).toHaveBeenCalledTimes(3); - const lastCall = updateOutput.mock.calls[2][0] as SubagentProgress; + expect(updateOutput).toHaveBeenCalledTimes(4); // Initial + 2 updates + Final completion + const lastCall = updateOutput.mock.calls[3][0] as SubagentProgress; expect(lastCall.recentActivity).toContainEqual( expect.objectContaining({ type: 'thought', @@ -312,7 +315,10 @@ describe('LocalSubagentInvocation', () => { // Execute without the optional callback const result = await invocation.execute(signal); expect(result.error).toBeUndefined(); - expect(result.returnDisplay).toBe('Done'); + const display = result.returnDisplay as SubagentProgress; + expect(display.isSubagentProgress).toBe(true); + expect(display.state).toBe('completed'); + expect(display.result).toBe('Done'); }); it('should handle executor run failure', async () => { diff --git a/packages/core/src/agents/local-invocation.ts b/packages/core/src/agents/local-invocation.ts index 6ef30e773c..142a0bc518 100644 --- a/packages/core/src/agents/local-invocation.ts +++ b/packages/core/src/agents/local-invocation.ts @@ -6,7 +6,6 @@ import { type AgentLoopContext } from '../config/agent-loop-context.js'; import { LocalAgentExecutor } from './local-executor.js'; -import { safeJsonToMarkdown } from '../utils/markdownUtils.js'; import { BaseToolInvocation, type ToolResult, @@ -246,28 +245,27 @@ export class LocalSubagentInvocation extends BaseToolInvocation< throw cancelError; } - const displayResult = safeJsonToMarkdown(output.result); + const progress: SubagentProgress = { + isSubagentProgress: true, + agentName: this.definition.name, + recentActivity: [...recentActivity], + state: 'completed', + result: output.result, + terminateReason: output.terminate_reason, + }; + + if (updateOutput) { + updateOutput(progress); + } const resultContent = `Subagent '${this.definition.name}' finished. Termination Reason: ${output.terminate_reason} Result: ${output.result}`; - const displayContent = - output.terminate_reason === AgentTerminateMode.GOAL - ? displayResult - : ` -### Subagent ${this.definition.name} Finished Early - -**Termination Reason:** ${output.terminate_reason} - -**Result/Summary:** -${displayResult} -`; - return { llmContent: [{ text: resultContent }], - returnDisplay: displayContent, + returnDisplay: progress, }; } catch (error) { const errorMessage = diff --git a/packages/core/src/agents/types.ts b/packages/core/src/agents/types.ts index 41db981a7b..2c703f90fd 100644 --- a/packages/core/src/agents/types.ts +++ b/packages/core/src/agents/types.ts @@ -87,6 +87,8 @@ export interface SubagentProgress { agentName: string; recentActivity: SubagentActivityItem[]; state?: 'running' | 'completed' | 'error' | 'cancelled'; + result?: string; + terminateReason?: AgentTerminateMode; } export function isSubagentProgress(obj: unknown): obj is SubagentProgress { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a76e7aa2d4..47412dd73c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -118,6 +118,7 @@ export * from './utils/channel.js'; export * from './utils/constants.js'; export * from './utils/sessionUtils.js'; export * from './utils/cache.js'; +export * from './utils/markdownUtils.js'; // Export services export * from './services/fileDiscoveryService.js'; From 4ecb4bb24b8f986818c42698b2a84974188e0b3a Mon Sep 17 00:00:00 2001 From: Abhi <43648792+abhipatel12@users.noreply.github.com> Date: Wed, 18 Mar 2026 00:44:01 -0400 Subject: [PATCH 7/7] refactor(ui): extract SessionBrowser search and navigation components (#22377) --- .../cli/src/ui/components/SessionBrowser.tsx | 90 ++----------------- .../SessionBrowser/SessionBrowserNav.tsx | 72 +++++++++++++++ .../SessionBrowserSearchNav.test.tsx | 69 ++++++++++++++ .../SessionBrowser/SessionListHeader.tsx | 29 ++++++ .../SessionBrowserSearchNav.test.tsx.snap | 29 ++++++ 5 files changed, 206 insertions(+), 83 deletions(-) create mode 100644 packages/cli/src/ui/components/SessionBrowser/SessionBrowserNav.tsx create mode 100644 packages/cli/src/ui/components/SessionBrowser/SessionBrowserSearchNav.test.tsx create mode 100644 packages/cli/src/ui/components/SessionBrowser/SessionListHeader.tsx create mode 100644 packages/cli/src/ui/components/SessionBrowser/__snapshots__/SessionBrowserSearchNav.test.tsx.snap diff --git a/packages/cli/src/ui/components/SessionBrowser.tsx b/packages/cli/src/ui/components/SessionBrowser.tsx index 0fc80a1d4e..ac9b2c2b00 100644 --- a/packages/cli/src/ui/components/SessionBrowser.tsx +++ b/packages/cli/src/ui/components/SessionBrowser.tsx @@ -110,78 +110,17 @@ const SESSIONS_PER_PAGE = 20; // If the SessionItem layout changes, update this accordingly. const FIXED_SESSION_COLUMNS_WIDTH = 30; -const Kbd = ({ name, shortcut }: { name: string; shortcut: string }) => ( - <> - {name}: {shortcut} - -); - +import { + SearchModeDisplay, + NavigationHelpDisplay, + NoResultsDisplay, +} from './SessionBrowser/SessionBrowserNav.js'; +import { SessionListHeader } from './SessionBrowser/SessionListHeader.js'; import { SessionBrowserLoading } from './SessionBrowser/SessionBrowserLoading.js'; import { SessionBrowserError } from './SessionBrowser/SessionBrowserError.js'; import { SessionBrowserEmpty } from './SessionBrowser/SessionBrowserEmpty.js'; - import { sortSessions, filterSessions } from './SessionBrowser/utils.js'; -/** - * Search input display component. - */ -const SearchModeDisplay = ({ - state, -}: { - state: SessionBrowserState; -}): React.JSX.Element => ( - - Search: - {state.searchQuery} - (Esc to cancel) - -); - -/** - * Header component showing session count and sort information. - */ -const SessionListHeader = ({ - state, -}: { - state: SessionBrowserState; -}): React.JSX.Element => ( - - - Chat Sessions ({state.totalSessions} total - {state.searchQuery ? `, filtered` : ''}) - - - sorted by {state.sortOrder} {state.sortReverse ? 'asc' : 'desc'} - - -); - -/** - * Navigation help component showing keyboard shortcuts. - */ -const NavigationHelp = (): React.JSX.Element => ( - - - - {' '} - - {' '} - - {' '} - - {' '} - - - - - {' '} - - {' '} - - - -); - /** * Table header component with column labels and scroll indicators. */ @@ -219,21 +158,6 @@ const SessionTableHeader = ({ ); -/** - * No results display component for empty search results. - */ -const NoResultsDisplay = ({ - state, -}: { - state: SessionBrowserState; -}): React.JSX.Element => ( - - - No sessions found matching '{state.searchQuery}'. - - -); - /** * Match snippet display component for search results. */ @@ -398,7 +322,7 @@ const SessionList = ({ {/* Table Header */} - {!state.isSearchMode && } + {!state.isSearchMode && } diff --git a/packages/cli/src/ui/components/SessionBrowser/SessionBrowserNav.tsx b/packages/cli/src/ui/components/SessionBrowser/SessionBrowserNav.tsx new file mode 100644 index 0000000000..99d0363ed5 --- /dev/null +++ b/packages/cli/src/ui/components/SessionBrowser/SessionBrowserNav.tsx @@ -0,0 +1,72 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { Colors } from '../../colors.js'; +import type { SessionBrowserState } from '../SessionBrowser.js'; + +const Kbd = ({ name, shortcut }: { name: string; shortcut: string }) => ( + <> + {name}: {shortcut} + +); + +/** + * Navigation help component showing keyboard shortcuts. + */ +export const NavigationHelpDisplay = (): React.JSX.Element => ( + + + + {' '} + + {' '} + + {' '} + + {' '} + + + + + {' '} + + {' '} + + + +); + +/** + * Search input display component. + */ +export const SearchModeDisplay = ({ + state, +}: { + state: SessionBrowserState; +}): React.JSX.Element => ( + + Search: + {state.searchQuery} + (Esc to cancel) + +); + +/** + * No results display component for empty search results. + */ +export const NoResultsDisplay = ({ + state, +}: { + state: SessionBrowserState; +}): React.JSX.Element => ( + + + No sessions found matching '{state.searchQuery}'. + + +); diff --git a/packages/cli/src/ui/components/SessionBrowser/SessionBrowserSearchNav.test.tsx b/packages/cli/src/ui/components/SessionBrowser/SessionBrowserSearchNav.test.tsx new file mode 100644 index 0000000000..af7f1a6906 --- /dev/null +++ b/packages/cli/src/ui/components/SessionBrowser/SessionBrowserSearchNav.test.tsx @@ -0,0 +1,69 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../../test-utils/render.js'; +import { describe, it, expect } from 'vitest'; +import { + SearchModeDisplay, + NavigationHelpDisplay, + NoResultsDisplay, +} from './SessionBrowserNav.js'; +import { SessionListHeader } from './SessionListHeader.js'; +import type { SessionBrowserState } from '../SessionBrowser.js'; + +describe('SessionBrowser Search and Navigation Components', () => { + it('SearchModeDisplay renders correctly with query', async () => { + const mockState = { searchQuery: 'test query' } as SessionBrowserState; + const { lastFrame, waitUntilReady } = render( + , + ); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('NavigationHelp renders correctly', async () => { + const { lastFrame, waitUntilReady } = render(); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('SessionListHeader renders correctly', async () => { + const mockState = { + totalSessions: 10, + searchQuery: '', + sortOrder: 'date', + sortReverse: false, + } as SessionBrowserState; + const { lastFrame, waitUntilReady } = render( + , + ); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('SessionListHeader renders correctly with filter', async () => { + const mockState = { + totalSessions: 5, + searchQuery: 'test', + sortOrder: 'name', + sortReverse: true, + } as SessionBrowserState; + const { lastFrame, waitUntilReady } = render( + , + ); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('NoResultsDisplay renders correctly', async () => { + const mockState = { searchQuery: 'no match' } as SessionBrowserState; + const { lastFrame, waitUntilReady } = render( + , + ); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/SessionBrowser/SessionListHeader.tsx b/packages/cli/src/ui/components/SessionBrowser/SessionListHeader.tsx new file mode 100644 index 0000000000..2b7fb79d40 --- /dev/null +++ b/packages/cli/src/ui/components/SessionBrowser/SessionListHeader.tsx @@ -0,0 +1,29 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { Colors } from '../../colors.js'; +import type { SessionBrowserState } from '../SessionBrowser.js'; + +/** + * Header component showing session count and sort information. + */ +export const SessionListHeader = ({ + state, +}: { + state: SessionBrowserState; +}): React.JSX.Element => ( + + + Chat Sessions ({state.totalSessions} total + {state.searchQuery ? `, filtered` : ''}) + + + sorted by {state.sortOrder} {state.sortReverse ? 'asc' : 'desc'} + + +); diff --git a/packages/cli/src/ui/components/SessionBrowser/__snapshots__/SessionBrowserSearchNav.test.tsx.snap b/packages/cli/src/ui/components/SessionBrowser/__snapshots__/SessionBrowserSearchNav.test.tsx.snap new file mode 100644 index 0000000000..c5ed5e5454 --- /dev/null +++ b/packages/cli/src/ui/components/SessionBrowser/__snapshots__/SessionBrowserSearchNav.test.tsx.snap @@ -0,0 +1,29 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`SessionBrowser Search and Navigation Components > NavigationHelp renders correctly 1`] = ` +"Navigate: ↑/↓ Resume: Enter Search: / Delete: x Quit: q +Sort: s Reverse: r First/Last: g/G +" +`; + +exports[`SessionBrowser Search and Navigation Components > NoResultsDisplay renders correctly 1`] = ` +" +No sessions found matching 'no match'. +" +`; + +exports[`SessionBrowser Search and Navigation Components > SearchModeDisplay renders correctly with query 1`] = ` +" +Search: test query (Esc to cancel) +" +`; + +exports[`SessionBrowser Search and Navigation Components > SessionListHeader renders correctly 1`] = ` +"Chat Sessions (10 total) sorted by date desc +" +`; + +exports[`SessionBrowser Search and Navigation Components > SessionListHeader renders correctly with filter 1`] = ` +"Chat Sessions (5 total, filtered) sorted by name asc +" +`;