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:
Taylor Mullen
2026-04-01 15:58:16 -07:00
parent 5aba28c8be
commit 1c5646b68d
15 changed files with 554 additions and 15 deletions
+1
View File
@@ -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(),
};
+25
View File
@@ -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;
+11 -6
View File
@@ -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);
});