Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions src/tests/utils/suggestionManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { ISTerm } from "../../isterm/pty.js";
import type { SuggestionBlob } from "../../runtime/model.js";
import { Shell } from "../../utils/shell.js";
import { SuggestionManager } from "../../ui/suggestionManager.js";
import { getConfig } from "../../utils/config.js";

const deferred = <T>() => {
let resolve!: (value: T) => void;
Expand Down Expand Up @@ -114,3 +115,73 @@ test("does not consume suggestion bindings when the UI is hidden", async () => {
expect(manager.update({ name: "tab", sequence: "\t", shift: false, ctrl: false }, false)).toBe(false);
expect(writes).toEqual([]);
});

describe("enter as the accept binding", () => {
let previousAcceptBinding: ReturnType<typeof getConfig>["bindings"]["acceptSuggestion"];

beforeEach(() => {
previousAcceptBinding = getConfig().bindings.acceptSuggestion;
getConfig().bindings.acceptSuggestion = { key: "return" };
});

afterEach(() => {
getConfig().bindings.acceptSuggestion = previousAcceptBinding;
});

const setup = async (commandText: string, token: string) => {
const writes: string[] = [];
let clearCommandCalls = 0;
const terminal = {
cwd: process.cwd(),
cols: 80,
write: (data: string) => writes.push(data),
clearCommand: () => {
clearCommandCalls += 1;
},
getCommandState: () => ({ commandText, cursorTerminated: true, hasOutput: false }),
getCursorState: () => ({ cursorX: 0 }),
} as unknown as ISTerm;
const runtime = Promise.resolve({
getSuggestions: async () => ({ ...blob("checkout"), activeToken: { token, tokenLength: token.length, complete: false, isOption: false } }),
clearTransientSuggestionState: () => {},
});
const manager = new SuggestionManager(terminal, Shell.Bash, runtime);
await manager.exec();
return { manager, writes, clearCommandCalls: () => clearCommandCalls };
};

const enter = { name: "return", sequence: "\r", shift: false, ctrl: false };

test("keeps tracking the command when enter accepts a suggestion", async () => {
const { manager, writes, clearCommandCalls } = await setup("git chec", "chec");

expect(manager.update(enter, true)).toBe(true);
expect(writes).not.toEqual([]);
expect(clearCommandCalls()).toBe(0);
});

test("clears the command when enter is not consumed by a binding", async () => {
const { manager, writes, clearCommandCalls } = await setup("git chec", "chec");

expect(manager.update(enter, false)).toBe(false);
expect(writes).toEqual([]);
expect(clearCommandCalls()).toBe(1);
});

test("runs the command when enter is pressed on an empty token without a selection", async () => {
const { manager, writes, clearCommandCalls } = await setup("git checkout ", "");

expect(manager.update(enter, true)).toBe(false);
expect(writes).toEqual([]);
expect(clearCommandCalls()).toBe(1);
});

test("accepts the selected suggestion when enter is pressed on an empty token after navigating", async () => {
const { manager, writes, clearCommandCalls } = await setup("git checkout ", "");

manager.update({ name: "down", sequence: "\u001b[B", shift: false, ctrl: false }, true);
expect(manager.update(enter, true)).toBe(true);
expect(writes).not.toEqual([]);
expect(clearCommandCalls()).toBe(0);
});
});
23 changes: 19 additions & 4 deletions src/ui/suggestionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export class SuggestionManager {
#term: ISTerm;
#commandKey: string;
#activeSuggestionIdx: number;
#suggestionSelected = false;
#suggestBlob?: SuggestionBlob;
#shell: Shell;
#hideSuggestions: boolean = false;
Expand Down Expand Up @@ -72,6 +73,7 @@ export class SuggestionManager {
this.#commandKey = "";
this.#suggestBlob = undefined;
this.#activeSuggestionIdx = 0;
this.#suggestionSelected = false;
this.#hideSuggestions = false;
if (commandCleared) {
try {
Expand Down Expand Up @@ -105,6 +107,7 @@ export class SuggestionManager {
const changed = this.#suggestBlob != null || this.#activeSuggestionIdx !== 0;
this.#suggestBlob = undefined;
this.#activeSuggestionIdx = 0;
this.#suggestionSelected = false;
return changed;
}
const abortController = new AbortController();
Expand All @@ -119,6 +122,7 @@ export class SuggestionManager {
this.#commandKey = commandKey;
this.#suggestBlob = suggestionBlob;
this.#activeSuggestionIdx = 0;
this.#suggestionSelected = false;
return true;
} catch (e) {
if (e instanceof Error && e.name === "AbortError") {
Expand Down Expand Up @@ -220,10 +224,15 @@ export class SuggestionManager {
}

update(keyPress: KeyPress, allowBindings = true): boolean {
const { name, shift, ctrl } = keyPress;
if (name == "return") {
this.#term.clearCommand(); // clear the current command on enter
const handled = this.#handleKeyPress(keyPress, allowBindings);
if (!handled && keyPress.name == "return") {
this.#term.clearCommand(); // clear the current command on enter, unless enter was consumed by a binding
}
return handled;
}

#handleKeyPress(keyPress: KeyPress, allowBindings: boolean): boolean {
const { name, shift, ctrl } = keyPress;

// if suggestions are hidden, keep them hidden until during command navigation
if (this.#hideSuggestions) {
Expand All @@ -245,19 +254,25 @@ export class SuggestionManager {
this.#hideSuggestions = true;
} else if (name == prevKey && shift == !!prevShift && ctrl == !!prevCtrl) {
this.#activeSuggestionIdx = Math.max(0, this.#activeSuggestionIdx - 1);
this.#suggestionSelected = true;
} else if (name == nextKey && shift == !!nextShift && ctrl == !!nextCtrl) {
this.#activeSuggestionIdx = Math.min(this.#activeSuggestionIdx + 1, (this.#suggestBlob?.suggestions.length ?? 1) - 1);
this.#suggestionSelected = true;
} else if (name == acceptKey && shift == !!acceptShift && ctrl == !!acceptCtrl) {
const suggestion = this.#suggestBlob?.suggestions.at(this.#activeSuggestionIdx);
if (suggestion == null || this.#suggestBlob?.suggestions.length == 0) {
return false;
}
// enter on an empty token runs the command unless a suggestion was explicitly selected
if (name == "return" && !this.#suggestionSelected && !this.#suggestBlob?.activeToken?.token) {
return false;
}
const action = calculateReplacement(this.#suggestBlob?.activeToken, suggestion);
if (action == null) {
return false;
}
this.#term.write(applyReplacement(action));
} else if (name == "return" || (name == "c" && ctrl)) {
} else if (name == "c" && ctrl) {
this.#term.clearCommand();
return false;
} else {
Expand Down