mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-14 03:50:49 -07:00
feat(cli): implement startup team selection dialog and UI integration
- Add TeamSelectionDialog component for team discovery UX - Intercept startup flow in AppContainer to show team selection if needed - Update DialogManager to orchestrate team selection UI - Add handleTeamSelect and isTeamSelectionActive to UI contexts - Fix pre-existing StyledLine compilation error in TableRenderer - Refactor promptProvider-teams.test.ts to resolve type errors - Add unit tests for TeamSelectionDialog - Fix ESLint warnings for exhaustive-deps in AppContainer - Resolve syntax corruptions in AppContainer.tsx and DialogManager.tsx
This commit is contained in:
@@ -589,6 +589,7 @@ const mockUIActions: UIActions = {
|
||||
onHintSubmit: vi.fn(),
|
||||
handleRestart: vi.fn(),
|
||||
handleNewAgentsSelect: vi.fn(),
|
||||
handleTeamSelect: vi.fn(),
|
||||
getPreferredEditor: vi.fn(),
|
||||
clearAccountSuspension: vi.fn(),
|
||||
};
|
||||
|
||||
@@ -415,6 +415,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
);
|
||||
|
||||
const [isConfigInitialized, setConfigInitialized] = useState(false);
|
||||
const [isTeamSelectionActive, setIsTeamSelectionActive] = useState(false);
|
||||
|
||||
const logger = useLogger(config.storage);
|
||||
const { inputHistory, addInput, initializeFromLogger } =
|
||||
@@ -444,6 +445,16 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
if (!config.isInitialized()) {
|
||||
await config.initialize();
|
||||
}
|
||||
|
||||
// Check if team selection is needed
|
||||
const teamRegistry = config.getTeamRegistry();
|
||||
const availableTeams = teamRegistry.getAllTeams();
|
||||
const activeTeam = config.getActiveTeam();
|
||||
|
||||
if (availableTeams.length > 0 && !activeTeam) {
|
||||
setIsTeamSelectionActive(true);
|
||||
}
|
||||
|
||||
setConfigInitialized(true);
|
||||
startupProfiler.flush(config);
|
||||
|
||||
@@ -1407,6 +1418,15 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit);
|
||||
|
||||
const handleTeamSelect = useCallback(
|
||||
(teamName: string | undefined) => {
|
||||
config.setActiveTeam(teamName);
|
||||
setIsTeamSelectionActive(false);
|
||||
refreshStatic();
|
||||
},
|
||||
[config, refreshStatic],
|
||||
);
|
||||
|
||||
/**
|
||||
* Determines if the input prompt should be active and accept user input.
|
||||
* Input is disabled during:
|
||||
@@ -2046,6 +2066,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
const nightly = props.version.includes('nightly');
|
||||
|
||||
const dialogsVisible =
|
||||
isTeamSelectionActive ||
|
||||
shouldShowIdePrompt ||
|
||||
shouldShowIdePrompt ||
|
||||
isFolderTrustDialogOpen ||
|
||||
@@ -2377,6 +2398,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isBackgroundTaskListOpen,
|
||||
adminSettingsChanged,
|
||||
newAgents,
|
||||
isTeamSelectionActive,
|
||||
showIsExpandableHint,
|
||||
hintMode:
|
||||
config.isModelSteeringEnabled() && isToolExecuting(pendingHistoryItems),
|
||||
@@ -2504,6 +2526,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
adminSettingsChanged,
|
||||
newAgents,
|
||||
showIsExpandableHint,
|
||||
isTeamSelectionActive,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2600,6 +2623,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}
|
||||
setNewAgents(null);
|
||||
},
|
||||
handleTeamSelect,
|
||||
getPreferredEditor,
|
||||
clearAccountSuspension: () => {
|
||||
setAccountSuspensionInfo(null);
|
||||
@@ -2661,6 +2685,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
config,
|
||||
historyManager,
|
||||
getPreferredEditor,
|
||||
handleTeamSelect,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import { relaunchApp } from '../../utils/processUtils.js';
|
||||
import { SessionBrowser } from './SessionBrowser.js';
|
||||
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
|
||||
import { ModelDialog } from './ModelDialog.js';
|
||||
import { TeamSelectionDialog } from './TeamSelectionDialog.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
@@ -60,6 +61,14 @@ export const DialogManager = ({
|
||||
terminalWidth: uiTerminalWidth,
|
||||
} = uiState;
|
||||
|
||||
if (uiState.isTeamSelectionActive) {
|
||||
return (
|
||||
<TeamSelectionDialog
|
||||
teams={config.getTeamRegistry().getAllTeams()}
|
||||
onSelect={uiActions.handleTeamSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.adminSettingsChanged) {
|
||||
return <AdminSettingsChangedDialog />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { TeamSelectionDialog } from './TeamSelectionDialog.js';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { type TeamDefinition } from '@google/gemini-cli-core';
|
||||
|
||||
describe('<TeamSelectionDialog />', () => {
|
||||
const mockTeams: TeamDefinition[] = [
|
||||
{
|
||||
name: 'team-1',
|
||||
displayName: 'Team One',
|
||||
description: 'First team description',
|
||||
instructions: 'Instructions 1',
|
||||
agents: [],
|
||||
},
|
||||
{
|
||||
name: 'team-2',
|
||||
displayName: 'Team Two',
|
||||
description: 'Second team description',
|
||||
instructions: 'Instructions 2',
|
||||
agents: [],
|
||||
},
|
||||
];
|
||||
|
||||
const mockOnSelect = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnSelect.mockReset();
|
||||
});
|
||||
|
||||
const renderComponent = async () => renderWithProviders(
|
||||
<TeamSelectionDialog teams={mockTeams} onSelect={mockOnSelect} />,
|
||||
);
|
||||
|
||||
it('renders all options correctly', async () => {
|
||||
const { lastFrame, unmount } = await renderComponent();
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('Select an Agent Team');
|
||||
expect(output).toContain('Team One');
|
||||
expect(output).toContain('First team description');
|
||||
expect(output).toContain('Team Two');
|
||||
expect(output).toContain('Second team description');
|
||||
expect(output).toContain('No Team');
|
||||
expect(output).toContain('Browse Marketplace (Coming Soon)');
|
||||
expect(output).toContain('Create Team (Coming Soon)');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('calls onSelect with team name when a team is selected', async () => {
|
||||
const { stdin, waitUntilReady, unmount } = await renderComponent();
|
||||
|
||||
// Default selection is index 0 (Team One)
|
||||
await act(async () => {
|
||||
stdin.write('\r'); // Enter
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSelect).toHaveBeenCalledWith('team-1');
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('calls onSelect with undefined when "No Team" is selected', async () => {
|
||||
const { stdin, waitUntilReady, unmount } = await renderComponent();
|
||||
|
||||
// Navigate to "No Team" (index 2)
|
||||
await act(async () => {
|
||||
stdin.write('\u001B[B'); // Down
|
||||
});
|
||||
await waitUntilReady();
|
||||
await act(async () => {
|
||||
stdin.write('\u001B[B'); // Down
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r'); // Enter
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSelect).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('does not call onSelect for placeholder options', async () => {
|
||||
const { stdin, waitUntilReady, unmount } = await renderComponent();
|
||||
|
||||
// Navigate to "Browse Marketplace" (index 3)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await act(async () => {
|
||||
stdin.write('\u001B[B'); // Down
|
||||
});
|
||||
await waitUntilReady();
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r'); // Enter
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
expect(mockOnSelect).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { type TeamDefinition } from '@google/gemini-cli-core';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
|
||||
|
||||
interface TeamSelectionDialogProps {
|
||||
teams: TeamDefinition[];
|
||||
onSelect: (teamName: string | undefined) => void;
|
||||
}
|
||||
|
||||
export function TeamSelectionDialog({
|
||||
teams,
|
||||
onSelect,
|
||||
}: TeamSelectionDialogProps): React.JSX.Element {
|
||||
const options = useMemo(() => {
|
||||
const list = teams.map((team) => ({
|
||||
value: team.name,
|
||||
title: team.displayName,
|
||||
description: team.description,
|
||||
key: team.name,
|
||||
}));
|
||||
|
||||
list.push({
|
||||
value: 'none',
|
||||
title: 'No Team',
|
||||
description: 'Continue with standard Gemini CLI experience',
|
||||
key: 'none',
|
||||
});
|
||||
|
||||
list.push({
|
||||
value: 'marketplace',
|
||||
title: 'Browse Marketplace (Coming Soon)',
|
||||
description: 'Discover and install teams from the community',
|
||||
key: 'marketplace',
|
||||
});
|
||||
|
||||
list.push({
|
||||
value: 'create',
|
||||
title: 'Create Team (Coming Soon)',
|
||||
description: 'Define your own agent team and orchestration instructions',
|
||||
key: 'create',
|
||||
});
|
||||
|
||||
return list;
|
||||
}, [teams]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(value: string) => {
|
||||
if (value === 'none') {
|
||||
onSelect(undefined);
|
||||
} else if (value === 'marketplace' || value === 'create') {
|
||||
// No-op for coming soon features
|
||||
return;
|
||||
} else {
|
||||
onSelect(value);
|
||||
}
|
||||
},
|
||||
[onSelect],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Select an Agent Team
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
Choose a specialized team to orchestrate your tasks.
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<DescriptiveRadioButtonSelect
|
||||
items={options}
|
||||
onSelect={handleSelect}
|
||||
showNumbers={true}
|
||||
maxItemsToShow={10}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
(Use arrow keys to navigate, Enter to select)
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -91,7 +91,9 @@ export interface UIActions {
|
||||
onHintSubmit: (hint: string) => void;
|
||||
handleRestart: () => void;
|
||||
handleNewAgentsSelect: (choice: NewAgentsChoice) => Promise<void>;
|
||||
getPreferredEditor: () => EditorType | undefined;
|
||||
handleTeamSelect: (teamName: string | undefined) => void;
|
||||
getPreferredEditor: () => EditorType;
|
||||
|
||||
clearAccountSuspension: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -221,6 +221,7 @@ export interface UIState {
|
||||
isBackgroundTaskListOpen: boolean;
|
||||
adminSettingsChanged: boolean;
|
||||
newAgents: AgentDefinition[] | null;
|
||||
isTeamSelectionActive: boolean;
|
||||
showIsExpandableHint: boolean;
|
||||
hintMode: boolean;
|
||||
hintBuffer: string;
|
||||
|
||||
@@ -8,7 +8,6 @@ import React, { useMemo } from 'react';
|
||||
import {
|
||||
Text,
|
||||
Box,
|
||||
StyledLine,
|
||||
toStyledCharacters,
|
||||
wordBreakStyledChars,
|
||||
wrapStyledChars,
|
||||
@@ -16,6 +15,12 @@ import {
|
||||
styledCharsWidth,
|
||||
styledCharsToString,
|
||||
} from 'ink';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
type StyledLine = any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { parseMarkdownToANSI } from './markdownParsingUtils.js';
|
||||
import { stripUnsafeCharacters } from './textUtils.js';
|
||||
@@ -100,12 +105,12 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
|
||||
// --- Define Constraints per Column ---
|
||||
const constraints = Array.from({ length: numColumns }).map(
|
||||
(_, colIndex) => {
|
||||
const headerStyledLine = styledHeaders[colIndex] || StyledLine.empty(0);
|
||||
const headerStyledLine = styledHeaders[colIndex] || [];
|
||||
let { contentWidth: maxContentWidth, maxWordWidth } =
|
||||
calculateWidths(headerStyledLine);
|
||||
|
||||
styledRows.forEach((row) => {
|
||||
const cellStyledLine = row[colIndex] || StyledLine.empty(0);
|
||||
const cellStyledLine = row[colIndex] || [];
|
||||
const { contentWidth: cellWidth, maxWordWidth: cellWordWidth } =
|
||||
calculateWidths(cellStyledLine);
|
||||
|
||||
@@ -180,7 +185,7 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
|
||||
const rowResult: ProcessedLine[][] = [];
|
||||
// Ensure we iterate up to numColumns, filling with empty cells if needed
|
||||
for (let colIndex = 0; colIndex < numColumns; colIndex++) {
|
||||
const cellStyledLine = row[colIndex] || StyledLine.empty(0);
|
||||
const cellStyledLine = row[colIndex] || [];
|
||||
const allocatedWidth = finalContentWidths[colIndex];
|
||||
const contentWidth = Math.max(1, allocatedWidth);
|
||||
|
||||
@@ -210,7 +215,7 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
|
||||
// Use the TIGHTEST widths that fit the wrapped content + padding
|
||||
const adjustedWidths = actualColumnWidths.map(
|
||||
(w) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
|
||||
w + COLUMN_PADDING,
|
||||
);
|
||||
|
||||
@@ -263,7 +268,7 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
|
||||
isHeader = false,
|
||||
): React.ReactNode => {
|
||||
const renderedCells = cells.map((cell, index) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
|
||||
const width = adjustedWidths[index] || 0;
|
||||
return renderCell(cell, width, isHeader);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user