diff --git a/package-lock.json b/package-lock.json index 6b68d5d..a817fc6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "chalk": "^5.3.0", "commander": "^11.0.0", "find-process": "^1.4.7", + "wcwidth": "^1.0.1", "which": "^4.0.0", "wrap-ansi": "^8.1.0", "xterm-headless": "^5.3.0" @@ -30,6 +31,7 @@ "@tsconfig/node18": "^18.2.2", "@types/jest": "^29.5.5", "@types/react": "^18.2.24", + "@types/wcwidth": "^1.0.2", "@types/which": "^3.0.3", "@typescript-eslint/eslint-plugin": "^6.7.4", "@typescript-eslint/parser": "^6.7.4", @@ -2164,6 +2166,12 @@ "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==" }, + "node_modules/@types/wcwidth": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@types/wcwidth/-/wcwidth-1.0.2.tgz", + "integrity": "sha512-flVlIrTSL6Z0RS2/IQYP0sUDajwuZ21FQAdOjzitcraVAarBFBwBpixtvxMno56vzFvLAbaGZEhkmMVYmWVUQA==", + "dev": true + }, "node_modules/@types/which": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/which/-/which-3.0.3.tgz", @@ -3216,6 +3224,14 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -3452,6 +3468,17 @@ "node": ">=0.10.0" } }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/define-data-property": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.0.tgz", @@ -8966,6 +8993,14 @@ "makeerror": "1.0.12" } }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dependencies": { + "defaults": "^1.0.3" + } + }, "node_modules/which": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", @@ -10901,6 +10936,12 @@ "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==" }, + "@types/wcwidth": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@types/wcwidth/-/wcwidth-1.0.2.tgz", + "integrity": "sha512-flVlIrTSL6Z0RS2/IQYP0sUDajwuZ21FQAdOjzitcraVAarBFBwBpixtvxMno56vzFvLAbaGZEhkmMVYmWVUQA==", + "dev": true + }, "@types/which": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/which/-/which-3.0.3.tgz", @@ -11609,6 +11650,11 @@ } } }, + "clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==" + }, "co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -11785,6 +11831,14 @@ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "dev": true }, + "defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "requires": { + "clone": "^1.0.2" + } + }, "define-data-property": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.0.tgz", @@ -15695,6 +15749,14 @@ "makeerror": "1.0.12" } }, + "wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "requires": { + "defaults": "^1.0.3" + } + }, "which": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", diff --git a/package.json b/package.json index 9e994ad..a5fb9a0 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "chalk": "^5.3.0", "commander": "^11.0.0", "find-process": "^1.4.7", + "wcwidth": "^1.0.1", "which": "^4.0.0", "wrap-ansi": "^8.1.0", "xterm-headless": "^5.3.0" @@ -56,6 +57,7 @@ "@tsconfig/node18": "^18.2.2", "@types/jest": "^29.5.5", "@types/react": "^18.2.24", + "@types/wcwidth": "^1.0.2", "@types/which": "^3.0.3", "@typescript-eslint/eslint-plugin": "^6.7.4", "@typescript-eslint/parser": "^6.7.4", diff --git a/src/runtime/generator.ts b/src/runtime/generator.ts index fe422c8..2327aa2 100644 --- a/src/runtime/generator.ts +++ b/src/runtime/generator.ts @@ -27,13 +27,14 @@ export const runGenerator = async (generator: Fig.Generator, tokens: string[], c if (script) { const shellInput = typeof script === "function" ? script(tokens) : script; const scriptOutput = Array.isArray(shellInput) - ? await executeShellCommand({ command: shellInput.at(0) ?? "", args: shellInput.slice(1) }) - : await executeShellCommand(shellInput); + ? await executeShellCommand({ command: shellInput.at(0) ?? "", args: shellInput.slice(1), cwd }) + : await executeShellCommand({ ...shellInput, cwd }); + const scriptStdout = scriptOutput.stdout.trim(); if (postProcess) { - suggestions.push(...postProcess(scriptOutput.stdout, tokens)); + suggestions.push(...postProcess(scriptStdout, tokens)); } else if (splitOn) { - suggestions.push(...scriptOutput.stdout.split(splitOn).map((s) => ({ name: s }))); + suggestions.push(...scriptStdout.split(splitOn).map((s) => ({ name: s }))); } } @@ -51,7 +52,8 @@ export const runGenerator = async (generator: Fig.Generator, tokens: string[], c } return suggestions; } catch (e) { - log.debug({ msg: "generator failed", e, script, splitOn, template }); + const err = typeof e === "string" ? e : e instanceof Error ? e.message : e; + log.debug({ msg: "generator failed", err, script, splitOn, template }); } return suggestions; }; diff --git a/src/runtime/suggestion.ts b/src/runtime/suggestion.ts index 9a945bb..265d2bf 100644 --- a/src/runtime/suggestion.ts +++ b/src/runtime/suggestion.ts @@ -20,7 +20,11 @@ enum SuggestionIcons { Default = "📀", } -const getIcon = (suggestionType?: Fig.SuggestionType | undefined): string => { +const getIcon = (icon: string | undefined, suggestionType: Fig.SuggestionType | undefined): string => { + // TODO: enable fig icons once spacing is better + // if (icon && /[^\u0000-\u00ff]/.test(icon)) { + // return icon; + // } switch (suggestionType) { case "arg": return SuggestionIcons.Argument; @@ -51,7 +55,7 @@ const toSuggestion = (suggestion: Fig.Suggestion, name?: string, type?: Fig.Sugg return { name: name ?? getLong(suggestion.name), description: suggestion.description, - icon: getIcon(type ?? suggestion.type), + icon: getIcon(suggestion.icon, type ?? suggestion.type), allNames: suggestion.name instanceof Array ? suggestion.name : [suggestion.name], priority: suggestion.priority ?? 50, insertValue: suggestion.insertValue, @@ -77,7 +81,7 @@ function filter => { const generators = generator instanceof Array ? generator : generator ? [generator] : []; const tokens = acceptedTokens.map((t) => t.token); + if (partialCmd) tokens.push(partialCmd); const suggestions = (await Promise.all(generators.map((gen) => runGenerator(gen, tokens, cwd)))).flat(); - return filter(suggestions, filterStrategy, partialCmd, undefined); + return filter( + suggestions.map((suggestion) => ({ ...suggestion, priority: suggestion.priority ?? 60 })), + filterStrategy, + partialCmd, + undefined, + ); }; const templateSuggestions = async ( diff --git a/src/runtime/template.ts b/src/runtime/template.ts index 02385a7..24ca78c 100644 --- a/src/runtime/template.ts +++ b/src/runtime/template.ts @@ -6,12 +6,12 @@ import log from "../utils/log.js"; const filepathsTemplate = async (cwd: string): Promise => { const files = await fsAsync.readdir(cwd, { withFileTypes: true }); - return files.filter((f) => f.isFile() || f.isDirectory()).map((f) => ({ name: f.name, priority: 90, context: { templateType: "filepaths" } })); + return files.filter((f) => f.isFile() || f.isDirectory()).map((f) => ({ name: f.name, priority: 55, context: { templateType: "filepaths" } })); }; const foldersTemplate = async (cwd: string): Promise => { const files = await fsAsync.readdir(cwd, { withFileTypes: true }); - return files.filter((f) => f.isDirectory()).map((f) => ({ name: f.name, priority: 90, context: { templateType: "folders" } })); + return files.filter((f) => f.isDirectory()).map((f) => ({ name: f.name, priority: 55, context: { templateType: "folders" } })); }; // TODO: implement history template diff --git a/src/tests/runtime/__snapshots__/runtime.test.ts.snap b/src/tests/runtime/__snapshots__/runtime.test.ts.snap index c05c0db..95a8885 100644 --- a/src/tests/runtime/__snapshots__/runtime.test.ts.snap +++ b/src/tests/runtime/__snapshots__/runtime.test.ts.snap @@ -414,6 +414,24 @@ exports[`parseCommand fullyTypedSuggestion 1`] = ` } `; +exports[`parseCommand generatorUsingPartialInput 1`] = ` +{ + "charactersToDrop": 21, + "suggestions": [ + { + "allNames": [ + "Microsoft.Azure.WebJobs", + ], + "description": "This package contains the runtime assemblies for Microsoft.Azure.WebJobs.Host. It also adds rich diagnostics capabilities which makes it easier to monitor the WebJobs in the dashboard. For more information, please visit http://go.microsoft.com/fwlink/?LinkID=320971", + "icon": "📀", + "insertValue": "Microsoft.Azure.WebJobs", + "name": "Microsoft.Azure.WebJobs", + "priority": 60, + }, + ], +} +`; + exports[`parseCommand loadSpec 1`] = ` { "charactersToDrop": 3, @@ -490,7 +508,7 @@ exports[`parseCommand optionsSuggestedAfterVariadicArg 1`] = ` "icon": "📀", "insertValue": undefined, "name": "package-lock.json", - "priority": 90, + "priority": 55, }, { "allNames": [ diff --git a/src/tests/runtime/runtime.test.ts b/src/tests/runtime/runtime.test.ts index 212d05d..d1820c6 100644 --- a/src/tests/runtime/runtime.test.ts +++ b/src/tests/runtime/runtime.test.ts @@ -21,6 +21,7 @@ const testData = [ { name: "nestedNonCommands", command: "az az ", skip: true }, // TODO: fix skipped test { name: "loadSpec", command: "aws acm add" }, { name: "noArgsArgumentGiven", command: "gcc lab ", maxSuggestions: 3 }, + { name: "generatorUsingPartialInput", command: "dotnet add package Microsoft.Azure.WebJo", maxSuggestions: 1 }, ]; describe(`parseCommand`, () => { diff --git a/src/tests/ui/autocomplete.test.ts b/src/tests/ui/autocomplete.test.ts index 7027580..f2c8126 100644 --- a/src/tests/ui/autocomplete.test.ts +++ b/src/tests/ui/autocomplete.test.ts @@ -162,7 +162,14 @@ shells.map((activeShell) => { await expect(terminal.getByText("clear")).toBeVisible(); }); - test.when([Shell.Zsh, Shell.Powershell].includes(activeShell), "command detection with suggestions", async ({ terminal }) => { + test("proper overflow truncation in command", async ({ terminal }) => { + await expect(terminal.getByText("> ")).toBeVisible(); + + terminal.write("dotnet add package Holoon.Newtonsoft"); + await expect(terminal.getByText("CanBeUndefi…│")).toBeVisible(); + }); + + test.skip("command detection with suggestions", async ({ terminal }) => { await expect(terminal.getByText("> ")).toBeVisible(); terminal.write(`dotnet add item\r`); diff --git a/src/ui/suggestionManager.ts b/src/ui/suggestionManager.ts index 9a0dfcc..90d51a2 100644 --- a/src/ui/suggestionManager.ts +++ b/src/ui/suggestionManager.ts @@ -78,7 +78,7 @@ export class SuggestionManager { private _renderSuggestions(suggestions: Suggestion[], activeSuggestionIdx: number, x: number) { return renderBox( suggestions.map((suggestion, idx) => { - const suggestionText = `${suggestion.icon} ${suggestion.name}`.padEnd(suggestionWidth - borderWidth, " "); + const suggestionText = `${suggestion.icon} ${suggestion.name}`; const truncatedSuggestion = truncateText(suggestionText, suggestionWidth - 2); return idx == activeSuggestionIdx ? chalk.bgHex(activeSuggestionBackgroundColor)(truncatedSuggestion) : truncatedSuggestion; }), diff --git a/src/ui/utils.ts b/src/ui/utils.ts index 06164d3..a6f3e4e 100644 --- a/src/ui/utils.ts +++ b/src/ui/utils.ts @@ -4,6 +4,7 @@ import ansi from "ansi-escapes"; import wrapAnsi from "wrap-ansi"; import chalk from "chalk"; +import wcwdith from "wcwidth"; /** * Renders a box around the given rows @@ -40,6 +41,7 @@ export const truncateMultilineText = (description: string, width: number, maxHei */ export const truncateText = (text: string, width: number) => { const textPoints = [...text]; - const slicedText = textPoints.slice(0, width - 1); + const wcOffset = Math.max(wcwdith(text) - textPoints.length, 0); + const slicedText = textPoints.slice(0, width - 1 - wcOffset); return slicedText.length == textPoints.length ? text.padEnd(width) : (slicedText.join("") + "…").padEnd(width); };