Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fallback to Talon actions when focus is not on the text editor #2235

Merged
merged 36 commits into from
Mar 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
dcfe499
Started implementing extension side fallback
AndreasArvidsson Feb 10, 2024
56b3f44
Made homophones work
AndreasArvidsson Feb 10, 2024
b44593d
Implemented bring and call
AndreasArvidsson Feb 10, 2024
a716e07
Added move
AndreasArvidsson Feb 10, 2024
2a6cca5
Cleanup
AndreasArvidsson Feb 10, 2024
4577c7b
unwrap return value
AndreasArvidsson Feb 10, 2024
3064930
[pre-commit.ci lite] apply automatic fixes
pre-commit-ci-lite[bot] Feb 10, 2024
f2bf6ac
remove print
AndreasArvidsson Feb 10, 2024
3c325d6
updates
AndreasArvidsson Feb 12, 2024
407dc94
[pre-commit.ci lite] apply automatic fixes
pre-commit-ci-lite[bot] Feb 12, 2024
3205c10
move fall back into command runner
AndreasArvidsson Feb 18, 2024
f716a58
refactoring
AndreasArvidsson Feb 18, 2024
0742049
Recorded test
AndreasArvidsson Feb 18, 2024
2e74d9e
Mock focused element in command server api
AndreasArvidsson Feb 18, 2024
96be54c
assert fallback
AndreasArvidsson Feb 18, 2024
71303d6
Added more tests
AndreasArvidsson Feb 18, 2024
0de126a
Fix return value in tests
AndreasArvidsson Feb 18, 2024
bb9a20b
cleanup
AndreasArvidsson Feb 18, 2024
98e9529
set default focused element
AndreasArvidsson Feb 18, 2024
5b7371e
move and rename fallback
AndreasArvidsson Feb 19, 2024
300511c
more tests
AndreasArvidsson Feb 19, 2024
817c02a
test
AndreasArvidsson Feb 19, 2024
cde86b0
rename
AndreasArvidsson Feb 19, 2024
8e604b4
rename
AndreasArvidsson Feb 19, 2024
b32439f
rename
AndreasArvidsson Feb 19, 2024
7225594
Restore sha
AndreasArvidsson Feb 20, 2024
b02f334
Fix circular import error
pokey Feb 20, 2024
4237c6b
Fix call_as_function with arg
pokey Feb 20, 2024
bfeef40
minor cleanup
pokey Feb 20, 2024
88740e9
more pythonification
pokey Feb 20, 2024
06edc07
Rename
pokey Feb 20, 2024
d9f6f5a
Merge branch 'main' into fallback
AndreasArvidsson Feb 21, 2024
4a9386c
Remove superfluous next homophone action
AndreasArvidsson Feb 21, 2024
5f79f3e
Added pre and post fallback actions
AndreasArvidsson Feb 26, 2024
ad13216
Merge branch 'main' into fallback
pokey Mar 18, 2024
d74175f
add release notes
pokey Mar 19, 2024
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
6 changes: 6 additions & 0 deletions changelog/2024-03-fallBackToTalonActions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
tags: [enhancement]
pullRequest: 2235
---

- 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.
16 changes: 13 additions & 3 deletions cursorless-talon/src/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@

from talon import Module, actions, speech_system

from .fallback import perform_fallback
from .versions import COMMAND_VERSION


