Skip to content

Commit 1c18476

Browse files
AndreasArvidssonpre-commit-ci-lite[bot]pokey
authored
Fallback to Talon actions when focus is not on the text editor (#2235)
Edit operations supported by community will now work in vscode outside of the text editor. eg the search widget `take line` `chuck token` Everything appears to be working when I have tested it. With that said I have not tested on community and we should probably have a discussion about some of the finer details of this. ## Checklist - [x] I have added [tests](https://www.cursorless.org/docs/contributing/test-case-recorder/) - [ ] I have updated the [docs](https://github.com/cursorless-dev/cursorless/tree/main/docs) and [cheatsheet](https://github.com/cursorless-dev/cursorless/tree/main/cursorless-talon/src/cheatsheet) - [x] I have not broken the cheatsheet --------- Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Pokey Rule <[email protected]>
1 parent 0b5cdee commit 1c18476

36 files changed

+728
-68
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
tags: [enhancement]
3+
pullRequest: 2235
4+
---
5+
6+
- Fall back to text-based Talon actions when editor is not focused. This allows you to say things like "take token", "bring air", etc, when in the terminal, search bar, etc.

cursorless-talon/src/command.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@
33

44
from talon import Module, actions, speech_system
55

6+
from .fallback import perform_fallback
7+
from .versions import COMMAND_VERSION
8+
69

710
@dataclasses.dataclass
811
class CursorlessCommand:
9-
version = 6
12+
version = COMMAND_VERSION
1013
spokenForm: str
1114
usePrePhraseSnapshot: bool
1215
action: dict
@@ -30,10 +33,12 @@ def on_phrase(d):
3033
class Actions:
3134
def private_cursorless_command_and_wait(action: dict):
3235
"""Execute cursorless command and wait for it to finish"""
33-
actions.user.private_cursorless_run_rpc_command_and_wait(
36+
response = actions.user.private_cursorless_run_rpc_command_get(
3437
CURSORLESS_COMMAND_ID,
3538
construct_cursorless_command(action),
3639
)
40+
if "fallback" in response:
41+
perform_fallback(response["fallback"])
3742

3843
def private_cursorless_command_no_wait(action: dict):
3944
"""Execute cursorless command without waiting"""
@@ -44,10 +49,15 @@ def private_cursorless_command_no_wait(action: dict):
4449

4550
def private_cursorless_command_get(action: dict):
4651
"""Execute cursorless command and return result"""
47-
return actions.user.private_cursorless_run_rpc_command_get(
52+
response = actions.user.private_cursorless_run_rpc_command_get(
4853
CURSORLESS_COMMAND_ID,
4954
construct_cursorless_command(action),
5055
)
56+
if "fallback" in response:
57+
return perform_fallback(response["fallback"])
58+
if "returnValue" in response:
59+
return response["returnValue"]
60+
return None
5161

5262

5363
def construct_cursorless_command(action: dict) -> dict:

cursorless-talon/src/fallback.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
from typing import Callable
2+
3+
from talon import actions
4+
5+
from .versions import COMMAND_VERSION
6+
7+
# This ensures that we remember to update fallback if the response payload changes
8+
assert COMMAND_VERSION == 7
9+
10+
action_callbacks = {
11+
"getText": lambda: [actions.edit.selected_text()],
12+
"setSelection": actions.skip,
13+
"setSelectionBefore": actions.edit.left,
14+
"setSelectionAfter": actions.edit.right,
15+
"copyToClipboard": actions.edit.copy,
16+
"cutToClipboard": actions.edit.cut,
17+
"pasteFromClipboard": actions.edit.paste,
18+
"clearAndSetSelection": actions.edit.delete,
19+
"remove": actions.edit.delete,
20+
"editNewLineBefore": actions.edit.line_insert_up,
21+
"editNewLineAfter": actions.edit.line_insert_down,
22+
}
23+
24+
modifier_callbacks = {
25+
"extendThroughStartOf.line": actions.user.select_line_start,
26+
"extendThroughEndOf.line": actions.user.select_line_end,
27+
"containingScope.document": actions.edit.select_all,
28+
"containingScope.paragraph": actions.edit.select_paragraph,
29+
"containingScope.line": actions.edit.select_line,
30+
"containingScope.token": actions.edit.select_word,
31+
}
32+
33+
34+
def call_as_function(callee: str):
35+
wrap_with_paired_delimiter(f"{callee}(", ")")
36+
37+
38+
def wrap_with_paired_delimiter(left: str, right: str):
39+
selected = actions.edit.selected_text()
40+
actions.insert(f"{left}{selected}{right}")
41+
for _ in right:
42+
actions.edit.left()
43+
44+
45+
def containing_token_if_empty():
46+
if actions.edit.selected_text() == "":
47+
actions.edit.select_word()
48+
49+
50+
def perform_fallback(fallback: dict):
51+
try:
52+
modifier_callbacks = get_modifier_callbacks(fallback)
53+
action_callback = get_action_callback(fallback)
54+
for callback in reversed(modifier_callbacks):
55+
callback()
56+
return action_callback()
57+
except ValueError as ex:
58+
actions.app.notify(str(ex))
59+
60+
61+
def get_action_callback(fallback: dict) -> Callable:
62+
action = fallback["action"]
63+
64+
if action in action_callbacks:
65+
return action_callbacks[action]
66+
67+
match action:
68+
case "insert":
69+
return lambda: actions.insert(fallback["text"])
70+
case "callAsFunction":
71+
return lambda: call_as_function(fallback["callee"])
72+
case "wrapWithPairedDelimiter":
73+
return lambda: wrap_with_paired_delimiter(
74+
fallback["left"], fallback["right"]
75+
)
76+
77+
raise ValueError(f"Unknown Cursorless fallback action: {action}")
78+
79+
80+
def get_modifier_callbacks(fallback: dict) -> list[Callable]:
81+
return [get_modifier_callback(modifier) for modifier in fallback["modifiers"]]
82+
83+
84+
def get_modifier_callback(modifier: dict) -> Callable:
85+
modifier_type = modifier["type"]
86+
87+
match modifier_type:
88+
case "containingTokenIfEmpty":
89+
return containing_token_if_empty
90+
case "containingScope":
91+
scope_type_type = modifier["scopeType"]["type"]
92+
return get_simple_modifier_callback(f"{modifier_type}.{scope_type_type}")
93+
case "extendThroughStartOf":
94+
if "modifiers" not in modifier:
95+
return get_simple_modifier_callback(f"{modifier_type}.line")
96+
case "extendThroughEndOf":
97+
if "modifiers" not in modifier:
98+
return get_simple_modifier_callback(f"{modifier_type}.line")
99+
100+
raise ValueError(f"Unknown Cursorless fallback modifier: {modifier_type}")
101+
102+
103+
def get_simple_modifier_callback(key: str) -> Callable:
104+
try:
105+
return modifier_callbacks[key]
106+
except KeyError:
107+
raise ValueError(f"Unknown Cursorless fallback modifier: {key}")

cursorless-talon/src/versions.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
COMMAND_VERSION = 7
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import {
2+
CommandServerApi,
3+
FocusedElementType,
4+
InboundSignal,
5+
} from "./types/CommandServerApi";
6+
7+
export class FakeCommandServerApi implements CommandServerApi {
8+
private focusedElementType: FocusedElementType | undefined;
9+
signals: { prePhrase: InboundSignal };
10+
11+
constructor() {
12+
this.signals = { prePhrase: { getVersion: async () => null } };
13+
this.focusedElementType = "textEditor";
14+
}
15+
16+
getFocusedElementType(): FocusedElementType | undefined {
17+
return this.focusedElementType;
18+
}
19+
20+
setFocusedElementType(
21+
focusedElementType: FocusedElementType | undefined,
22+
): void {
23+
this.focusedElementType = focusedElementType;
24+
}
25+
}

packages/common/src/getFakeCommandServerApi.ts

Lines changed: 0 additions & 11 deletions
This file was deleted.

packages/common/src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,16 +85,18 @@ export * from "./types/command/legacy/ActionCommandV5";
8585
export * from "./types/command/legacy/CommandV5.types";
8686
export * from "./types/command/legacy/PartialTargetDescriptorV5.types";
8787
export * from "./types/command/CommandV6.types";
88+
export * from "./types/command/CommandV7.types";
8889
export * from "./types/command/legacy/PartialTargetDescriptorV3.types";
8990
export * from "./types/command/legacy/PartialTargetDescriptorV4.types";
9091
export * from "./types/CommandServerApi";
9192
export * from "./util/itertools";
9293
export * from "./extensionDependencies";
93-
export * from "./getFakeCommandServerApi";
94+
export * from "./FakeCommandServerApi";
9495
export * from "./types/TestCaseFixture";
9596
export * from "./util/getEnvironmentVariableStrict";
9697
export * from "./util/CompositeKeyDefaultMap";
9798
export * from "./util/toPlainObject";
99+
export * from "./util/clientSupportsFallback";
98100
export * from "./scopeSupportFacets/scopeSupportFacets.types";
99101
export * from "./scopeSupportFacets/scopeSupportFacetInfos";
100102
export * from "./scopeSupportFacets/textualScopeSupportFacetInfos";

packages/common/src/testUtil/serializeTestFixture.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ function reorderFields(
77
): EnforceUndefined<TestCaseFixtureLegacy> {
88
return {
99
languageId: fixture.languageId,
10+
focusedElementType: fixture.focusedElementType,
1011
postEditorOpenSleepTimeMs: fixture.postEditorOpenSleepTimeMs,
1112
postCommandSleepTimeMs: fixture.postCommandSleepTimeMs,
1213
command: fixture.command,
@@ -15,6 +16,7 @@ function reorderFields(
1516
initialState: fixture.initialState,
1617
finalState: fixture.finalState,
1718
returnValue: fixture.returnValue,
19+
fallback: fixture.fallback,
1820
thrownError: fixture.thrownError,
1921
ide: fixture.ide,
2022
};

packages/common/src/types/CommandServerApi.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@
22
* API object for interacting with the command server
33
*/
44
export interface CommandServerApi {
5+
getFocusedElementType: () => FocusedElementType | undefined;
6+
57
signals: {
68
prePhrase: InboundSignal;
79
};
810
}
911

12+
export type FocusedElementType = "textEditor" | "terminal";
13+
1014
export interface InboundSignal {
1115
getVersion(): Promise<string | null>;
1216
}

packages/common/src/types/TestCaseFixture.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { Command, CommandLatest } from "..";
2-
import { TestCaseSnapshot } from "../testUtil/TestCaseSnapshot";
3-
import { PlainSpyIDERecordedValues } from "../testUtil/spyToPlainObject";
1+
import type { Command, CommandLatest, Fallback, FocusedElementType } from "..";
2+
import type { TestCaseSnapshot } from "../testUtil/TestCaseSnapshot";
3+
import type { PlainSpyIDERecordedValues } from "../testUtil/spyToPlainObject";
44

55
export type ThrownError = {
66
name: string;
@@ -12,6 +12,11 @@ interface TestCaseFixtureBase {
1212
postCommandSleepTimeMs?: number;
1313
spokenFormError?: string;
1414

15+
/**
16+
* The type of element that is focused before the command is executed. If undefined default to text editor.
17+
*/
18+
focusedElementType?: FocusedElementType | "other";
19+
1520
/**
1621
* A list of marks to check in the case of navigation map test otherwise undefined
1722
*/
@@ -30,6 +35,11 @@ interface TestCaseFixtureBase {
3035
* error test case.
3136
*/
3237
returnValue?: unknown;
38+
39+
/**
40+
* The fallback of the command. Will be undefined if the command was executed by the extension.
41+
*/
42+
fallback?: Fallback;
3343
}
3444

3545
export interface TestCaseFixture extends TestCaseFixtureBase {

0 commit comments

Comments
 (0)