feat: add AskUserDialog for UI component of AskUser tool (#17344)

Co-authored-by: jacob314 <jacob314@gmail.com>
This commit is contained in:
Jack Wotherspoon
2026-01-23 15:42:48 -05:00
committed by GitHub
parent 25c0802b52
commit 2c0cc7b9a5
10 changed files with 3009 additions and 1 deletions

View File

@@ -31,6 +31,7 @@ export interface BaseSelectionListProps<
showScrollArrows?: boolean;
maxItemsToShow?: number;
wrapAround?: boolean;
focusKey?: string;
renderItem: (item: TItem, context: RenderItemContext) => React.ReactNode;
}
@@ -61,6 +62,7 @@ export function BaseSelectionList<
showScrollArrows = false,
maxItemsToShow = 10,
wrapAround = true,
focusKey,
renderItem,
}: BaseSelectionListProps<T, TItem>): React.JSX.Element {
const { activeIndex } = useSelectionList({
@@ -71,6 +73,7 @@ export function BaseSelectionList<
isFocused,
showNumbers,
wrapAround,
focusKey,
});
const [scrollOffset, setScrollOffset] = useState(0);
@@ -143,7 +146,7 @@ export function BaseSelectionList<
</Box>
{/* Item number */}
{showNumbers && (
{showNumbers && !item.hideNumber && (
<Box
marginRight={1}
flexShrink={0}

View File

@@ -0,0 +1,157 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { renderWithProviders } from '../../../test-utils/render.js';
import { TabHeader, type Tab } from './TabHeader.js';
const MOCK_TABS: Tab[] = [
{ key: '0', header: 'Tab 1' },
{ key: '1', header: 'Tab 2' },
{ key: '2', header: 'Tab 3' },
];
describe('TabHeader', () => {
describe('rendering', () => {
it('renders null for single tab', () => {
const { lastFrame } = renderWithProviders(
<TabHeader
tabs={[{ key: '0', header: 'Only Tab' }]}
currentIndex={0}
/>,
);
expect(lastFrame()).toBe('');
});
it('renders all tab headers', () => {
const { lastFrame } = renderWithProviders(
<TabHeader tabs={MOCK_TABS} currentIndex={0} />,
);
const frame = lastFrame();
expect(frame).toContain('Tab 1');
expect(frame).toContain('Tab 2');
expect(frame).toContain('Tab 3');
});
it('renders separators between tabs', () => {
const { lastFrame } = renderWithProviders(
<TabHeader tabs={MOCK_TABS} currentIndex={0} />,
);
const frame = lastFrame();
// Should have 2 separators for 3 tabs
const separatorCount = (frame?.match(/│/g) || []).length;
expect(separatorCount).toBe(2);
});
});
describe('arrows', () => {
it('shows arrows by default', () => {
const { lastFrame } = renderWithProviders(
<TabHeader tabs={MOCK_TABS} currentIndex={0} />,
);
const frame = lastFrame();
expect(frame).toContain('←');
expect(frame).toContain('→');
});
it('hides arrows when showArrows is false', () => {
const { lastFrame } = renderWithProviders(
<TabHeader tabs={MOCK_TABS} currentIndex={0} showArrows={false} />,
);
const frame = lastFrame();
expect(frame).not.toContain('←');
expect(frame).not.toContain('→');
});
});
describe('status icons', () => {
it('shows status icons by default', () => {
const { lastFrame } = renderWithProviders(
<TabHeader tabs={MOCK_TABS} currentIndex={0} />,
);
const frame = lastFrame();
// Default uncompleted icon is □
expect(frame).toContain('□');
});
it('hides status icons when showStatusIcons is false', () => {
const { lastFrame } = renderWithProviders(
<TabHeader tabs={MOCK_TABS} currentIndex={0} showStatusIcons={false} />,
);
const frame = lastFrame();
expect(frame).not.toContain('□');
expect(frame).not.toContain('✓');
});
it('shows checkmark for completed tabs', () => {
const { lastFrame } = renderWithProviders(
<TabHeader
tabs={MOCK_TABS}
currentIndex={0}
completedIndices={new Set([0, 2])}
/>,
);
const frame = lastFrame();
// Should have 2 checkmarks and 1 box
const checkmarkCount = (frame?.match(/✓/g) || []).length;
const boxCount = (frame?.match(/□/g) || []).length;
expect(checkmarkCount).toBe(2);
expect(boxCount).toBe(1);
});
it('shows special icon for special tabs', () => {
const tabsWithSpecial: Tab[] = [
{ key: '0', header: 'Tab 1' },
{ key: '1', header: 'Review', isSpecial: true },
];
const { lastFrame } = renderWithProviders(
<TabHeader tabs={tabsWithSpecial} currentIndex={0} />,
);
const frame = lastFrame();
// Special tab shows ≡ icon
expect(frame).toContain('≡');
});
it('uses tab statusIcon when provided', () => {
const tabsWithCustomIcon: Tab[] = [
{ key: '0', header: 'Tab 1', statusIcon: '★' },
{ key: '1', header: 'Tab 2' },
];
const { lastFrame } = renderWithProviders(
<TabHeader tabs={tabsWithCustomIcon} currentIndex={0} />,
);
const frame = lastFrame();
expect(frame).toContain('★');
});
it('uses custom renderStatusIcon when provided', () => {
const renderStatusIcon = () => '•';
const { lastFrame } = renderWithProviders(
<TabHeader
tabs={MOCK_TABS}
currentIndex={0}
renderStatusIcon={renderStatusIcon}
/>,
);
const frame = lastFrame();
const bulletCount = (frame?.match(/•/g) || []).length;
expect(bulletCount).toBe(3);
});
it('falls back to default when renderStatusIcon returns undefined', () => {
const renderStatusIcon = () => undefined;
const { lastFrame } = renderWithProviders(
<TabHeader
tabs={MOCK_TABS}
currentIndex={0}
renderStatusIcon={renderStatusIcon}
/>,
);
const frame = lastFrame();
expect(frame).toContain('□');
});
});
});

View File

@@ -0,0 +1,110 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Text, Box } from 'ink';
import { theme } from '../../semantic-colors.js';
/**
* Represents a single tab in the TabHeader.
*/
export interface Tab {
/** Unique identifier for this tab */
key: string;
/** Header text displayed in the tab indicator */
header: string;
/** Optional custom status icon for this tab */
statusIcon?: string;
/** Whether this is a special tab (like "Review") - uses different default icon */
isSpecial?: boolean;
}
/**
* Props for the TabHeader component.
*/
export interface TabHeaderProps {
/** Array of tab definitions */
tabs: Tab[];
/** Currently active tab index */
currentIndex: number;
/** Set of indices for tabs that show a completion indicator */
completedIndices?: Set<number>;
/** Show navigation arrow hints on sides (default: true) */
showArrows?: boolean;
/** Show status icons (checkmark/box) before tab headers (default: true) */
showStatusIcons?: boolean;
/**
* Custom status icon renderer. Return undefined to use default icons.
* Default icons: '✓' for completed, '□' for incomplete, '≡' for special tabs
*/
renderStatusIcon?: (
tab: Tab,
index: number,
isCompleted: boolean,
) => string | undefined;
}
/**
* A header component that displays tab indicators for multi-tab interfaces.
*
* Renders in the format: `← Tab1 │ Tab2 │ Tab3 →`
*
* Features:
* - Shows completion status (✓ or □) per tab
* - Highlights current tab with accent color
* - Supports special tabs (like "Review") with different icons
* - Customizable status icons
*/
export function TabHeader({
tabs,
currentIndex,
completedIndices = new Set(),
showArrows = true,
showStatusIcons = true,
renderStatusIcon,
}: TabHeaderProps): React.JSX.Element | null {
if (tabs.length <= 1) return null;
const getStatusIcon = (tab: Tab, index: number): string => {
const isCompleted = completedIndices.has(index);
// Try custom renderer first
if (renderStatusIcon) {
const customIcon = renderStatusIcon(tab, index, isCompleted);
if (customIcon !== undefined) return customIcon;
}
// Use tab's own icon if provided
if (tab.statusIcon) return tab.statusIcon;
// Default icons
if (tab.isSpecial) return '\u2261'; // ≡
return isCompleted ? '\u2713' : '\u25A1'; // ✓ or □
};
return (
<Box flexDirection="row" marginBottom={1}>
{showArrows && <Text color={theme.text.secondary}>{'\u2190 '}</Text>}
{tabs.map((tab, i) => (
<React.Fragment key={tab.key}>
{i > 0 && <Text color={theme.text.secondary}>{' \u2502 '}</Text>}
{showStatusIcons && (
<Text color={theme.text.secondary}>{getStatusIcon(tab, i)} </Text>
)}
<Text
color={
i === currentIndex ? theme.text.accent : theme.text.secondary
}
bold={i === currentIndex}
>
{tab.header}
</Text>
</React.Fragment>
))}
{showArrows && <Text color={theme.text.secondary}>{' \u2192'}</Text>}
</Box>
);
}