@dataclasses.dataclass
class CursorlessCommand:
version = 6
version = COMMAND_VERSION
spokenForm: str
usePrePhraseSnapshot: bool
action: dict
Expand All @@ -30,10 +33,12 @@ def on_phrase(d):
class Actions:
def private_cursorless_command_and_wait(action: dict):
"""Execute cursorless command and wait for it to finish"""
actions.user.private_cursorless_run_rpc_command_and_wait(
response = actions.user.private_cursorless_run_rpc_command_get(
CURSORLESS_COMMAND_ID,
construct_cursorless_command(action),
)
if "fallback" in response:
perform_fallback(response["fallback"])

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

def private_cursorless_command_get(action: dict):
"""Execute cursorless command and return result"""
return actions.user.private_cursorless_run_rpc_command_get(
response = actions.user.private_cursorless_run_rpc_command_get(
CURSORLESS_COMMAND_ID,
construct_cursorless_command(action),
)
if "fallback" in response:
return perform_fallback(response["fallback"])
if "returnValue" in response:
return response["returnValue"]
return None


def construct_cursorless_command(action: dict) -> dict:
Expand Down
107 changes: 107 additions & 0 deletions cursorless-talon/src/fallback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
from typing import Callable

from talon import actions

pokey marked this conversation as resolved.
Show resolved Hide resolved
from .versions import COMMAND_VERSION

# This ensures that we remember to update fallback if the response payload changes
assert COMMAND_VERSION == 7

action_callbacks = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be cool to support clone; fine leaving out of scope for this PR tho. We should prob file with some other improvements we'd like to make

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I will leave this for a follow up.

"getText": lambda: [actions.edit.selected_text()],
"setSelection": actions.skip,
"setSelectionBefore": actions.edit.left,
"setSelectionAfter": actions.edit.right,
"copyToClipboard": actions.edit.copy,
"cutToClipboard": actions.edit.cut,
"pasteFromClipboard": actions.edit.paste,
"clearAndSetSelection": actions.edit.delete,
"remove": actions.edit.delete,
"editNewLineBefore": actions.edit.line_insert_up,
"editNewLineAfter": actions.edit.line_insert_down,
Comment on lines +20 to +21
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do we do about "pour token"?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not supported at the moment

}

modifier_callbacks = {
"extendThroughStartOf.line": actions.user.select_line_start,
"extendThroughEndOf.line": actions.user.select_line_end,
"containingScope.document": actions.edit.select_all,
"containingScope.paragraph": actions.edit.select_paragraph,
"containingScope.line": actions.edit.select_line,
"containingScope.token": actions.edit.select_word,
}


def call_as_function(callee: str):
wrap_with_paired_delimiter(f"{callee}(", ")")


def wrap_with_paired_delimiter(left: str, right: str):
selected = actions.edit.selected_text()
actions.insert(f"{left}{selected}{right}")
for _ in right:
actions.edit.left()


def containing_token_if_empty():
if actions.edit.selected_text() == "":
actions.edit.select_word()


def perform_fallback(fallback: dict):
try:
modifier_callbacks = get_modifier_callbacks(fallback)
action_callback = get_action_callback(fallback)
for callback in reversed(modifier_callbacks):
callback()
return action_callback()
except ValueError as ex:
actions.app.notify(str(ex))


def get_action_callback(fallback: dict) -> Callable:
action = fallback["action"]

if action in action_callbacks:
return action_callbacks[action]

match action:
case "insert":
return lambda: actions.insert(fallback["text"])
case "callAsFunction":
return lambda: call_as_function(fallback["callee"])
case "wrapWithPairedDelimiter":
return lambda: wrap_with_paired_delimiter(
fallback["left"], fallback["right"]
)

raise ValueError(f"Unknown Cursorless fallback action: {action}")


def get_modifier_callbacks(fallback: dict) -> list[Callable]:
return [get_modifier_callback(modifier) for modifier in fallback["modifiers"]]


def get_modifier_callback(modifier: dict) -> Callable:
modifier_type = modifier["type"]

match modifier_type:
case "containingTokenIfEmpty":
return containing_token_if_empty
case "containingScope":
scope_type_type = modifier["scopeType"]["type"]
return get_simple_modifier_callback(f"{modifier_type}.{scope_type_type}")
case "extendThroughStartOf":
if "modifiers" not in modifier:
return get_simple_modifier_callback(f"{modifier_type}.line")
case "extendThroughEndOf":
if "modifiers" not in modifier:
return get_simple_modifier_callback(f"{modifier_type}.line")

raise ValueError(f"Unknown Cursorless fallback modifier: {modifier_type}")


def get_simple_modifier_callback(key: str) -> Callable:
try:
return modifier_callbacks[key]
except KeyError:
raise ValueError(f"Unknown Cursorless fallback modifier: {key}")
1 change: 1 addition & 0 deletions cursorless-talon/src/versions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
COMMAND_VERSION = 7
25 changes: 25 additions & 0 deletions packages/common/src/FakeCommandServerApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import {
CommandServerApi,
FocusedElementType,
InboundSignal,
} from "./types/CommandServerApi";

export class FakeCommandServerApi implements CommandServerApi {
private focusedElementType: FocusedElementType | undefined;
signals: { prePhrase: InboundSignal };

constructor() {
this.signals = { prePhrase: { getVersion: async () => null } };
this.focusedElementType = "textEditor";
}

getFocusedElementType(): FocusedElementType | undefined {
return this.focusedElementType;
}

setFocusedElementType(
focusedElementType: FocusedElementType | undefined,
): void {
this.focusedElementType = focusedElementType;
}
}
11 changes: 0 additions & 11 deletions packages/common/src/getFakeCommandServerApi.ts

This file was deleted.

4 changes: 3 additions & 1 deletion packages/common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,16 +85,18 @@ export * from "./types/command/legacy/ActionCommandV5";
export * from "./types/command/legacy/CommandV5.types";
export * from "./types/command/legacy/PartialTargetDescriptorV5.types";
export * from "./types/command/CommandV6.types";
export * from "./types/command/CommandV7.types";
export * from "./types/command/legacy/PartialTargetDescriptorV3.types";
export * from "./types/command/legacy/PartialTargetDescriptorV4.types";
export * from "./types/CommandServerApi";
export * from "./util/itertools";
export * from "./extensionDependencies";
export * from "./getFakeCommandServerApi";
export * from "./FakeCommandServerApi";
export * from "./types/TestCaseFixture";
export * from "./util/getEnvironmentVariableStrict";
export * from "./util/CompositeKeyDefaultMap";
export * from "./util/toPlainObject";
export * from "./util/clientSupportsFallback";
export * from "./scopeSupportFacets/scopeSupportFacets.types";
export * from "./scopeSupportFacets/scopeSupportFacetInfos";
export * from "./scopeSupportFacets/textualScopeSupportFacetInfos";
Expand Down
2 changes: 2 additions & 0 deletions packages/common/src/testUtil/serializeTestFixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ function reorderFields(
): EnforceUndefined<TestCaseFixtureLegacy> {
return {
languageId: fixture.languageId,
focusedElementType: fixture.focusedElementType,
postEditorOpenSleepTimeMs: fixture.postEditorOpenSleepTimeMs,
postCommandSleepTimeMs: fixture.postCommandSleepTimeMs,
command: fixture.command,
Expand All @@ -15,6 +16,7 @@ function reorderFields(
initialState: fixture.initialState,
finalState: fixture.finalState,
returnValue: fixture.returnValue,
fallback: fixture.fallback,
thrownError: fixture.thrownError,
ide: fixture.ide,
};
Expand Down
4 changes: 4 additions & 0 deletions packages/common/src/types/CommandServerApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@
* API object for interacting with the command server
*/
export interface CommandServerApi {
getFocusedElementType: () => FocusedElementType | undefined;

signals: {
prePhrase: InboundSignal;
};
}

export type FocusedElementType = "textEditor" | "terminal";

export interface InboundSignal {
getVersion(): Promise<string | null>;
}
16 changes: 13 additions & 3 deletions packages/common/src/types/TestCaseFixture.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Command, CommandLatest } from "..";
import { TestCaseSnapshot } from "../testUtil/TestCaseSnapshot";
import { PlainSpyIDERecordedValues } from "../testUtil/spyToPlainObject";
import type { Command, CommandLatest, Fallback, FocusedElementType } from "..";
import type { TestCaseSnapshot } from "../testUtil/TestCaseSnapshot";
import type { PlainSpyIDERecordedValues } from "../testUtil/spyToPlainObject";

export type ThrownError = {
name: string;
Expand All @@ -12,6 +12,11 @@ interface TestCaseFixtureBase {
postCommandSleepTimeMs?: number;
spokenFormError?: string;

/**
* The type of element that is focused before the command is executed. If undefined default to text editor.
*/
focusedElementType?: FocusedElementType | "other";

/**
* A list of marks to check in the case of navigation map test otherwise undefined
*/
Expand All @@ -30,6 +35,11 @@ interface TestCaseFixtureBase {
* error test case.
*/
returnValue?: unknown;

/**
* The fallback of the command. Will be undefined if the command was executed by the extension.
*/
fallback?: Fallback;
}

export interface TestCaseFixture extends TestCaseFixtureBase {
Expand Down
8 changes: 8 additions & 0 deletions packages/common/src/types/command/CommandV7.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { CommandV6 } from "./CommandV6.types";

export interface CommandV7 extends Omit<CommandV6, "version"> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we usually move the old one to legacy when we bump version. But I guess this is ok?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think so. Otherwise we just have to copy identical types

/**
* The version number of the command API
*/
version: 7;
}
25 changes: 22 additions & 3 deletions packages/common/src/types/command/command.types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { CommandV6 } from "./CommandV6.types";
import type { ActionDescriptor } from "./ActionDescriptor";
import type { CommandV6 } from "./CommandV6.types";
import type { CommandV7 } from "./CommandV7.types";
import type { Modifier } from "./PartialTargetDescriptor.types";
import type { CommandV0, CommandV1 } from "./legacy/CommandV0V1.types";
import type { CommandV2 } from "./legacy/CommandV2.types";
import type { CommandV3 } from "./legacy/CommandV3.types";
Expand All @@ -7,7 +10,7 @@ import type { CommandV5 } from "./legacy/CommandV5.types";

export type CommandComplete = Required<Omit<CommandLatest, "spokenForm">> &
Pick<CommandLatest, "spokenForm">;
export const LATEST_VERSION = 6 as const;
export const LATEST_VERSION = 7 as const;

export type CommandLatest = Command & {
version: typeof LATEST_VERSION;
Expand All @@ -20,4 +23,20 @@ export type Command =
| CommandV3
| CommandV4
| CommandV5
| CommandV6;
| CommandV6
| CommandV7;

export type CommandResponse = { returnValue: unknown } | { fallback: Fallback };

export type FallbackModifier = Modifier | { type: "containingTokenIfEmpty" };

export type Fallback =
| { action: ActionDescriptor["name"]; modifiers: FallbackModifier[] }
| { action: "insert"; modifiers: FallbackModifier[]; text: string }
| { action: "callAsFunction"; modifiers: FallbackModifier[]; callee: string }
| {
action: "wrapWithPairedDelimiter" | "rewrapWithPairedDelimiter";
modifiers: FallbackModifier[];
left: string;
right: string;
};
5 changes: 5 additions & 0 deletions packages/common/src/util/clientSupportsFallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { Command } from "../types/command/command.types";

export function clientSupportsFallback(command: Command): boolean {
return command.version >= 7;
}
4 changes: 2 additions & 2 deletions packages/cursorless-engine/src/CommandRunner.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CommandComplete } from "@cursorless/common";
import type { CommandComplete, CommandResponse } from "@cursorless/common";

export interface CommandRunner {
run(command: CommandComplete): Promise<unknown>;
run(command: CommandComplete): Promise<CommandResponse>;
}
21 changes: 13 additions & 8 deletions packages/cursorless-engine/src/api/CursorlessEngineApi.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { Command, HatTokenMap, IDE } from "@cursorless/common";
import { Snippets } from "../core/Snippets";
import { StoredTargetMap } from "../core/StoredTargets";
import { ScopeProvider } from "@cursorless/common";
import { CommandRunner } from "../CommandRunner";
import { ReadOnlyHatMap } from "@cursorless/common";
import type {
Command,
CommandResponse,
HatTokenMap,
IDE,
ReadOnlyHatMap,
ScopeProvider,
} from "@cursorless/common";
import type { CommandRunner } from "../CommandRunner";
import type { Snippets } from "../core/Snippets";
import type { StoredTargetMap } from "../core/StoredTargets";

export interface CursorlessEngine {
commandApi: CommandApi;
Expand Down Expand Up @@ -34,13 +39,13 @@ export interface CommandApi {
* Runs a command. This is the core of the Cursorless engine.
* @param command The command to run
*/
runCommand(command: Command): Promise<unknown>;
runCommand(command: Command): Promise<CommandResponse | unknown>;

/**
* Designed to run commands that come directly from the user. Ensures that
* the command args are of the correct shape.
*/
runCommandSafe(...args: unknown[]): Promise<unknown>;
runCommandSafe(...args: unknown[]): Promise<CommandResponse | unknown>;
}

export interface CommandRunnerDecorator {
Expand Down
Loading
Loading