Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Ctrl+C breaks my terminal with "nested" prompts #1493

Closed
medallyon opened this issue Jul 31, 2024 · 4 comments
Closed

Ctrl+C breaks my terminal with "nested" prompts #1493

medallyon opened this issue Jul 31, 2024 · 4 comments

Comments

@medallyon
Copy link

medallyon commented Jul 31, 2024

Windows: Windows 10 Pro 20H2 v19042.1706
Windows Terminal: v1.20.11781.0
Using Cmd

I'm having trouble implementing Ctrl+C for "nested" prompts. I have a main search prompt on an indefinite loop. When selecting an option, it runs a module that may also create search, input, or other prompts. I think I have a good implementation for handling Ctrl+C in the main prompt loop, which captures the ExitPromptError and gracefully exits the program. However, I'm not seemingly able to achieve the same with a prompt that was spawned at a later point. The desired functionality is to be able to use Ctrl+C to abort a "nested" prompt and return to the main loop, however trying to do so breaks the CLI. It's difficult to explain in words, so here's a video:

15-47-01.mp4

This is the relevant code:

index.ts

let exited = false;
let inputPromptPromise: Nullable<Promise<string> & { cancel: () => void; }>;
let currentAction: Nullable<{ Module: IActionModule; Promise: Promise<any>; }>;
let modules: { [actionName: string]: IActionModule; } = {};

function exit()
{
	exited = true;

	if (inputPromptPromise)
		inputPromptPromise.cancel();

	console.log("Farewell, Sailor!");

	process.exit();
}

async function init()
{
	modules = await getModules();

	process.on("SIGINT", async function ()
	{
		// Never seen this log, so not sure that it works
		console.log("Received SIGINT!!");

		if (currentAction)
		{
			const { Module: module, Promise: actionPromise } = currentAction;
			await Promise.race([actionPromise, new Promise(resolve => wait(0).then(resolve))]);
			if (typeof module.Cleanup === "function")
				await module.Cleanup();
		}

		else
			exit();
	});
}

async function main()
{
	inputPromptPromise = search<string>({
		message: "(e.g. " + RANDOM_DEFAULT_ACTION_PROMPTS[Math.floor(Math.random() * RANDOM_DEFAULT_ACTION_PROMPTS.length)]?.name + ") >",
		source: async (term) =>
		{
			if (!term)
				return RANDOM_DEFAULT_ACTION_PROMPTS;

			const closestActions = inferClosestActions(term, 7);
			const actions = closestActions.map(action => ({
				name: `${(action.matchingDisplayName || action.action)}`,
				value: action.action,
				description: action.module.Description
			}));

			return actions;
		},
	});

	let actionInput: string;
	try
	{
		actionInput = await inputPromptPromise;
		inputPromptPromise = null;
	}
	catch (error: Error | any)
	{
		// Exit cleanly instead of throwing an error on Ctrl+C (SIGINT)
		if (error.constructor.name === "ExitPromptError")
		{
			exit();
			return;
		}

		console.error(error);
		return;
	}

	const actionModule = modules[actionInput];
	if (!actionModule)
	{
		console.error(`Action module not yet implemented: ${actionInput}\n`);
		return;
	}

	const spinner = ora("Running action...").start();

	try
	{
		currentAction = {
			Module: actionModule,
			Promise: actionModule.Run(null, spinner)
		};

		const actionResult = await currentAction.Promise;
		if (typeof actionModule.Cleanup === "function")
			await actionModule.Cleanup();

		currentAction = null;

		if (spinner)
			spinner.stop();

		if (typeof actionResult === "string")
			console.log(actionResult);

		if (spinner)
			spinner.succeed("Action completed successfully.");
	}
	catch (error: Error | any)
	{
		if (error.constructor.name === "ExitPromptError")
		{
			// FIXME: Breaks the CLI. No further output is shown after "Action Aborted".
			console.log();
			if (spinner)
				spinner.warn("Action aborted.\n");
			return;
		}

		if (spinner)
			spinner.fail("Action failed.");

		console.error(`Something went wrong while running the "${actionInput}" action. Here's the stack for the developer:`);
		console.error(error);
	}

	console.log();
};

(async () =>
{
	await init();
	while (!exited)
		await main();
})();

sample-register-action.ts

class Register implements IActionModule
{
	...

