Skip to content

Commit 76c2bf0

Browse files
committed
Support mapping from key to VSCode command in Cursorless keyboard mode
- Fixes #1963
1 parent 7efcbfd commit 76c2bf0

File tree

7 files changed

+222
-8
lines changed

7 files changed

+222
-8
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
tags: [enhancement, keyboard]
3+
pullRequest: 2026
4+
---
5+
6+
- Add support for running VSCode commands from the experimental modal keyboard interface. See the [keyboard modal docs](https://www.cursorless.org/docs/user/experimental/keyboard/modal/) for more info.

docs/user/experimental/keyboard/modal.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,24 @@ To bind keys that do not have modifiers (eg just pressing `a`), add entries like
9797
"z": "bolt",
9898
"w": "crosshairs"
9999
},
100+
"cursorless.experimental.keyboard.modal.keybindings.vscodeCommands": {
101+
// For simple commands, just use the command name
102+
// "aa": "workbench.action.editor.changeLanguageMode",
103+
104+
// For commands with args, use the following format
105+
// "am": {
106+
// "commandId": "some.command.id",
107+
// "args": ["foo", 0]
108+
// }
109+
110+
// If you'd like to run the command on the active target, use the following format
111+
"am": {
112+
"commandId": "editor.action.joinLines",
113+
"executeAtTarget": true,
114+
// "keepChangedSelection": true,
115+
// "exitCursorlessMode": true,
116+
}
117+
}
100118
```
101119

102120
Any supported scopes, actions, or colors can be added to these sections, using the same identifiers that appear in the second column of your customisation csvs. Feel free to add / tweak / remove the keyboard shortcuts above as you see fit.

packages/cursorless-vscode-e2e/src/suite/keyboard/basic.vscode.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ suite("Basic keyboard test", async function () {
1212

1313
test("Don't take keyboard control on startup", () => checkKeyboardStartup());
1414
test("Basic keyboard test", () => basic());
15+
test("Run vscode command", () => vscodeCommand());
1516
test("Check that entering and leaving mode is no-op", () =>
1617
enterAndLeaveIsNoOp());
1718
});
@@ -56,6 +57,37 @@ async function basic() {
5657
assert.equal(editor.document.getText().trim(), "a");
5758
}
5859

60+
async function vscodeCommand() {
61+
const { hatTokenMap } = (await getCursorlessApi()).testHelpers!;
62+
63+
const editor = await openNewEditor("aaa;\nbbb;\nccc;\n", {
64+
languageId: "typescript",
65+
});
66+
await hatTokenMap.allocateHats();
67+
68+
editor.selection = new vscode.Selection(0, 0, 0, 0);
69+
70+
await vscode.commands.executeCommand("cursorless.keyboard.modal.modeOn");
71+
72+
// Target default b
73+
await typeText("db");
74+
75+
// Comment line containing *selection*
76+
await typeText("c");
77+
assert.equal(editor.document.getText(), "// aaa;\nbbb;\nccc;\n");
78+
79+
// Comment line containing *target*
80+
await typeText("mc");
81+
assert.equal(editor.document.getText(), "// aaa;\n// bbb;\nccc;\n");
82+
83+
// Comment line containing *target*, keeping changed selection and exiting
84+
// cursorless mode
85+
await typeText("dcmma");
86+
assert.equal(editor.document.getText(), "// aaa;\n// bbb;\n// a;\n");
87+
88+
await vscode.commands.executeCommand("cursorless.keyboard.modal.modeOff");
89+
}
90+
5991
async function enterAndLeaveIsNoOp() {
6092
const editor = await openNewEditor("hello");
6193

packages/cursorless-vscode/package.json

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -909,6 +909,41 @@
909909
]
910910
}
911911
},
912+
"cursorless.experimental.keyboard.modal.keybindings.vscodeCommands": {
913+
"description": "Define modal keybindings for running vscode commands",
914+
"type": "object",
915+
"additionalProperties": {
916+
"type": [
917+
"string",
918+
"object"
919+
],
920+
"properties": {
921+
"commandId": {
922+
"type": "string",
923+
"description": "The vscode command to run"
924+
},
925+
"args": {
926+
"type": "array",
927+
"description": "The arguments to pass to the command"
928+
},
929+
"executeAtTarget": {
930+
"type": "boolean",
931+
"description": "If `true`, indicates that the command should be executed at the target by moving the cursor there first, running the command, and then moving the cursor back to the original position"
932+
},
933+
"keepChangedSelection": {
934+
"type": "boolean",
935+
"description": "If `true`, the selection will be retained after the command is run, rather than being restored to its original position. This setting only applies when `executeAtTarget` is `true`."
936+
},
937+
"exitCursorlessMode": {
938+
"type": "boolean",
939+
"description": "If `true`, indicates that the command should exit cursorless mode after it is run."
940+
}
941+
},
942+
"required": [
943+
"commandId"
944+
]
945+
}
946+
},
912947
"cursorless.experimental.keyboard.modal.keybindings.colors": {
913948
"description": "Define modal keybindings for colors",
914949
"type": "object",

packages/cursorless-vscode/src/keyboard/KeyboardCommandsModal.ts

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,24 @@
1+
import { isTesting } from "@cursorless/common";
12
import { keys, merge, toPairs } from "lodash";
23
import * as vscode from "vscode";
4+
import KeyboardCommandsTargeted from "./KeyboardCommandsTargeted";
5+
import KeyboardHandler from "./KeyboardHandler";
36
import {
47
DEFAULT_ACTION_KEYMAP,
58
DEFAULT_COLOR_KEYMAP,
6-
Keymap,
79
DEFAULT_SCOPE_KEYMAP,
810
DEFAULT_SHAPE_KEYMAP,
11+
DEFAULT_VSCODE_COMMAND_KEYMAP,
12+
Keymap,
13+
ModalVscodeCommandDescriptor,
914
} from "./defaultKeymaps";
10-
import KeyboardCommandsTargeted from "./KeyboardCommandsTargeted";
11-
import KeyboardHandler from "./KeyboardHandler";
1215

13-
type SectionName = "actions" | "scopes" | "colors" | "shapes";
16+
type SectionName =
17+
| "actions"
18+
| "scopes"
19+
| "colors"
20+
| "shapes"
21+
| "vscodeCommands";
1422

1523
interface KeyHandler<T> {
1624
sectionName: SectionName;
@@ -61,6 +69,30 @@ export default class KeyboardCommandsModal {
6169
);
6270
}
6371

72+
private async handleVscodeCommand(commandInfo: ModalVscodeCommandDescriptor) {
73+
const {
74+
commandId,
75+
args,
76+
executeAtTarget,
77+
keepChangedSelection,
78+
exitCursorlessMode,
79+
} =
80+
typeof commandInfo === "string" || commandInfo instanceof String
81+
? ({ commandId: commandInfo } as Exclude<
82+
ModalVscodeCommandDescriptor,
83+
string
84+
>)
85+
: commandInfo;
86+
if (executeAtTarget) {
87+
return await this.targeted.performVscodeCommandOnTarget(commandId, {
88+
args,
89+
keepChangedSelection,
90+
exitCursorlessMode,
91+
});
92+
}
93+
return await vscode.commands.executeCommand(commandId, ...(args ?? []));
94+
}
95+
6496
private constructMergedKeymap() {
6597
this.mergedKeymap = {};
6698

@@ -82,6 +114,11 @@ export default class KeyboardCommandsModal {
82114
shape: value,
83115
}),
84116
);
117+
this.handleSection(
118+
"vscodeCommands",
119+
DEFAULT_VSCODE_COMMAND_KEYMAP,
120+
(value) => this.handleVscodeCommand(value),
121+
);
85122
}
86123

87124
/**
@@ -96,10 +133,13 @@ export default class KeyboardCommandsModal {
96133
defaultKeyMap: Keymap<T>,
97134
handleValue: (value: T) => Promise<unknown>,
98135
) {
99-
const userOverrides: Keymap<T> =
100-
vscode.workspace
101-
.getConfiguration("cursorless.experimental.keyboard.modal.keybindings")
102-
.get<Keymap<T>>(sectionName) ?? {};
136+
const userOverrides: Keymap<T> = isTesting()
137+
? {}
138+
: vscode.workspace
139+
.getConfiguration(
140+
"cursorless.experimental.keyboard.modal.keybindings",
141+
)
142+
.get<Keymap<T>>(sectionName) ?? {};
103143
const keyMap = merge({}, defaultKeyMap, userOverrides);
104144

105145
for (const [key, value] of toPairs(keyMap)) {

packages/cursorless-vscode/src/keyboard/KeyboardCommandsTargeted.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ export default class KeyboardCommandsTargeted {
4242
constructor(private keyboardHandler: KeyboardHandler) {
4343
this.targetDecoratedMark = this.targetDecoratedMark.bind(this);
4444
this.performActionOnTarget = this.performActionOnTarget.bind(this);
45+
this.performVscodeCommandOnTarget =
46+
this.performVscodeCommandOnTarget.bind(this);
4547
this.targetScopeType = this.targetScopeType.bind(this);
4648
this.targetSelection = this.targetSelection.bind(this);
4749
this.clearTarget = this.clearTarget.bind(this);
@@ -244,6 +246,54 @@ export default class KeyboardCommandsTargeted {
244246
return returnValue;
245247
};
246248

249+
/**
250+
* Performs the given VSCode command on the current target. If
251+
* {@link keepChangedSelection} is true, then the selection will not be
252+
* restored after the command is run.
253+
*
254+
* @param commandId The command to run
255+
* @param args The arguments to pass to the command
256+
* @param keepChangedSelection If true, the selection will not be restored
257+
* after the command is run
258+
* @returns A promise that resolves to the result of the VSCode command
259+
*/
260+
performVscodeCommandOnTarget = async (
261+
commandId: string,
262+
{
263+
args,
264+
keepChangedSelection,
265+
exitCursorlessMode,
266+
}: VscodeCommandOnTargetOptions = {},
267+
) => {
268+
const target: PartialPrimitiveTargetDescriptor = {
269+
type: "primitive",
270+
mark: {
271+
type: "that",
272+
},
273+
};
274+
275+
const returnValue = await executeCursorlessCommand({
276+
name: "executeCommand",
277+
target,
278+
commandId,
279+
options: {
280+
restoreSelection: !keepChangedSelection,
281+
showDecorations: true,
282+
commandArgs: args,
283+
},
284+
});
285+
286+
await this.highlightTarget();
287+
288+
if (exitCursorlessMode) {
289+
// For some Cursorless actions, it is more convenient if we automatically
290+
// exit modal mode
291+
await this.modal.modeOff();
292+
}
293+
294+
return returnValue;
295+
};
296+
247297
/**
248298
* Sets the current target to the current selection
249299
* @returns A promise that resolves to the result of the cursorless command
@@ -276,6 +326,12 @@ export default class KeyboardCommandsTargeted {
276326
});
277327
}
278328

329+
interface VscodeCommandOnTargetOptions {
330+
args?: unknown[];
331+
keepChangedSelection?: boolean;
332+
exitCursorlessMode?: boolean;
333+
}
334+
279335
function executeCursorlessCommand(action: ActionDescriptor) {
280336
return runCursorlessCommand({
281337
action,

packages/cursorless-vscode/src/keyboard/defaultKeymaps.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@ import { isTesting } from "@cursorless/common";
55

66
export type Keymap<T> = Record<string, T>;
77

8+
export type ModalVscodeCommandDescriptor =
9+
| string
10+
| {
11+
commandId: string;
12+
args?: unknown[];
13+
executeAtTarget?: boolean;
14+
keepChangedSelection?: boolean;
15+
exitCursorlessMode?: boolean;
16+
};
17+
818
// FIXME: Switch to a better mocking setup. We don't use our built in
919
// configuration set up because that is probably going to live server side, and
1020
// the keyboard setup will probably live client side
@@ -21,4 +31,21 @@ export const DEFAULT_COLOR_KEYMAP: Keymap<HatColor> = isTesting()
2131
? { d: "default" }
2232
: {};
2333

34+
export const DEFAULT_VSCODE_COMMAND_KEYMAP: Keymap<ModalVscodeCommandDescriptor> =
35+
isTesting()
36+
? {
37+
c: "editor.action.addCommentLine",
38+
mc: {
39+
commandId: "editor.action.addCommentLine",
40+
executeAtTarget: true,
41+
},
42+
mm: {
43+
commandId: "editor.action.addCommentLine",
44+
executeAtTarget: true,
45+
keepChangedSelection: true,
46+
exitCursorlessMode: true,
47+
},
48+
}
49+
: {};
50+
2451
export const DEFAULT_SHAPE_KEYMAP: Keymap<HatShape> = isTesting() ? {} : {};

0 commit comments

Comments
 (0)