diff --git a/src/shell_impl.ts b/src/shell_impl.ts index 4c0a7a5..72d14e6 100644 --- a/src/shell_impl.ts +++ b/src/shell_impl.ts @@ -95,7 +95,11 @@ export class ShellImpl implements IShellImpl { this._options.color ? ansi.styleReset : undefined ); - this._tabCompleter = new TabCompleter(this._runContext); + this._tabCompleter = new TabCompleter( + this._runContext, + this._options.enableBufferedStdinCallback, + this._options.termios + ); } get aliases(): Aliases { diff --git a/src/tab_completer.ts b/src/tab_completer.ts index ab18e98..427db18 100644 --- a/src/tab_completer.ts +++ b/src/tab_completer.ts @@ -1,13 +1,22 @@ import { ansi } from './ansi'; +import { IEnableBufferedStdinCallback } from './callback_internal'; import { ICommandLine } from './command_line'; import { IRunContext } from './context'; import { CommandNode, parse } from './parse'; import { ITabCompleteResult, PathType } from './tab_complete'; +import { Termios } from './termios'; import { RuntimeExports } from './types/wasm_module'; import { longestStartsWith, toColumns } from './utils'; export class TabCompleter { - constructor(readonly context: IRunContext) {} + /** + * Note: do not use context's stdin/stdout/stderr, use context.workerIO instead. + */ + constructor( + readonly context: IRunContext, + readonly enableBufferedStdinCallback: IEnableBufferedStdinCallback, + readonly termios: Termios.Termios + ) {} async complete(commandLine: ICommandLine): Promise { const text = commandLine.text.slice(0, commandLine.cursorIndex); @@ -164,11 +173,62 @@ export class TabCompleter { suffix: string, possibles: string[] ): Promise { - // Write all the possibles in columns across the terminal, and re-output the same command line. + // Write all the possibles completions in columns across the terminal, and re-output the same + // command line. Maybe prompt user first, if there are many possible completions. const { environment } = this.context; const lines = toColumns(possibles, environment.getNumber('COLUMNS') ?? 0); - const output = `\n${lines.join('\n')}\n${environment.getPrompt()}${commandLine.text}`; - this.context.workerIO.write(output + ansi.cursorLeft(suffix.length)); + + // Display immediately or prompt user to confirm first? + const termLines = environment.getNumber('LINES'); + let showPossibles = true; + if (possibles.length > 99 || (termLines !== null && lines.length > termLines - 2)) { + showPossibles = await this._yesNoPrompt( + `Display all ${possibles.length} possibilities (y or n)?` + ); + } + + if (showPossibles) { + this.context.workerIO.write('\n' + lines.join('\n') + '\n'); + } else { + this.context.workerIO.write('\n'); + } + + // Rewrite prompt and command line. + this.context.workerIO.write( + environment.getPrompt() + commandLine.text + ansi.cursorLeft(suffix.length) + ); + } + + /** + * Prompt the user + */ + private async _yesNoPrompt(prompt: string): Promise { + const { workerIO } = this.context; + workerIO.write('\n' + prompt); + + await this.enableBufferedStdinCallback(true); + this.termios.setRawMode(); + + let ret = false; + let haveResponse = false; + while (!haveResponse) { + const read = await workerIO.readAsync(1, 0); + if (read.length > 0) { + const char = read[0]; + if (char === 121) { + // 121='y' + ret = true; + haveResponse = true; + } else if ([3, 4, 110].includes(char)) { + // 3=ETX, 4=EOT, 110='n' + haveResponse = true; + } + } + } + + this.termios.setDefaultShell(); + await this.enableBufferedStdinCallback(false); + return ret; } } diff --git a/test/integration-tests/shell.test.ts b/test/integration-tests/shell.test.ts index e63428b..f16c90f 100644 --- a/test/integration-tests/shell.test.ts +++ b/test/integration-tests/shell.test.ts @@ -516,4 +516,97 @@ test.describe('Shell', () => { }); }); }); + + test.describe('tab complete with synchronous stdin prompt', () => { + // Initial files includes filename long enough to be over half terminal width so that tab + // complete possiblities are displayed in a single column, one file per line. + const initialFiles = { + a0_very_very_very_very_very_very_very_very_very_very_very_long_file_name: '', + a1: '', + a2: '', + a3: '', + a4: '', + a5: '', + a6: '', + a7: '', + a8: '', + a9: '' + }; + + test('check no prompt if enough terminal lines to display them all', async ({ page }) => { + const output = await page.evaluate( + async ({ initialFiles }) => { + const { shellSetupEmpty, terminalInput } = globalThis.cockle; + const { shell, output } = await shellSetupEmpty({ initialFiles }); + await shell.inputLine('export LINES=12'); // 2 more than number of files + output.clear(); + await terminalInput(shell, ['l', 's', ' ', 'a', '\t']); + return output.text; + }, + { initialFiles } + ); + const lines = output.split('\r\n'); + expect(lines).toHaveLength(12); + expect(lines[0]).toEqual('ls a'); + expect(lines[1]).toMatch(/^a0_very_very/); + expect(lines[10]).toEqual('a9'); + expect(lines.at(-1)).toMatch(/ls a$/); + }); + + const stdinOptions = ['sab', 'sw']; + stdinOptions.forEach(stdinOption => { + test(`check prompt displayed and accepted using y via ${stdinOption}`, async ({ page }) => { + const output = await page.evaluate( + async ({ stdinOption, initialFiles }) => { + const { delay, shellSetupEmpty, terminalInput } = globalThis.cockle; + const { shell, output } = await shellSetupEmpty({ initialFiles, stdinOption }); + await shell.inputLine('export LINES=11'); // 1 more than number of files + output.clear(); + await Promise.all([ + terminalInput(shell, ['l', 's', ' ', 'a', '\t']), + // Short delay for prompt to be displayed before responding to it. + delay(100).then(() => terminalInput(shell, ['y'])) + ]); + return output.text; + }, + { stdinOption, initialFiles } + ); + const lines = output.split('\r\n'); + expect(lines).toHaveLength(13); + expect(lines[0]).toEqual('ls a'); + expect(lines[1]).toEqual('Display all 10 possibilities (y or n)?'); + expect(lines[2]).toMatch(/^a0_very_very/); + expect(lines[11]).toEqual('a9'); + expect(lines.at(-1)).toMatch(/ls a$/); + }); + + const rejectChars = ['n', '\x03', '\x04']; + rejectChars.forEach(rejectChar => { + test(`check prompt displayed and rejected using ${rejectChar} via ${stdinOption}`, async ({ + page + }) => { + const output = await page.evaluate( + async ({ stdinOption, initialFiles, rejectChar }) => { + const { delay, shellSetupEmpty, terminalInput } = globalThis.cockle; + const { shell, output } = await shellSetupEmpty({ initialFiles, stdinOption }); + await shell.inputLine('export LINES=11'); // 1 more than number of files + output.clear(); + await Promise.all([ + terminalInput(shell, ['l', 's', ' ', 'a', '\t']), + // Short delay for prompt to be displayed before responding to it. + delay(100).then(() => terminalInput(shell, [rejectChar])) + ]); + return output.text; + }, + { stdinOption, initialFiles, rejectChar } + ); + const lines = output.split('\r\n'); + expect(lines).toHaveLength(3); + expect(lines[0]).toEqual('ls a'); + expect(lines[1]).toEqual('Display all 10 possibilities (y or n)?'); + expect(lines.at(-1)).toMatch(/ls a$/); + }); + }); + }); + }); });