Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions src/cli/tui/hooks/__tests__/useListNavigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,17 @@ function ListNav({
isDisabled,
getHotkeys,
onHotkeySelect,
initialSelectedIndex,
resetKey,
}: {
items: string[];
onSelect?: (item: string, index: number) => void;
onExit?: () => void;
isDisabled?: (item: string) => boolean;
getHotkeys?: (item: string) => string[] | undefined;
onHotkeySelect?: (item: string, index: number) => void;
initialSelectedIndex?: number;
resetKey?: string | number;
}) {
const { selectedIndex } = useListNavigation({
items,
Expand All @@ -98,6 +102,8 @@ function ListNav({
isDisabled,
getHotkeys,
onHotkeySelect,
initialSelectedIndex,
resetKey,
});
return <Text>idx:{selectedIndex}</Text>;
}
Expand Down Expand Up @@ -233,4 +239,43 @@ describe('useListNavigation hook', () => {

expect(lastFrame()).toContain('idx:0');
});

describe('initialSelectedIndex', () => {
it('honors initialSelectedIndex on mount', () => {
const { lastFrame } = render(<ListNav items={items} initialSelectedIndex={2} />);
expect(lastFrame()).toContain('idx:2');
});

it('still defaults to 0 when initialSelectedIndex is unset', () => {
const { lastFrame } = render(<ListNav items={items} />);
expect(lastFrame()).toContain('idx:0');
});

it('ignores an out-of-range initialSelectedIndex and falls back to first enabled', () => {
const { lastFrame } = render(<ListNav items={items} initialSelectedIndex={99} />);
expect(lastFrame()).toContain('idx:0');
});

it('falls back to first enabled when initialSelectedIndex points to a disabled item', () => {
const isDisabled = (item: string) => item === 'alpha';
const { lastFrame } = render(<ListNav items={items} initialSelectedIndex={0} isDisabled={isDisabled} />);
expect(lastFrame()).toContain('idx:1');
});

it('re-applies initialSelectedIndex when resetKey changes', async () => {
const { lastFrame, stdin, rerender } = render(
<ListNav items={items} initialSelectedIndex={2} resetKey="step-a" />
);
await new Promise(resolve => setTimeout(resolve, 50));
expect(lastFrame()).toContain('idx:2');

stdin.write(UP_ARROW); // move off the seeded index
await new Promise(resolve => setTimeout(resolve, 50));
expect(lastFrame()).toContain('idx:1');

rerender(<ListNav items={items} initialSelectedIndex={2} resetKey="step-b" />);
await new Promise(resolve => setTimeout(resolve, 50));
expect(lastFrame()).toContain('idx:2');
});
});
});
24 changes: 19 additions & 5 deletions src/cli/tui/hooks/useListNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ interface UseListNavigationOptions<T> {
isDisabled?: (item: T) => boolean;
/** Optional key to reset selection when changed */
resetKey?: string | number;
/** Optional index to start the cursor on (default: first enabled). Honored on mount and on resetKey change. */
initialSelectedIndex?: number;
}

interface UseListNavigationResult {
Expand Down Expand Up @@ -85,20 +87,32 @@ export function useListNavigation<T>({
onHotkeySelect,
isDisabled,
resetKey,
initialSelectedIndex,
}: UseListNavigationOptions<T>): UseListNavigationResult {
// Initialize with first enabled index (parent should ensure data is loaded before mounting)
const [selectedIndex, setSelectedIndex] = useState(() => {
// Resolve the starting index: honor initialSelectedIndex when it points to a valid,
// non-disabled item; otherwise fall back to the first enabled index (default: 0).
const resolveInitialIndex = (): number => {
if (
initialSelectedIndex !== undefined &&
initialSelectedIndex >= 0 &&
initialSelectedIndex < items.length &&
!isDisabled?.(items[initialSelectedIndex] as T)
) {
return initialSelectedIndex;
}
if (!isDisabled) return 0;
const idx = items.findIndex(item => !isDisabled(item));
return idx >= 0 ? idx : 0;
});
};

// Initialize with the resolved index (parent should ensure data is loaded before mounting)
const [selectedIndex, setSelectedIndex] = useState(resolveInitialIndex);

// Reset selection when resetKey changes (using state sync pattern to avoid setState in effect)
const [prevResetKey, setPrevResetKey] = useState(resetKey);
if (resetKey !== undefined && resetKey !== prevResetKey) {
setPrevResetKey(resetKey);
const idx = isDisabled ? items.findIndex(item => !isDisabled(item)) : 0;
setSelectedIndex(idx >= 0 ? idx : 0);
setSelectedIndex(resolveInitialIndex());
}

// Find next non-disabled index in given direction (delegates to standalone function)
Expand Down
2 changes: 1 addition & 1 deletion src/cli/tui/screens/agent/AddAgentScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -997,7 +997,7 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg
<Panel>
<TextInput
prompt="Agent name"
initialValue={generateUniqueName('MyAgent', existingAgentNames)}
initialValue={name || generateUniqueName('MyAgent', existingAgentNames)}
onSubmit={handleSetName}
onCancel={onExit}
schema={AgentNameSchema}
Expand Down
10 changes: 8 additions & 2 deletions src/cli/tui/screens/generate/GenerateWizardUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,13 +161,19 @@ export function GenerateWizardUI({
}
};

// On (re)entry to a select step, seed the cursor from the previously chosen value
// so back-navigation lands on the user's prior selection rather than the first option.
const selectedValueForStep = (wizard.config as unknown as Record<string, unknown>)[wizard.step];
const initialSelectedIndex = items.findIndex(item => item.id === selectedValueForStep);

const { selectedIndex } = useListNavigation({
items,
onSelect: handleSelect,
onExit: onBack,
isActive: isActive && isSelectStep && !isAdvancedStep,
isDisabled: item => item.disabled ?? false,
resetKey: wizard.step,
initialSelectedIndex: initialSelectedIndex >= 0 ? initialSelectedIndex : undefined,
});

const advancedNav = useMultiSelectNavigation({
Expand Down Expand Up @@ -390,7 +396,7 @@ export function GenerateWizardUI({
{isIdleTimeoutStep && (
<TextInput
prompt={`Idle session timeout in seconds (${LIFECYCLE_TIMEOUT_MIN}-${LIFECYCLE_TIMEOUT_MAX}, or press Enter to skip)`}
initialValue=""
initialValue={wizard.config.idleRuntimeSessionTimeout?.toString() ?? ''}
allowEmpty
customValidation={value => {
if (!value) return true;
Expand All @@ -413,7 +419,7 @@ export function GenerateWizardUI({
{isMaxLifetimeStep && (
<TextInput
prompt={`Max instance lifetime in seconds (${LIFECYCLE_TIMEOUT_MIN}-${LIFECYCLE_TIMEOUT_MAX}, or press Enter to skip)`}
initialValue=""
initialValue={wizard.config.maxLifetime?.toString() ?? ''}
allowEmpty
customValidation={value => {
if (!value) return true;
Expand Down
Loading