From d558049fb45ed1e588bb200b4f4995836d2adaca Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Wed, 17 Jun 2026 21:23:49 -0400 Subject: [PATCH] fix: stop web-search TUI early exit when no gateway --- .../screens/web-search/AddWebSearchScreen.tsx | 2 +- .../__tests__/AddWebSearchScreen.test.tsx | 107 ++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 src/cli/tui/screens/web-search/__tests__/AddWebSearchScreen.test.tsx diff --git a/src/cli/tui/screens/web-search/AddWebSearchScreen.tsx b/src/cli/tui/screens/web-search/AddWebSearchScreen.tsx index 1dcd2f2e2..fd741c545 100644 --- a/src/cli/tui/screens/web-search/AddWebSearchScreen.tsx +++ b/src/cli/tui/screens/web-search/AddWebSearchScreen.tsx @@ -92,7 +92,7 @@ export function AddWebSearchScreen({ onExit={onExit} helpText={helpText} headerContent={headerContent} - exitEnabled={isNameStep} + exitEnabled={isNameStep || (isGatewayStep && noGatewaysAvailable)} > {isNameStep && ( diff --git a/src/cli/tui/screens/web-search/__tests__/AddWebSearchScreen.test.tsx b/src/cli/tui/screens/web-search/__tests__/AddWebSearchScreen.test.tsx new file mode 100644 index 000000000..0bcdf9a37 --- /dev/null +++ b/src/cli/tui/screens/web-search/__tests__/AddWebSearchScreen.test.tsx @@ -0,0 +1,107 @@ +import { AddWebSearchScreen } from '../AddWebSearchScreen'; +import type { AddWebSearchConfig } from '../types'; +import { render } from 'ink-testing-library'; +import React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const ENTER = '\r'; +const ESCAPE = '\x1B'; +const BACKSPACE = '\x7f'; +const delay = (ms = 50) => new Promise(resolve => setTimeout(resolve, ms)); + +function makeProps(overrides: Partial> = {}) { + return { + onComplete: vi.fn<(config: AddWebSearchConfig) => void>(), + onExit: vi.fn(), + existingGatewayNames: [], + existingToolNames: [], + ...overrides, + }; +} + +// Walk past the name step by accepting the default and pressing Enter. +async function submitName(stdin: ReturnType['stdin']) { + stdin.write(ENTER); + await delay(); +} + +afterEach(() => vi.restoreAllMocks()); + +describe('AddWebSearchScreen — Escape navigation', () => { + it('Escape on the no-gateways view calls onExit (the only step where Screen owns Esc)', async () => { + const props = makeProps({ existingGatewayNames: [] }); + const { lastFrame, stdin } = render(); + + await submitName(stdin); + expect(lastFrame() ?? '').toContain('No gateways found'); + + stdin.write(ESCAPE); + await delay(); + + expect(props.onExit).toHaveBeenCalledTimes(1); + }); + + it('Escape on the gateway step (with gateways) goes back to name, does NOT call onExit', async () => { + const props = makeProps({ existingGatewayNames: ['gw1'] }); + const { lastFrame, stdin } = render(); + + await submitName(stdin); + expect(lastFrame() ?? '').toContain('Attach to which gateway?'); + + stdin.write(ESCAPE); + await delay(); + + expect(props.onExit).not.toHaveBeenCalled(); + // Back at the name step: the name input is mounted again. + expect(lastFrame() ?? '').toContain('Web search target name'); + }); + + it('Escape on the exclude-domains step goes back to gateway, does NOT call onExit', async () => { + const props = makeProps({ existingGatewayNames: ['gw1'] }); + const { lastFrame, stdin } = render(); + + await submitName(stdin); + // Pick gw1 (single item, already selected), advance to exclude-domains. + stdin.write(ENTER); + await delay(); + expect(lastFrame() ?? '').toContain('Exclude domains'); + + stdin.write(ESCAPE); + await delay(); + + expect(props.onExit).not.toHaveBeenCalled(); + expect(lastFrame() ?? '').toContain('Attach to which gateway?'); + }); + + it('Escape on the confirm step goes back to exclude-domains, does NOT call onExit', async () => { + const props = makeProps({ existingGatewayNames: ['gw1'] }); + const { lastFrame, stdin } = render(); + + await submitName(stdin); + stdin.write(ENTER); // pick gw1 + await delay(); + stdin.write(ENTER); // submit empty exclude-domains, advance to confirm + await delay(); + expect(lastFrame() ?? '').toContain('Confirm'); + + stdin.write(ESCAPE); + await delay(); + + expect(props.onExit).not.toHaveBeenCalled(); + expect(lastFrame() ?? '').toContain('Exclude domains'); + }); + + it('Escape on the name step calls onExit', async () => { + const props = makeProps({ existingGatewayNames: ['gw1'] }); + const { stdin } = render(); + + // Clear the default value first so TextInput's onCancel fires onExit + // rather than just clearing input. + for (let i = 0; i < 30; i++) stdin.write(BACKSPACE); + await delay(); + stdin.write(ESCAPE); + await delay(); + + expect(props.onExit).toHaveBeenCalled(); + }); +});