Skip to content

Commit

Permalink
Autocomplete using the Newton parser (#599)
Browse files Browse the repository at this point in the history
I've reworked the autocomplete parser to more closely match Newton, the
Fig-compatible parser I prototyped earlier this year. I was able to move
a lot faster by reusing patterns that inshellisense proved out, such as
for templates and generators.

I also support some features that inshellisense doesn't, like proper
combining of Posix-compatible flags, handling of option argument
separators, and handling of cursors in insertValues.
  • Loading branch information
esimkowitz authored May 29, 2024
1 parent cd15beb commit 13f4203
Show file tree
Hide file tree
Showing 35 changed files with 2,957 additions and 44 deletions.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"@table-nav/react": "^0.0.7",
"@tanstack/match-sorter-utils": "^8.8.4",
"@tanstack/react-table": "^8.10.3",
"@withfig/autocomplete": "^2.652.3",
"autobind-decorator": "^2.4.0",
"base64-js": "^1.5.1",
"clsx": "^2.1.1",
Expand Down Expand Up @@ -79,6 +80,7 @@
"@types/throttle-debounce": "^5.0.1",
"@types/uuid": "^9.0.7",
"@types/webpack-env": "^1.18.3",
"@withfig/autocomplete-types": "^1.30.0",
"babel-loader": "^9.1.3",
"babel-plugin-jsx-control-statements": "^4.1.2",
"copy-webpack-plugin": "^12.0.0",
Expand Down
1 change: 1 addition & 0 deletions src/app/appconst.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,4 @@ export const ErrorCode_InvalidCwd = "ERRCWD";
export const InputAuxView_History = "history";
export const InputAuxView_Info = "info";
export const InputAuxView_AIChat = "aichat";
export const InputAuxView_Suggestions = "suggestions";
35 changes: 33 additions & 2 deletions src/app/clientsettings/clientsettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ import { Toggle, InlineSettingsTextEdit, SettingsError, Dropdown } from "@/commo
import { commandRtnHandler, isBlank } from "@/util/util";
import { getTermThemes } from "@/util/themeutil";
import * as appconst from "@/app/appconst";
import { MainView } from "@/common/elements/mainview";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";

import "./clientsettings.less";
import { MainView } from "../common/elements/mainview";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";

class ClientSettingsKeybindings extends React.Component<{}, {}> {
componentDidMount() {
Expand Down Expand Up @@ -110,6 +110,19 @@ class ClientSettingsView extends React.Component<{ model: RemotesModel }, { hove
GlobalModel.getElectronApi().changeAutoUpdate(val);
}

@boundMethod
handleChangeAutocompleteEnabled(val: boolean): void {
const prtn: Promise<CommandRtnType> = GlobalCommandRunner.setAutocompleteEnabled(val);
commandRtnHandler(prtn, this.errorMessage);
}

@boundMethod
handleChangeAutocompleteDebuggingEnabled(val: boolean): void {
mobx.action(() => {
GlobalModel.autocompleteModel.loggingEnabled = val;
})();
}

getFontSizes(): DropdownItem[] {
const availableFontSizes: DropdownItem[] = [];
for (let s = appconst.MinFontSize; s <= appconst.MaxFontSize; s++) {
Expand Down Expand Up @@ -447,6 +460,24 @@ class ClientSettingsView extends React.Component<{ model: RemotesModel }, { hove
/>
</div>
</div>
<div className="settings-field">
<div className="settings-label">Command Autocomplete</div>
<div className="settings-input">
<Toggle
checked={cdata.clientopts.autocompleteenabled ?? false}
onChange={this.handleChangeAutocompleteEnabled}
/>
</div>
</div>
<div className="settings-field">
<div className="settings-label">Command Autocomplete Debugging</div>
<div className="settings-input">
<Toggle
checked={GlobalModel.autocompleteModel.loggingEnabled}
onChange={this.handleChangeAutocompleteDebuggingEnabled}
/>
</div>
</div>
<SettingsError errorMessage={this.errorMessage} />
</div>
</MainView>
Expand Down
4 changes: 4 additions & 0 deletions src/app/workspace/cmdinput/cmdinput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { CenteredIcon, RotateIcon } from "@/common/icons/icons";
import { AIChat } from "./aichat";
import * as util from "@/util/util";
import * as appconst from "@/app/appconst";
import { AutocompleteSuggestionView } from "./suggestionview";

import "./cmdinput.less";

Expand Down Expand Up @@ -198,6 +199,9 @@ class CmdInput extends React.Component<{}, {}> {
<When condition={openView === appconst.InputAuxView_Info}>
<InfoMsg key="infomsg" />
</When>
<When condition={openView === appconst.InputAuxView_Suggestions}>
<AutocompleteSuggestionView />
</When>
</Choose>
<If condition={remote && remote.status != "connected"}>
<div className="remote-status-warning">
Expand Down
25 changes: 25 additions & 0 deletions src/app/workspace/cmdinput/suggestionview.less
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
.suggestions-view .auxview-content {
display: flex;
flex-direction: column;
min-height: 1em;
.suggestion-item {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
cursor: pointer;
border-radius: 5px;

&:hover {
background-color: var(--table-tr-hover-bg-color);
}

&.is-selected {
font-weight: bold;
color: var(--app-text-primary-color);
background-color: var(--table-tr-selected-bg-color);
&:hover {
background-color: var(--table-tr-selected-hover-bg-color);
}
}
}
}
87 changes: 87 additions & 0 deletions src/app/workspace/cmdinput/suggestionview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { getAll, getFirst } from "@/autocomplete/runtime/utils";
import { AuxiliaryCmdView } from "./auxview";
import { clsx } from "clsx";
import { action } from "mobx";
import { observer } from "mobx-react";
import { GlobalModel } from "@/models";
import React, { useEffect } from "react";
import { If } from "tsx-control-statements/components";

import "./suggestionview.less";

export const AutocompleteSuggestionView: React.FC = observer(() => {
const inputModel = GlobalModel.inputModel;
const autocompleteModel = GlobalModel.autocompleteModel;
const selectedSuggestion = autocompleteModel.getPrimarySuggestionIndex();

const updateScroll = action((index: number) => {
autocompleteModel.setPrimarySuggestionIndex(index);
const element = document.getElementsByClassName("suggestion-item")[index] as HTMLElement;
if (element) {
element.scrollIntoView({ block: "nearest" });
}
});

const closeView = action(() => {
inputModel.closeAuxView();
});

const setSuggestion = action((idx: number) => {
autocompleteModel.applySuggestion(idx);
autocompleteModel.loadSuggestions();
closeView();
});

useEffect(() => {
const keybindManager = GlobalModel.keybindManager;

keybindManager.registerKeybinding("pane", "autocomplete", "generic:confirm", (waveEvent) => {
setSuggestion(selectedSuggestion);
return true;
});
keybindManager.registerKeybinding("pane", "autocomplete", "generic:cancel", (waveEvent) => {
closeView();
return true;
});
keybindManager.registerKeybinding("pane", "autocomplete", "generic:selectAbove", (waveEvent) => {
updateScroll(Math.max(0, selectedSuggestion - 1));
return true;
});
keybindManager.registerKeybinding("pane", "autocomplete", "generic:selectBelow", (waveEvent) => {
updateScroll(Math.min(suggestions?.length - 1, selectedSuggestion + 1));
return true;
});
keybindManager.registerKeybinding("pane", "autocomplete", "generic:tab", (waveEvent) => {
updateScroll(Math.min(suggestions?.length - 1, selectedSuggestion + 1));
return true;
});

return () => {
GlobalModel.keybindManager.unregisterDomain("autocomplete");
};
});

const suggestions: Fig.Suggestion[] = autocompleteModel.getSuggestions();

return (
<AuxiliaryCmdView title="Suggestions" className="suggestions-view" onClose={closeView} scrollable={true}>
<If condition={!suggestions || suggestions.length == 0}>
<div className="no-suggestions">No suggestions</div>
</If>
{suggestions?.map((suggestion, idx) => (
<option
key={getFirst(suggestion.name)}
title={suggestion.description}
className={clsx("suggestion-item", { "is-selected": selectedSuggestion === idx })}
onClick={() => {
setSuggestion(idx);
}}
>
{`${suggestion.icon} ${suggestion.displayName ?? getAll(suggestion.name).join(",")} ${
suggestion.description ? `- ${suggestion.description}` : ""
}`}
</option>
))}
</AuxiliaryCmdView>
);
});
85 changes: 62 additions & 23 deletions src/app/workspace/cmdinput/textareainput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,30 +110,43 @@ class CmdInputKeybindings extends React.Component<{ inputObject: TextAreaInput }
return;
}
const inputObject = this.props.inputObject;
this.lastTab = false;
const keybindManager = GlobalModel.keybindManager;
const inputModel = GlobalModel.inputModel;
keybindManager.registerKeybinding("pane", "cmdinput", "cmdinput:autocomplete", (waveEvent) => {
const lastTab = this.lastTab;
this.lastTab = true;
this.curPress = "tab";
const curLine = inputModel.curLine;
if (lastTab) {
GlobalModel.submitCommand(
"_compgen",
null,
[curLine],
{ comppos: String(curLine.length), compshow: "1", nohist: "1" },
true
);
// For now, we want to preserve the old behavior if autocomplete is disabled
if (GlobalModel.autocompleteModel.isEnabled) {
if (this.lastTab) {
const curLine = inputModel.curLine;
if (curLine != "") {
inputModel.setActiveAuxView(appconst.InputAuxView_Suggestions);
}
} else {
this.lastTab = true;
}
} else {
GlobalModel.submitCommand(
"_compgen",
null,
[curLine],
{ comppos: String(curLine.length), nohist: "1" },
true
);
const lastTab = this.lastTab;
this.lastTab = true;
this.curPress = "tab";
const curLine = inputModel.curLine;
if (lastTab) {
GlobalModel.submitCommand(
"_compgen",
null,
[curLine],
{ comppos: String(curLine.length), compshow: "1", nohist: "1" },
true
);
} else {
GlobalModel.submitCommand(
"_compgen",
null,
[curLine],
{ comppos: String(curLine.length), nohist: "1" },
true
);
}
return true;
}
return true;
});
Expand Down Expand Up @@ -204,6 +217,9 @@ class CmdInputKeybindings extends React.Component<{ inputObject: TextAreaInput }
const rtn = inputObject.arrowDownPressed();
return rtn;
});
keybindManager.registerKeybinding("pane", "cmdinput", "generic:selectRight", (waveEvent) => {
return inputObject.arrowRightPressed();
});
keybindManager.registerKeybinding("pane", "cmdinput", "generic:selectPageAbove", (waveEvent) => {
this.curPress = "historyupdown";
inputObject.scrollPage(true);
Expand Down Expand Up @@ -255,7 +271,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
mobx.makeObservable(this);
}

@mobx.action
@mobx.action.bound
incVersion(): void {
const v = this.version.get();
this.version.set(v + 1);
Expand Down Expand Up @@ -417,6 +433,17 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
return true;
}

@boundMethod
arrowRightPressed(): boolean {
// If the cursor is at the end of the line, apply the primary suggestion
const curSP = this.getCurSP();
if (curSP.pos < curSP.str.length) {
return false;
}
GlobalModel.autocompleteModel.applyPrimarySuggestion();
return true;
}

scrollPage(up: boolean) {
const inputModel = GlobalModel.inputModel;
const infoScroll = inputModel.hasScrollingInfoMsg();
Expand Down Expand Up @@ -444,7 +471,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
GlobalModel.inputModel.curLine = e.target.value;
}

@mobx.action.bound
@boundMethod
onSelect(e: any) {
this.incVersion();
}
Expand Down Expand Up @@ -621,22 +648,34 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
inputModel.shouldRenderAuxViewKeybindings(null) ||
inputModel.shouldRenderAuxViewKeybindings(appconst.InputAuxView_Info);
const renderHistoryKeybindings = inputModel.shouldRenderAuxViewKeybindings(appconst.InputAuxView_History);

// Will be null if the feature is disabled
const primaryAutocompleteSuggestion = GlobalModel.autocompleteModel.getPrimarySuggestionCompletion();

return (
<div
className="textareainput-div control is-expanded"
ref={this.controlRef}
style={{ height: computedOuterHeight }}
>
<If condition={renderCmdInputKeybindings}>
<CmdInputKeybindings inputObject={this}></CmdInputKeybindings>
<CmdInputKeybindings inputObject={this} />
</If>
<If condition={renderHistoryKeybindings}>
<HistoryKeybindings></HistoryKeybindings>
<HistoryKeybindings />
</If>

<If condition={!util.isBlank(shellType)}>
<div className="shelltag">{shellType}</div>
</If>
<If condition={primaryAutocompleteSuggestion}>
<div
className="textarea-ghost"
style={{ height: computedInnerHeight, minHeight: computedInnerHeight, fontSize: termFontSize }}
>
{`${"\xa0".repeat(curLine.length)}${primaryAutocompleteSuggestion}`}
</div>
</If>
<textarea
key="main"
ref={this.mainInputRef}
Expand Down
39 changes: 39 additions & 0 deletions src/autocomplete/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Newton autocomplete parser

Newton is a Fig-compatible autocomplete parser. It builds on a lot of goodness from the [@microsoft/inshellisense project](https://github.com/microsoft/inshellisense), with heavy modifications to minimize recursion and allow for caching of intermediate states. All suggestions, as with inshellisense, come from the [@withfig/autocomplete project](https://github.com/withfig/autocomplete).

Any exec commands that need to be run are proxied through the Wave backend to ensure no additional permissions are required.

The following features from Fig's object definitions are not yet supported:

- Specs
- Versioned specs, such as the `az` CLI
- Custom specs from your filesystem
- Wave's slash commands and bracket syntax
- Slash commands will be added in a future PR, we just need to generate the proper specs for them
- Bracket syntax should not break the parser right now, you just won't get any suggestions when filling out metacommands within brackets
- Suggestions
- Rich icons support and icons served from the filesystem
- `isDangerous` field
- `hidden` field
- `deprecated` field
- `replaceValue` field - this requires a bit more work to properly parse out the text that needs to be replaced.
- `previewComponent` field - this does not appear to be used by any specs right now
- Subcommands
- `cache` field - All script outputs are currently cached for 5 minutes
- Options
- `isPersistent` field - this requires a bit of work to make sure we pass forward the correct options to subcommands
- `isRequired` field - this should prioritize options that are required
- `isRepeatable` field - this should let a flag be repeated a specified number of times before being invalidated and no longer suggested
- `requiresEquals` field - this is deprecated, but some popular specs still use it
- Args
- `suggestCurrentToken` field
- `isDangerous` field
- `isScript` field
- `isModule` field - only Python uses this right now
- `debounce` field
- `default` field
- `parserDirectives.alias` field
- Generators
- `getQueryTerm` field
- `cache` field - All script outputs are currently cached for 5 minutes
Loading

0 comments on commit 13f4203

Please sign in to comment.