	#SymbolPromise: Undefinable<Promise<string> & { cancel: () => void; }>;
	#FactionPromise: Undefinable<Promise<string> & { cancel: () => void; }>;
	#EmailPromise: Undefinable<Promise<string> & { cancel: () => void; }>;

	async Run(_, spinner: Undefinable<Ora>): Promise<string>
	{
		spinner?.stop();

		this.#SymbolPromise = input({
			message: "Enter your desired agent symbol:",
			required: true,

			validate: (input: string) =>
			{
				return input.length < 3 || input.length > 14 ? "Symbol must be between 3 and 14 characters." : true;
			}
		});
		const symbol = await this.#SymbolPromise;

		this.#FactionPromise = search({
			message: "Select a faction:",
			source: async (term) =>
			{
				if (!term)
					return FACTIONS_LIST;

				const closestFactions = FACTIONS_LIST.filter(faction => String(faction.name).toLowerCase().includes(term.toLowerCase()));
				return closestFactions;
			}
		});
		const faction = await this.#FactionPromise;

		this.#EmailPromise = input({
			message: "Enter your email address (Optional):",
			required: false,

			validate: (input: string) =>
			{
				if (!input)
					return true;

				const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
				return !EMAIL_REGEX.test(input) ? "Invalid email address." : true;
			}
		});
		const email = await this.#EmailPromise;

		const options = {
			symbol,
			faction: faction as FactionSymbol,
			email
		};

		spinner?.start("Registering");

		const client = await register(options);
		return `Successfully registered as ${client.Agent?.symbol}!`;
	}

	async Cleanup()
	{
		this.#SymbolPromise?.cancel();
		this.#FactionPromise?.cancel();
		this.#EmailPromise?.cancel();
	}
};

export default new Register();

@SBoudrias
Copy link
Owner

I think ctrl+c might close the stdin stream... 🤔 Maybe try resuming it?

process.stdin.resume();

@tanepiper
Copy link

@medallyon @SBoudrias I ended up writing this to handle exiting correctly (unfortunately it relies on a string comparison as the error.name returns Error and not the one that shows in the stack-trace)

/**
 * Wrapper for prompts that correctly exit the application when the user cancels the prompt.
 * @private
 * @param type
 * @param options
 * @returns
 */
export async function handleRequestWithExit(
  type: Function,
  options: Record<string, any>,
): Promise<any> {
  return type(options)
    .then((value: any) => (typeof value === 'string' ? value.trim() : value))
    .catch((error: Error) => {
      if (error.message.includes('User force closed the prompt with 0 null')) {
        process.emit('SIGINT', 'SIGINT');
        return;
      }
      console.error(`CLI exited: ${error.message}`, { cause: error });
      process.exit(1);
    });
}

Then in my main CLI file I have this at the top:

process.on('SIGINT', () => {
  console.log('🚪 User exited process');
  // Perform any cleanup tasks here
  process.exit(0);
});

Now when I use a inquirer function I wrap it like this:

  const name = await handleRequestWithExit(input, {
    message: `Enter your name`,
    required: true,
  });

This was the only reliable way I could find to work with multiple prompts

@medallyon
Copy link
Author

medallyon commented Aug 1, 2024

I think ctrl+c might close the stdin stream... 🤔 Maybe try resuming it?

process.stdin.resume();

@SBoudrias I'm not sure where I'd implement this - I tried plastering it around my code but it seemingly has no effect.

Thanks for this @tanepiper. I might still find use for your snippet but I think my use case is a little different as I'm looking to "move between menus" so to speak; the main menu being on an infinite loop until the user exits, and nested menus appearing when selecting something from the main menu. I'd like the user to be able to cancel the current prompt and return to the main menu/prompt, I was hoping to achieve this with Ctrl+C however I'm starting to see that listening to ESC or BACKSPACE may be a better idea due to the behaviour that using Ctrl+C exudes.

P.S. You can check the name of the constructor for the error class, that's how I did it:

if (error.constructor.name === "ExitPromptError")

@tanepiper
Copy link

@medallyon if you look at #1489 (comment) you'll see I do something similar too - I go into a loop for search by returning it, similarly you could return other menus such a sub-menu.

I removed it for that example, but I use the same function there too and it wraps nicely

# for free to join this conversation on GitHub. Already have an account? # to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants