Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 7db33c6

Browse files
committedOct 6, 2023
More sophisticated custom spoken forms
1 parent d7b3ba6 commit 7db33c6

18 files changed

+377
-94
lines changed
 

‎cursorless-talon/src/cursorless.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,4 @@ def private_cursorless_show_settings_in_ide():
1414
"""Show Cursorless-specific settings in ide"""
1515

1616
def private_cursorless_show_sidebar():
17-
"""Show Cursorless sidebar"""
17+
"""Show Cursorless-specific settings in ide"""

‎packages/common/src/types/command/PartialTargetDescriptor.types.ts

+9
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,15 @@ export function isSimpleScopeType(
175175
return (simpleScopeTypeTypes as readonly string[]).includes(scopeType.type);
176176
}
177177

178+
const SECRET_SCOPE_TYPES = [
179+
"string",
180+
"switchStatementSubject",
181+
] as const satisfies readonly SimpleScopeTypeType[];
182+
183+
export function isSecretScopeType(scopeType: ScopeType): boolean {
184+
return (SECRET_SCOPE_TYPES as readonly string[]).includes(scopeType.type);
185+
}
186+
178187
export type SimpleScopeTypeType = (typeof simpleScopeTypeTypes)[number];
179188

180189
export interface SimpleScopeType {

‎packages/cursorless-engine/src/CustomSpokenForms.ts

+90-14
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,25 @@ import {
33
Disposer,
44
FileSystem,
55
Notifier,
6+
showError,
67
} from "@cursorless/common";
7-
import { homedir } from "os";
8-
import * as path from "path";
9-
import { getSpokenFormEntries } from "./scopeProviders/getSpokenFormEntries";
10-
import { SpokenFormMap } from "./SpokenFormMap";
11-
import { defaultSpokenFormMap } from "./DefaultSpokenFormMap";
12-
13-
export const spokenFormsPath = path.join(
14-
homedir(),
15-
".cursorless",
16-
"spokenForms.json",
17-
);
8+
import { isEqual } from "lodash";
9+
import {
10+
defaultSpokenFormInfo,
11+
defaultSpokenFormMap,
12+
} from "./DefaultSpokenFormMap";
13+
import {
14+
SpokenFormMap,
15+
SpokenFormMapEntry,
16+
SpokenFormType,
17+
} from "./SpokenFormMap";
18+
import {
19+
SpokenFormEntry,
20+
getSpokenFormEntries,
21+
spokenFormsPath,
22+
} from "./scopeProviders/getSpokenFormEntries";
23+
import { ide } from "./singletons/ide.singleton";
24+
import { dirname } from "node:path";
1825

1926
const ENTRY_TYPES = [
2027
"simpleScopeTypeType",
@@ -41,6 +48,15 @@ export class CustomSpokenForms implements SpokenFormMap {
4148
modifierExtra = defaultSpokenFormMap.modifierExtra;
4249

4350
private isInitialized_ = false;
51+
private needsInitialTalonUpdate_: boolean | undefined;
52+
53+
/**
54+
* If `true`, indicates they need to update their Talon files to get the
55+
* machinery used to share spoken forms from Talon to the VSCode extension.
56+
*/
57+
get needsInitialTalonUpdate() {
58+
return this.needsInitialTalonUpdate_;
59+
}
4460

4561
/**
4662
* Whether the custom spoken forms have been initialized. If `false`, the
@@ -53,7 +69,9 @@ export class CustomSpokenForms implements SpokenFormMap {
5369

5470
constructor(fileSystem: FileSystem) {
5571
this.disposer.push(
56-
fileSystem.watch(spokenFormsPath, () => this.updateSpokenFormMaps()),
72+
fileSystem.watch(dirname(spokenFormsPath), () =>
73+
this.updateSpokenFormMaps(),
74+
),
5775
);
5876

5977
this.updateSpokenFormMaps();
@@ -67,7 +85,30 @@ export class CustomSpokenForms implements SpokenFormMap {
6785
onDidChangeCustomSpokenForms = this.notifier.registerListener;
6886

6987
private async updateSpokenFormMaps(): Promise<void> {
70-
const entries = await getSpokenFormEntries();
88+
let entries: SpokenFormEntry[];
89+
try {
90+
entries = await getSpokenFormEntries();
91+
} catch (err) {
92+
if ((err as any)?.code === "ENOENT") {
93+
// Handle case where spokenForms.json doesn't exist yet
94+
console.log(
95+
`Custom spoken forms file not found at ${spokenFormsPath}. Using default spoken forms.`,
96+
);
97+
this.needsInitialTalonUpdate_ = true;
98+
this.notifier.notifyListeners();
99+
} else {
100+
console.error("Error loading custom spoken forms", err);
101+
showError(
102+
ide().messages,
103+
"CustomSpokenForms.updateSpokenFormMaps",
104+
`Error loading custom spoken forms: ${
105+
(err as Error).message
106+
}}}. Falling back to default spoken forms.`,
107+
);
108+
}
109+
110+
return;
111+
}
71112

72113
for (const entryType of ENTRY_TYPES) {
73114
// TODO: Handle case where we've added a new scope type but they haven't yet
@@ -76,10 +117,45 @@ export class CustomSpokenForms implements SpokenFormMap {
76117
// be able to speak it. We could just detect that there's no entry for it in
77118
// the spoken forms file, but that feels a bit brittle.
78119
// FIXME: How to avoid the type assertion?
79-
this[entryType] = Object.fromEntries(
120+
const entry = Object.fromEntries(
80121
entries
81122
.filter((entry) => entry.type === entryType)
82123
.map(({ id, spokenForms }) => [id, spokenForms]),
124+
);
125+
126+
this[entryType] = Object.fromEntries(
127+
Object.entries(defaultSpokenFormInfo[entryType]).map(
128+
([key, { defaultSpokenForms, isSecret }]): [
129+
SpokenFormType,
130+
SpokenFormMapEntry,
131+
] => {
132+
const customSpokenForms = entry[key];
133+
if (customSpokenForms != null) {
134+
return [
135+
key as SpokenFormType,
136+
{
137+
defaultSpokenForms,
138+
spokenForms: customSpokenForms,
139+
requiresTalonUpdate: false,
140+
isCustom: isEqual(defaultSpokenForms, customSpokenForms),
141+
isSecret,
142+
},
143+
];
144+
} else {
145+
return [
146+
key as SpokenFormType,
147+
{
148+
defaultSpokenForms,
149+
spokenForms: [],
150+
// If it's not a secret spoken form, then it's a new scope type
151+
requiresTalonUpdate: !isSecret,
152+
isCustom: false,
153+
isSecret,
154+
},
155+
];
156+
}
157+
},
158+
),
83159
) as any;
84160
}
85161

‎packages/cursorless-engine/src/DefaultSpokenFormMap.ts

+76-15
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import { mapValues } from "lodash";
2-
import { SpokenFormMap, SpokenFormMapKeyTypes } from "./SpokenFormMap";
2+
import {
3+
SpokenFormMap,
4+
SpokenFormMapEntry,
5+
SpokenFormMapKeyTypes,
6+
} from "./SpokenFormMap";
37

4-
type DefaultSpokenFormMap = {
8+
type DefaultSpokenFormMapDefinition = {
59
readonly [K in keyof SpokenFormMapKeyTypes]: Readonly<
6-
Record<SpokenFormMapKeyTypes[K], string | null>
10+
Record<SpokenFormMapKeyTypes[K], string | DefaultSpokenFormMapEntry>
711
>;
812
};
913

10-
const defaultSpokenFormMapCore: DefaultSpokenFormMap = {
14+
const defaultSpokenFormMapCore: DefaultSpokenFormMapDefinition = {
1115
pairedDelimiter: {
1216
curlyBrackets: "curly",
1317
angleBrackets: "diamond",
@@ -45,15 +49,14 @@ const defaultSpokenFormMapCore: DefaultSpokenFormMap = {
4549
name: "name",
4650
regularExpression: "regex",
4751
section: "section",
48-
sectionLevelOne: "one section",
49-
sectionLevelTwo: "two section",
50-
sectionLevelThree: "three section",
51-
sectionLevelFour: "four section",
52-
sectionLevelFive: "five section",
53-
sectionLevelSix: "six section",
52+
sectionLevelOne: disabledByDefault("one section"),
53+
sectionLevelTwo: disabledByDefault("two section"),
54+
sectionLevelThree: disabledByDefault("three section"),
55+
sectionLevelFour: disabledByDefault("four section"),
56+
sectionLevelFive: disabledByDefault("five section"),
57+
sectionLevelSix: disabledByDefault("six section"),
5458
selector: "selector",
5559
statement: "state",
56-
string: "string",
5760
branch: "branch",
5861
type: "type",
5962
value: "value",
@@ -88,7 +91,8 @@ const defaultSpokenFormMapCore: DefaultSpokenFormMap = {
8891
url: "link",
8992
notebookCell: "cell",
9093

91-
switchStatementSubject: null,
94+
string: secret("parse tree string"),
95+
switchStatementSubject: secret("subject"),
9296
},
9397

9498
surroundingPairForceDirection: {
@@ -124,10 +128,67 @@ const defaultSpokenFormMapCore: DefaultSpokenFormMap = {
124128
customRegex: {},
125129
};
126130

127-
// TODO: Don't cast here; need to make our own mapValues with stronger typing
131+
function disabledByDefault(
132+
...spokenForms: string[]
133+
): DefaultSpokenFormMapEntry {
134+
return {
135+
defaultSpokenForms: spokenForms,
136+
isDisabledByDefault: true,
137+
isSecret: false,
138+
};
139+
}
140+
141+
function secret(...spokenForms: string[]): DefaultSpokenFormMapEntry {
142+
return {
143+
defaultSpokenForms: spokenForms,
144+
isDisabledByDefault: true,
145+
isSecret: true,
146+
};
147+
}
148+
149+
interface DefaultSpokenFormMapEntry {
150+
defaultSpokenForms: string[];
151+
isDisabledByDefault: boolean;
152+
isSecret: boolean;
153+
}
154+
155+
export type DefaultSpokenFormMap = {
156+
readonly [K in keyof SpokenFormMapKeyTypes]: Readonly<
157+
Record<SpokenFormMapKeyTypes[K], DefaultSpokenFormMapEntry>
158+
>;
159+
};
160+
161+
// FIXME: Don't cast here; need to make our own mapValues with stronger typing
128162
// using tricks from our object.d.ts
129-
export const defaultSpokenFormMap = mapValues(
163+
export const defaultSpokenFormInfo = mapValues(
130164
defaultSpokenFormMapCore,
131165
(entry) =>
132-
mapValues(entry, (subEntry) => (subEntry == null ? [] : [subEntry])),
166+
mapValues(entry, (subEntry) =>
167+
typeof subEntry === "string"
168+
? {
169+
defaultSpokenForms: [subEntry],
170+
isDisabledByDefault: false,
171+
isSecret: false,
172+
}
173+
: subEntry,
174+
),
175+
) as DefaultSpokenFormMap;
176+
177+
// FIXME: Don't cast here; need to make our own mapValues with stronger typing
178+
// using tricks from our object.d.ts
179+
export const defaultSpokenFormMap = mapValues(defaultSpokenFormInfo, (entry) =>
180+
mapValues(
181+
entry,
182+
({
183+
defaultSpokenForms,
184+
isDisabledByDefault,
185+
isSecret,
186+
}): SpokenFormMapEntry => ({
187+
spokenForms: isDisabledByDefault ? [] : defaultSpokenForms,
188+
isCustom: false,
189+
defaultSpokenForms,
190+
requiresTalonUpdate: false,
191+
isSecret,
192+
}),
193+
),
133194
) as SpokenFormMap;

‎packages/cursorless-engine/src/SpokenFormMap.ts

+9-1
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,16 @@ export interface SpokenFormMapKeyTypes {
3737

3838
export type SpokenFormType = keyof SpokenFormMapKeyTypes;
3939

40+
export interface SpokenFormMapEntry {
41+
spokenForms: string[];
42+
isCustom: boolean;
43+
defaultSpokenForms: string[];
44+
requiresTalonUpdate: boolean;
45+
isSecret: boolean;
46+
}
47+
4048
export type SpokenFormMap = {
4149
readonly [K in keyof SpokenFormMapKeyTypes]: Readonly<
42-
Record<SpokenFormMapKeyTypes[K], string[]>
50+
Record<SpokenFormMapKeyTypes[K], SpokenFormMapEntry>
4351
>;
4452
};

‎packages/cursorless-engine/src/api/CursorlessEngineApi.ts

+11
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ScopeProvider } from "./ScopeProvider";
77
export interface CursorlessEngine {
88
commandApi: CommandApi;
99
scopeProvider: ScopeProvider;
10+
customSpokenFormGenerator: CustomSpokenFormGenerator;
1011
testCaseRecorder: TestCaseRecorder;
1112
storedTargets: StoredTargetMap;
1213
hatTokenMap: HatTokenMap;
@@ -15,6 +16,16 @@ export interface CursorlessEngine {
1516
runIntegrationTests: () => Promise<void>;
1617
}
1718

19+
export interface CustomSpokenFormGenerator {
20+
/**
21+
* If `true`, indicates they need to update their Talon files to get the
22+
* machinery used to share spoken forms from Talon to the VSCode extension.
23+
*/
24+
readonly needsInitialTalonUpdate: boolean | undefined;
25+
26+
onDidChangeCustomSpokenForms: (listener: () => void) => void;
27+
}
28+
1829
export interface CommandApi {
1930
/**
2031
* Runs a command. This is the core of the Cursorless engine.

‎packages/cursorless-engine/src/cursorlessEngine.ts

+4-3
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { HatTokenMapImpl } from "./core/HatTokenMapImpl";
1313
import { Snippets } from "./core/Snippets";
1414
import { ensureCommandShape } from "./core/commandVersionUpgrades/ensureCommandShape";
1515
import { RangeUpdater } from "./core/updateSelections/RangeUpdater";
16-
import { CustomSpokenFormGenerator } from "./generateSpokenForm/CustomSpokenFormGenerator";
16+
import { CustomSpokenFormGeneratorImpl } from "./generateSpokenForm/CustomSpokenFormGeneratorImpl";
1717
import { LanguageDefinitions } from "./languages/LanguageDefinitions";
1818
import { ModifierStageFactoryImpl } from "./processTargets/ModifierStageFactoryImpl";
1919
import { ScopeHandlerFactoryImpl } from "./processTargets/modifiers/scopeHandlers";
@@ -56,7 +56,7 @@ export function createCursorlessEngine(
5656

5757
const languageDefinitions = new LanguageDefinitions(fileSystem, treeSitter);
5858

59-
const customSpokenFormGenerator = new CustomSpokenFormGenerator(fileSystem);
59+
const customSpokenFormGenerator = new CustomSpokenFormGeneratorImpl(fileSystem);
6060

6161
ide.disposeOnExit(rangeUpdater, languageDefinitions, hatTokenMap, debug);
6262

@@ -97,6 +97,7 @@ export function createCursorlessEngine(
9797
storedTargets,
9898
customSpokenFormGenerator,
9999
),
100+
customSpokenFormGenerator,
100101
testCaseRecorder,
101102
storedTargets,
102103
hatTokenMap,
@@ -110,7 +111,7 @@ export function createCursorlessEngine(
110111
function createScopeProvider(
111112
languageDefinitions: LanguageDefinitions,
112113
storedTargets: StoredTargetMap,
113-
customSpokenFormGenerator: CustomSpokenFormGenerator,
114+
customSpokenFormGenerator: CustomSpokenFormGeneratorImpl,
114115
): ScopeProvider {
115116
const scopeHandlerFactory = new ScopeHandlerFactoryImpl(languageDefinitions);
116117

‎packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGenerator.ts renamed to ‎packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@ import {
66
ScopeType,
77
} from "@cursorless/common";
88
import { SpokenFormGenerator } from ".";
9+
import { CustomSpokenFormGenerator } from "..";
910
import { CustomSpokenForms } from "../CustomSpokenForms";
1011

11-
export class CustomSpokenFormGenerator {
12+
export class CustomSpokenFormGeneratorImpl
13+
implements CustomSpokenFormGenerator
14+
{
1215
private customSpokenForms: CustomSpokenForms;
1316
private spokenFormGenerator: SpokenFormGenerator;
1417
private disposer = new Disposer();
@@ -41,5 +44,9 @@ export class CustomSpokenFormGenerator {
4144
return this.customSpokenForms.getCustomRegexScopeTypes();
4245
}
4346

47+
get needsInitialTalonUpdate() {
48+
return this.customSpokenForms.needsInitialTalonUpdate;
49+
}
50+
4451
dispose = this.disposer.dispose;
4552
}

‎packages/cursorless-engine/src/generateSpokenForm/GeneratorSpokenFormMap.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
SpokenFormMap,
3+
SpokenFormMapEntry,
34
SpokenFormMapKeyTypes,
45
SpokenFormType,
56
} from "../SpokenFormMap";
@@ -13,7 +14,7 @@ export type GeneratorSpokenFormMap = {
1314

1415
export interface SingleTermSpokenForm {
1516
type: "singleTerm";
16-
spokenForms: string[];
17+
spokenForms: SpokenFormMapEntry;
1718
spokenFormType: SpokenFormType;
1819
id: string;
1920
}
@@ -26,7 +27,7 @@ export type SpokenFormComponent =
2627
export function getGeneratorSpokenForms(
2728
spokenFormMap: SpokenFormMap,
2829
): GeneratorSpokenFormMap {
29-
// TODO: Don't cast here; need to make our own mapValues with stronger typing
30+
// FIXME: Don't cast here; need to make our own mapValues with stronger typing
3031
// using tricks from our object.d.ts
3132
return Object.fromEntries(
3233
Object.entries(spokenFormMap).map(([spokenFormType, map]) => [
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
export class NoSpokenFormError extends Error {
2-
constructor(public reason: string) {
2+
constructor(
3+
public reason: string,
4+
public requiresTalonUpdate: boolean = false,
5+
public isSecret: boolean = false,
6+
) {
37
super(`No spoken form for: ${reason}`);
48
}
59
}

‎packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.test.ts

+27-4
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,22 @@ import { promises as fsp } from "node:fs";
1010
import { canonicalizeAndValidateCommand } from "../core/commandVersionUpgrades/canonicalizeAndValidateCommand";
1111
import { getHatMapCommand } from "./getHatMapCommand";
1212
import { SpokenFormGenerator } from ".";
13-
import { defaultSpokenFormMap } from "../DefaultSpokenFormMap";
13+
import { defaultSpokenFormInfo } from "../DefaultSpokenFormMap";
14+
import { mapValues } from "lodash";
15+
import { SpokenFormMap, SpokenFormMapEntry } from "../SpokenFormMap";
16+
17+
const spokenFormMap = mapValues(defaultSpokenFormInfo, (entry) =>
18+
mapValues(
19+
entry,
20+
({ defaultSpokenForms }): SpokenFormMapEntry => ({
21+
spokenForms: defaultSpokenForms,
22+
isCustom: false,
23+
defaultSpokenForms,
24+
requiresTalonUpdate: false,
25+
isSecret: false,
26+
}),
27+
),
28+
) as SpokenFormMap;
1429

1530
suite("Generate spoken forms", () => {
1631
getRecordedTestPaths().forEach(({ name, path }) =>
@@ -19,8 +34,16 @@ suite("Generate spoken forms", () => {
1934

2035
test("generate spoken form for custom regex", () => {
2136
const generator = new SpokenFormGenerator({
22-
...defaultSpokenFormMap,
23-
customRegex: { foo: ["bar"] },
37+
...spokenFormMap,
38+
customRegex: {
39+
foo: {
40+
spokenForms: ["bar"],
41+
isCustom: false,
42+
defaultSpokenForms: ["bar"],
43+
requiresTalonUpdate: false,
44+
isSecret: false,
45+
},
46+
},
2447
});
2548

2649
const spokenForm = generator.scopeType({
@@ -37,7 +60,7 @@ async function runTest(file: string) {
3760
const buffer = await fsp.readFile(file);
3861
const fixture = yaml.load(buffer.toString()) as TestCaseFixtureLegacy;
3962

40-
const generator = new SpokenFormGenerator(defaultSpokenFormMap);
63+
const generator = new SpokenFormGenerator(spokenFormMap);
4164

4265
const generatedSpokenForm = generator.command(
4366
canonicalizeAndValidateCommand(fixture.command),

‎packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.ts

+12-3
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export interface SpokenFormSuccess {
3333
export interface SpokenFormError {
3434
type: "error";
3535
reason: string;
36+
requiresTalonUpdate: boolean;
37+
isSecret: boolean;
3638
}
3739

3840
export type SpokenForm = SpokenFormSuccess | SpokenFormError;
@@ -80,7 +82,12 @@ export class SpokenFormGenerator {
8082
return { type: "success", preferred, alternatives };
8183
} catch (e) {
8284
if (e instanceof NoSpokenFormError) {
83-
return { type: "error", reason: e.reason };
85+
return {
86+
type: "error",
87+
reason: e.reason,
88+
requiresTalonUpdate: e.requiresTalonUpdate,
89+
isSecret: e.isSecret,
90+
};
8491
}
8592

8693
throw e;
@@ -259,15 +266,17 @@ function constructSpokenForms(component: SpokenFormComponent): string[] {
259266
);
260267
}
261268

262-
if (component.spokenForms.length === 0) {
269+
if (component.spokenForms.spokenForms.length === 0) {
263270
throw new NoSpokenFormError(
264271
`${camelCaseToAllDown(component.spokenFormType)} with id ${
265272
component.id
266273
}; please see https://www.cursorless.org/docs/user/customization/ for more information`,
274+
component.spokenForms.requiresTalonUpdate,
275+
component.spokenForms.isSecret,
267276
);
268277
}
269278

270-
return component.spokenForms;
279+
return component.spokenForms.spokenForms;
271280
}
272281

273282
/**

‎packages/cursorless-engine/src/generateSpokenForm/primitiveTargetToSpokenForm.ts

+4-3
Original file line numberDiff line numberDiff line change
@@ -209,8 +209,6 @@ export class PrimitiveTargetSpokenFormGenerator {
209209
handleScopeType(scopeType: ScopeType): SpokenFormComponent {
210210
switch (scopeType.type) {
211211
case "oneOf":
212-
case "switchStatementSubject":
213-
case "string":
214212
throw new NoSpokenFormError(`Scope type '${scopeType.type}'`);
215213
case "surroundingPair": {
216214
if (scopeType.delimiter === "collectionBoundary") {
@@ -337,7 +335,10 @@ function pluralize(name: SpokenFormComponent): SpokenFormComponent {
337335

338336
return {
339337
...name,
340-
spokenForms: name.spokenForms.map(pluralizeString),
338+
spokenForms: {
339+
...name.spokenForms,
340+
spokenForms: name.spokenForms.spokenForms.map(pluralizeString),
341+
},
341342
};
342343
}
343344

‎packages/cursorless-engine/src/scopeProviders/ScopeInfoProvider.ts

+5-17
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,12 @@ import {
77
surroundingPairNames,
88
} from "@cursorless/common";
99
import { pull } from "lodash";
10-
import { homedir } from "os";
11-
import * as path from "path";
1210
import { ScopeTypeInfo, ScopeTypeInfoEventCallback } from "..";
1311

14-
import { CustomSpokenFormGenerator } from "../generateSpokenForm/CustomSpokenFormGenerator";
12+
import { CustomSpokenFormGeneratorImpl } from "../generateSpokenForm/CustomSpokenFormGeneratorImpl";
1513
import { scopeTypeToString } from "./scopeTypeToString";
1614
import { SpeakableSurroundingPairName } from "../SpokenFormMap";
1715

18-
export const spokenFormsPath = path.join(
19-
homedir(),
20-
".cursorless",
21-
"spokenForms.json",
22-
);
23-
2416
/**
2517
* Maintains a list of all scope types and notifies listeners when it changes.
2618
*/
@@ -29,7 +21,9 @@ export class ScopeInfoProvider {
2921
private listeners: ScopeTypeInfoEventCallback[] = [];
3022
private scopeInfos!: ScopeTypeInfo[];
3123

32-
constructor(private customSpokenFormGenerator: CustomSpokenFormGenerator) {
24+
constructor(
25+
private customSpokenFormGenerator: CustomSpokenFormGeneratorImpl,
26+
) {
3327
this.disposer.push(
3428
customSpokenFormGenerator.onDidChangeCustomSpokenForms(() =>
3529
this.onChange(),
@@ -70,13 +64,7 @@ export class ScopeInfoProvider {
7064
const scopeTypes: ScopeType[] = [
7165
...simpleScopeTypeTypes
7266
// Ignore instance pseudo-scope because it's not really a scope
73-
// Skip "string" because we use surrounding pair for that
74-
.filter(
75-
(scopeTypeType) =>
76-
scopeTypeType !== "instance" &&
77-
scopeTypeType !== "string" &&
78-
scopeTypeType !== "switchStatementSubject",
79-
)
67+
.filter((scopeTypeType) => scopeTypeType !== "instance")
8068
.map((scopeTypeType) => ({
8169
type: scopeTypeType,
8270
})),
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import { LATEST_VERSION, SimpleScopeTypeType } from "@cursorless/common";
22
import { readFile } from "fs/promises";
3-
import { spokenFormsPath } from "./ScopeInfoProvider";
3+
import { homedir } from "os";
44
import { SpeakableSurroundingPairName } from "../SpokenFormMap";
5+
import * as path from "path";
6+
7+
export const spokenFormsPath = path.join(
8+
homedir(),
9+
".cursorless",
10+
"spokenForms.json",
11+
);
512

613
export interface CustomRegexSpokenFormEntry {
714
type: "customRegex";
@@ -21,26 +28,26 @@ export interface SimpleScopeTypeTypeSpokenFormEntry {
2128
spokenForms: string[];
2229
}
2330

24-
type SpokenFormEntry =
31+
export type SpokenFormEntry =
2532
| CustomRegexSpokenFormEntry
2633
| PairedDelimiterSpokenFormEntry
2734
| SimpleScopeTypeTypeSpokenFormEntry;
2835

2936
export async function getSpokenFormEntries(): Promise<SpokenFormEntry[]> {
30-
try {
31-
const payload = JSON.parse(await readFile(spokenFormsPath, "utf-8"));
32-
33-
if (payload.version !== LATEST_VERSION) {
34-
// In the future, we'll need to handle migrations. Not sure exactly how yet.
35-
throw new Error(
36-
`Invalid spoken forms version. Expected ${LATEST_VERSION} but got ${payload.version}`,
37-
);
38-
}
39-
40-
return payload.entries;
41-
} catch (err) {
42-
console.error(`Error getting spoken forms`);
43-
console.error(err);
44-
return [];
37+
const payload = JSON.parse(await readFile(spokenFormsPath, "utf-8"));
38+
39+
/**
40+
* This assignment is to ensure that the compiler will error if we forget to
41+
* handle spokenForms.json when we bump the command version.
42+
*/
43+
const latestCommandVersion: 6 = LATEST_VERSION;
44+
45+
if (payload.version !== latestCommandVersion) {
46+
// In the future, we'll need to handle migrations. Not sure exactly how yet.
47+
throw new Error(
48+
`Invalid spoken forms version. Expected ${LATEST_VERSION} but got ${payload.version}`,
49+
);
4550
}
51+
52+
return payload.entries;
4653
}

‎packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts

+75-10
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,29 @@
11
import { CursorlessCommandId, Disposer } from "@cursorless/common";
22
import {
3+
CustomSpokenFormGenerator,
34
ScopeProvider,
45
ScopeSupport,
56
ScopeSupportLevels,
67
ScopeTypeInfo,
78
} from "@cursorless/cursorless-engine";
9+
import { VscodeApi } from "@cursorless/vscode-common";
10+
import { isEqual } from "lodash";
811
import * as vscode from "vscode";
12+
import { URI } from "vscode-uri";
913
import {
1014
ScopeVisualizer,
1115
VisualizationType,
1216
} from "./ScopeVisualizerCommandApi";
13-
import { isEqual } from "lodash";
17+
18+
export const DONT_SHOW_TALON_UPDATE_MESSAGE_KEY = "dontShowUpdateTalonMessage";
1419

1520
export class ScopeSupportTreeProvider
1621
implements vscode.TreeDataProvider<MyTreeItem>
1722
{
1823
private visibleDisposable: Disposer | undefined;
1924
private treeView: vscode.TreeView<MyTreeItem>;
2025
private supportLevels: ScopeSupportLevels = [];
26+
private shownUpdateTalonMessage = false;
2127

2228
private _onDidChangeTreeData: vscode.EventEmitter<
2329
MyTreeItem | undefined | null | void
@@ -27,11 +33,14 @@ export class ScopeSupportTreeProvider
2733
> = this._onDidChangeTreeData.event;
2834

2935
constructor(
36+
private vscodeApi: VscodeApi,
3037
private context: vscode.ExtensionContext,
3138
private scopeProvider: ScopeProvider,
3239
private scopeVisualizer: ScopeVisualizer,
40+
private customSpokenFormGenerator: CustomSpokenFormGenerator,
41+
private hasCommandServer: boolean,
3342
) {
34-
this.treeView = vscode.window.createTreeView("cursorless.scopeSupport", {
43+
this.treeView = vscodeApi.window.createTreeView("cursorless.scopeSupport", {
3544
treeDataProvider: this,
3645
});
3746

@@ -43,14 +52,20 @@ export class ScopeSupportTreeProvider
4352
}
4453

4554
static create(
55+
vscodeApi: VscodeApi,
4656
context: vscode.ExtensionContext,
4757
scopeProvider: ScopeProvider,
4858
scopeVisualizer: ScopeVisualizer,
59+
customSpokenFormGenerator: CustomSpokenFormGenerator,
60+
hasCommandServer: boolean,
4961
): ScopeSupportTreeProvider {
5062
const treeProvider = new ScopeSupportTreeProvider(
63+
vscodeApi,
5164
context,
5265
scopeProvider,
5366
scopeVisualizer,
67+
customSpokenFormGenerator,
68+
hasCommandServer,
5469
);
5570
treeProvider.init();
5671
return treeProvider;
@@ -98,6 +113,7 @@ export class ScopeSupportTreeProvider
98113

99114
getChildren(element?: MyTreeItem): MyTreeItem[] {
100115
if (element == null) {
116+
this.possiblyShowUpdateTalonMessage();
101117
return getSupportCategories();
102118
}
103119

@@ -108,9 +124,46 @@ export class ScopeSupportTreeProvider
108124
throw new Error("Unexpected element");
109125
}
110126

127+
private async possiblyShowUpdateTalonMessage() {
128+
if (
129+
!this.customSpokenFormGenerator.needsInitialTalonUpdate ||
130+
this.shownUpdateTalonMessage ||
131+
!this.hasCommandServer ||
132+
(await this.context.globalState.get(DONT_SHOW_TALON_UPDATE_MESSAGE_KEY))
133+
) {
134+
return;
135+
}
136+
137+
this.shownUpdateTalonMessage = true;
138+
139+
const result = await this.vscodeApi.window.showInformationMessage(
140+
"In order to see your custom spoken forms in the sidebar, you'll need to update your Cursorless Talon files.",
141+
"How?",
142+
"Don't show again",
143+
);
144+
145+
if (result === "How?") {
146+
await this.vscodeApi.env.openExternal(
147+
URI.parse(
148+
"https://www.cursorless.org/docs/user/updating/#updating-the-talon-side",
149+
),
150+
);
151+
} else if (result === "Don't show again") {
152+
await this.context.globalState.update(
153+
DONT_SHOW_TALON_UPDATE_MESSAGE_KEY,
154+
true,
155+
);
156+
}
157+
}
158+
111159
getScopeTypesWithSupport(scopeSupport: ScopeSupport): ScopeSupportTreeItem[] {
112160
return this.supportLevels
113-
.filter((supportLevel) => supportLevel.support === scopeSupport)
161+
.filter(
162+
(supportLevel) =>
163+
supportLevel.support === scopeSupport &&
164+
(supportLevel.spokenForm.type !== "error" ||
165+
!supportLevel.spokenForm.isSecret),
166+
)
114167
.map(
115168
(supportLevel) =>
116169
new ScopeSupportTreeItem(
@@ -169,6 +222,11 @@ function getSupportCategories(): SupportCategoryTreeItem[] {
169222
class ScopeSupportTreeItem extends vscode.TreeItem {
170223
public label: vscode.TreeItemLabel;
171224

225+
/**
226+
* @param scopeTypeInfo The scope type info
227+
* @param isVisualized Whether the scope type is currently being visualized
228+
with the scope visualizer
229+
*/
172230
constructor(
173231
public scopeTypeInfo: ScopeTypeInfo,
174232
isVisualized: boolean,
@@ -181,20 +239,27 @@ class ScopeSupportTreeItem extends vscode.TreeItem {
181239

182240
super(label, vscode.TreeItemCollapsibleState.None);
183241

242+
const requiresTalonUpdate =
243+
scopeTypeInfo.spokenForm.type === "error" &&
244+
scopeTypeInfo.spokenForm.requiresTalonUpdate;
245+
184246
this.label = {
185247
label,
186248
highlights: isVisualized ? [[0, label.length]] : [],
187249
};
188250

189251
this.description = description;
190252

191-
if (
192-
scopeTypeInfo.spokenForm.type === "success" &&
193-
scopeTypeInfo.spokenForm.alternatives.length > 0
194-
) {
195-
this.tooltip = scopeTypeInfo.spokenForm.alternatives
196-
.map((spokenForm) => `"${spokenForm}"`)
197-
.join("\n");
253+
if (scopeTypeInfo.spokenForm.type === "success") {
254+
if (scopeTypeInfo.spokenForm.alternatives.length > 0) {
255+
this.tooltip = scopeTypeInfo.spokenForm.alternatives
256+
.map((spokenForm) => `"${spokenForm}"`)
257+
.join("\n");
258+
}
259+
} else if (requiresTalonUpdate) {
260+
this.tooltip = "Requires Talon update";
261+
} else {
262+
this.tooltip = "Spoken form disabled; see customization docs";
198263
}
199264

200265
this.command = isVisualized

‎packages/cursorless-vscode/src/extension.ts

+11-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
Disposable,
23
FakeIDE,
34
getFakeCommandServerApi,
45
IDE,
@@ -37,6 +38,7 @@ import { ReleaseNotes } from "./ReleaseNotes";
3738
import {
3839
ScopeVisualizer,
3940
VisualizationType,
41+
VisualizerScopeTypeListener,
4042
} from "./ScopeVisualizerCommandApi";
4143
import { StatusBarItem } from "./StatusBarItem";
4244
import { vscodeApi } from "./vscodeApi";
@@ -82,6 +84,7 @@ export async function activate(
8284
snippets,
8385
injectIde,
8486
runIntegrationTests,
87+
customSpokenFormGenerator,
8588
} = createCursorlessEngine(
8689
treeSitter,
8790
normalizedIde,
@@ -93,7 +96,14 @@ export async function activate(
9396
const statusBarItem = StatusBarItem.create("cursorless.showQuickPick");
9497
const keyboardCommands = KeyboardCommands.create(context, statusBarItem);
9598
const scopeVisualizer = createScopeVisualizer(normalizedIde, scopeProvider);
96-
ScopeSupportTreeProvider.create(context, scopeProvider, scopeVisualizer);
99+
ScopeSupportTreeProvider.create(
100+
vscodeApi,
101+
context,
102+
scopeProvider,
103+
scopeVisualizer,
104+
customSpokenFormGenerator,
105+
commandServerApi != null,
106+
);
97107

98108
registerCommands(
99109
context,

‎packages/cursorless-vscode/src/ide/vscode/VscodeGlobalState.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
1-
import type { ExtensionContext } from "vscode";
21
import type { State, StateData, StateKey } from "@cursorless/common";
32
import { STATE_DEFAULTS } from "@cursorless/common";
3+
import type { ExtensionContext } from "vscode";
44
import { VERSION_KEY } from "../../ReleaseNotes";
5+
import { DONT_SHOW_TALON_UPDATE_MESSAGE_KEY } from "../../ScopeSupportTreeProvider";
56

67
export default class VscodeGlobalState implements State {
78
constructor(private extensionContext: ExtensionContext) {
89
// Mark all keys for synchronization
910
extensionContext.globalState.setKeysForSync([
1011
...Object.keys(STATE_DEFAULTS),
1112
VERSION_KEY,
13+
DONT_SHOW_TALON_UPDATE_MESSAGE_KEY,
1214
]);
1315
}
1416

0 commit comments

Comments
 (0)
Please sign in to comment.