From 79712d0a9f4a7d49eb54af19ddb4b27480ca76cc Mon Sep 17 00:00:00 2001 From: Chapman Pendery <35637443+cpendery@users.noreply.github.com> Date: Sat, 9 Mar 2024 13:22:29 -0800 Subject: [PATCH] feat: add keybinding support & configuration documentation / tweaks (#197) * feat: add keybinding support Signed-off-by: Chapman Pendery * docs: point to toml site Signed-off-by: Chapman Pendery * docs: clarify example Signed-off-by: Chapman Pendery * fix: failures when no config exists Signed-off-by: Chapman Pendery --------- Signed-off-by: Chapman Pendery --- README.md | 44 ++++++++++- package-lock.json | 11 +++ package.json | 1 + src/isterm/commandManager.ts | 46 +++++++---- src/ui/suggestionManager.ts | 19 +++-- src/ui/ui-uninstall.ts | 6 +- src/utils/config.ts | 143 ++++++++++++++++++++++++----------- 7 files changed, 200 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index e747639..8acaee9 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,7 @@ ### Requirements -- `21 < node >= 16.x` -- node-gyp dependencies installed for your platform (see [node-gyp](https://github.com/nodejs/node-gyp) for more details) +- Node.js 20.X, 18.X, 16.X (16.6.0 >=) ### Installation @@ -31,7 +30,7 @@ After completing the installation, you can run `is` to start the autocomplete se #### Keybindings -All other keys are passed through to the shell. The keybindings below are only captured when the inshellisense suggestions are visible, otherwise they are passed through to the shell as well. +All other keys are passed through to the shell. The keybindings below are only captured when the inshellisense suggestions are visible, otherwise they are passed through to the shell as well. These can be customized in the [config](#configuration). | Action | Keybinding | | ------------------------- | -------------- | @@ -52,6 +51,45 @@ inshellisense supports the following shells: - [cmd](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/cmd) _(experimental)_ - [xonsh](https://xon.sh/) +## Configuration + +All configuration is done through a [toml](https://toml.io/) file located at `~/.inshellisenserc`. The [JSON schema](https://json-schema.org/) for the configuration file can be found [here](https://github.com/microsoft/inshellisense/blob/main/src/utils/config.ts). + +### Keybindings + +You can customize the keybindings for inshellisense by adding a `bindings` section to your config file. The following is the default configuration for the [keybindings](#keybindings): + +```toml +[bindings.acceptSuggestion] +key = "tab" +# shift and tab are optional and default to false +shift = false +ctrl = false + +[bindings.nextSuggestion] +key = "down" + +[bindings.previousSuggestion] +key = "up" + +[bindings.dismissSuggestions] +key = "escape" +``` + +Key names are matched against the Node.js [keypress](https://nodejs.org/api/readline.html#readlineemitkeypresseventsstream-interface) events. + +### Custom Prompts (Windows) + +If you are using a custom prompt in your shell (anything that is not the default PS1), you will need to set up a custom prompt in the inshellisense config file. This is because Windows strips details from your prompt which are required for inshellisense to work. To do this, update your config file in your home directory and add the following configuration: + +```toml +[[prompt.bash]] +regex = "(?^>\\s*)" # the prompt match group will be used to detect the prompt +postfix = ">" # the postfix is the last expected character in your prompt +``` + +This example adds custom prompt detection for bash where the prompt is expected to be only `> `. You can add similar configurations for other shells as well as well as multiple configurations for each shell. + ## Contributing This project welcomes contributions and suggestions. Most contributions require you to agree to a diff --git a/package-lock.json b/package-lock.json index d086242..2dce413 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "color-convert": "^2.0.1", "commander": "^11.0.0", "find-process": "^1.4.7", + "toml": "^3.0.0", "wcwidth": "^1.0.1", "which": "^4.0.0", "wrap-ansi": "^8.1.0", @@ -8399,6 +8400,11 @@ "node": ">=8.0" } }, + "node_modules/toml": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz", + "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==" + }, "node_modules/ts-api-utils": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.3.tgz", @@ -15049,6 +15055,11 @@ "is-number": "^7.0.0" } }, + "toml": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz", + "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==" + }, "ts-api-utils": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.3.tgz", diff --git a/package.json b/package.json index 9b61156..9e820b8 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "color-convert": "^2.0.1", "commander": "^11.0.0", "find-process": "^1.4.7", + "toml": "^3.0.0", "wcwidth": "^1.0.1", "which": "^4.0.0", "wrap-ansi": "^8.1.0", diff --git a/src/isterm/commandManager.ts b/src/isterm/commandManager.ts index bcb159c..d6cfa08 100644 --- a/src/isterm/commandManager.ts +++ b/src/isterm/commandManager.ts @@ -82,11 +82,13 @@ export class CommandManager { // User defined prompt const inshellisenseConfig = getConfig(); if (this.#shell == Shell.Bash) { - if (inshellisenseConfig.promptRegex?.bash != null) { - const customBashPrompt = lineText.match(new RegExp(inshellisenseConfig.promptRegex?.bash.regex))?.groups?.prompt; - const adjustedPrompt = this._adjustPrompt(customBashPrompt, lineText, inshellisenseConfig.promptRegex?.bash.postfix); - if (adjustedPrompt) { - return adjustedPrompt; + if (inshellisenseConfig?.prompt?.bash != null) { + for (const { regex, postfix } of inshellisenseConfig.prompt.bash) { + const customPrompt = lineText.match(new RegExp(regex))?.groups?.prompt; + const adjustedPrompt = this._adjustPrompt(customPrompt, lineText, postfix); + if (adjustedPrompt) { + return adjustedPrompt; + } } } @@ -100,6 +102,16 @@ export class CommandManager { } if (this.#shell == Shell.Xonsh) { + if (inshellisenseConfig?.prompt?.xonsh != null) { + for (const { regex, postfix } of inshellisenseConfig.prompt.xonsh) { + const customPrompt = lineText.match(new RegExp(regex))?.groups?.prompt; + const adjustedPrompt = this._adjustPrompt(customPrompt, lineText, postfix); + if (adjustedPrompt) { + return adjustedPrompt; + } + } + } + let xonshPrompt = lineText.match(/(?.*@\s?)/)?.groups?.prompt; if (xonshPrompt) { const adjustedPrompt = this._adjustPrompt(xonshPrompt, lineText, "@"); @@ -118,19 +130,23 @@ export class CommandManager { } if (this.#shell == Shell.Powershell || this.#shell == Shell.Pwsh) { - if (inshellisenseConfig.promptRegex?.pwsh != null && this.#shell == Shell.Pwsh) { - const customPwshPrompt = lineText.match(new RegExp(inshellisenseConfig.promptRegex?.pwsh.regex))?.groups?.prompt; - const adjustedPrompt = this._adjustPrompt(customPwshPrompt, lineText, inshellisenseConfig.promptRegex?.pwsh.postfix); - if (adjustedPrompt) { - return adjustedPrompt; + if (inshellisenseConfig?.prompt?.powershell != null) { + for (const { regex, postfix } of inshellisenseConfig.prompt.powershell) { + const customPrompt = lineText.match(new RegExp(regex))?.groups?.prompt; + const adjustedPrompt = this._adjustPrompt(customPrompt, lineText, postfix); + if (adjustedPrompt) { + return adjustedPrompt; + } } } - if (inshellisenseConfig.promptRegex?.powershell != null && this.#shell == Shell.Powershell) { - const customPowershellPrompt = lineText.match(new RegExp(inshellisenseConfig.promptRegex?.powershell.regex))?.groups?.prompt; - const adjustedPrompt = this._adjustPrompt(customPowershellPrompt, lineText, inshellisenseConfig.promptRegex?.powershell.postfix); - if (adjustedPrompt) { - return adjustedPrompt; + if (inshellisenseConfig?.prompt?.pwsh != null) { + for (const { regex, postfix } of inshellisenseConfig.prompt.pwsh) { + const customPrompt = lineText.match(new RegExp(regex))?.groups?.prompt; + const adjustedPrompt = this._adjustPrompt(customPrompt, lineText, postfix); + if (adjustedPrompt) { + return adjustedPrompt; + } } } diff --git a/src/ui/suggestionManager.ts b/src/ui/suggestionManager.ts index 90d51a2..5f7fdda 100644 --- a/src/ui/suggestionManager.ts +++ b/src/ui/suggestionManager.ts @@ -9,6 +9,7 @@ import ansi from "ansi-escapes"; import chalk from "chalk"; import { Shell } from "../utils/shell.js"; import log from "../utils/log.js"; +import { getConfig } from "../utils/config.js"; const maxSuggestions = 5; const suggestionWidth = 40; @@ -27,6 +28,8 @@ export type KeyPressEvent = [string | null | undefined, KeyPress]; type KeyPress = { sequence: string; name: string; + ctrl: boolean; + shift: boolean; }; export class SuggestionManager { @@ -157,18 +160,24 @@ export class SuggestionManager { } update(keyPress: KeyPress): boolean { - const { name } = keyPress; + const { name, shift, ctrl } = keyPress; if (!this.#suggestBlob) { return false; } + const { + dismissSuggestions: { key: dismissKey, shift: dismissShift, control: dismissCtrl }, + acceptSuggestion: { key: acceptKey, shift: acceptShift, control: acceptCtrl }, + nextSuggestion: { key: nextKey, shift: nextShift, control: nextCtrl }, + previousSuggestion: { key: prevKey, shift: prevShift, control: prevCtrl }, + } = getConfig().bindings; - if (name == "escape") { + if (name == dismissKey && shift == !!dismissShift && ctrl == !!dismissCtrl) { this.#suggestBlob = undefined; - } else if (name == "up") { + } else if (name == prevKey && shift == !!prevShift && ctrl == !!prevCtrl) { this.#activeSuggestionIdx = Math.max(0, this.#activeSuggestionIdx - 1); - } else if (name == "down") { + } else if (name == nextKey && shift == !!nextShift && ctrl == !!nextCtrl) { this.#activeSuggestionIdx = Math.min(this.#activeSuggestionIdx + 1, (this.#suggestBlob?.suggestions.length ?? 1) - 1); - } else if (name == "tab") { + } else if (name == acceptKey && shift == !!acceptShift && ctrl == !!acceptCtrl) { const removals = "\u007F".repeat(this.#suggestBlob?.charactersToDrop ?? 0); const suggestion = this.#suggestBlob?.suggestions.at(this.#activeSuggestionIdx); const chars = suggestion?.insertValue ?? suggestion?.name + " "; diff --git a/src/ui/ui-uninstall.ts b/src/ui/ui-uninstall.ts index 2e4f33e..c96a30e 100644 --- a/src/ui/ui-uninstall.ts +++ b/src/ui/ui-uninstall.ts @@ -2,11 +2,11 @@ // Licensed under the MIT License. import chalk from "chalk"; -import { deleteConfigFolder } from "../utils/config.js"; +import { deleteCacheFolder } from "../utils/config.js"; export const render = async () => { - deleteConfigFolder(); - process.stdout.write(chalk.green("✓") + " successfully deleted the .inshellisense config folder \n"); + deleteCacheFolder(); + process.stdout.write(chalk.green("✓") + " successfully deleted the .inshellisense cache folder \n"); process.stdout.write( chalk.magenta("•") + " to complete the uninstall, run the the command: " + chalk.underline(chalk.cyan("npm uninstall -g @microsoft/inshellisense")) + "\n", ); diff --git a/src/utils/config.ts b/src/utils/config.ts index 2a47d37..d1c9980 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -5,6 +5,7 @@ import os from "node:os"; import path from "node:path"; import fs from "node:fs"; import fsAsync from "node:fs/promises"; +import toml from "toml"; import _Ajv, { JSONSchemaType } from "ajv"; import { Command } from "commander"; @@ -15,72 +16,126 @@ type PromptPattern = { regex: string; postfix: string; }; + +type Binding = { + shift?: boolean; + control?: boolean; + key: string; +}; + type Config = { - promptRegex?: { - bash?: PromptPattern; - pwsh?: PromptPattern; - powershell?: PromptPattern; + bindings: { + nextSuggestion: Binding; + previousSuggestion: Binding; + dismissSuggestions: Binding; + acceptSuggestion: Binding; + }; + prompt?: { + bash?: PromptPattern[]; + pwsh?: PromptPattern[]; + xonsh?: PromptPattern[]; + powershell?: PromptPattern[]; }; }; -const configSchema: JSONSchemaType = { +const bindingSchema: JSONSchemaType = { type: "object", + nullable: true, properties: { - promptRegex: { + shift: { type: "boolean", nullable: true }, + control: { type: "boolean", nullable: true }, + key: { type: "string" }, + }, + required: ["key"], +}; + +const promptPatternsSchema: JSONSchemaType = { + type: "array", + nullable: true, + items: { + type: "object", + properties: { + regex: { type: "string" }, + postfix: { type: "string" }, + }, + required: ["regex", "postfix"], + }, +}; + +const configSchema = { + type: "object", + nullable: true, + properties: { + bindings: { type: "object", - properties: { - bash: { - type: "object", - nullable: true, - properties: { - regex: { type: "string" }, - postfix: { type: "string" }, - }, - required: ["regex", "postfix"], - }, - pwsh: { - type: "object", - nullable: true, - properties: { - regex: { type: "string" }, - postfix: { type: "string" }, - }, - required: ["regex", "postfix"], - }, - powershell: { - type: "object", - nullable: true, - properties: { - regex: { type: "string" }, - postfix: { type: "string" }, - }, - required: ["regex", "postfix"], - }, - }, nullable: true, + properties: { + nextSuggestion: bindingSchema, + previousSuggestion: bindingSchema, + dismissSuggestions: bindingSchema, + acceptSuggestion: bindingSchema, + }, + }, + prompt: { + type: "object", + nullable: true, + properties: { + bash: promptPatternsSchema, + pwsh: promptPatternsSchema, + powershell: promptPatternsSchema, + xonsh: promptPatternsSchema, + }, }, }, additionalProperties: false, }; -const configFolder = ".inshellisense"; -const cachePath = path.join(os.homedir(), configFolder, "config.json"); +const configFile = ".inshellisenserc"; +const cachePath = path.join(os.homedir(), ".inshellisense"); +const configPath = path.join(os.homedir(), configFile); + +let globalConfig: Config = { + bindings: { + nextSuggestion: { key: "down" }, + previousSuggestion: { key: "up" }, + acceptSuggestion: { key: "tab" }, + dismissSuggestions: { key: "escape" }, + }, +}; -let globalConfig: Config = {}; export const getConfig = (): Config => globalConfig; export const loadConfig = async (program: Command) => { - if (fs.existsSync(cachePath)) { - const config = JSON.parse((await fsAsync.readFile(cachePath)).toString()); + if (fs.existsSync(configPath)) { + let config: Config; + try { + config = toml.parse((await fsAsync.readFile(configPath)).toString()); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (e: any) { + program.error(`${configFile} is invalid toml. Parsing error on line ${e.line}, column ${e.column}: ${e.message}`); + } const isValid = ajv.validate(configSchema, config); if (!isValid) { - program.error("inshellisense config is invalid: " + ajv.errorsText()); + program.error(`${configFile} is invalid: ${ajv.errorsText()}`); } - globalConfig = config; + globalConfig = { + bindings: { + nextSuggestion: config?.bindings?.nextSuggestion ?? globalConfig.bindings.nextSuggestion, + previousSuggestion: config?.bindings?.previousSuggestion ?? globalConfig.bindings.previousSuggestion, + acceptSuggestion: config?.bindings?.acceptSuggestion ?? globalConfig.bindings.acceptSuggestion, + dismissSuggestions: config?.bindings?.dismissSuggestions ?? globalConfig.bindings.dismissSuggestions, + }, + prompt: { + bash: config.prompt?.bash, + powershell: config.prompt?.powershell, + xonsh: config.prompt?.xonsh, + pwsh: config.prompt?.pwsh, + }, + }; } }; -export const deleteConfigFolder = async (): Promise => { - const cliConfigPath = path.join(os.homedir(), configFolder); +export const deleteCacheFolder = async (): Promise => { + const cliConfigPath = path.join(os.homedir(), cachePath); if (fs.existsSync(cliConfigPath)) { fs.rmSync(cliConfigPath, { recursive: true }); }