Skip to content

Commit

Permalink
Support command-line editing (#35)
Browse files Browse the repository at this point in the history
  • Loading branch information
ianthomas23 committed Aug 15, 2024
1 parent 80904be commit 57dd992
Show file tree
Hide file tree
Showing 3 changed files with 160 additions and 32 deletions.
8 changes: 4 additions & 4 deletions src/ansi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
const ESC = '\x1B[';

export const ansi = {
cursorUp: (count = 1) => ESC + count + 'A',
cursorDown: (count = 1) => ESC + count + 'B',
cursorRight: (count = 1) => ESC + count + 'C',
cursorLeft: (count = 1) => ESC + count + 'D',
cursorUp: (count = 1) => (count > 0 ? ESC + count + 'A' : ''),
cursorDown: (count = 1) => (count > 0 ? ESC + count + 'B' : ''),
cursorRight: (count = 1) => (count > 0 ? ESC + count + 'C' : ''),
cursorLeft: (count = 1) => (count > 0 ? ESC + count + 'D' : ''),

eraseEndLine: ESC + 'K',
eraseStartLine: ESC + '1K',
Expand Down
126 changes: 98 additions & 28 deletions src/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export class Shell {
this._enableBufferedStdinCallback = options.enableBufferedStdinCallback;
this._stdinCallback = options.stdinCallback;
this._currentLine = '';
this._cursorIndex = 0;
this._aliases = new Aliases();
this._environment = new Environment();
this._history = new History();
Expand All @@ -55,20 +56,26 @@ export class Shell {
await this.output('\r\n');
const cmdText = this._currentLine.trimStart();
this._currentLine = '';
this._cursorIndex = 0;
await this._runCommands(cmdText);
await this.output(this._environment.getPrompt());
} else if (code === 127) {
// Backspace
if (this._currentLine.length > 0) {
this._currentLine = this._currentLine.slice(0, -1);
await this.output(ansi.cursorLeft() + ansi.eraseEndLine);
if (this._cursorIndex > 0) {
const suffix = this._currentLine.slice(this._cursorIndex);
this._currentLine = this._currentLine.slice(0, this._cursorIndex - 1) + suffix;
this._cursorIndex--;
await this.output(
ansi.cursorLeft(1) + suffix + ansi.eraseEndLine + ansi.cursorLeft(suffix.length)
);
}
} else if (code === 9) {
// Tab \t
await this._tabComplete(this._currentLine);
await this._tabComplete();
} else if (code === 27) {
// Escape following by 1+ more characters
const remainder = char.slice(1);
console.log('Escape code', char);
if (
remainder === '[A' || // Up arrow
remainder === '[1A' ||
Expand All @@ -77,14 +84,76 @@ export class Shell {
) {
const cmdText = this._history.scrollCurrent(remainder.endsWith('B'));
this._currentLine = cmdText !== null ? cmdText : '';
this._cursorIndex = this._currentLine.length;
// Re-output whole line.
this.output(ansi.eraseStartLine + `\r${this._environment.getPrompt()}${this._currentLine}`);
} else if (remainder === '[D' || remainder === '[1D') {
// Left arrow
if (this._cursorIndex > 0) {
this._cursorIndex--;
await this.output(ansi.cursorLeft());
}
} else if (remainder === '[C' || remainder === '[1C') {
// Right arrow
if (this._cursorIndex < this._currentLine.length) {
this._cursorIndex++;
await this.output(ansi.cursorRight());
}
} else if (remainder === '[3~') {
// Delete
if (this._cursorIndex < this._currentLine.length) {
const suffix = this._currentLine.slice(this._cursorIndex + 1);
this._currentLine = this._currentLine.slice(0, this._cursorIndex) + suffix;
await this.output(ansi.eraseEndLine + suffix + ansi.cursorLeft(suffix.length));
}
} else if (remainder === '[H' || remainder === '[1;2H') {
// Home
if (this._cursorIndex > 0) {
await this.output(ansi.cursorLeft(this._cursorIndex));
this._cursorIndex = 0;
}
} else if (remainder === '[F' || remainder === '[1;2F') {
// End
const { length } = this._currentLine;
if (this._cursorIndex < length) {
await this.output(ansi.cursorRight(length - this._cursorIndex));
this._cursorIndex = length;
}
} else if (remainder === '[1;2D' || remainder === '[1;5D') {
// Start of previous word
if (this._cursorIndex > 0) {
const index =
this._currentLine.slice(0, this._cursorIndex).trimEnd().lastIndexOf(' ') + 1;
this.output(ansi.cursorLeft(this._cursorIndex - index));
this._cursorIndex = index;
}
} else if (remainder === '[1;2C' || remainder === '[1;5C') {
// End of next word
const { length } = this._currentLine;
if (this._cursorIndex < length - 1) {
const end = this._currentLine.slice(this._cursorIndex);
const trimmed = end.trimStart();
const i = trimmed.indexOf(' ');
const index = i < 0 ? length : this._cursorIndex + end.length - trimmed.length + i;
this.output(ansi.cursorRight(index - this._cursorIndex));
this._cursorIndex = index;
}
}
} else if (code === 4) {
// EOT, usually = Ctrl-D
} else {
this._currentLine += char;
await this.output(char);
// Add char to command line at cursor position.
if (this._cursorIndex === this._currentLine.length) {
// Append char.
this._currentLine += char;
await this.output(char);
} else {
// Insert char.
const suffix = this._currentLine.slice(this._cursorIndex);
this._currentLine = this._currentLine.slice(0, this._cursorIndex) + char + suffix;
await this.output(ansi.eraseEndLine + char + suffix + ansi.cursorLeft(suffix.length));
}
this._cursorIndex++;
}
}

Expand Down Expand Up @@ -241,18 +310,19 @@ export class Shell {
return exitCode;
}

private async _tabComplete(text: string): Promise<void> {
private async _tabComplete(): Promise<void> {
const text = this._currentLine.slice(0, this._cursorIndex);
if (text.endsWith(' ') && text.trim().length > 0) {
return;
}

const suffix = this._currentLine.slice(this._cursorIndex);
const parsed = parse(text, false);
const [lastToken, isCommand] =
parsed.length > 0 ? parsed[parsed.length - 1].lastToken() : [null, true];
let lookup = lastToken?.value ?? '';

let possibles: string[] = [];
//let prefix = '';
if (isCommand) {
const commandMatches = CommandRegistry.instance().match(lookup);
const aliasMatches = this._aliases.match(lookup);
Expand Down Expand Up @@ -301,30 +371,29 @@ export class Shell {
}
}

if (possibles.length === 0) {
return;
} else if (possibles.length === 1) {
if (possibles.length === 1) {
let extra = possibles[0].slice(lookup.length);
if (!extra.endsWith('/')) {
extra += ' ';
}
this._currentLine += extra;
await this.output(extra);
return;
}

// Multiple possibles.
const startsWith = longestStartsWith(possibles, lookup.length);
if (startsWith.length > lookup.length) {
// Complete up to the longest common startsWith.
const extra = startsWith.slice(lookup.length);
this._currentLine += extra;
await this.output(extra);
} else {
// Write all the possibles in columns across the terminal.
const lines = toColumns(possibles, this._environment.getNumber('COLUMNS') ?? 0);
const output = `\r\n${lines.join('\r\n')}\r\n${this._environment.getPrompt()}${this._currentLine}`;
await this.output(output);
this._currentLine = this._currentLine.slice(0, this._cursorIndex) + extra + suffix;
this._cursorIndex += extra.length;
await this.output(extra + suffix + ansi.cursorLeft(suffix.length));
} else if (possibles.length > 1) {
// Multiple possibles.
const startsWith = longestStartsWith(possibles, lookup.length);
if (startsWith.length > lookup.length) {
// Complete up to the longest common startsWith.
const extra = startsWith.slice(lookup.length);
this._currentLine = this._currentLine.slice(0, this._cursorIndex) + extra + suffix;
this._cursorIndex += extra.length;
await this.output(extra + suffix + ansi.cursorLeft(suffix.length));
} else {
// Write all the possibles in columns across the terminal.
const lines = toColumns(possibles, this._environment.getNumber('COLUMNS') ?? 0);
const output = `\r\n${lines.join('\r\n')}\r\n${this._environment.getPrompt()}${this._currentLine}`;
await this.output(output + ansi.cursorLeft(suffix.length));
}
}
}

Expand All @@ -333,6 +402,7 @@ export class Shell {
private readonly _stdinCallback?: IStdinCallback;

private _currentLine: string;
private _cursorIndex: number;
private _aliases: Aliases;
private _environment: Environment;
private _history: History;
Expand Down
58 changes: 58 additions & 0 deletions test/tests/shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ import {
test
} from './utils';

const left = '\x1B[D';
const backspace = '\x7F';
const delete_ = '\x1B[3~';
const home = '\x1B[H';
const end = '\x1B[F';
const prev = '\x1B[1;2D';
const next = '\x1B[1;2C';

test.describe('Shell', () => {
test.describe('_runCommands', () => {
test('should run ls command', async ({ page }) => {
Expand Down Expand Up @@ -175,6 +183,11 @@ test.describe('Shell', () => {
test('should include aliases', async ({ page }) => {
expect(await shellInputsSimple(page, ['l', '\t'])).toMatch(/^l\r\nll {2}ln {2}logname {2}ls/);
});

test('should complete within a command preserving suffix', async ({ page }) => {
const output = await shellInputsSimpleN(page, [['e', 'c', 'X', left, '\t'], ['\r']]);
expect(output[1]).toMatch(/^\r\nX\r\n/);
});
});

test.describe('input tab complete filenames', () => {
Expand Down Expand Up @@ -317,4 +330,49 @@ test.describe('Shell', () => {
expect(output['COLUMNS2']).toBeNull();
});
});

test.describe('command line editing', () => {
// We can't explicitly check the cursor position without performing a visual test or decoding
// the ANSI escape sequences, so here we use an echo command that will write to stdout and
// insert an easily identified character at the cursor location.
test('should delete forward and backward', async ({ page }) => {
const common = ['e', 'c', 'h', 'o', ' ', 'A', 'B', 'C', 'D'];
const output = await shellInputsSimpleN(page, [
[...common, left, left, backspace, '\r'],
[...common, left, left, delete_, '\r']
]);
expect(output[0]).toMatch(/\r\nACD\r\n/);
expect(output[1]).toMatch(/\r\nABD\r\n/);
});

test('should support home and end', async ({ page }) => {
const common = ['c', 'h', 'o', ' ', 'A', 'B', 'C'];
const output = await shellInputsSimpleN(page, [
[...common, left, left, home, 'e', '\r'],
['e', ...common, left, left, end, 'D', '\r']
]);
expect(output[0]).toMatch(/\r\nABC\r\n/);
expect(output[1]).toMatch(/\r\nABCD\r\n/);
});

test('should support prev word', async ({ page }) => {
const common = ['e', 'c', 'h', 'o', ' ', 'A', 'B', ' ', ' ', 'C', 'D'];
const output = await shellInputsSimpleN(page, [
[...common, prev, 'Z', '\r'],
[...common, prev, prev, 'Y', '\r']
]);
expect(output[0]).toMatch(/\r\nAB ZCD\r\n/);
expect(output[1]).toMatch(/\r\nYAB CD\r\n/);
});

test('should support next word', async ({ page }) => {
const common = ['e', 'c', 'h', 'o', ' ', 'A', 'B', ' ', ' ', 'C', 'D'];
const output = await shellInputsSimpleN(page, [
[...common, home, next, next, 'Z', '\r'],
[...common, home, next, next, next, 'Y', '\r']
]);
expect(output[0]).toMatch(/\r\nABZ CD\r\n/);
expect(output[1]).toMatch(/\r\nAB CDY\r\n/);
});
});
});

0 comments on commit 57dd992

Please sign in to comment.