From 6eb1bf0f8b404b29a9bdcfea132c9e21021f574b Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Sun, 30 Aug 2026 06:01:51 +0200 Subject: [PATCH 01/28] Removed the default cheatsheet info json file and replaced it with a function that returns the default cheatsheet info. --- .../src/cheatsheet/cheat_sheet.py | 14 - packages/app-neovim/src/registerCommands.ts | 1 - packages/app-vscode/package.json | 5 - packages/app-vscode/src/registerCommands.ts | 3 +- .../src/docs/contributing/CONTRIBUTING.mdx | 11 - .../src/docs/contributing/cheatsheet.md | 4 +- packages/app-web/src/Cheatsheet.tsx | 8 +- packages/lib-cheatsheet-local/vite.config.ts | 4 +- packages/lib-cheatsheet/package.json | 3 +- packages/lib-cheatsheet/src/index.ts | 1 + .../src/lib/getDefaultCheatsheetInfo.ts | 369 +++ .../lib/sampleSpokenFormInfos/defaults.json | 2023 ----------------- .../src/test/getDefaultCheatsheetInfo.spec.ts | 40 + packages/lib-common/package.json | 1 + .../lib-common/src/cursorlessCommandIds.ts | 4 - packages/lib-node-common/src/Cheatsheet.ts | 25 - 16 files changed, 421 insertions(+), 2095 deletions(-) create mode 100644 packages/lib-cheatsheet/src/lib/getDefaultCheatsheetInfo.ts delete mode 100644 packages/lib-cheatsheet/src/lib/sampleSpokenFormInfos/defaults.json create mode 100644 packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts diff --git a/cursorless-talon/src/cheatsheet/cheat_sheet.py b/cursorless-talon/src/cheatsheet/cheat_sheet.py index 60eeae2a27..655bb8be1e 100644 --- a/cursorless-talon/src/cheatsheet/cheat_sheet.py +++ b/cursorless-talon/src/cheatsheet/cheat_sheet.py @@ -30,12 +30,6 @@ def private_cursorless_cheat_sheet_show_html(): 'Please first focus an app that supports cursorless, eg say "focus code"' ) - def private_cursorless_cheat_sheet_update_json(): - """Update default cursorless cheatsheet json (for developer use only)""" - app.notify( - 'Please first focus an app that supports cursorless, eg say "focus code"' - ) - def private_cursorless_open_instructions(): """Open web page with cursorless instructions""" actions.user.private_cursorless_notify_docs_opened() @@ -66,14 +60,6 @@ def private_cursorless_cheat_sheet_show_html(): ) webbrowser.open(cheatsheet_out_path.as_uri()) - def private_cursorless_cheat_sheet_update_json(): - """Update default cursorless cheatsheet json (for developer use only)""" - actions.user.private_cursorless_run_rpc_command_and_wait( - "cursorless.internal.updateCheatsheetDefaults", - cursorless_cheat_sheet_get_json(), - ) - - def cheatsheet_dir_linux() -> Path: """Get cheatsheet directory for Linux""" try: diff --git a/packages/app-neovim/src/registerCommands.ts b/packages/app-neovim/src/registerCommands.ts index 37dd30252a..d676cb28a1 100644 --- a/packages/app-neovim/src/registerCommands.ts +++ b/packages/app-neovim/src/registerCommands.ts @@ -70,7 +70,6 @@ export function registerCommands( // Cheatsheet commands "cursorless.showCheatsheet": dummyCommandHandler, - "cursorless.internal.updateCheatsheetDefaults": dummyCommandHandler, // Testcase recorder commands "cursorless.recordTestCase": dummyCommandHandler, diff --git a/packages/app-vscode/package.json b/packages/app-vscode/package.json index 21c4614c3b..04a571a18e 100644 --- a/packages/app-vscode/package.json +++ b/packages/app-vscode/package.json @@ -177,11 +177,6 @@ "title": "Cursorless: Display the cursorless cheatsheet", "enablement": "false" }, - { - "command": "cursorless.internal.updateCheatsheetDefaults", - "title": "Cursorless: Update the default values of the cheatsheet payload used on the website and for local development. Be sure to run this on stock community and cursorless.", - "enablement": "false" - }, { "command": "cursorless.private.logQuickActions", "title": "Cursorless: Log the quick actions available at the current cursor position", diff --git a/packages/app-vscode/src/registerCommands.ts b/packages/app-vscode/src/registerCommands.ts index 3109b3216e..5ae5506682 100644 --- a/packages/app-vscode/src/registerCommands.ts +++ b/packages/app-vscode/src/registerCommands.ts @@ -7,7 +7,7 @@ import type { import { CURSORLESS_COMMAND_ID } from "@cursorless/lib-common"; import type { CommandApi, StoredTargetMap } from "@cursorless/lib-engine"; import { analyzeCommandHistory } from "@cursorless/lib-engine"; -import { showCheatsheet, updateDefaults } from "@cursorless/lib-node-common"; +import { showCheatsheet } from "@cursorless/lib-node-common"; import type { CheatSheetCommandArg } from "@cursorless/lib-node-common"; import type { ScopeTestRecorder, @@ -70,7 +70,6 @@ export function registerCommands( // Cheatsheet commands "cursorless.showCheatsheet": (arg: CheatSheetCommandArg) => showCheatsheet(vscodeIde, arg), - "cursorless.internal.updateCheatsheetDefaults": updateDefaults, // Testcase recorder commands "cursorless.recordTestCase": testCaseRecorder.toggle, diff --git a/packages/app-web-docs/src/docs/contributing/CONTRIBUTING.mdx b/packages/app-web-docs/src/docs/contributing/CONTRIBUTING.mdx index 774b0d47a9..39588754de 100644 --- a/packages/app-web-docs/src/docs/contributing/CONTRIBUTING.mdx +++ b/packages/app-web-docs/src/docs/contributing/CONTRIBUTING.mdx @@ -214,17 +214,6 @@ pnpm -F @cursorless/app-vscode uninstall-local ## Regular manual maintenance tasks -### Updating the cheatsheet - -We need to keep the default cheatsheet up-to-date for use with local development and for https://www.cursorless.org/cheatsheet. - -1. Switch to vanilla community + cursorless_talon -2. `"debug extension"` -3. `"cursorless update cheatsheet"` -4. See diff and cleanup if necessary. In particular, if you have shapes enabled, you'll need to remove these from the generated cheatsheet as they're not on by default - -This should be done every time we do something that will change the custom cheatsheet, but in practice we don't, so it rots a bit and needs to be redone regularly. - ## Changing SVGs ### SVG preprocessing script diff --git a/packages/app-web-docs/src/docs/contributing/cheatsheet.md b/packages/app-web-docs/src/docs/contributing/cheatsheet.md index e5355539a8..2cbe03c1ad 100644 --- a/packages/app-web-docs/src/docs/contributing/cheatsheet.md +++ b/packages/app-web-docs/src/docs/contributing/cheatsheet.md @@ -6,9 +6,7 @@ The implementation of the local version of the cheatsheet is split between the T ## Adding a new spoken form -When you add a new scope type, action, modifier, etc, you'll need to ensure that it shows up both locally and on the website. It will usually automatically show up in the local cheatsheet. You can verify this by saying `"cursorless cheatsheet"` with your development version of `cursorless-talon` active in your Talon user directory, and inspecting the cheatsheet that appears. If it does not, you'll need to make fixes to [the Talon side of the cheatsheet](../../../../../cursorless-talon/src/cheatsheet). - -In either case, to get your changes to appear on the website, you need to update the defaults in [`defaults.json`](../../../../../packages/lib-cheatsheet/src/lib/sampleSpokenFormInfos/defaults.json). First make sure you have the `cursorless-talon-dev` user file set in your Talon home directory, as indicated in the [initial contributor setup instructions](CONTRIBUTING.mdx#initial-setup). Then you can say `"cursorless update cheatsheet"` to update the default spoken forms. Note that this will use your custom spoken forms, so you may need to do some manual cleanup. +When you add a new scope type, action, modifier, etc, you'll need to ensure that it shows up both locally and on the website. The website cheatsheet is constructed from the reference definitions in `lib-common`, so reference changes appear automatically. The local cheatsheet will usually update automatically as well. You can verify it by saying `"cursorless cheatsheet"` with your development version of `cursorless-talon` active in your Talon user directory. If it does not, you'll need to make fixes to [the Talon side of the cheatsheet](../../../../../cursorless-talon/src/cheatsheet). ## Running the cheatsheet in development mode diff --git a/packages/app-web/src/Cheatsheet.tsx b/packages/app-web/src/Cheatsheet.tsx index e57c4952f4..438422e94c 100644 --- a/packages/app-web/src/Cheatsheet.tsx +++ b/packages/app-web/src/Cheatsheet.tsx @@ -1,12 +1,14 @@ -import { Cheatsheet as OriginalCheatsheet } from "@cursorless/lib-cheatsheet"; -import defaultCheatsheetInfo from "@cursorless/lib-cheatsheet/defaultSpokenForms"; +import { + Cheatsheet as OriginalCheatsheet, + getDefaultCheatsheetInfo, +} from "@cursorless/lib-cheatsheet"; import { Title } from "./Title"; export function Cheatsheet() { return ( <> Cursorless cheatsheet - + ); } diff --git a/packages/lib-cheatsheet-local/vite.config.ts b/packages/lib-cheatsheet-local/vite.config.ts index 572edcab3e..52bc55a0fd 100644 --- a/packages/lib-cheatsheet-local/vite.config.ts +++ b/packages/lib-cheatsheet-local/vite.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "vite"; import type { UserConfig } from "vite"; import purgeCss from "vite-plugin-purgecss"; import { viteSingleFile } from "vite-plugin-singlefile"; -import defaultCheatsheetInfo from "@cursorless/lib-cheatsheet/defaultSpokenForms"; +import { getDefaultCheatsheetInfo } from "@cursorless/lib-cheatsheet"; import { purgeCssOptions, viteHtmlParams, @@ -24,7 +24,7 @@ export default defineConfig((): UserConfig => { purgeCss(purgeCssOptions), viteSingleFile(), viteHtmlParams({ - FAKE_CHEATSHEET_INFO: JSON.stringify(defaultCheatsheetInfo), + FAKE_CHEATSHEET_INFO: JSON.stringify(getDefaultCheatsheetInfo()), }), ], }; diff --git a/packages/lib-cheatsheet/package.json b/packages/lib-cheatsheet/package.json index 2687c97df8..b9a07a8485 100644 --- a/packages/lib-cheatsheet/package.json +++ b/packages/lib-cheatsheet/package.json @@ -5,8 +5,7 @@ "license": "MIT", "type": "module", "exports": { - ".": "./src/index.ts", - "./defaultSpokenForms": "./src/lib/sampleSpokenFormInfos/defaults.json" + ".": "./src/index.ts" }, "sideEffects": [ "*.css" diff --git a/packages/lib-cheatsheet/src/index.ts b/packages/lib-cheatsheet/src/index.ts index 3a37b41635..20e2ab3f95 100644 --- a/packages/lib-cheatsheet/src/index.ts +++ b/packages/lib-cheatsheet/src/index.ts @@ -1,3 +1,4 @@ export * from "./lib/Cheatsheet"; export * from "./lib/cheatsheet.types"; +export * from "./lib/getDefaultCheatsheetInfo"; export * from "./lib/utils/fakeCheatsheetInfo"; diff --git a/packages/lib-cheatsheet/src/lib/getDefaultCheatsheetInfo.ts b/packages/lib-cheatsheet/src/lib/getDefaultCheatsheetInfo.ts new file mode 100644 index 0000000000..34434f6e98 --- /dev/null +++ b/packages/lib-cheatsheet/src/lib/getDefaultCheatsheetInfo.ts @@ -0,0 +1,369 @@ +import { + actionReferences, + connectiveDefaultSpokenForms, + graphemeDefaultSpokenForms, + hatColorDefaultSpokenForms, + lineDirectionDefaultSpokenForms, + markDefaultSpokenForms, + modifierReferences, + pairedDelimiterReferences, + scopeReferences, +} from "@cursorless/lib-common/references"; +import type { SpokenFormReference } from "@cursorless/lib-common/references"; +import type { CheatsheetInfo, CheatsheetSection } from "./cheatsheet.types"; + +interface CheatsheetReference { + defaultSpokenForm?: string; + disabledByDefault?: boolean; + private?: boolean; + syntaxes: readonly { + pattern: string; + cheatsheet: string; + }[]; +} + +type ReferenceMap = Readonly>; + +const REFERENCE_SPOKEN_FORM = ""; + +/** Construct the stock cheatsheet directly from the canonical references. */ +export function getDefaultCheatsheetInfo(): CheatsheetInfo { + return { + sections: [ + referenceSection("Actions", "actions", "action", actionReferences), + colorsSection, + compoundTargetsSection, + destinationsSection, + referenceSection( + "Modifiers", + "modifiers", + "modifier", + modifierReferences, + { + endOf: "end", + everyScope: "every", + startOf: "start", + }, + ), + pairedDelimitersSection(), + scopeVisualizerSection, + referenceSection("Scopes", "scopes", "scopeType", scopeReferences, { + surroundingPair: "pair", + }), + shapesSection, + specialMarksSection, + tutorialSection, + ], + }; +} + +function referenceSection( + name: string, + id: string, + type: string, + references: ReferenceMap, + itemIdOverrides: Readonly> = {}, +): CheatsheetSection { + return { + name, + id, + items: Object.entries(references) + .filter(([, reference]) => isEnabledPublicReference(reference)) + .map(([referenceId, reference]) => ({ + id: itemIdOverrides[referenceId] ?? referenceId, + type, + variations: reference.syntaxes.map(({ pattern, cheatsheet }) => ({ + spokenForm: pattern.replaceAll( + REFERENCE_SPOKEN_FORM, + reference.defaultSpokenForm ?? REFERENCE_SPOKEN_FORM, + ), + description: cheatsheet, + })), + })) + .filter(({ variations }) => variations.length > 0), + }; +} + +function isEnabledPublicReference(reference: CheatsheetReference): boolean { + return !reference.private && !reference.disabledByDefault; +} + +function pairedDelimitersSection(): CheatsheetSection { + return { + name: "Paired delimiters", + id: "pairedDelimiters", + items: Object.entries(pairedDelimiterReferences) + .filter(([, reference]) => isEnabledSpokenFormReference(reference)) + .map(([id, reference]) => ({ + id, + type: "pairedDelimiter", + variations: [ + { + spokenForm: reference.defaultSpokenForm, + description: capitalize(reference.name), + }, + ], + })), + }; +} + +function isEnabledSpokenFormReference(reference: SpokenFormReference): boolean { + return !reference.private && !reference.disabledByDefault; +} + +function capitalize(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1); +} + +const colorsSection: CheatsheetSection = { + name: "Colors", + id: "colors", + items: [ + ["blue", requiredSpokenForm(hatColorDefaultSpokenForms.blue)], + ["green", requiredSpokenForm(hatColorDefaultSpokenForms.green)], + ["pink", requiredSpokenForm(hatColorDefaultSpokenForms.pink)], + ["red", requiredSpokenForm(hatColorDefaultSpokenForms.red)], + ["yellow", requiredSpokenForm(hatColorDefaultSpokenForms.yellow)], + ].map(([id, spokenForm]) => ({ + id, + type: "hatColor", + variations: [{ spokenForm, description: capitalize(id) }], + })), +}; + +function requiredSpokenForm(spokenForm: string | null): string { + if (spokenForm == null) { + throw new Error("Expected a default spoken form"); + } + + return spokenForm; +} + +const compoundTargetsSection: CheatsheetSection = { + name: "Compound targets", + id: "compoundTargets", + items: [ + { + id: "listConnective", + spokenForm: connectiveDefaultSpokenForms.listConnective, + descriptions: [" and "], + }, + { + id: "rangeExclusive", + spokenForm: connectiveDefaultSpokenForms.rangeExclusive, + descriptions: [ + "between and ", + "between selection and ", + ], + }, + { + id: "rangeInclusive", + spokenForm: connectiveDefaultSpokenForms.rangeInclusive, + descriptions: [ + " through ", + "selection through ", + ], + }, + { + id: "rangeExcludingEnd", + spokenForm: connectiveDefaultSpokenForms.rangeExcludingEnd, + descriptions: [ + " until start of ", + "selection until start of ", + ], + }, + { + id: "verticalRange", + spokenForm: connectiveDefaultSpokenForms.verticalRange, + descriptions: [ + " vertically through ", + "selection vertically through ", + ], + }, + ].map(({ id, spokenForm, descriptions }) => ({ + id, + type: "compoundTargetConnective", + variations: descriptions.map((description, index) => ({ + spokenForm: + index === 0 + ? ` ${spokenForm} ` + : `${spokenForm} `, + description, + })), + })), +}; + +const destinationsSection: CheatsheetSection = { + name: "Destinations", + id: "destinations", + items: [ + { + id: "destination_after", + spokenForm: connectiveDefaultSpokenForms.after, + description: "Insert after ", + }, + { + id: "destination_before", + spokenForm: connectiveDefaultSpokenForms.before, + description: "Insert before ", + }, + { + id: "destination_to", + spokenForm: connectiveDefaultSpokenForms.sourceDestinationConnective, + description: "Replace ", + }, + ].map(({ id, spokenForm, description }) => ({ + id, + type: "destination", + variations: [{ spokenForm: `${spokenForm} `, description }], + })), +}; + +const scopeVisualizerSection: CheatsheetSection = { + name: "Scope visualizer", + id: "scopeVisualizer", + items: [ + item( + "hideScopeVisualizer", + "command", + "visualize nothing", + "Hide scope visualizer", + ), + item( + "show_scope_sidebar", + "command", + "bar cursorless", + "Show cursorless sidebar", + ), + { + id: "show_scope_visualizer", + type: "command", + variations: [ + { spokenForm: "visualize ", description: "Visualize " }, + { + spokenForm: "visualize removal", + description: "Visualize removal range", + }, + { + spokenForm: "visualize iteration", + description: "Visualize iteration range", + }, + ], + }, + ], +}; + +const shapesSection: CheatsheetSection = { + name: "Shapes", + id: "shapes", + items: [], +}; + +const specialMarksSection: CheatsheetSection = { + name: "Special marks", + id: "specialMarks", + items: [ + item( + "currentSelection", + "mark", + markDefaultSpokenForms.cursor, + "Current selection", + ), + item( + "lineNumberModulo100", + "mark", + `${lineDirectionDefaultSpokenForms.modulo100} `, + "Line number modulo 100", + ), + item( + "lineNumberRelativeDown", + "mark", + `${lineDirectionDefaultSpokenForms.relativeDown} `, + "Line number down from cursor", + ), + item( + "lineNumberRelativeUp", + "mark", + `${lineDirectionDefaultSpokenForms.relativeUp} `, + "Line number up from cursor", + ), + item("nothing", "mark", markDefaultSpokenForms.nothing, "Nothing"), + item( + "previousSource", + "mark", + markDefaultSpokenForms.source, + "Previous source", + ), + item( + "previousTarget", + "mark", + markDefaultSpokenForms.that, + "Previous target", + ), + item( + "unknownSymbol", + "mark", + graphemeDefaultSpokenForms["\uFFFD"], + "Unknown symbol", + ), + ], +}; + +const tutorialSection: CheatsheetSection = { + name: "Tutorial", + id: "tutorial", + items: [ + item( + "start_tutorial", + "command", + "cursorless tutorial", + "Start the introductory Cursorless tutorial", + ), + item("tutorial_close", "command", "tutorial close", "Close the tutorial"), + item( + "tutorial_list", + "command", + "tutorial list", + "List all available tutorials", + ), + item( + "tutorial_next", + "command", + "tutorial next", + "Advance to next step in tutorial", + ), + item( + "tutorial_previous", + "command", + "tutorial previous", + "Go back to previous step in tutorial", + ), + item( + "tutorial_restart", + "command", + "tutorial restart", + "Restart the tutorial", + ), + item( + "tutorial_resume", + "command", + "tutorial resume", + "Resume the tutorial", + ), + item( + "tutorial_start_by_number", + "command", + "tutorial ", + "Start a specific tutorial by number", + ), + ], +}; + +function item( + id: string, + type: string, + spokenForm: string, + description: string, +): CheatsheetSection["items"][number] { + return { id, type, variations: [{ spokenForm, description }] }; +} diff --git a/packages/lib-cheatsheet/src/lib/sampleSpokenFormInfos/defaults.json b/packages/lib-cheatsheet/src/lib/sampleSpokenFormInfos/defaults.json deleted file mode 100644 index a65a1c5957..0000000000 --- a/packages/lib-cheatsheet/src/lib/sampleSpokenFormInfos/defaults.json +++ /dev/null @@ -1,2023 +0,0 @@ -{ - "sections": [ - { - "name": "Actions", - "id": "actions", - "items": [ - { - "id": "addSelection", - "type": "action", - "variations": [ - { - "spokenForm": "append ", - "description": "Add selection" - } - ] - }, - { - "id": "addSelectionAfter", - "type": "action", - "variations": [ - { - "spokenForm": "append post ", - "description": "Add selection after" - } - ] - }, - { - "id": "addSelectionBefore", - "type": "action", - "variations": [ - { - "spokenForm": "append pre ", - "description": "Add selection before" - } - ] - }, - { - "id": "applyFormatter", - "type": "action", - "variations": [ - { - "spokenForm": "format at ", - "description": "Reformat as " - } - ] - }, - { - "id": "breakLine", - "type": "action", - "variations": [ - { - "spokenForm": "break ", - "description": "Break line" - } - ] - }, - { - "id": "callAsFunction", - "type": "action", - "variations": [ - { - "spokenForm": "call ", - "description": "Insert call to on selection" - }, - { - "spokenForm": "call on ", - "description": "Insert call to on " - } - ] - }, - { - "id": "clearAndSetSelection", - "type": "action", - "variations": [ - { - "spokenForm": "change ", - "description": "Clear and set selection" - } - ] - }, - { - "id": "copyToClipboard", - "type": "action", - "variations": [ - { - "spokenForm": "copy ", - "description": "Copy to clipboard" - } - ] - }, - { - "id": "cutToClipboard", - "type": "action", - "variations": [ - { - "spokenForm": "carve ", - "description": "Cut to clipboard" - } - ] - }, - { - "id": "decrement", - "type": "action", - "variations": [ - { - "spokenForm": "decrement ", - "description": "Decrement" - } - ] - }, - { - "id": "deselect", - "type": "action", - "variations": [ - { - "spokenForm": "give ", - "description": "Deselect" - } - ] - }, - { - "id": "editNewLineAfter", - "type": "action", - "variations": [ - { - "spokenForm": "pour ", - "description": "Edit new line/scope after" - } - ] - }, - { - "id": "editNewLineBefore", - "type": "action", - "variations": [ - { - "spokenForm": "drink ", - "description": "Edit new line/scope before" - } - ] - }, - { - "id": "experimental.setInstanceReference", - "type": "action", - "variations": [ - { - "spokenForm": "from ", - "description": "Set instance reference" - } - ] - }, - { - "id": "extractVariable", - "type": "action", - "variations": [ - { - "spokenForm": "extract ", - "description": "Extract variable" - } - ] - }, - { - "id": "findInDocument", - "type": "action", - "variations": [ - { - "spokenForm": "scout ", - "description": "Find in document" - } - ] - }, - { - "id": "findInWorkspace", - "type": "action", - "variations": [ - { - "spokenForm": "scout all ", - "description": "Find in workspace" - } - ] - }, - { - "id": "flashTargets", - "type": "action", - "variations": [ - { - "spokenForm": "flash ", - "description": "Flash targets" - } - ] - }, - { - "id": "foldRegion", - "type": "action", - "variations": [ - { - "spokenForm": "fold ", - "description": "Fold region" - } - ] - }, - { - "id": "followLink", - "type": "action", - "variations": [ - { - "spokenForm": "follow ", - "description": "Follow link" - } - ] - }, - { - "id": "followLinkAside", - "type": "action", - "variations": [ - { - "spokenForm": "follow split ", - "description": "Follow link aside" - } - ] - }, - { - "id": "generateSnippet", - "type": "action", - "variations": [ - { - "spokenForm": "snip make ", - "description": "Generate snippet" - } - ] - }, - { - "id": "gitAccept", - "type": "action", - "variations": [ - { - "spokenForm": "git accept ", - "description": "Git accept" - } - ] - }, - { - "id": "gitRevert", - "type": "action", - "variations": [ - { - "spokenForm": "git revert ", - "description": "Git revert" - } - ] - }, - { - "id": "gitStage", - "type": "action", - "variations": [ - { - "spokenForm": "git stage ", - "description": "Git stage" - } - ] - }, - { - "id": "gitUnstage", - "type": "action", - "variations": [ - { - "spokenForm": "git unstage ", - "description": "Git unstage" - } - ] - }, - { - "id": "highlight", - "type": "action", - "variations": [ - { - "spokenForm": "highlight ", - "description": "Highlight" - } - ] - }, - { - "id": "increment", - "type": "action", - "variations": [ - { - "spokenForm": "increment ", - "description": "Increment" - } - ] - }, - { - "id": "indentLine", - "type": "action", - "variations": [ - { - "spokenForm": "indent ", - "description": "Indent line" - } - ] - }, - { - "id": "insertCopyAfter", - "type": "action", - "variations": [ - { - "spokenForm": "clone ", - "description": "Insert copy after" - } - ] - }, - { - "id": "insertCopyBefore", - "type": "action", - "variations": [ - { - "spokenForm": "clone up ", - "description": "Insert copy before" - } - ] - }, - { - "id": "insertEmptyLineAfter", - "type": "action", - "variations": [ - { - "spokenForm": "float ", - "description": "Insert empty line after" - } - ] - }, - { - "id": "insertEmptyLineBefore", - "type": "action", - "variations": [ - { - "spokenForm": "drop ", - "description": "Insert empty line before" - } - ] - }, - { - "id": "insertEmptyLinesAround", - "type": "action", - "variations": [ - { - "spokenForm": "puff ", - "description": "Insert empty lines around" - } - ] - }, - { - "id": "insertSnippet", - "type": "action", - "variations": [ - { - "spokenForm": "snip ", - "description": "Insert snippet at " - } - ] - }, - { - "id": "joinLines", - "type": "action", - "variations": [ - { - "spokenForm": "join ", - "description": "Join lines" - } - ] - }, - { - "id": "moveToTarget", - "type": "action", - "variations": [ - { - "spokenForm": "move ", - "description": "Move to " - }, - { - "spokenForm": "move ", - "description": "Move to selection" - } - ] - }, - { - "id": "nextHomophone", - "type": "action", - "variations": [ - { - "spokenForm": "phones ", - "description": "Next homophone" - } - ] - }, - { - "id": "outdentLine", - "type": "action", - "variations": [ - { - "spokenForm": "dedent ", - "description": "Outdent line" - } - ] - }, - { - "id": "pasteFromClipboard", - "type": "action", - "variations": [ - { - "spokenForm": "paste ", - "description": "Paste from clipboard at " - } - ] - }, - { - "id": "randomizeTargets", - "type": "action", - "variations": [ - { - "spokenForm": "shuffle ", - "description": "Randomize targets" - } - ] - }, - { - "id": "remove", - "type": "action", - "variations": [ - { - "spokenForm": "chuck ", - "description": "Remove" - } - ] - }, - { - "id": "rename", - "type": "action", - "variations": [ - { - "spokenForm": "rename ", - "description": "Rename" - } - ] - }, - { - "id": "replaceWithTarget", - "type": "action", - "variations": [ - { - "spokenForm": "bring ", - "description": "Copy to " - }, - { - "spokenForm": "bring ", - "description": "Insert copy of at selection" - } - ] - }, - { - "id": "revealDefinition", - "type": "action", - "variations": [ - { - "spokenForm": "define ", - "description": "Reveal definition" - } - ] - }, - { - "id": "revealTypeDefinition", - "type": "action", - "variations": [ - { - "spokenForm": "type deaf ", - "description": "Reveal type definition" - } - ] - }, - { - "id": "reverseTargets", - "type": "action", - "variations": [ - { - "spokenForm": "reverse ", - "description": "Reverse targets" - } - ] - }, - { - "id": "rewrap", - "type": "action", - "variations": [ - { - "spokenForm": " repack ", - "description": "Rewrap with " - } - ] - }, - { - "id": "scrollToBottom", - "type": "action", - "variations": [ - { - "spokenForm": "bottom ", - "description": "Scroll to bottom" - } - ] - }, - { - "id": "scrollToCenter", - "type": "action", - "variations": [ - { - "spokenForm": "center ", - "description": "Scroll to center" - } - ] - }, - { - "id": "scrollToTop", - "type": "action", - "variations": [ - { - "spokenForm": "crown ", - "description": "Scroll to top" - } - ] - }, - { - "id": "setSelection", - "type": "action", - "variations": [ - { - "spokenForm": "take ", - "description": "Set selection" - } - ] - }, - { - "id": "setSelectionAfter", - "type": "action", - "variations": [ - { - "spokenForm": "post ", - "description": "Set selection after" - } - ] - }, - { - "id": "setSelectionBefore", - "type": "action", - "variations": [ - { - "spokenForm": "pre ", - "description": "Set selection before" - } - ] - }, - { - "id": "showDebugHover", - "type": "action", - "variations": [ - { - "spokenForm": "inspect ", - "description": "Show debug hover" - } - ] - }, - { - "id": "showHover", - "type": "action", - "variations": [ - { - "spokenForm": "hover ", - "description": "Show hover" - } - ] - }, - { - "id": "showQuickFix", - "type": "action", - "variations": [ - { - "spokenForm": "quick fix ", - "description": "Show quick fix" - } - ] - }, - { - "id": "showReferences", - "type": "action", - "variations": [ - { - "spokenForm": "reference ", - "description": "Show references" - } - ] - }, - { - "id": "sortTargets", - "type": "action", - "variations": [ - { - "spokenForm": "sort ", - "description": "Sort targets" - } - ] - }, - { - "id": "swapTargets", - "type": "action", - "variations": [ - { - "spokenForm": "swap with ", - "description": "Swap with " - }, - { - "spokenForm": "swap with ", - "description": "Swap selection with " - } - ] - }, - { - "id": "toggleLineBreakpoint", - "type": "action", - "variations": [ - { - "spokenForm": "break point ", - "description": "Toggle line breakpoint" - } - ] - }, - { - "id": "toggleLineComment", - "type": "action", - "variations": [ - { - "spokenForm": "comment ", - "description": "Toggle line comment" - } - ] - }, - { - "id": "unfoldRegion", - "type": "action", - "variations": [ - { - "spokenForm": "unfold ", - "description": "Unfold region" - } - ] - }, - { - "id": "wrapWithPairedDelimiter", - "type": "action", - "variations": [ - { - "spokenForm": " wrap ", - "description": "Wrap with " - }, - { - "spokenForm": " wrap ", - "description": "Wrap with " - } - ] - } - ] - }, - { - "name": "Colors", - "id": "colors", - "items": [ - { - "id": "blue", - "type": "hatColor", - "variations": [ - { - "spokenForm": "blue", - "description": "Blue" - } - ] - }, - { - "id": "green", - "type": "hatColor", - "variations": [ - { - "spokenForm": "green", - "description": "Green" - } - ] - }, - { - "id": "pink", - "type": "hatColor", - "variations": [ - { - "spokenForm": "pink", - "description": "Pink" - } - ] - }, - { - "id": "red", - "type": "hatColor", - "variations": [ - { - "spokenForm": "red", - "description": "Red" - } - ] - }, - { - "id": "yellow", - "type": "hatColor", - "variations": [ - { - "spokenForm": "yellow", - "description": "Yellow" - } - ] - } - ] - }, - { - "name": "Compound targets", - "id": "compoundTargets", - "items": [ - { - "id": "listConnective", - "type": "compoundTargetConnective", - "variations": [ - { - "spokenForm": " and ", - "description": " and " - } - ] - }, - { - "id": "rangeExcludingEnd", - "type": "compoundTargetConnective", - "variations": [ - { - "spokenForm": " until ", - "description": " until start of " - }, - { - "spokenForm": "until ", - "description": "selection until start of " - } - ] - }, - { - "id": "rangeExclusive", - "type": "compoundTargetConnective", - "variations": [ - { - "spokenForm": " between ", - "description": "between and " - }, - { - "spokenForm": "between ", - "description": "between selection and " - } - ] - }, - { - "id": "rangeInclusive", - "type": "compoundTargetConnective", - "variations": [ - { - "spokenForm": " past ", - "description": " through " - }, - { - "spokenForm": "past ", - "description": "selection through " - } - ] - }, - { - "id": "verticalRange", - "type": "compoundTargetConnective", - "variations": [ - { - "spokenForm": " slice ", - "description": " vertically through " - }, - { - "spokenForm": "slice ", - "description": "selection vertically through " - } - ] - } - ] - }, - { - "name": "Destinations", - "id": "destinations", - "items": [ - { - "id": "destination_after", - "type": "destination", - "variations": [ - { - "spokenForm": "after ", - "description": "Insert after " - } - ] - }, - { - "id": "destination_before", - "type": "destination", - "variations": [ - { - "spokenForm": "before ", - "description": "Insert before " - } - ] - }, - { - "id": "destination_to", - "type": "destination", - "variations": [ - { - "spokenForm": "to ", - "description": "Replace " - } - ] - } - ] - }, - { - "name": "Modifiers", - "id": "modifiers", - "items": [ - { - "id": "ancestor", - "type": "modifier", - "variations": [ - { - "spokenForm": "grand ", - "description": "Grandparent containing instance of " - } - ] - }, - { - "id": "containingScope", - "type": "modifier", - "variations": [ - { - "spokenForm": "", - "description": "Containing instance of " - } - ] - }, - { - "id": "end", - "type": "modifier", - "variations": [ - { - "spokenForm": "end of", - "description": "Empty position at end of target" - } - ] - }, - { - "id": "every", - "type": "modifier", - "variations": [ - { - "spokenForm": "every ", - "description": "Every instance of " - } - ] - }, - { - "id": "excludeInterior", - "type": "modifier", - "variations": [ - { - "spokenForm": "bounds", - "description": "Bounding paired delimiters" - } - ] - }, - { - "id": "extendThroughEndOf", - "type": "modifier", - "variations": [ - { - "spokenForm": "tail", - "description": "Extend through end of line/pair" - }, - { - "spokenForm": "tail ", - "description": "Extend through end of " - } - ] - }, - { - "id": "extendThroughStartOf", - "type": "modifier", - "variations": [ - { - "spokenForm": "head", - "description": "Extend through start of line/pair" - }, - { - "spokenForm": "head ", - "description": "Extend through start of " - } - ] - }, - { - "id": "inferPreviousMark", - "type": "modifier", - "variations": [ - { - "spokenForm": "its", - "description": "Infer previous mark" - } - ] - }, - { - "id": "interiorOnly", - "type": "modifier", - "variations": [ - { - "spokenForm": "inside", - "description": "Interior only" - } - ] - }, - { - "id": "keepContentFilter", - "type": "modifier", - "variations": [ - { - "spokenForm": "content", - "description": "Keep content filter" - } - ] - }, - { - "id": "keepEmptyFilter", - "type": "modifier", - "variations": [ - { - "spokenForm": "empty", - "description": "Keep empty filter" - } - ] - }, - { - "id": "leading", - "type": "modifier", - "variations": [ - { - "spokenForm": "leading", - "description": "Leading delimiter range" - } - ] - }, - { - "id": "ordinalScope", - "type": "modifier", - "variations": [ - { - "spokenForm": " ", - "description": " instance of in iteration scope" - }, - { - "spokenForm": " last ", - "description": "-to-last instance of in iteration scope" - }, - { - "spokenForm": "first s", - "description": "first instances of in iteration scope, as contiguous range" - }, - { - "spokenForm": "every first s", - "description": "first instances of in iteration scope, as individual targets" - }, - { - "spokenForm": "last s", - "description": "last instances of in iteration scope, as contiguous range" - }, - { - "spokenForm": "every last s", - "description": "last instances of in iteration scope, as individual targets" - } - ] - }, - { - "id": "relativeScope", - "type": "modifier", - "variations": [ - { - "spokenForm": "previous ", - "description": "Previous instance of " - }, - { - "spokenForm": " previous ", - "description": " instance of before target" - }, - { - "spokenForm": "next ", - "description": "Next instance of " - }, - { - "spokenForm": " next ", - "description": " instance of after target" - }, - { - "spokenForm": " backward", - "description": "single instance of including target, going backwards" - }, - { - "spokenForm": " forward", - "description": "single instance of including target, going forwards" - }, - { - "spokenForm": " s backward", - "description": " instances of including target, going backwards, as contiguous range" - }, - { - "spokenForm": "every s backward", - "description": " instances of including target, going backwards, as individual targets" - }, - { - "spokenForm": " s", - "description": " instances of including target, going forwards, as contiguous range" - }, - { - "spokenForm": "every s", - "description": " instances of including target, going forwards, as individual targets" - }, - { - "spokenForm": "previous s", - "description": "previous instances of , as contiguous range" - }, - { - "spokenForm": "every previous s", - "description": "previous instances of , as individual targets" - }, - { - "spokenForm": "next s", - "description": "next instances of , as contiguous range" - }, - { - "spokenForm": "every next s", - "description": "next instances of , as individual targets" - } - ] - }, - { - "id": "start", - "type": "modifier", - "variations": [ - { - "spokenForm": "start of", - "description": "Empty position at start of target" - } - ] - }, - { - "id": "toRawSelection", - "type": "modifier", - "variations": [ - { - "spokenForm": "just", - "description": "No inference" - } - ] - }, - { - "id": "trailing", - "type": "modifier", - "variations": [ - { - "spokenForm": "trailing", - "description": "Trailing delimiter range" - } - ] - }, - { - "id": "visible", - "type": "modifier", - "variations": [ - { - "spokenForm": "visible", - "description": "Visible" - } - ] - } - ] - }, - { - "name": "Paired delimiters", - "id": "pairedDelimiters", - "items": [ - { - "id": "angleBrackets", - "type": "pairedDelimiter", - "variations": [ - { - "spokenForm": "diamond", - "description": "Angle brackets" - } - ] - }, - { - "id": "any", - "type": "pairedDelimiter", - "variations": [ - { - "spokenForm": "pair", - "description": "Any" - } - ] - }, - { - "id": "backtickQuotes", - "type": "pairedDelimiter", - "variations": [ - { - "spokenForm": "skis", - "description": "Backtick quotes" - } - ] - }, - { - "id": "curlyBrackets", - "type": "pairedDelimiter", - "variations": [ - { - "spokenForm": "curly", - "description": "Curly brackets" - } - ] - }, - { - "id": "doubleQuotes", - "type": "pairedDelimiter", - "variations": [ - { - "spokenForm": "quad", - "description": "Double quotes" - } - ] - }, - { - "id": "escapedDoubleQuotes", - "type": "pairedDelimiter", - "variations": [ - { - "spokenForm": "escaped quad", - "description": "Escaped double quotes" - } - ] - }, - { - "id": "escapedParentheses", - "type": "pairedDelimiter", - "variations": [ - { - "spokenForm": "escaped round", - "description": "Escaped parentheses" - } - ] - }, - { - "id": "escapedSingleQuotes", - "type": "pairedDelimiter", - "variations": [ - { - "spokenForm": "escaped twin", - "description": "Escaped single quotes" - } - ] - }, - { - "id": "escapedSquareBrackets", - "type": "pairedDelimiter", - "variations": [ - { - "spokenForm": "escaped box", - "description": "Escaped square brackets" - } - ] - }, - { - "id": "parentheses", - "type": "pairedDelimiter", - "variations": [ - { - "spokenForm": "round", - "description": "Parentheses" - } - ] - }, - { - "id": "singleQuotes", - "type": "pairedDelimiter", - "variations": [ - { - "spokenForm": "twin", - "description": "Single quotes" - } - ] - }, - { - "id": "squareBrackets", - "type": "pairedDelimiter", - "variations": [ - { - "spokenForm": "box", - "description": "Square brackets" - } - ] - }, - { - "id": "string", - "type": "pairedDelimiter", - "variations": [ - { - "spokenForm": "string", - "description": "String" - } - ] - }, - { - "id": "whitespace", - "type": "pairedDelimiter", - "variations": [ - { - "spokenForm": "void", - "description": "Whitespace" - } - ] - } - ] - }, - { - "name": "Scope visualizer", - "id": "scopeVisualizer", - "items": [ - { - "id": "hideScopeVisualizer", - "type": "command", - "variations": [ - { - "spokenForm": "visualize nothing", - "description": "Hide scope visualizer" - } - ] - }, - { - "id": "show_scope_sidebar", - "type": "command", - "variations": [ - { - "spokenForm": "bar cursorless", - "description": "Show cursorless sidebar" - } - ] - }, - { - "id": "show_scope_visualizer", - "type": "command", - "variations": [ - { - "spokenForm": "visualize ", - "description": "Visualize " - }, - { - "spokenForm": "visualize removal", - "description": "Visualize removal range" - }, - { - "spokenForm": "visualize iteration", - "description": "Visualize iteration range" - } - ] - } - ] - }, - { - "name": "Scopes", - "id": "scopes", - "items": [ - { - "id": "anonymousFunction", - "type": "scopeType", - "variations": [ - { - "spokenForm": "lambda", - "description": "Anonymous function" - } - ] - }, - { - "id": "argumentList", - "type": "scopeType", - "variations": [ - { - "spokenForm": "arg list", - "description": "Argument list" - } - ] - }, - { - "id": "argumentOrParameter", - "type": "scopeType", - "variations": [ - { - "spokenForm": "arg", - "description": "Argument" - } - ] - }, - { - "id": "attribute", - "type": "scopeType", - "variations": [ - { - "spokenForm": "attribute", - "description": "Attribute" - } - ] - }, - { - "id": "boundedNonWhitespaceSequence", - "type": "scopeType", - "variations": [ - { - "spokenForm": "short paint", - "description": "Non-whitespace sequence bounded by surrounding pair delimiters" - } - ] - }, - { - "id": "boundedParagraph", - "type": "scopeType", - "variations": [ - { - "spokenForm": "short block", - "description": "Paragraph bounded by surrounding pair delimiters" - } - ] - }, - { - "id": "branch", - "type": "scopeType", - "variations": [ - { - "spokenForm": "branch", - "description": "Branch" - } - ] - }, - { - "id": "chapter", - "type": "scopeType", - "variations": [ - { - "spokenForm": "chapter", - "description": "Chapter" - } - ] - }, - { - "id": "character", - "type": "scopeType", - "variations": [ - { - "spokenForm": "char", - "description": "Character" - } - ] - }, - { - "id": "class", - "type": "scopeType", - "variations": [ - { - "spokenForm": "class", - "description": "Class" - } - ] - }, - { - "id": "className", - "type": "scopeType", - "variations": [ - { - "spokenForm": "class name", - "description": "Class name" - } - ] - }, - { - "id": "collectionItem", - "type": "scopeType", - "variations": [ - { - "spokenForm": "item", - "description": "Collection item" - } - ] - }, - { - "id": "collectionKey", - "type": "scopeType", - "variations": [ - { - "spokenForm": "key", - "description": "Collection key" - } - ] - }, - { - "id": "command", - "type": "scopeType", - "variations": [ - { - "spokenForm": "command", - "description": "Command" - } - ] - }, - { - "id": "comment", - "type": "scopeType", - "variations": [ - { - "spokenForm": "comment", - "description": "Comment" - } - ] - }, - { - "id": "condition", - "type": "scopeType", - "variations": [ - { - "spokenForm": "condition", - "description": "Condition" - } - ] - }, - { - "id": "document", - "type": "scopeType", - "variations": [ - { - "spokenForm": "file", - "description": "Document" - } - ] - }, - { - "id": "environment", - "type": "scopeType", - "variations": [ - { - "spokenForm": "environment", - "description": "Environment" - } - ] - }, - { - "id": "fullLine", - "type": "scopeType", - "variations": [ - { - "spokenForm": "full line", - "description": "Full line" - } - ] - }, - { - "id": "functionCall", - "type": "scopeType", - "variations": [ - { - "spokenForm": "call", - "description": "Function call" - } - ] - }, - { - "id": "functionCallee", - "type": "scopeType", - "variations": [ - { - "spokenForm": "callee", - "description": "Function callee" - } - ] - }, - { - "id": "functionName", - "type": "scopeType", - "variations": [ - { - "spokenForm": "funk name", - "description": "Function name" - } - ] - }, - { - "id": "glyph", - "type": "scopeType", - "variations": [ - { - "spokenForm": "glyph ", - "description": "Instance of single character " - } - ] - }, - { - "id": "identifier", - "type": "scopeType", - "variations": [ - { - "spokenForm": "identifier", - "description": "Identifier" - } - ] - }, - { - "id": "ifStatement", - "type": "scopeType", - "variations": [ - { - "spokenForm": "if state", - "description": "If statement" - } - ] - }, - { - "id": "instance", - "type": "scopeType", - "variations": [ - { - "spokenForm": "instance", - "description": "Instance" - } - ] - }, - { - "id": "line", - "type": "scopeType", - "variations": [ - { - "spokenForm": "line", - "description": "Line" - } - ] - }, - { - "id": "list", - "type": "scopeType", - "variations": [ - { - "spokenForm": "list", - "description": "List" - } - ] - }, - { - "id": "map", - "type": "scopeType", - "variations": [ - { - "spokenForm": "map", - "description": "Map" - } - ] - }, - { - "id": "name", - "type": "scopeType", - "variations": [ - { - "spokenForm": "name", - "description": "Name" - } - ] - }, - { - "id": "namedFunction", - "type": "scopeType", - "variations": [ - { - "spokenForm": "funk", - "description": "Named function" - } - ] - }, - { - "id": "namedParagraph", - "type": "scopeType", - "variations": [ - { - "spokenForm": "paragraph", - "description": "Named paragraph" - } - ] - }, - { - "id": "nonWhitespaceSequence", - "type": "scopeType", - "variations": [ - { - "spokenForm": "paint", - "description": "Non whitespace sequence" - } - ] - }, - { - "id": "notebookCell", - "type": "scopeType", - "variations": [ - { - "spokenForm": "cell", - "description": "Notebook cell" - } - ] - }, - { - "id": "pair", - "type": "scopeType", - "variations": [ - { - "spokenForm": "", - "description": "Paired delimiters" - } - ] - }, - { - "id": "paragraph", - "type": "scopeType", - "variations": [ - { - "spokenForm": "block", - "description": "Paragraph" - } - ] - }, - { - "id": "part", - "type": "scopeType", - "variations": [ - { - "spokenForm": "part", - "description": "Part" - } - ] - }, - { - "id": "regularExpression", - "type": "scopeType", - "variations": [ - { - "spokenForm": "regex", - "description": "Regular expression" - } - ] - }, - { - "id": "section", - "type": "scopeType", - "variations": [ - { - "spokenForm": "section", - "description": "Section" - } - ] - }, - { - "id": "selector", - "type": "scopeType", - "variations": [ - { - "spokenForm": "selector", - "description": "Selector" - } - ] - }, - { - "id": "sentence", - "type": "scopeType", - "variations": [ - { - "spokenForm": "sentence", - "description": "Sentence" - } - ] - }, - { - "id": "statement", - "type": "scopeType", - "variations": [ - { - "spokenForm": "state", - "description": "Statement" - } - ] - }, - { - "id": "subParagraph", - "type": "scopeType", - "variations": [ - { - "spokenForm": "subparagraph", - "description": "Sub paragraph" - } - ] - }, - { - "id": "subSection", - "type": "scopeType", - "variations": [ - { - "spokenForm": "subsection", - "description": "Sub section" - } - ] - }, - { - "id": "subSubSection", - "type": "scopeType", - "variations": [ - { - "spokenForm": "subsubsection", - "description": "Sub sub section" - } - ] - }, - { - "id": "token", - "type": "scopeType", - "variations": [ - { - "spokenForm": "token", - "description": "Token" - } - ] - }, - { - "id": "type", - "type": "scopeType", - "variations": [ - { - "spokenForm": "type", - "description": "Type" - } - ] - }, - { - "id": "unit", - "type": "scopeType", - "variations": [ - { - "spokenForm": "unit", - "description": "Unit" - } - ] - }, - { - "id": "url", - "type": "scopeType", - "variations": [ - { - "spokenForm": "link", - "description": "Url" - } - ] - }, - { - "id": "value", - "type": "scopeType", - "variations": [ - { - "spokenForm": "value", - "description": "Value" - } - ] - }, - { - "id": "word", - "type": "scopeType", - "variations": [ - { - "spokenForm": "sub", - "description": "Word" - } - ] - }, - { - "id": "xmlBothTags", - "type": "scopeType", - "variations": [ - { - "spokenForm": "tags", - "description": "Xml both tags" - } - ] - }, - { - "id": "xmlElement", - "type": "scopeType", - "variations": [ - { - "spokenForm": "element", - "description": "Xml element" - } - ] - }, - { - "id": "xmlEndTag", - "type": "scopeType", - "variations": [ - { - "spokenForm": "end tag", - "description": "Xml end tag" - } - ] - }, - { - "id": "xmlStartTag", - "type": "scopeType", - "variations": [ - { - "spokenForm": "start tag", - "description": "Xml start tag" - } - ] - } - ] - }, - { - "name": "Shapes", - "id": "shapes", - "items": [] - }, - { - "name": "Special marks", - "id": "specialMarks", - "items": [ - { - "id": "currentSelection", - "type": "mark", - "variations": [ - { - "spokenForm": "this", - "description": "Current selection" - } - ] - }, - { - "id": "lineNumberModulo100", - "type": "mark", - "variations": [ - { - "spokenForm": "row ", - "description": "Line number modulo 100" - } - ] - }, - { - "id": "lineNumberRelativeDown", - "type": "mark", - "variations": [ - { - "spokenForm": "down ", - "description": "Line number down from cursor" - } - ] - }, - { - "id": "lineNumberRelativeUp", - "type": "mark", - "variations": [ - { - "spokenForm": "up ", - "description": "Line number up from cursor" - } - ] - }, - { - "id": "nothing", - "type": "mark", - "variations": [ - { - "spokenForm": "nothing", - "description": "Nothing" - } - ] - }, - { - "id": "previousSource", - "type": "mark", - "variations": [ - { - "spokenForm": "source", - "description": "Previous source" - } - ] - }, - { - "id": "previousTarget", - "type": "mark", - "variations": [ - { - "spokenForm": "that", - "description": "Previous target" - } - ] - }, - { - "id": "unknownSymbol", - "type": "mark", - "variations": [ - { - "spokenForm": "special", - "description": "Unknown symbol" - } - ] - } - ] - }, - { - "name": "Tutorial", - "id": "tutorial", - "items": [ - { - "id": "start_tutorial", - "type": "command", - "variations": [ - { - "spokenForm": "cursorless tutorial", - "description": "Start the introductory Cursorless tutorial" - } - ] - }, - { - "id": "tutorial_close", - "type": "command", - "variations": [ - { - "spokenForm": "tutorial close", - "description": "Close the tutorial" - } - ] - }, - { - "id": "tutorial_list", - "type": "command", - "variations": [ - { - "spokenForm": "tutorial list", - "description": "List all available tutorials" - } - ] - }, - { - "id": "tutorial_next", - "type": "command", - "variations": [ - { - "spokenForm": "tutorial next", - "description": "Advance to next step in tutorial" - } - ] - }, - { - "id": "tutorial_previous", - "type": "command", - "variations": [ - { - "spokenForm": "tutorial previous", - "description": "Go back to previous step in tutorial" - } - ] - }, - { - "id": "tutorial_restart", - "type": "command", - "variations": [ - { - "spokenForm": "tutorial restart", - "description": "Restart the tutorial" - } - ] - }, - { - "id": "tutorial_resume", - "type": "command", - "variations": [ - { - "spokenForm": "tutorial resume", - "description": "Resume the tutorial" - } - ] - }, - { - "id": "tutorial_start_by_number", - "type": "command", - "variations": [ - { - "spokenForm": "tutorial ", - "description": "Start a specific tutorial by number" - } - ] - } - ] - } - ] -} diff --git a/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts b/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts new file mode 100644 index 0000000000..3c1ed3c574 --- /dev/null +++ b/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts @@ -0,0 +1,40 @@ +import { getDefaultCheatsheetInfo } from "../lib/getDefaultCheatsheetInfo"; + +describe("getDefaultCheatsheetInfo", () => { + const cheatsheetInfo = getDefaultCheatsheetInfo(); + + test("constructs action syntax from the reference definition", () => { + expect(getItem("actions", "swapTargets").variations).toEqual([ + { + spokenForm: "swap with ", + description: "Swap selection with ", + }, + { + spokenForm: "swap with ", + description: "Swap with ", + }, + ]); + }); + + test("omits private and disabled-by-default references", () => { + expect(getSection("actions").items).not.toContainEqual( + expect.objectContaining({ id: "private.showParseTree" }), + ); + expect(getSection("scopes").items).not.toContainEqual( + expect.objectContaining({ id: "sectionLevelOne" }), + ); + }); + + test("maps reference ids to the established cheatsheet ids", () => { + expect(getItem("modifiers", "every")).toBeDefined(); + expect(getItem("scopes", "pair")).toBeDefined(); + }); + + function getSection(sectionId: string) { + return cheatsheetInfo.sections.find(({ id }) => id === sectionId)!; + } + + function getItem(sectionId: string, itemId: string) { + return getSection(sectionId).items.find(({ id }) => id === itemId)!; + } +}); diff --git a/packages/lib-common/package.json b/packages/lib-common/package.json index fd96e847d3..3b974cb362 100644 --- a/packages/lib-common/package.json +++ b/packages/lib-common/package.json @@ -10,6 +10,7 @@ "exports": { ".": "./src/index.ts", "./jest": "./src/tooling/jest.ts", + "./references": "./src/references/index.ts", "./vite": "./src/tooling/vite.ts" }, "scripts": { diff --git a/packages/lib-common/src/cursorlessCommandIds.ts b/packages/lib-common/src/cursorlessCommandIds.ts index 6f366540b2..bc64fda55b 100644 --- a/packages/lib-common/src/cursorlessCommandIds.ts +++ b/packages/lib-common/src/cursorlessCommandIds.ts @@ -25,7 +25,6 @@ class VisibleCommand extends Command implements CommandDescription { export const cursorlessCommandIds = [ "cursorless.command", "cursorless.repeatPreviousCommand", - "cursorless.internal.updateCheatsheetDefaults", "cursorless.private.logQuickActions", "cursorless.keyboard.escape", "cursorless.keyboard.modal.modeOff", @@ -125,9 +124,6 @@ export const cursorlessCommandDescriptions: Record< "cursorless.showCheatsheet": new HiddenCommand( "Display the cursorless cheatsheet", ), - "cursorless.internal.updateCheatsheetDefaults": new HiddenCommand( - "Update the default values of the cheatsheet payload used on the website and for local development. Be sure to run this on stock community and cursorless.", - ), "cursorless.private.logQuickActions": new HiddenCommand( "Log the quick actions available at the current cursor position", ), diff --git a/packages/lib-node-common/src/Cheatsheet.ts b/packages/lib-node-common/src/Cheatsheet.ts index d1d89fb08b..2df7313371 100644 --- a/packages/lib-node-common/src/Cheatsheet.ts +++ b/packages/lib-node-common/src/Cheatsheet.ts @@ -1,10 +1,7 @@ import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; -import { produce } from "immer"; -import { sortBy } from "lodash-es"; import { parse } from "node-html-parser"; import type { IDE } from "@cursorless/lib-common"; -import { getCursorlessRepoRoot } from "./getCursorlessRepoRoot"; /** * The argument expected by the cheatsheet command. @@ -47,28 +44,6 @@ export async function showCheatsheet( await writeFile(outputPath, root.toString()); } -/** - * Updates the default spoken forms stored in `defaults.json` for - * development. - * @param spokenFormInfo The new value to use for default spoken forms. - */ -export async function updateDefaults(spokenFormInfo: CheatsheetInfo) { - const defaultsPath = path.join( - getCursorlessRepoRoot(), - "packages/lib-cheatsheet/src/lib/sampleSpokenFormInfos/defaults.json", - ); - - const outputObject = produce(spokenFormInfo, (draft) => { - draft.sections = sortBy(draft.sections, "id"); - for (const section of draft.sections) { - section.items = sortBy(section.items, "id"); - } - }); - - const json = JSON.stringify(outputObject, null, 2); - await writeFile(defaultsPath, `${json}\n`); -} - // FIXME: Stop duplicating these types once we have #945 // The source of truth is at /cursorless-nx/libs/cheatsheet/src/lib/CheatsheetInfo.tsx interface Variation { From 63d3fff161506f6930e7150d3f8e052cf175b9bc Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Sun, 30 Aug 2026 12:04:27 +0200 Subject: [PATCH 02/28] Generate cheat cheat extension side --- .../src/cheatsheet/cheat_sheet.py | 85 +- cursorless-talon/src/cheatsheet/get_list.py | 97 --- .../src/cheatsheet/sections/actions.py | 144 ---- .../cheatsheet/sections/compound_targets.py | 63 -- .../src/cheatsheet/sections/destinations.py | 28 - .../sections/get_scope_visualizer.py | 47 - .../src/cheatsheet/sections/modifiers.py | 301 ------- .../src/cheatsheet/sections/scopes.py | 44 - .../src/cheatsheet/sections/special_marks.py | 20 - .../src/cheatsheet/sections/tutorial.py | 86 -- cursorless-talon/src/marks/decorated_mark.py | 25 +- cursorless-talon/src/spoken_forms.py | 40 +- cursorless-talon/src/spoken_forms_output.py | 13 +- packages/app-vscode/src/extension.ts | 1 + .../src/getCheatsheetInfoForCommand.ts | 25 + packages/app-vscode/src/registerCommands.ts | 16 +- .../src/docs/contributing/cheatsheet.md | 4 +- packages/lib-cheatsheet-local/README.md | 2 +- packages/lib-cheatsheet/src/index.ts | 2 +- .../src/lib/cheatsheet.types.tsx | 23 +- .../src/lib/getDefaultCheatsheetInfo.ts | 369 -------- .../src/test/getDefaultCheatsheetInfo.spec.ts | 133 ++- packages/lib-common/package.json | 1 + .../cheatsheet/applyLegacyCheatsheetInfo.ts | 135 +++ .../src/cheatsheet/cheatsheet.types.ts | 20 + .../src/cheatsheet/getCheatsheetInfo.ts | 815 ++++++++++++++++++ packages/lib-common/src/index.ts | 3 + .../lib-common/src/types/TalonSpokenForms.ts | 7 + packages/lib-node-common/src/Cheatsheet.ts | 47 +- .../src/FileSystemTalonSpokenForms.ts | 14 +- 30 files changed, 1248 insertions(+), 1362 deletions(-) delete mode 100644 cursorless-talon/src/cheatsheet/get_list.py delete mode 100644 cursorless-talon/src/cheatsheet/sections/actions.py delete mode 100644 cursorless-talon/src/cheatsheet/sections/compound_targets.py delete mode 100644 cursorless-talon/src/cheatsheet/sections/destinations.py delete mode 100644 cursorless-talon/src/cheatsheet/sections/get_scope_visualizer.py delete mode 100644 cursorless-talon/src/cheatsheet/sections/modifiers.py delete mode 100644 cursorless-talon/src/cheatsheet/sections/scopes.py delete mode 100644 cursorless-talon/src/cheatsheet/sections/special_marks.py delete mode 100644 cursorless-talon/src/cheatsheet/sections/tutorial.py create mode 100644 packages/app-vscode/src/getCheatsheetInfoForCommand.ts delete mode 100644 packages/lib-cheatsheet/src/lib/getDefaultCheatsheetInfo.ts create mode 100644 packages/lib-common/src/cheatsheet/applyLegacyCheatsheetInfo.ts create mode 100644 packages/lib-common/src/cheatsheet/cheatsheet.types.ts create mode 100644 packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts diff --git a/cursorless-talon/src/cheatsheet/cheat_sheet.py b/cursorless-talon/src/cheatsheet/cheat_sheet.py index 655bb8be1e..11d6d8e9ed 100644 --- a/cursorless-talon/src/cheatsheet/cheat_sheet.py +++ b/cursorless-talon/src/cheatsheet/cheat_sheet.py @@ -3,16 +3,6 @@ from talon import Context, Module, actions, app -from .get_list import get_list, get_lists -from .sections.actions import get_actions -from .sections.compound_targets import get_compound_targets -from .sections.destinations import get_destinations -from .sections.get_scope_visualizer import get_scope_visualizer -from .sections.modifiers import get_modifiers -from .sections.scopes import get_scopes -from .sections.special_marks import get_special_marks -from .sections.tutorial import get_tutorial_entries - mod = Module() ctx = Context() ctx.matches = r""" @@ -53,13 +43,13 @@ def private_cursorless_cheat_sheet_show_html(): actions.user.private_cursorless_run_rpc_command_and_wait( "cursorless.showCheatsheet", { - "version": 0, - "spokenFormInfo": cursorless_cheat_sheet_get_json(), + "version": 1, "outputPath": str(cheatsheet_out_path), }, ) webbrowser.open(cheatsheet_out_path.as_uri()) + def cheatsheet_dir_linux() -> Path: """Get cheatsheet directory for Linux""" try: @@ -75,74 +65,3 @@ def cheatsheet_dir_linux() -> Path: # 3. Fall back to user home return Path.home() - - -def cursorless_cheat_sheet_get_json(): - """Get cursorless cheat sheet json""" - return { - "sections": [ - { - "name": "Actions", - "id": "actions", - "items": get_actions(), - }, - { - "name": "Destinations", - "id": "destinations", - "items": get_destinations(), - }, - { - "name": "Scopes", - "id": "scopes", - "items": get_scopes(), - }, - { - "name": "Scope visualizer", - "id": "scopeVisualizer", - "items": get_scope_visualizer(), - }, - { - "name": "Modifiers", - "id": "modifiers", - "items": get_modifiers(), - }, - { - "name": "Paired delimiters", - "id": "pairedDelimiters", - "items": get_lists( - [ - "wrapper_only_paired_delimiter", - "wrapper_selectable_paired_delimiter", - "selectable_only_paired_delimiter", - "surrounding_pair_scope_type", - ], - "pairedDelimiter", - ), - }, - { - "name": "Special marks", - "id": "specialMarks", - "items": get_special_marks(), - }, - { - "name": "Compound targets", - "id": "compoundTargets", - "items": get_compound_targets(), - }, - { - "name": "Colors", - "id": "colors", - "items": get_list("hat_color", "hatColor"), - }, - { - "name": "Shapes", - "id": "shapes", - "items": get_list("hat_shape", "hatShape"), - }, - { - "name": "Tutorial", - "id": "tutorial", - "items": get_tutorial_entries(), - }, - ] - } diff --git a/cursorless-talon/src/cheatsheet/get_list.py b/cursorless-talon/src/cheatsheet/get_list.py deleted file mode 100644 index 20c3c26665..0000000000 --- a/cursorless-talon/src/cheatsheet/get_list.py +++ /dev/null @@ -1,97 +0,0 @@ -import re -import typing -from collections.abc import Mapping, Sequence -from typing import Optional, TypedDict - -from talon import registry - -from ..conventions import get_cursorless_list_name - - -class Variation(TypedDict): - spokenForm: str - description: str - - -class ListItemDescriptor(TypedDict): - id: str - type: str - variations: list[Variation] - - -def get_list( - name: str, type: str, descriptions: Optional[Mapping[str, str]] = None -) -> list[ListItemDescriptor]: - if descriptions is None: - descriptions = {} - - items = get_raw_list(name) - - return make_dict_readable(type, items, descriptions) - - -def get_lists( - names: Sequence[str], type: str, descriptions: Optional[Mapping[str, str]] = None -) -> list[ListItemDescriptor]: - return [item for name in names for item in get_list(name, type, descriptions)] - - -def get_raw_list(name: str) -> Mapping[str, str]: - cursorless_list_name = get_cursorless_list_name(name) - return typing.cast(dict[str, str], registry.lists[cursorless_list_name][0]).copy() - - -def get_spoken_form_from_list(list_name: str, value: str) -> str | None: - """Get the spoken form of a value from a list. - - Args: - list_name (str): The name of the list. - value (str): The value to look up. - - Returns: - str: The spoken form of the value if found, otherwise None. - """ - return next( - ( - spoken_form - for spoken_form, v in get_raw_list(list_name).items() - if v == value - ), - None, - ) - - -def make_dict_readable( - type: str, dict: Mapping[str, str], descriptions: Mapping[str, str] -) -> list[ListItemDescriptor]: - return [ - { - "id": value, - "type": type, - "variations": [ - { - "spokenForm": key, - "description": descriptions.get(value, make_readable(value)), - } - ], - } - for key, value in dict.items() - ] - - -def make_readable(text: str) -> str: - text, is_private = ( - (text[8:], True) if text.startswith("private.") else (text, False) - ) - text = text.replace(".", " ") - text = de_camel(text).lower().capitalize() - return f"{text} (PRIVATE)" if is_private else text - - -def de_camel(text: str) -> str: - """Replacing camelCase boundaries with blank space""" - return re.sub( - r"(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|(?<=[a-zA-Z])(?=[0-9])|(?<=[0-9])(?=[a-zA-Z])", - " ", - text, - ) diff --git a/cursorless-talon/src/cheatsheet/sections/actions.py b/cursorless-talon/src/cheatsheet/sections/actions.py deleted file mode 100644 index 99d1bdf1a9..0000000000 --- a/cursorless-talon/src/cheatsheet/sections/actions.py +++ /dev/null @@ -1,144 +0,0 @@ -from typing import Callable - -from ...actions.actions import ACTION_LIST_NAMES -from ..get_list import ListItemDescriptor, get_raw_list, make_dict_readable - - -def get_actions() -> list[ListItemDescriptor]: - all_actions = {} - for name in ACTION_LIST_NAMES: - all_actions.update(get_raw_list(name)) - - complex_action_names = [ - "replaceWithTarget", - "moveToTarget", - "swapTargets", - "applyFormatter", - "callAsFunction", - "wrapWithPairedDelimiter", - "rewrap", - "pasteFromClipboard", - "insertSnippet", - ] - simple_actions = { - f"{key} ": value - for key, value in all_actions.items() - if value not in complex_action_names - } - complex_actions = { - value: key - for key, value in all_actions.items() - if value in complex_action_names - } - - swap_connectives = list(get_raw_list("swap_connective").keys()) - swap_connective = swap_connectives[0] if swap_connectives else None - - items = make_dict_readable( - "action", - simple_actions, - { - "editNewLineAfter": "Edit new line/scope after", - "editNewLineBefore": "Edit new line/scope before", - "experimental.setInstanceReference": "Set instance reference", - }, - ) - - complex_action_defs: dict[str, list[tuple[Callable, str]]] = { - "replaceWithTarget": [ - ( - lambda value: f"{value} ", - "Copy to ", - ), - ( - lambda value: f"{value} ", - "Insert copy of at selection", - ), - ], - "pasteFromClipboard": [ - ( - lambda value: f"{value} ", - "Paste from clipboard at ", - ) - ], - "moveToTarget": [ - ( - lambda value: f"{value} ", - "Move to ", - ), - ( - lambda value: f"{value} ", - "Move to selection", - ), - ], - "applyFormatter": [ - ( - lambda value: f"{value} at ", - "Reformat as ", - ) - ], - "callAsFunction": [ - ( - lambda value: f"{value} ", - "Insert call to on selection", - ), - ( - lambda value: f"{value} on ", - "Insert call to on ", - ), - ], - "wrapWithPairedDelimiter": [ - ( - lambda value: f" {value} ", - "Wrap with ", - ), - ( - lambda value: f" {value} ", - "Wrap with ", - ), - ], - "rewrap": [ - ( - lambda value: f" {value} ", - "Rewrap with ", - ) - ], - "insertSnippet": [ - ( - lambda value: f"{value} ", - "Insert snippet at ", - ) - ], - } - - if swap_connective: - complex_action_defs["swapTargets"] = [ - ( - lambda value: f"{value} {swap_connective} ", - "Swap with ", - ), - ( - lambda value: f"{value} {swap_connective} ", - "Swap selection with ", - ), - ] - - for action_id, variations in complex_action_defs.items(): - # This happens if the user has disabled the spoken form for a complex action - if action_id not in complex_actions: - continue - action = complex_actions[action_id] - items.append( - { - "id": action_id, - "type": "action", - "variations": [ - { - "spokenForm": callback(action), - "description": description, - } - for callback, description in variations - ], - } - ) - return items diff --git a/cursorless-talon/src/cheatsheet/sections/compound_targets.py b/cursorless-talon/src/cheatsheet/sections/compound_targets.py deleted file mode 100644 index ce89cfbb61..0000000000 --- a/cursorless-talon/src/cheatsheet/sections/compound_targets.py +++ /dev/null @@ -1,63 +0,0 @@ -from ..get_list import ListItemDescriptor, get_raw_list, get_spoken_form_from_list - -FORMATTERS = { - "rangeExclusive": lambda start, end: f"between {start} and {end}", - "rangeInclusive": lambda start, end: f"{start} through {end}", - "rangeExcludingStart": lambda start, end: f"end of {start} through {end}", - "rangeExcludingEnd": lambda start, end: f"{start} until start of {end}", - "verticalRange": lambda start, end: f"{start} vertically through {end}", -} - - -def get_compound_targets() -> list[ListItemDescriptor]: - list_connective_term = get_spoken_form_from_list( - "list_connective", "listConnective" - ) - vertical_range_term = get_spoken_form_from_list("range_type", "verticalRange") - - items: list[ListItemDescriptor] = [] - - if list_connective_term: - items.append( - { - "id": "listConnective", - "type": "compoundTargetConnective", - "variations": [ - { - "spokenForm": f" {list_connective_term} ", - "description": " and ", - }, - ], - } - ) - - items.extend( - [ - get_entry(spoken_form, id) - for spoken_form, id in get_raw_list("range_connective").items() - ] - ) - - if vertical_range_term: - items.append(get_entry(vertical_range_term, "verticalRange")) - - return items - - -def get_entry(spoken_form, id) -> ListItemDescriptor: - formatter = FORMATTERS[id] - - return { - "id": id, - "type": "compoundTargetConnective", - "variations": [ - { - "spokenForm": f" {spoken_form} ", - "description": formatter("", ""), - }, - { - "spokenForm": f"{spoken_form} ", - "description": formatter("selection", ""), - }, - ], - } diff --git a/cursorless-talon/src/cheatsheet/sections/destinations.py b/cursorless-talon/src/cheatsheet/sections/destinations.py deleted file mode 100644 index 2c5fd1a832..0000000000 --- a/cursorless-talon/src/cheatsheet/sections/destinations.py +++ /dev/null @@ -1,28 +0,0 @@ -from ..get_list import ListItemDescriptor, get_raw_list - - -def get_destinations() -> list[ListItemDescriptor]: - insertion_modes = { - **dict.fromkeys(get_raw_list("insertion_mode_to"), "to"), - **get_raw_list("insertion_mode_before_after"), - } - - descriptions = { - "to": "Replace ", - "before": "Insert before ", - "after": "Insert after ", - } - - return [ - { - "id": f"destination_{id}", - "type": "destination", - "variations": [ - { - "spokenForm": f"{spoken_form} ", - "description": descriptions[id], - } - ], - } - for spoken_form, id in insertion_modes.items() - ] diff --git a/cursorless-talon/src/cheatsheet/sections/get_scope_visualizer.py b/cursorless-talon/src/cheatsheet/sections/get_scope_visualizer.py deleted file mode 100644 index 8ae2cffc85..0000000000 --- a/cursorless-talon/src/cheatsheet/sections/get_scope_visualizer.py +++ /dev/null @@ -1,47 +0,0 @@ -from ..get_list import ListItemDescriptor, get_list, get_raw_list, make_readable - - -def get_scope_visualizer() -> list[ListItemDescriptor]: - show_scope_visualizers = list(get_raw_list("show_scope_visualizer").keys()) - show_scope_visualizer = ( - show_scope_visualizers[0] if show_scope_visualizers else None - ) - visualization_types = get_raw_list("visualization_type") - - items = get_list("hide_scope_visualizer", "command") - - if show_scope_visualizer: - items.append( - { - "id": "show_scope_visualizer", - "type": "command", - "variations": [ - { - "spokenForm": f"{show_scope_visualizer} ", - "description": "Visualize ", - }, - *[ - { - "spokenForm": f"{show_scope_visualizer} {spoken_form}", - "description": f"Visualize {make_readable(id).lower()} range", - } - for spoken_form, id in visualization_types.items() - ], - ], - } - ) - - items.append( - { - "id": "show_scope_sidebar", - "type": "command", - "variations": [ - { - "spokenForm": "bar cursorless", - "description": "Show cursorless sidebar", - }, - ], - } - ) - - return items diff --git a/cursorless-talon/src/cheatsheet/sections/modifiers.py b/cursorless-talon/src/cheatsheet/sections/modifiers.py deleted file mode 100644 index 398f972808..0000000000 --- a/cursorless-talon/src/cheatsheet/sections/modifiers.py +++ /dev/null @@ -1,301 +0,0 @@ -from itertools import chain -from typing import Callable, TypedDict - -from ..get_list import ListItemDescriptor, Variation, get_raw_list, make_dict_readable - -MODIFIER_LIST_NAMES = [ - "simple_modifier", - "interior_modifier", - "head_tail_modifier", - "every_scope_modifier", - "ancestor_scope_modifier", - "first_modifier", - "last_modifier", - "previous_next_modifier", - "forward_backward_modifier", - "position", -] - - -class Entry(TypedDict): - spokenForm: str - description: str - - -def get_modifiers() -> list[ListItemDescriptor]: - all_modifiers = {} - for name in MODIFIER_LIST_NAMES: - all_modifiers.update(get_raw_list(name)) - - complex_modifier_ids = [ - "extendThroughStartOf", - "extendThroughEndOf", - "every", - "ancestor", - "first", - "last", - "previous", - "next", - "backward", - "forward", - ] - simple_modifiers = { - key: value - for key, value in all_modifiers.items() - if value not in complex_modifier_ids - } - complex_modifiers = { - value: key - for key, value in all_modifiers.items() - if value in complex_modifier_ids - } - - items = make_dict_readable( - "modifier", - simple_modifiers, - { - "excludeInterior": "Bounding paired delimiters", - "toRawSelection": "No inference", - "leading": "Leading delimiter range", - "trailing": "Trailing delimiter range", - "start": "Empty position at start of target", - "end": "Empty position at end of target", - }, - ) - - if "extendThroughStartOf" in complex_modifiers: - items.append( - { - "id": "extendThroughStartOf", - "type": "modifier", - "variations": [ - { - "spokenForm": complex_modifiers["extendThroughStartOf"], - "description": "Extend through start of line/pair", - }, - { - "spokenForm": f"{complex_modifiers['extendThroughStartOf']} ", - "description": "Extend through start of ", - }, - ], - } - ) - - if "extendThroughEndOf" in complex_modifiers: - items.append( - { - "id": "extendThroughEndOf", - "type": "modifier", - "variations": [ - { - "spokenForm": complex_modifiers["extendThroughEndOf"], - "description": "Extend through end of line/pair", - }, - { - "spokenForm": f"{complex_modifiers['extendThroughEndOf']} ", - "description": "Extend through end of ", - }, - ], - } - ) - - items.append( - { - "id": "containingScope", - "type": "modifier", - "variations": [ - { - "spokenForm": "", - "description": "Containing instance of ", - }, - ], - } - ) - - if "every" in complex_modifiers: - items.append( - { - "id": "every", - "type": "modifier", - "variations": [ - { - "spokenForm": f"{complex_modifiers['every']} ", - "description": "Every instance of ", - }, - ], - } - ) - - if "ancestor" in complex_modifiers: - items.append( - { - "id": "ancestor", - "type": "modifier", - "variations": [ - { - "spokenForm": f"{complex_modifiers['ancestor']} ", - "description": "Grandparent containing instance of ", - }, - ], - } - ) - - items.append(get_relative_scope(complex_modifiers)) - items.append(get_ordinal_scope(complex_modifiers)) - - return items - - -def get_relative_scope(complex_modifiers: dict[str, str]) -> ListItemDescriptor: - variations: list[Variation] = [] - - fixtures: dict[str, list[tuple[Callable, str]]] = { - "previous": [ - ( - lambda value: f"{value} ", - "Previous instance of ", - ), - ( - lambda value: f" {value} ", - " instance of before target", - ), - ], - "next": [ - ( - lambda value: f"{value} ", - "Next instance of ", - ), - ( - lambda value: f" {value} ", - " instance of after target", - ), - ], - "backward": [ - ( - lambda value: f" {value}", - "single instance of including target, going backwards", - ) - ], - "forward": [ - ( - lambda value: f" {value}", - "single instance of including target, going forwards", - ) - ], - } - - for mod_id, vars in fixtures.items(): - if mod_id not in complex_modifiers: - continue - mod = complex_modifiers[mod_id] - for callback, description in vars: - variations.append( - { - "spokenForm": callback(mod), - "description": description, - } - ) - - if "every" in complex_modifiers: - entries: list[Entry] = [] - - if "backward" in complex_modifiers: - entries.append( - { - "spokenForm": f" s {complex_modifiers['backward']}", - "description": " instances of including target, going backwards", - } - ) - - entries.append( - { - "spokenForm": " s", - "description": " instances of including target, going forwards", - } - ) - - if "previous" in complex_modifiers: - entries.append( - { - "spokenForm": f"{complex_modifiers['previous']} s", - "description": "previous instances of ", - } - ) - - if "next" in complex_modifiers: - entries.append( - { - "spokenForm": f"{complex_modifiers['next']} s", - "description": "next instances of ", - } - ) - - variations.extend(generateOptionalEvery(complex_modifiers["every"], *entries)) - - return { - "id": "relativeScope", - "type": "modifier", - "variations": variations, - } - - -def get_ordinal_scope(complex_modifiers: dict[str, str]) -> ListItemDescriptor: - variations: list[Variation] = [ - { - "spokenForm": " ", - "description": " instance of in iteration scope", - } - ] - - if "last" in complex_modifiers: - variations.append( - { - "spokenForm": f" {complex_modifiers['last']} ", - "description": "-to-last instance of in iteration scope", - } - ) - - if "every" in complex_modifiers: - entries: list[Entry] = [] - - if "first" in complex_modifiers: - entries.append( - { - "spokenForm": f"{complex_modifiers['first']} s", - "description": "first instances of in iteration scope", - } - ) - - if "last" in complex_modifiers: - entries.append( - { - "spokenForm": f"{complex_modifiers['last']} s", - "description": "last instances of in iteration scope", - } - ) - - variations.extend(generateOptionalEvery(complex_modifiers["every"], *entries)) - - return { - "id": "ordinalScope", - "type": "modifier", - "variations": variations, - } - - -def generateOptionalEvery(every: str, *entries: Entry) -> list[Entry]: - return list( - chain.from_iterable( - [ - { - "spokenForm": entry["spokenForm"], - "description": f"{entry['description']}, as contiguous range", - }, - { - "spokenForm": f"{every} {entry['spokenForm']}", - "description": f"{entry['description']}, as individual targets", - }, - ] - for entry in entries - ) - ) diff --git a/cursorless-talon/src/cheatsheet/sections/scopes.py b/cursorless-talon/src/cheatsheet/sections/scopes.py deleted file mode 100644 index 5eedc2eb51..0000000000 --- a/cursorless-talon/src/cheatsheet/sections/scopes.py +++ /dev/null @@ -1,44 +0,0 @@ -from ..get_list import ListItemDescriptor, get_lists, get_spoken_form_from_list - - -def get_scopes() -> list[ListItemDescriptor]: - glyph_spoken_form = get_spoken_form_from_list("glyph_scope_type", "glyph") - - items = get_lists( - ["scope_type"], - "scopeType", - { - "argumentOrParameter": "Argument", - "boundedNonWhitespaceSequence": "Non-whitespace sequence bounded by surrounding pair delimiters", - "boundedParagraph": "Paragraph bounded by surrounding pair delimiters", - }, - ) - - if glyph_spoken_form: - items.append( - { - "id": "glyph", - "type": "scopeType", - "variations": [ - { - "spokenForm": f"{glyph_spoken_form} ", - "description": "Instance of single character ", - }, - ], - } - ) - - items.append( - { - "id": "pair", - "type": "scopeType", - "variations": [ - { - "spokenForm": "", - "description": "Paired delimiters", - }, - ], - }, - ) - - return items diff --git a/cursorless-talon/src/cheatsheet/sections/special_marks.py b/cursorless-talon/src/cheatsheet/sections/special_marks.py deleted file mode 100644 index cd388756b7..0000000000 --- a/cursorless-talon/src/cheatsheet/sections/special_marks.py +++ /dev/null @@ -1,20 +0,0 @@ -from ..get_list import ListItemDescriptor, get_lists, get_raw_list, make_dict_readable - - -def get_special_marks() -> list[ListItemDescriptor]: - line_direction_marks = make_dict_readable( - "mark", - { - f"{key} ": value - for key, value in get_raw_list("line_direction").items() - }, - { - "lineNumberRelativeUp": "Line number up from cursor", - "lineNumberRelativeDown": "Line number down from cursor", - }, - ) - - return [ - *get_lists(["simple_mark", "unknown_symbol"], "mark"), - *line_direction_marks, - ] diff --git a/cursorless-talon/src/cheatsheet/sections/tutorial.py b/cursorless-talon/src/cheatsheet/sections/tutorial.py deleted file mode 100644 index 1bc2727d75..0000000000 --- a/cursorless-talon/src/cheatsheet/sections/tutorial.py +++ /dev/null @@ -1,86 +0,0 @@ -from ..get_list import ListItemDescriptor - - -def get_tutorial_entries() -> list[ListItemDescriptor]: - return [ - { - "id": "start_tutorial", - "type": "command", - "variations": [ - { - "spokenForm": "cursorless tutorial", - "description": "Start the introductory Cursorless tutorial", - }, - ], - }, - { - "id": "tutorial_next", - "type": "command", - "variations": [ - { - "spokenForm": "tutorial next", - "description": "Advance to next step in tutorial", - }, - ], - }, - { - "id": "tutorial_previous", - "type": "command", - "variations": [ - { - "spokenForm": "tutorial previous", - "description": "Go back to previous step in tutorial", - }, - ], - }, - { - "id": "tutorial_restart", - "type": "command", - "variations": [ - { - "spokenForm": "tutorial restart", - "description": "Restart the tutorial", - }, - ], - }, - { - "id": "tutorial_resume", - "type": "command", - "variations": [ - { - "spokenForm": "tutorial resume", - "description": "Resume the tutorial", - }, - ], - }, - { - "id": "tutorial_list", - "type": "command", - "variations": [ - { - "spokenForm": "tutorial list", - "description": "List all available tutorials", - }, - ], - }, - { - "id": "tutorial_close", - "type": "command", - "variations": [ - { - "spokenForm": "tutorial close", - "description": "Close the tutorial", - }, - ], - }, - { - "id": "tutorial_start_by_number", - "type": "command", - "variations": [ - { - "spokenForm": "tutorial ", - "description": "Start a specific tutorial by number", - }, - ], - }, - ] diff --git a/cursorless-talon/src/marks/decorated_mark.py b/cursorless-talon/src/marks/decorated_mark.py index 4ab6650b0a..043807ba1c 100644 --- a/cursorless-talon/src/marks/decorated_mark.py +++ b/cursorless-talon/src/marks/decorated_mark.py @@ -1,9 +1,9 @@ from pathlib import Path -from typing import Any +from typing import Any, Callable, Sequence from talon import Module, actions, cron, fs -from ..csv_overrides import init_csv_and_watch_changes +from ..csv_overrides import SpokenFormEntry, init_csv_and_watch_changes from .mark_types import DecoratedSymbol mod = Module() @@ -88,7 +88,11 @@ def cursorless_decorated_symbol(m) -> DecoratedSymbol: unsubscribe_hat_styles: Any = None -def setup_hat_styles_csv(hat_colors: dict[str, str], hat_shapes: dict[str, str]): +def setup_hat_styles_csv( + hat_colors: dict[str, str], + hat_shapes: dict[str, str], + handle_new_values: Callable[[Sequence[SpokenFormEntry]], None], +): global unsubscribe_hat_styles ( @@ -140,6 +144,7 @@ def setup_hat_styles_csv(hat_colors: dict[str, str], hat_shapes: dict[str, str]) "hat_color": active_hat_colors, "hat_shape": active_hat_shapes, }, + handle_new_values, extra_ignored_values=[*hat_colors.values(), *hat_shapes.values()], no_update_file=is_shape_error or is_color_error, ) @@ -152,8 +157,12 @@ def setup_hat_styles_csv(hat_colors: dict[str, str], hat_shapes: dict[str, str]) slow_reload_job = None -def init_hats(hat_colors: dict[str, str], hat_shapes: dict[str, str]): - setup_hat_styles_csv(hat_colors, hat_shapes) +def init_hats( + hat_colors: dict[str, str], + hat_shapes: dict[str, str], + handle_new_values: Callable[[Sequence[SpokenFormEntry]], None], +): + setup_hat_styles_csv(hat_colors, hat_shapes, handle_new_values) vscode_settings_path: Path | None = None @@ -167,10 +176,12 @@ def on_watch(path, flags): cron.cancel(fast_reload_job) cron.cancel(slow_reload_job) fast_reload_job = cron.after( - "500ms", lambda: setup_hat_styles_csv(hat_colors, hat_shapes) + "500ms", + lambda: setup_hat_styles_csv(hat_colors, hat_shapes, handle_new_values), ) slow_reload_job = cron.after( - "10s", lambda: setup_hat_styles_csv(hat_colors, hat_shapes) + "10s", + lambda: setup_hat_styles_csv(hat_colors, hat_shapes, handle_new_values), ) if vscode_settings_path is not None: diff --git a/cursorless-talon/src/spoken_forms.py b/cursorless-talon/src/spoken_forms.py index 2452a762df..66d91e1e47 100644 --- a/cursorless-talon/src/spoken_forms.py +++ b/cursorless-talon/src/spoken_forms.py @@ -17,7 +17,11 @@ grapheme_capture_name, ) from .marks.decorated_mark import init_hats -from .spoken_forms_output import SpokenFormsOutput +from .spoken_forms_output import ( + SpokenFormListOutputEntry, + SpokenFormOutputEntry, + SpokenFormsOutput, +) from .spoken_scope_forms import init_scope_spoken_forms JSON_FILE = Path(__file__).parent / "spoken_forms.json" @@ -109,20 +113,31 @@ def update(): graphemes_talon_list = get_graphemes_talon_list() def update_spoken_forms_output(): + spoken_form_entries: list[SpokenFormOutputEntry] = [ + { + "type": LIST_TO_TYPE_MAP[entry.list_name], + "id": entry.id, + "spokenForms": entry.spoken_forms, + } + for spoken_form_list in custom_spoken_forms.values() + for entry in spoken_form_list + if entry.list_name in LIST_TO_TYPE_MAP + ] + list_entries: list[SpokenFormListOutputEntry] = [ + { + "listName": entry.list_name, + "id": entry.id, + "spokenForms": entry.spoken_forms, + } + for spoken_form_list in custom_spoken_forms.values() + for entry in spoken_form_list + ] spoken_forms_output.write( [ - *[ - { - "type": LIST_TO_TYPE_MAP[entry.list_name], - "id": entry.id, - "spokenForms": entry.spoken_forms, - } - for spoken_form_list in custom_spoken_forms.values() - for entry in spoken_form_list - if entry.list_name in LIST_TO_TYPE_MAP - ], + *spoken_form_entries, *get_grapheme_spoken_form_entries(graphemes_talon_list), - ] + ], + list_entries, ) def handle_new_values(csv_name: str, values: Sequence[SpokenFormEntry]): @@ -186,6 +201,7 @@ def handle_new_values(csv_name: str, values: Sequence[SpokenFormEntry]): init_hats( spoken_forms["hat_styles.csv"]["hat_color"], spoken_forms["hat_styles.csv"]["hat_shape"], + lambda values: handle_new_values("hat_styles.csv", values), ), ] diff --git a/cursorless-talon/src/spoken_forms_output.py b/cursorless-talon/src/spoken_forms_output.py index 6961c98ec2..6d41783aea 100644 --- a/cursorless-talon/src/spoken_forms_output.py +++ b/cursorless-talon/src/spoken_forms_output.py @@ -14,6 +14,12 @@ class SpokenFormOutputEntry(TypedDict): spokenForms: list[str] +class SpokenFormListOutputEntry(TypedDict): + listName: str + id: str + spokenForms: list[str] + + class SpokenFormsOutput: """ Writes spoken forms to a json file for use by the Cursorless vscode extension @@ -29,7 +35,11 @@ def init(self): print(error_message) app.notify(error_message) - def write(self, spoken_forms: list[SpokenFormOutputEntry]): + def write( + self, + spoken_forms: list[SpokenFormOutputEntry], + lists: list[SpokenFormListOutputEntry], + ): with open(SPOKEN_FORMS_OUTPUT_PATH, "w", encoding="UTF-8") as out: try: out.write( @@ -37,6 +47,7 @@ def write(self, spoken_forms: list[SpokenFormOutputEntry]): { "version": STATE_JSON_VERSION_NUMBER, "spokenForms": spoken_forms, + "lists": lists, } ) ) diff --git a/packages/app-vscode/src/extension.ts b/packages/app-vscode/src/extension.ts index 44c5d4fe5b..26a39e2d10 100644 --- a/packages/app-vscode/src/extension.ts +++ b/packages/app-vscode/src/extension.ts @@ -166,6 +166,7 @@ export async function activate( vscodeTutorial, installationDependencies, storedTargets, + talonSpokenForms, ); void new ReleaseNotes(vscodeApi, context, normalizedIde.messages).maybeShow(); diff --git a/packages/app-vscode/src/getCheatsheetInfoForCommand.ts b/packages/app-vscode/src/getCheatsheetInfoForCommand.ts new file mode 100644 index 0000000000..93148e7bf1 --- /dev/null +++ b/packages/app-vscode/src/getCheatsheetInfoForCommand.ts @@ -0,0 +1,25 @@ +import type { CheatsheetInfo } from "@cursorless/lib-common"; +import { + applyLegacyCheatsheetInfo, + getCheatsheetInfo, +} from "@cursorless/lib-common"; +import type { + CheatSheetCommandArg, + FileSystemTalonSpokenForms, +} from "@cursorless/lib-node-common"; + +export async function getCheatsheetInfoForCommand( + arg: CheatSheetCommandArg, + talonSpokenForms: FileSystemTalonSpokenForms, +): Promise { + if (arg.version === 0) { + return applyLegacyCheatsheetInfo( + getCheatsheetInfo({ includeDisabledByDefault: true }), + arg.spokenFormInfo, + ); + } + + return getCheatsheetInfo({ + listEntries: await talonSpokenForms.getSpokenFormLists(), + }); +} diff --git a/packages/app-vscode/src/registerCommands.ts b/packages/app-vscode/src/registerCommands.ts index 5ae5506682..64ea4e9e63 100644 --- a/packages/app-vscode/src/registerCommands.ts +++ b/packages/app-vscode/src/registerCommands.ts @@ -8,7 +8,10 @@ import { CURSORLESS_COMMAND_ID } from "@cursorless/lib-common"; import type { CommandApi, StoredTargetMap } from "@cursorless/lib-engine"; import { analyzeCommandHistory } from "@cursorless/lib-engine"; import { showCheatsheet } from "@cursorless/lib-node-common"; -import type { CheatSheetCommandArg } from "@cursorless/lib-node-common"; +import type { + CheatSheetCommandArg, + FileSystemTalonSpokenForms, +} from "@cursorless/lib-node-common"; import type { ScopeTestRecorder, TestCaseRecorder, @@ -18,6 +21,7 @@ import { showQuickPick, showScopeVisualizerItemDocumentation, } from "./commands"; +import { getCheatsheetInfoForCommand } from "./getCheatsheetInfoForCommand"; import type { VscodeHats } from "./ide/vscode/hats/VscodeHats"; import type { VscodeIDE } from "./ide/vscode/VscodeIDE"; import type { InstallationDependencies } from "./InstallationDependencies"; @@ -42,6 +46,7 @@ export function registerCommands( tutorial: VscodeTutorial, installationDependencies: InstallationDependencies, storedTargets: StoredTargetMap, + talonSpokenForms: FileSystemTalonSpokenForms, ): void { const runCommandWrapper = async (run: () => Promise) => { try { @@ -68,8 +73,13 @@ export function registerCommands( }, // Cheatsheet commands - "cursorless.showCheatsheet": (arg: CheatSheetCommandArg) => - showCheatsheet(vscodeIde, arg), + "cursorless.showCheatsheet": async (arg: CheatSheetCommandArg) => { + const cheatsheetInfo = await getCheatsheetInfoForCommand( + arg, + talonSpokenForms, + ); + return showCheatsheet(vscodeIde, arg, cheatsheetInfo); + }, // Testcase recorder commands "cursorless.recordTestCase": testCaseRecorder.toggle, diff --git a/packages/app-web-docs/src/docs/contributing/cheatsheet.md b/packages/app-web-docs/src/docs/contributing/cheatsheet.md index 2cbe03c1ad..f3f1cfcd8a 100644 --- a/packages/app-web-docs/src/docs/contributing/cheatsheet.md +++ b/packages/app-web-docs/src/docs/contributing/cheatsheet.md @@ -2,11 +2,11 @@ The cheatsheet can be activated locally to show your custom cheatsheet by saying `"cursorless cheatsheet"`, or visited on the web at https://www.cursorless.org/cheatsheet, which will show the default spoken forms. -The implementation of the local version of the cheatsheet is split between the Talon side and the extension side. +The extension constructs the local cheatsheet from the canonical reference definitions in `lib-common` and the user's spoken-form lists in Talon's `state.json`. Talon only asks the extension to display it. Older Talon versions remain supported: the extension applies the spoken forms from their cheatsheet payload to the current structure, descriptions, and syntax. ## Adding a new spoken form -When you add a new scope type, action, modifier, etc, you'll need to ensure that it shows up both locally and on the website. The website cheatsheet is constructed from the reference definitions in `lib-common`, so reference changes appear automatically. The local cheatsheet will usually update automatically as well. You can verify it by saying `"cursorless cheatsheet"` with your development version of `cursorless-talon` active in your Talon user directory. If it does not, you'll need to make fixes to [the Talon side of the cheatsheet](../../../../../cursorless-talon/src/cheatsheet). +When you add a new scope type, action, modifier, etc, you'll need to ensure that it shows up both locally and on the website. Both cheatsheets are constructed from the reference definitions in `lib-common`, so reference changes appear automatically. The local version then applies the user's spoken forms from `state.json`. You can verify it by saying `"cursorless cheatsheet"` with your development version of `cursorless-talon` active in your Talon user directory. If a kind of spoken form is missing, add its raw Talon list to `state.json` rather than adding cheatsheet-specific assembly to Talon. ## Running the cheatsheet in development mode diff --git a/packages/lib-cheatsheet-local/README.md b/packages/lib-cheatsheet-local/README.md index 0e9663a771..616e5ad700 100644 --- a/packages/lib-cheatsheet-local/README.md +++ b/packages/lib-cheatsheet-local/README.md @@ -1,6 +1,6 @@ # Local cheatsheet -This app just bundles up the cheatsheet into a single file to be used when the user says `"cursorless cheatsheet"`. The file inlines all css and js so that it can be opened as a single file by the end user. During actual production use, Talon will send the user's custom spoken forms to the Cursorless engine, which will [inject them](../lib-engine/src/core/Cheatsheet.ts) into the cheatsheet using a global variable. +This app just bundles up the cheatsheet into a single file to be used when the user says `"cursorless cheatsheet"`. The file inlines all css and js so that it can be opened as a single file by the end user. During actual production use, the extension constructs the cheatsheet from the canonical reference definitions and the user's spoken-form lists in Talon's `state.json`, then injects it into the bundled page using a global variable. Note that there is no development server for this app. It is just a bundle step. If you want a live development environment for the cheatsheet, you should use the cheatsheet page in [the `app-web` package](../app-web). diff --git a/packages/lib-cheatsheet/src/index.ts b/packages/lib-cheatsheet/src/index.ts index 20e2ab3f95..5b866b55e5 100644 --- a/packages/lib-cheatsheet/src/index.ts +++ b/packages/lib-cheatsheet/src/index.ts @@ -1,4 +1,4 @@ export * from "./lib/Cheatsheet"; export * from "./lib/cheatsheet.types"; -export * from "./lib/getDefaultCheatsheetInfo"; export * from "./lib/utils/fakeCheatsheetInfo"; +export { getDefaultCheatsheetInfo } from "@cursorless/lib-common/cheatsheet"; diff --git a/packages/lib-cheatsheet/src/lib/cheatsheet.types.tsx b/packages/lib-cheatsheet/src/lib/cheatsheet.types.tsx index 4770cac56c..738ec4f907 100644 --- a/packages/lib-cheatsheet/src/lib/cheatsheet.types.tsx +++ b/packages/lib-cheatsheet/src/lib/cheatsheet.types.tsx @@ -1,21 +1,8 @@ -export interface Variation { - spokenForm: string; - description: string; -} - -export interface CheatsheetSection { - name: string; - id: string; - items: { - id: string; - type: string; - variations: Variation[]; - }[]; -} - -export interface CheatsheetInfo { - sections: CheatsheetSection[]; -} +export type { + CheatsheetInfo, + CheatsheetSection, + CheatsheetVariation as Variation, +} from "@cursorless/lib-common/cheatsheet"; interface CheatsheetLegendEntry { term: string; diff --git a/packages/lib-cheatsheet/src/lib/getDefaultCheatsheetInfo.ts b/packages/lib-cheatsheet/src/lib/getDefaultCheatsheetInfo.ts deleted file mode 100644 index 34434f6e98..0000000000 --- a/packages/lib-cheatsheet/src/lib/getDefaultCheatsheetInfo.ts +++ /dev/null @@ -1,369 +0,0 @@ -import { - actionReferences, - connectiveDefaultSpokenForms, - graphemeDefaultSpokenForms, - hatColorDefaultSpokenForms, - lineDirectionDefaultSpokenForms, - markDefaultSpokenForms, - modifierReferences, - pairedDelimiterReferences, - scopeReferences, -} from "@cursorless/lib-common/references"; -import type { SpokenFormReference } from "@cursorless/lib-common/references"; -import type { CheatsheetInfo, CheatsheetSection } from "./cheatsheet.types"; - -interface CheatsheetReference { - defaultSpokenForm?: string; - disabledByDefault?: boolean; - private?: boolean; - syntaxes: readonly { - pattern: string; - cheatsheet: string; - }[]; -} - -type ReferenceMap = Readonly>; - -const REFERENCE_SPOKEN_FORM = ""; - -/** Construct the stock cheatsheet directly from the canonical references. */ -export function getDefaultCheatsheetInfo(): CheatsheetInfo { - return { - sections: [ - referenceSection("Actions", "actions", "action", actionReferences), - colorsSection, - compoundTargetsSection, - destinationsSection, - referenceSection( - "Modifiers", - "modifiers", - "modifier", - modifierReferences, - { - endOf: "end", - everyScope: "every", - startOf: "start", - }, - ), - pairedDelimitersSection(), - scopeVisualizerSection, - referenceSection("Scopes", "scopes", "scopeType", scopeReferences, { - surroundingPair: "pair", - }), - shapesSection, - specialMarksSection, - tutorialSection, - ], - }; -} - -function referenceSection( - name: string, - id: string, - type: string, - references: ReferenceMap, - itemIdOverrides: Readonly> = {}, -): CheatsheetSection { - return { - name, - id, - items: Object.entries(references) - .filter(([, reference]) => isEnabledPublicReference(reference)) - .map(([referenceId, reference]) => ({ - id: itemIdOverrides[referenceId] ?? referenceId, - type, - variations: reference.syntaxes.map(({ pattern, cheatsheet }) => ({ - spokenForm: pattern.replaceAll( - REFERENCE_SPOKEN_FORM, - reference.defaultSpokenForm ?? REFERENCE_SPOKEN_FORM, - ), - description: cheatsheet, - })), - })) - .filter(({ variations }) => variations.length > 0), - }; -} - -function isEnabledPublicReference(reference: CheatsheetReference): boolean { - return !reference.private && !reference.disabledByDefault; -} - -function pairedDelimitersSection(): CheatsheetSection { - return { - name: "Paired delimiters", - id: "pairedDelimiters", - items: Object.entries(pairedDelimiterReferences) - .filter(([, reference]) => isEnabledSpokenFormReference(reference)) - .map(([id, reference]) => ({ - id, - type: "pairedDelimiter", - variations: [ - { - spokenForm: reference.defaultSpokenForm, - description: capitalize(reference.name), - }, - ], - })), - }; -} - -function isEnabledSpokenFormReference(reference: SpokenFormReference): boolean { - return !reference.private && !reference.disabledByDefault; -} - -function capitalize(value: string): string { - return value.charAt(0).toUpperCase() + value.slice(1); -} - -const colorsSection: CheatsheetSection = { - name: "Colors", - id: "colors", - items: [ - ["blue", requiredSpokenForm(hatColorDefaultSpokenForms.blue)], - ["green", requiredSpokenForm(hatColorDefaultSpokenForms.green)], - ["pink", requiredSpokenForm(hatColorDefaultSpokenForms.pink)], - ["red", requiredSpokenForm(hatColorDefaultSpokenForms.red)], - ["yellow", requiredSpokenForm(hatColorDefaultSpokenForms.yellow)], - ].map(([id, spokenForm]) => ({ - id, - type: "hatColor", - variations: [{ spokenForm, description: capitalize(id) }], - })), -}; - -function requiredSpokenForm(spokenForm: string | null): string { - if (spokenForm == null) { - throw new Error("Expected a default spoken form"); - } - - return spokenForm; -} - -const compoundTargetsSection: CheatsheetSection = { - name: "Compound targets", - id: "compoundTargets", - items: [ - { - id: "listConnective", - spokenForm: connectiveDefaultSpokenForms.listConnective, - descriptions: [" and "], - }, - { - id: "rangeExclusive", - spokenForm: connectiveDefaultSpokenForms.rangeExclusive, - descriptions: [ - "between and ", - "between selection and ", - ], - }, - { - id: "rangeInclusive", - spokenForm: connectiveDefaultSpokenForms.rangeInclusive, - descriptions: [ - " through ", - "selection through ", - ], - }, - { - id: "rangeExcludingEnd", - spokenForm: connectiveDefaultSpokenForms.rangeExcludingEnd, - descriptions: [ - " until start of ", - "selection until start of ", - ], - }, - { - id: "verticalRange", - spokenForm: connectiveDefaultSpokenForms.verticalRange, - descriptions: [ - " vertically through ", - "selection vertically through ", - ], - }, - ].map(({ id, spokenForm, descriptions }) => ({ - id, - type: "compoundTargetConnective", - variations: descriptions.map((description, index) => ({ - spokenForm: - index === 0 - ? ` ${spokenForm} ` - : `${spokenForm} `, - description, - })), - })), -}; - -const destinationsSection: CheatsheetSection = { - name: "Destinations", - id: "destinations", - items: [ - { - id: "destination_after", - spokenForm: connectiveDefaultSpokenForms.after, - description: "Insert after ", - }, - { - id: "destination_before", - spokenForm: connectiveDefaultSpokenForms.before, - description: "Insert before ", - }, - { - id: "destination_to", - spokenForm: connectiveDefaultSpokenForms.sourceDestinationConnective, - description: "Replace ", - }, - ].map(({ id, spokenForm, description }) => ({ - id, - type: "destination", - variations: [{ spokenForm: `${spokenForm} `, description }], - })), -}; - -const scopeVisualizerSection: CheatsheetSection = { - name: "Scope visualizer", - id: "scopeVisualizer", - items: [ - item( - "hideScopeVisualizer", - "command", - "visualize nothing", - "Hide scope visualizer", - ), - item( - "show_scope_sidebar", - "command", - "bar cursorless", - "Show cursorless sidebar", - ), - { - id: "show_scope_visualizer", - type: "command", - variations: [ - { spokenForm: "visualize ", description: "Visualize " }, - { - spokenForm: "visualize removal", - description: "Visualize removal range", - }, - { - spokenForm: "visualize iteration", - description: "Visualize iteration range", - }, - ], - }, - ], -}; - -const shapesSection: CheatsheetSection = { - name: "Shapes", - id: "shapes", - items: [], -}; - -const specialMarksSection: CheatsheetSection = { - name: "Special marks", - id: "specialMarks", - items: [ - item( - "currentSelection", - "mark", - markDefaultSpokenForms.cursor, - "Current selection", - ), - item( - "lineNumberModulo100", - "mark", - `${lineDirectionDefaultSpokenForms.modulo100} `, - "Line number modulo 100", - ), - item( - "lineNumberRelativeDown", - "mark", - `${lineDirectionDefaultSpokenForms.relativeDown} `, - "Line number down from cursor", - ), - item( - "lineNumberRelativeUp", - "mark", - `${lineDirectionDefaultSpokenForms.relativeUp} `, - "Line number up from cursor", - ), - item("nothing", "mark", markDefaultSpokenForms.nothing, "Nothing"), - item( - "previousSource", - "mark", - markDefaultSpokenForms.source, - "Previous source", - ), - item( - "previousTarget", - "mark", - markDefaultSpokenForms.that, - "Previous target", - ), - item( - "unknownSymbol", - "mark", - graphemeDefaultSpokenForms["\uFFFD"], - "Unknown symbol", - ), - ], -}; - -const tutorialSection: CheatsheetSection = { - name: "Tutorial", - id: "tutorial", - items: [ - item( - "start_tutorial", - "command", - "cursorless tutorial", - "Start the introductory Cursorless tutorial", - ), - item("tutorial_close", "command", "tutorial close", "Close the tutorial"), - item( - "tutorial_list", - "command", - "tutorial list", - "List all available tutorials", - ), - item( - "tutorial_next", - "command", - "tutorial next", - "Advance to next step in tutorial", - ), - item( - "tutorial_previous", - "command", - "tutorial previous", - "Go back to previous step in tutorial", - ), - item( - "tutorial_restart", - "command", - "tutorial restart", - "Restart the tutorial", - ), - item( - "tutorial_resume", - "command", - "tutorial resume", - "Resume the tutorial", - ), - item( - "tutorial_start_by_number", - "command", - "tutorial ", - "Start a specific tutorial by number", - ), - ], -}; - -function item( - id: string, - type: string, - spokenForm: string, - description: string, -): CheatsheetSection["items"][number] { - return { id, type, variations: [{ spokenForm, description }] }; -} diff --git a/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts b/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts index 3c1ed3c574..ccb836de33 100644 --- a/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts +++ b/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts @@ -1,4 +1,9 @@ -import { getDefaultCheatsheetInfo } from "../lib/getDefaultCheatsheetInfo"; +import { + applyLegacyCheatsheetInfo, + getCheatsheetInfo, + getDefaultCheatsheetInfo, +} from "@cursorless/lib-common/cheatsheet"; +import type { CheatsheetInfo } from "@cursorless/lib-common/cheatsheet"; describe("getDefaultCheatsheetInfo", () => { const cheatsheetInfo = getDefaultCheatsheetInfo(); @@ -30,11 +35,129 @@ describe("getDefaultCheatsheetInfo", () => { expect(getItem("scopes", "pair")).toBeDefined(); }); - function getSection(sectionId: string) { - return cheatsheetInfo.sections.find(({ id }) => id === sectionId)!; + test("applies raw Talon list entries to the current syntax", () => { + const customCheatsheetInfo = getCheatsheetInfo({ + listEntries: [ + { + listName: "simple_action", + id: "editNewLineBefore", + spokenForms: ["gulp"], + }, + { + listName: "interior_modifier", + id: "interiorOnly", + spokenForms: ["within"], + }, + { listName: "scope_type", id: "token", spokenForms: ["word unit"] }, + { + listName: "scope_type", + id: "sectionLevelOne", + spokenForms: ["one section"], + }, + { + listName: "range_connective", + id: "rangeExcludingStart", + spokenForms: ["from end"], + }, + ], + }); + + expect( + getItem("actions", "editNewLineBefore", customCheatsheetInfo).variations, + ).toEqual([ + { spokenForm: "gulp ", description: "Edit new line before" }, + { + spokenForm: "gulp ", + description: "Edit new before", + }, + ]); + expect( + getItem("modifiers", "interiorOnly", customCheatsheetInfo).variations[0] + ?.spokenForm, + ).toBe("within"); + expect( + getItem("scopes", "token", customCheatsheetInfo).variations[0] + ?.spokenForm, + ).toBe("word unit"); + expect( + getItem("scopes", "sectionLevelOne", customCheatsheetInfo).variations[0] + ?.spokenForm, + ).toBe("one section"); + expect( + getItem("compoundTargets", "rangeExcludingStart", customCheatsheetInfo) + .variations[0]?.spokenForm, + ).toBe(" from end "); + }); + + test("an empty raw list entry disables only the corresponding item", () => { + const customCheatsheetInfo = getCheatsheetInfo({ + listEntries: [{ listName: "scope_type", id: "token", spokenForms: [] }], + }); + + expect(getSection("scopes", customCheatsheetInfo).items).not.toContainEqual( + expect.objectContaining({ id: "token" }), + ); + expect( + getItem("actions", "swapTargets", customCheatsheetInfo), + ).toBeDefined(); + }); + + test("uses a legacy payload's spoken forms with current syntax and descriptions", () => { + const current = getCheatsheetInfo({ includeDisabledByDefault: true }); + const legacy = getDefaultCheatsheetInfo(); + const legacyItem = getItem("actions", "editNewLineBefore", legacy); + legacyItem.variations = [ + { + spokenForm: "gulp ", + description: "An obsolete description", + }, + ]; + getSection("shapes", legacy).items.push({ + id: "fox", + type: "hatShape", + variations: [{ spokenForm: "animal", description: "Fox" }], + }); + + const result = applyLegacyCheatsheetInfo(current, legacy); + + expect(getItem("actions", "editNewLineBefore", result).variations).toEqual([ + { spokenForm: "gulp ", description: "Edit new line before" }, + { + spokenForm: "gulp ", + description: "Edit new before", + }, + ]); + expect(getItem("shapes", "fox", result).variations).toEqual([ + { spokenForm: "animal", description: "Fox" }, + ]); + }); + + test("does not re-enable items omitted from a legacy payload", () => { + const current = getCheatsheetInfo({ includeDisabledByDefault: true }); + const legacy = getDefaultCheatsheetInfo(); + const legacyScopes = getSection("scopes", legacy); + legacyScopes.items = legacyScopes.items.filter(({ id }) => id !== "token"); + + const result = applyLegacyCheatsheetInfo(current, legacy); + + expect(getSection("scopes", result).items).not.toContainEqual( + expect.objectContaining({ id: "token" }), + ); + }); + + // oxlint-disable-next-line unicorn/consistent-function-scoping + function getSection( + sectionId: string, + info: CheatsheetInfo = cheatsheetInfo, + ) { + return info.sections.find(({ id }) => id === sectionId)!; } - function getItem(sectionId: string, itemId: string) { - return getSection(sectionId).items.find(({ id }) => id === itemId)!; + function getItem( + sectionId: string, + itemId: string, + info: CheatsheetInfo = cheatsheetInfo, + ) { + return getSection(sectionId, info).items.find(({ id }) => id === itemId)!; } }); diff --git a/packages/lib-common/package.json b/packages/lib-common/package.json index 3b974cb362..03594af208 100644 --- a/packages/lib-common/package.json +++ b/packages/lib-common/package.json @@ -9,6 +9,7 @@ }, "exports": { ".": "./src/index.ts", + "./cheatsheet": "./src/cheatsheet/getCheatsheetInfo.ts", "./jest": "./src/tooling/jest.ts", "./references": "./src/references/index.ts", "./vite": "./src/tooling/vite.ts" diff --git a/packages/lib-common/src/cheatsheet/applyLegacyCheatsheetInfo.ts b/packages/lib-common/src/cheatsheet/applyLegacyCheatsheetInfo.ts new file mode 100644 index 0000000000..9359d409af --- /dev/null +++ b/packages/lib-common/src/cheatsheet/applyLegacyCheatsheetInfo.ts @@ -0,0 +1,135 @@ +import type { + CheatsheetInfo, + CheatsheetItem, + CheatsheetVariation, +} from "./cheatsheet.types"; + +const captureRegex = /<[^>]+>/gu; + +/** + * Applies the spoken forms produced by the version-0 Talon cheatsheet payload + * to the current cheatsheet structure and descriptions. + */ +export function applyLegacyCheatsheetInfo( + current: CheatsheetInfo, + legacy: CheatsheetInfo, +): CheatsheetInfo { + const legacySections = new Map( + legacy.sections.map((section) => [section.id, section]), + ); + + return { + sections: current.sections.map((section) => { + const legacySection = legacySections.get(section.id); + if (legacySection == null) { + return section; + } + + const legacyItems = new Map( + legacySection.items.map((item) => [item.id, item]), + ); + + return { + ...section, + items: section.items.flatMap((item) => { + const legacyItem = legacyItems.get( + getLegacyItemId(section.id, item.id), + ); + return legacyItem == null ? [] : [applyLegacyItem(item, legacyItem)]; + }), + }; + }), + }; +} + +function getLegacyItemId(sectionId: string, itemId: string): string { + return sectionId === "actions" && itemId === "rewrapWithPairedDelimiter" + ? "rewrap" + : itemId; +} + +function applyLegacyItem( + current: CheatsheetItem, + legacy: CheatsheetItem, +): CheatsheetItem { + const legacyBySignature = groupByCaptureSignature(legacy.variations); + const signatureIndexes = new Map(); + const replacements: Array = []; + + const matchedSpokenForms = current.variations.map((variation) => { + const signature = captureSignature(variation); + const signatureIndex = signatureIndexes.get(signature) ?? 0; + signatureIndexes.set(signature, signatureIndex + 1); + const legacyVariation = legacyBySignature.get(signature)?.[signatureIndex]; + + if (legacyVariation != null) { + collectLiteralReplacements(variation, legacyVariation, replacements); + } + + return legacyVariation?.spokenForm; + }); + + return { + ...current, + variations: current.variations.map((variation, index) => ({ + ...variation, + spokenForm: + matchedSpokenForms[index] ?? + applyLiteralReplacements(variation.spokenForm, replacements), + })), + }; +} + +function groupByCaptureSignature( + variations: readonly CheatsheetVariation[], +): Map { + const result = new Map(); + for (const variation of variations) { + const signature = captureSignature(variation); + const entries = result.get(signature) ?? []; + entries.push(variation); + result.set(signature, entries); + } + + return result; +} + +function captureSignature({ spokenForm }: CheatsheetVariation): string { + return Array.from( + spokenForm.matchAll(captureRegex), + ([capture]) => capture, + ).join("\0"); +} + +function collectLiteralReplacements( + current: CheatsheetVariation, + legacy: CheatsheetVariation, + replacements: Array, +) { + const currentParts = current.spokenForm.split(captureRegex); + const legacyParts = legacy.spokenForm.split(captureRegex); + + for (let index = 0; index < currentParts.length; index++) { + const currentPart = currentParts[index]?.trim(); + const legacyPart = legacyParts[index]?.trim(); + if ( + currentPart != null && + legacyPart != null && + currentPart.length > 0 && + currentPart !== legacyPart + ) { + replacements.push([currentPart, legacyPart]); + } + } +} + +function applyLiteralReplacements( + spokenForm: string, + replacements: readonly (readonly [string, string])[], +): string { + let result = spokenForm; + for (const [from, to] of replacements) { + result = result.replace(from, to); + } + return result; +} diff --git a/packages/lib-common/src/cheatsheet/cheatsheet.types.ts b/packages/lib-common/src/cheatsheet/cheatsheet.types.ts new file mode 100644 index 0000000000..9a1c716b52 --- /dev/null +++ b/packages/lib-common/src/cheatsheet/cheatsheet.types.ts @@ -0,0 +1,20 @@ +export interface CheatsheetVariation { + spokenForm: string; + description: string; +} + +export interface CheatsheetItem { + id: string; + type: string; + variations: CheatsheetVariation[]; +} + +export interface CheatsheetSection { + name: string; + id: string; + items: CheatsheetItem[]; +} + +export interface CheatsheetInfo { + sections: CheatsheetSection[]; +} diff --git a/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts b/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts new file mode 100644 index 0000000000..084b38f86a --- /dev/null +++ b/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts @@ -0,0 +1,815 @@ +import { + actionReferences, + connectiveDefaultSpokenForms, + graphemeDefaultSpokenForms, + hatColorDefaultSpokenForms, + hatShapeDefaultSpokenForms, + lineDirectionDefaultSpokenForms, + markDefaultSpokenForms, + modifierReferences, + pairedDelimiterReferences, + scopeReferences, +} from "../references"; +import type { TalonSpokenFormListEntry } from "../types/TalonSpokenForms"; +import type { CheatsheetInfo, CheatsheetSection } from "./cheatsheet.types"; + +export type { + CheatsheetInfo, + CheatsheetItem, + CheatsheetSection, + CheatsheetVariation, +} from "./cheatsheet.types"; +export { applyLegacyCheatsheetInfo } from "./applyLegacyCheatsheetInfo"; + +interface CheatsheetReference { + defaultSpokenForm?: string; + disabledByDefault?: boolean; + private?: boolean; + syntaxes: readonly { + pattern: string; + cheatsheet: string; + }[]; +} + +type ReferenceMap = Readonly>; + +const REFERENCE_SPOKEN_FORM = ""; + +const actionListNames = [ + "simple_action", + "callback_action", + "paste_action", + "bring_move_action", + "swap_action", + "wrap_action", + "insert_snippet_action", + "reformat_action", + "call_action", + "experimental_action", +] as const; + +type ReferenceKind = "action" | "modifier" | "scope"; + +export interface GetCheatsheetInfoOptions { + /** Include entries that need Talon state to become active. */ + includeDisabledByDefault?: boolean; + listEntries?: readonly TalonSpokenFormListEntry[]; +} + +/** Construct the stock cheatsheet directly from the canonical references. */ +export function getDefaultCheatsheetInfo(): CheatsheetInfo { + return getCheatsheetInfo(); +} + +/** Construct a cheatsheet using optional customized Talon list entries. */ +export function getCheatsheetInfo({ + includeDisabledByDefault = false, + listEntries = [], +}: GetCheatsheetInfoOptions = {}): CheatsheetInfo { + const resolver = new SpokenFormResolver(listEntries); + + return { + sections: [ + referenceSection( + resolver, + "Actions", + "actions", + "action", + "action", + actionReferences, + includeDisabledByDefault, + ), + colorsSection(resolver, includeDisabledByDefault), + compoundTargetsSection(resolver, includeDisabledByDefault), + destinationsSection(resolver), + referenceSection( + resolver, + "Modifiers", + "modifiers", + "modifier", + "modifier", + modifierReferences, + includeDisabledByDefault, + { + endOf: "end", + everyScope: "every", + startOf: "start", + }, + ), + pairedDelimitersSection(resolver, includeDisabledByDefault), + scopeVisualizerSection(resolver), + referenceSection( + resolver, + "Scopes", + "scopes", + "scopeType", + "scope", + scopeReferences, + includeDisabledByDefault, + { + surroundingPair: "pair", + }, + ), + shapesSection(resolver, includeDisabledByDefault), + specialMarksSection(resolver), + tutorialSection, + ], + }; +} + +function referenceSection( + resolver: SpokenFormResolver, + name: string, + id: string, + type: string, + referenceKind: ReferenceKind, + references: ReferenceMap, + includeDisabledByDefault: boolean, + itemIdOverrides: Readonly> = {}, +): CheatsheetSection { + return { + name, + id, + items: Object.entries(references) + .filter(([, reference]) => !reference.private) + .map(([referenceId, reference]) => { + const spokenForms = getReferenceSpokenForms( + resolver, + referenceKind, + referenceId, + reference.defaultSpokenForm, + includeDisabledByDefault || !reference.disabledByDefault, + ); + const replacements = getSyntaxReplacements(resolver); + + return { + id: itemIdOverrides[referenceId] ?? referenceId, + type, + variations: reference.syntaxes.flatMap(({ pattern, cheatsheet }) => + spokenForms.flatMap((spokenForm) => + applyReplacements( + pattern.replaceAll( + REFERENCE_SPOKEN_FORM, + reference.defaultSpokenForm ?? REFERENCE_SPOKEN_FORM, + ), + [ + ...replacements, + ...(reference.defaultSpokenForm == null || spokenForm == null + ? [] + : ([[reference.defaultSpokenForm, [spokenForm]]] as const)), + ], + ).map((customPattern) => ({ + spokenForm: customPattern, + description: cheatsheet, + })), + ), + ), + }; + }) + .filter(({ variations }) => variations.length > 0), + }; +} + +class SpokenFormResolver { + private entries = new Map(); + + constructor(listEntries: readonly TalonSpokenFormListEntry[]) { + for (const { listName, id, spokenForms } of listEntries) { + this.entries.set(`${listName}\0${id}`, spokenForms); + } + } + + get( + listNames: readonly string[], + id: string, + defaultSpokenForms: readonly string[], + ): readonly string[] { + const matches = listNames.flatMap( + (listName) => this.entries.get(`${listName}\0${id}`) ?? [], + ); + const hasEntry = listNames.some((listName) => + this.entries.has(`${listName}\0${id}`), + ); + + return hasEntry ? matches : defaultSpokenForms; + } +} + +function getReferenceSpokenForms( + resolver: SpokenFormResolver, + kind: ReferenceKind, + id: string, + defaultSpokenForm: string | undefined, + enabledByDefault: boolean, +): readonly (string | undefined)[] { + if (defaultSpokenForm == null) { + return [undefined]; + } + + if (kind === "action") { + const talonId = id === "rewrapWithPairedDelimiter" ? "rewrap" : id; + return resolver.get( + actionListNames, + talonId, + enabledByDefault ? [defaultSpokenForm] : [], + ); + } + + if (kind === "scope") { + const listNames = id === "glyph" ? ["glyph_scope_type"] : ["scope_type"]; + return resolver.get( + listNames, + id, + enabledByDefault ? [defaultSpokenForm] : [], + ); + } + + const definition = modifierSpokenFormDefinitions[id]; + const defaultSpokenForms = enabledByDefault ? [defaultSpokenForm] : []; + if (definition == null) { + return defaultSpokenForms; + } + + return resolver.get(definition.listNames, definition.id, defaultSpokenForms); +} + +const modifierSpokenFormDefinitions: Readonly< + Record +> = { + everyScope: { listNames: ["every_scope_modifier"], id: "every" }, + ancestor: { listNames: ["ancestor_scope_modifier"], id: "ancestor" }, + interiorOnly: { listNames: ["interior_modifier"], id: "interiorOnly" }, + excludeInterior: { listNames: ["simple_modifier"], id: "excludeInterior" }, + leading: { listNames: ["simple_modifier"], id: "leading" }, + trailing: { listNames: ["simple_modifier"], id: "trailing" }, + extendThroughStartOf: { + listNames: ["head_tail_modifier"], + id: "extendThroughStartOf", + }, + extendThroughEndOf: { + listNames: ["head_tail_modifier"], + id: "extendThroughEndOf", + }, + startOf: { listNames: ["position"], id: "start" }, + endOf: { listNames: ["position"], id: "end" }, + visible: { listNames: ["simple_modifier"], id: "visible" }, + keepContentFilter: { + listNames: ["simple_modifier"], + id: "keepContentFilter", + }, + keepEmptyFilter: { + listNames: ["simple_modifier"], + id: "keepEmptyFilter", + }, + toRawSelection: { listNames: ["simple_modifier"], id: "toRawSelection" }, + inferPreviousMark: { + listNames: ["simple_modifier"], + id: "inferPreviousMark", + }, +}; + +type SyntaxReplacement = readonly [string, readonly string[]]; + +function getSyntaxReplacements( + resolver: SpokenFormResolver, +): readonly SyntaxReplacement[] { + return [ + replacement( + connectiveDefaultSpokenForms.swapConnective, + resolver, + ["swap_connective"], + "swapConnective", + ), + replacement( + connectiveDefaultSpokenForms.first, + resolver, + ["first_modifier"], + "first", + ), + replacement( + connectiveDefaultSpokenForms.last, + resolver, + ["last_modifier"], + "last", + ), + replacement( + connectiveDefaultSpokenForms.previous, + resolver, + ["previous_next_modifier"], + "previous", + ), + replacement( + connectiveDefaultSpokenForms.next, + resolver, + ["previous_next_modifier"], + "next", + ), + replacement( + connectiveDefaultSpokenForms.forward, + resolver, + ["forward_backward_modifier"], + "forward", + ), + replacement( + connectiveDefaultSpokenForms.backward, + resolver, + ["forward_backward_modifier"], + "backward", + ), + replacement( + modifierReferences.everyScope.defaultSpokenForm, + resolver, + ["every_scope_modifier"], + "every", + ), + replacement( + scopeReferences.token.defaultSpokenForm, + resolver, + ["scope_type"], + "token", + ), + ]; +} + +function replacement( + defaultSpokenForm: string, + resolver: SpokenFormResolver, + listNames: readonly string[], + id: string, +): SyntaxReplacement { + return [defaultSpokenForm, resolver.get(listNames, id, [defaultSpokenForm])]; +} + +function applyReplacements( + pattern: string, + replacements: readonly SyntaxReplacement[], +): string[] { + let patterns = [pattern]; + + for (const [defaultSpokenForm, spokenForms] of replacements) { + const replacedPatterns: string[] = []; + for (const currentPattern of patterns) { + if (!termRegex(defaultSpokenForm).test(currentPattern)) { + replacedPatterns.push(currentPattern); + continue; + } + + for (const spokenForm of spokenForms) { + replacedPatterns.push( + replaceTerm(currentPattern, defaultSpokenForm, spokenForm), + ); + } + } + patterns = replacedPatterns; + } + + return patterns; +} + +function replaceTerm(pattern: string, from: string, to: string): string { + return pattern.replaceAll(termRegex(from), to); +} + +function termRegex(term: string): RegExp { + const escaped = term.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`); + return new RegExp(`(? !("private" in reference && reference.private)) + .map(([id, reference]) => ({ + id, + type: "pairedDelimiter", + variations: resolver + .get( + [ + "wrapper_only_paired_delimiter", + "wrapper_selectable_paired_delimiter", + "selectable_only_paired_delimiter", + "surrounding_pair_scope_type", + ], + id, + !includeDisabledByDefault && + "disabledByDefault" in reference && + reference.disabledByDefault + ? [] + : [reference.defaultSpokenForm], + ) + .map((spokenForm) => ({ + spokenForm, + description: capitalize(reference.name), + })), + })) + .filter(({ variations }) => variations.length > 0), + }; +} + +function capitalize(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1); +} + +function colorsSection( + resolver: SpokenFormResolver, + includeDisabledByDefault: boolean, +): CheatsheetSection { + const defaultEnabledIds = new Set(["blue", "green", "pink", "red", "yellow"]); + return { + name: "Colors", + id: "colors", + items: Object.entries(hatColorDefaultSpokenForms) + .filter(([id, spokenForm]) => id !== "default" && spokenForm != null) + .map(([id, defaultSpokenForm]) => ({ + id, + type: "hatColor", + variations: resolver + .get( + ["hat_color"], + id, + includeDisabledByDefault || defaultEnabledIds.has(id) + ? [requiredSpokenForm(defaultSpokenForm)] + : [], + ) + .map((spokenForm) => ({ + spokenForm, + description: capitalize(id), + })), + })) + .filter(({ variations }) => variations.length > 0), + }; +} + +function requiredSpokenForm(spokenForm: string | null): string { + if (spokenForm == null) { + throw new Error("Expected a default spoken form"); + } + + return spokenForm; +} + +function compoundTargetsSection( + resolver: SpokenFormResolver, + includeDisabledByDefault: boolean, +): CheatsheetSection { + return { + name: "Compound targets", + id: "compoundTargets", + items: [ + { + id: "listConnective", + listNames: ["list_connective"], + spokenForm: connectiveDefaultSpokenForms.listConnective, + descriptions: [" and "], + }, + { + id: "rangeExclusive", + listNames: ["range_connective"], + spokenForm: connectiveDefaultSpokenForms.rangeExclusive, + descriptions: [ + "between and ", + "between selection and ", + ], + }, + { + id: "rangeInclusive", + listNames: ["range_connective"], + spokenForm: connectiveDefaultSpokenForms.rangeInclusive, + descriptions: [ + " through ", + "selection through ", + ], + }, + { + id: "rangeExcludingStart", + listNames: ["range_connective"], + spokenForm: connectiveDefaultSpokenForms.rangeExcludingStart, + descriptions: [ + "end of through ", + "end of selection through ", + ], + }, + { + id: "rangeExcludingEnd", + listNames: ["range_connective"], + spokenForm: connectiveDefaultSpokenForms.rangeExcludingEnd, + descriptions: [ + " until start of ", + "selection until start of ", + ], + }, + { + id: "verticalRange", + listNames: ["range_type"], + spokenForm: connectiveDefaultSpokenForms.verticalRange, + descriptions: [ + " vertically through ", + "selection vertically through ", + ], + }, + ] + .map(({ id, listNames, spokenForm, descriptions }) => { + let defaultSpokenForms: string[]; + if (spokenForm != null) { + defaultSpokenForms = [spokenForm]; + } else if (includeDisabledByDefault) { + defaultSpokenForms = [id]; + } else { + defaultSpokenForms = []; + } + + return { + id, + type: "compoundTargetConnective", + variations: resolver + .get(listNames, id, defaultSpokenForms) + .flatMap((customSpokenForm) => + descriptions.map((description, index) => ({ + spokenForm: + index === 0 + ? ` ${customSpokenForm} ` + : `${customSpokenForm} `, + description, + })), + ), + }; + }) + .filter(({ variations }) => variations.length > 0), + }; +} + +function destinationsSection(resolver: SpokenFormResolver): CheatsheetSection { + return { + name: "Destinations", + id: "destinations", + items: [ + { + id: "destination_after", + listNames: ["insertion_mode_before_after"], + valueId: "after", + spokenForm: connectiveDefaultSpokenForms.after, + description: "Insert after ", + }, + { + id: "destination_before", + listNames: ["insertion_mode_before_after"], + valueId: "before", + spokenForm: connectiveDefaultSpokenForms.before, + description: "Insert before ", + }, + { + id: "destination_to", + listNames: ["insertion_mode_to"], + valueId: "sourceDestinationConnective", + spokenForm: connectiveDefaultSpokenForms.sourceDestinationConnective, + description: "Replace ", + }, + ] + .map(({ id, listNames, valueId, spokenForm, description }) => ({ + id, + type: "destination", + variations: resolver + .get(listNames, valueId, [spokenForm]) + .map((customSpokenForm) => ({ + spokenForm: `${customSpokenForm} `, + description, + })), + })) + .filter(({ variations }) => variations.length > 0), + }; +} + +function scopeVisualizerSection( + resolver: SpokenFormResolver, +): CheatsheetSection { + const showSpokenForms = resolver.get( + ["show_scope_visualizer"], + "showScopeVisualizer", + ["visualize"], + ); + return { + name: "Scope visualizer", + id: "scopeVisualizer", + items: [ + items( + "hideScopeVisualizer", + "command", + resolver.get(["hide_scope_visualizer"], "hideScopeVisualizer", [ + "visualize nothing", + ]), + "Hide scope visualizer", + ), + item( + "show_scope_sidebar", + "command", + "bar cursorless", + "Show cursorless sidebar", + ), + { + id: "show_scope_visualizer", + type: "command", + variations: showSpokenForms.flatMap((showSpokenForm) => [ + { + spokenForm: `${showSpokenForm} `, + description: "Visualize ", + }, + ...["removal", "iteration"].flatMap((visualizationType) => + resolver + .get(["visualization_type"], visualizationType, [ + visualizationType, + ]) + .map((spokenForm) => ({ + spokenForm: `${showSpokenForm} ${spokenForm}`, + description: `Visualize ${visualizationType} range`, + })), + ), + ]), + }, + ].filter(({ variations }) => variations.length > 0), + }; +} + +function shapesSection( + resolver: SpokenFormResolver, + includeDisabledByDefault: boolean, +): CheatsheetSection { + return { + name: "Shapes", + id: "shapes", + items: Object.entries(hatShapeDefaultSpokenForms) + .filter(([id, spokenForm]) => id !== "default" && spokenForm != null) + .map(([id, defaultSpokenForm]) => ({ + id, + type: "hatShape", + variations: resolver + .get( + ["hat_shape"], + id, + includeDisabledByDefault + ? [requiredSpokenForm(defaultSpokenForm)] + : [], + ) + .map((spokenForm) => ({ + spokenForm, + description: capitalize(id), + })), + })) + .filter(({ variations }) => variations.length > 0), + }; +} + +function specialMarksSection(resolver: SpokenFormResolver): CheatsheetSection { + return { + name: "Special marks", + id: "specialMarks", + items: [ + items( + "currentSelection", + "mark", + resolver.get(["simple_mark"], "currentSelection", [ + markDefaultSpokenForms.cursor, + ]), + "Current selection", + ), + items( + "lineNumberModulo100", + "mark", + resolver + .get(["line_direction"], "lineNumberModulo100", [ + lineDirectionDefaultSpokenForms.modulo100, + ]) + .map((spokenForm) => `${spokenForm} `), + "Line number modulo 100", + ), + items( + "lineNumberRelativeDown", + "mark", + resolver + .get(["line_direction"], "lineNumberRelativeDown", [ + lineDirectionDefaultSpokenForms.relativeDown, + ]) + .map((spokenForm) => `${spokenForm} `), + "Line number down from cursor", + ), + items( + "lineNumberRelativeUp", + "mark", + resolver + .get(["line_direction"], "lineNumberRelativeUp", [ + lineDirectionDefaultSpokenForms.relativeUp, + ]) + .map((spokenForm) => `${spokenForm} `), + "Line number up from cursor", + ), + items( + "nothing", + "mark", + resolver.get(["simple_mark"], "nothing", [ + markDefaultSpokenForms.nothing, + ]), + "Nothing", + ), + items( + "previousSource", + "mark", + resolver.get(["simple_mark"], "previousSource", [ + markDefaultSpokenForms.source, + ]), + "Previous source", + ), + items( + "previousTarget", + "mark", + resolver.get(["simple_mark"], "previousTarget", [ + markDefaultSpokenForms.that, + ]), + "Previous target", + ), + items( + "unknownSymbol", + "mark", + resolver.get(["unknown_symbol"], "unknownSymbol", [ + graphemeDefaultSpokenForms["\uFFFD"], + ]), + "Unknown symbol", + ), + ].filter(({ variations }) => variations.length > 0), + }; +} + +const tutorialSection: CheatsheetSection = { + name: "Tutorial", + id: "tutorial", + items: [ + item( + "start_tutorial", + "command", + "cursorless tutorial", + "Start the introductory Cursorless tutorial", + ), + item("tutorial_close", "command", "tutorial close", "Close the tutorial"), + item( + "tutorial_list", + "command", + "tutorial list", + "List all available tutorials", + ), + item( + "tutorial_next", + "command", + "tutorial next", + "Advance to next step in tutorial", + ), + item( + "tutorial_previous", + "command", + "tutorial previous", + "Go back to previous step in tutorial", + ), + item( + "tutorial_restart", + "command", + "tutorial restart", + "Restart the tutorial", + ), + item( + "tutorial_resume", + "command", + "tutorial resume", + "Resume the tutorial", + ), + item( + "tutorial_start_by_number", + "command", + "tutorial ", + "Start a specific tutorial by number", + ), + ], +}; + +function item( + id: string, + type: string, + spokenForm: string, + description: string, +): CheatsheetSection["items"][number] { + return { id, type, variations: [{ spokenForm, description }] }; +} + +function items( + id: string, + type: string, + spokenForms: readonly string[], + description: string, +): CheatsheetSection["items"][number] { + return { + id, + type, + variations: spokenForms.map((spokenForm) => ({ spokenForm, description })), + }; +} diff --git a/packages/lib-common/src/index.ts b/packages/lib-common/src/index.ts index 1b88c6acc6..c9ff27ac5e 100644 --- a/packages/lib-common/src/index.ts +++ b/packages/lib-common/src/index.ts @@ -1,4 +1,7 @@ export * from "./constants"; +export * from "./cheatsheet/cheatsheet.types"; +export * from "./cheatsheet/applyLegacyCheatsheetInfo"; +export * from "./cheatsheet/getCheatsheetInfo"; export * from "./cursorlessCommandIds"; export * from "./cursorlessSideBarIds"; export * from "./Debouncer"; diff --git a/packages/lib-common/src/types/TalonSpokenForms.ts b/packages/lib-common/src/types/TalonSpokenForms.ts index a55ab61023..fe9b719123 100644 --- a/packages/lib-common/src/types/TalonSpokenForms.ts +++ b/packages/lib-common/src/types/TalonSpokenForms.ts @@ -10,6 +10,13 @@ export interface TalonSpokenForms { onDidChange: Notifier["registerListener"]; } +/** A raw customizable Talon list entry, used to assemble user-facing syntax. */ +export interface TalonSpokenFormListEntry { + listName: string; + id: string; + spokenForms: string[]; +} + /** * The types of entries for which we currently support getting custom spoken * forms from Talon. diff --git a/packages/lib-node-common/src/Cheatsheet.ts b/packages/lib-node-common/src/Cheatsheet.ts index 2df7313371..d7f6f3206a 100644 --- a/packages/lib-node-common/src/Cheatsheet.ts +++ b/packages/lib-node-common/src/Cheatsheet.ts @@ -1,12 +1,17 @@ import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { parse } from "node-html-parser"; -import type { IDE } from "@cursorless/lib-common"; +import type { CheatsheetInfo, IDE } from "@cursorless/lib-common"; /** * The argument expected by the cheatsheet command. */ -export interface CheatSheetCommandArg { +interface CheatSheetCommandArgBase { + /** The file to write the cheatsheet to. */ + outputPath: string; +} + +export interface CheatSheetCommandArgV0 extends CheatSheetCommandArgBase { /** * The version of the cheatsheet command. */ @@ -17,18 +22,23 @@ export interface CheatSheetCommandArg { * cheatsheet. */ spokenFormInfo: CheatsheetInfo; +} - /** - * The file to write the cheatsheet to - */ - outputPath: string; +export interface CheatSheetCommandArgV1 extends CheatSheetCommandArgBase { + /** The extension assembles the cheatsheet from the Talon state file. */ + version: 1; } +export type CheatSheetCommandArg = + | CheatSheetCommandArgV0 + | CheatSheetCommandArgV1; + export async function showCheatsheet( ide: IDE, - { version, spokenFormInfo, outputPath }: CheatSheetCommandArg, + { version, outputPath }: CheatSheetCommandArg, + spokenFormInfo: CheatsheetInfo, ) { - if (version !== 0) { + if (version !== 0 && version !== 1) { throw new Error(`Unsupported cheatsheet api version: ${version}`); } @@ -43,24 +53,3 @@ export async function showCheatsheet( await writeFile(outputPath, root.toString()); } - -// FIXME: Stop duplicating these types once we have #945 -// The source of truth is at /cursorless-nx/libs/cheatsheet/src/lib/CheatsheetInfo.tsx -interface Variation { - spokenForm: string; - description: string; -} - -interface CheatsheetSection { - name: string; - id: string; - items: { - id: string; - type: string; - variations: Variation[]; - }[]; -} - -interface CheatsheetInfo { - sections: CheatsheetSection[]; -} diff --git a/packages/lib-node-common/src/FileSystemTalonSpokenForms.ts b/packages/lib-node-common/src/FileSystemTalonSpokenForms.ts index 471374d40a..4a3d88005b 100644 --- a/packages/lib-node-common/src/FileSystemTalonSpokenForms.ts +++ b/packages/lib-node-common/src/FileSystemTalonSpokenForms.ts @@ -5,6 +5,7 @@ import type { FileSystem, Listener, SpokenFormEntry, + TalonSpokenFormListEntry, TalonSpokenForms, } from "@cursorless/lib-common"; import { NeedsInitialTalonUpdateError, Notifier } from "@cursorless/lib-common"; @@ -13,6 +14,7 @@ import { isEnoentError } from "./isError"; interface TalonSpokenFormsPayload { version: number; spokenForms: SpokenFormEntry[]; + lists?: TalonSpokenFormListEntry[]; } const LATEST_SPOKEN_FORMS_JSON_VERSION = 0; @@ -38,6 +40,16 @@ export class FileSystemTalonSpokenForms implements TalonSpokenForms { } async getSpokenFormEntries(): Promise { + const payload = await this.getPayload(); + return payload.spokenForms; + } + + async getSpokenFormLists(): Promise { + const payload = await this.getPayload(); + return payload.lists; + } + + private async getPayload(): Promise { let payload: TalonSpokenFormsPayload; try { payload = JSON.parse( @@ -60,7 +72,7 @@ export class FileSystemTalonSpokenForms implements TalonSpokenForms { ); } - return payload.spokenForms; + return payload; } dispose() { From a731eb8a03941d0036510b51238842aae5d0f614 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Sun, 30 Aug 2026 18:51:01 +0200 Subject: [PATCH 03/28] Clean up --- .../src/getCheatsheetInfoForCommand.ts | 25 ++++++++---- .../src/ide/CheatSheetCommandArg.ts | 29 ++++++++++++++ packages/app-vscode/src/registerCommands.ts | 8 ++-- packages/lib-node-common/src/Cheatsheet.ts | 40 ++----------------- 4 files changed, 52 insertions(+), 50 deletions(-) create mode 100644 packages/app-vscode/src/ide/CheatSheetCommandArg.ts diff --git a/packages/app-vscode/src/getCheatsheetInfoForCommand.ts b/packages/app-vscode/src/getCheatsheetInfoForCommand.ts index 93148e7bf1..1fed598c47 100644 --- a/packages/app-vscode/src/getCheatsheetInfoForCommand.ts +++ b/packages/app-vscode/src/getCheatsheetInfoForCommand.ts @@ -3,23 +3,32 @@ import { applyLegacyCheatsheetInfo, getCheatsheetInfo, } from "@cursorless/lib-common"; -import type { - CheatSheetCommandArg, - FileSystemTalonSpokenForms, -} from "@cursorless/lib-node-common"; +import type { FileSystemTalonSpokenForms } from "@cursorless/lib-node-common"; +import type { VscodeApi } from "@cursorless/lib-vscode-common"; +import type { CheatSheetCommandArg } from "./ide/CheatSheetCommandArg"; export async function getCheatsheetInfoForCommand( + vscodeApi: VscodeApi, arg: CheatSheetCommandArg, talonSpokenForms: FileSystemTalonSpokenForms, ): Promise { - if (arg.version === 0) { + const version = arg.version; + + if (version === 0) { + void vscodeApi.window.showWarningMessage( + "Cheatsheet command version 0 is deprecated. Please update cursorless-talon", + ); return applyLegacyCheatsheetInfo( getCheatsheetInfo({ includeDisabledByDefault: true }), arg.spokenFormInfo, ); } - return getCheatsheetInfo({ - listEntries: await talonSpokenForms.getSpokenFormLists(), - }); + if (version === 1) { + return getCheatsheetInfo({ + listEntries: await talonSpokenForms.getSpokenFormLists(), + }); + } + + throw new Error(`Unsupported cheatsheet command version: ${version}`); } diff --git a/packages/app-vscode/src/ide/CheatSheetCommandArg.ts b/packages/app-vscode/src/ide/CheatSheetCommandArg.ts new file mode 100644 index 0000000000..04b7c5b8a6 --- /dev/null +++ b/packages/app-vscode/src/ide/CheatSheetCommandArg.ts @@ -0,0 +1,29 @@ +import type { CheatsheetInfo } from "@cursorless/lib-common"; + +export interface CheatSheetCommandArgV0 { + /** + * The version of the cheatsheet command. + */ + version: 0; + + /** The file to write the cheatsheet to. */ + outputPath: string; + + /** + * A representation of all spoken forms that is used to generate the + * cheatsheet. + */ + spokenFormInfo: CheatsheetInfo; +} + +export interface CheatSheetCommandArgV1 { + /** The extension assembles the cheatsheet from the Talon state file. */ + version: 1; + + /** The file to write the cheatsheet to. */ + outputPath: string; +} + +export type CheatSheetCommandArg = + | CheatSheetCommandArgV0 + | CheatSheetCommandArgV1; diff --git a/packages/app-vscode/src/registerCommands.ts b/packages/app-vscode/src/registerCommands.ts index 64ea4e9e63..b813e397b1 100644 --- a/packages/app-vscode/src/registerCommands.ts +++ b/packages/app-vscode/src/registerCommands.ts @@ -7,11 +7,8 @@ import type { import { CURSORLESS_COMMAND_ID } from "@cursorless/lib-common"; import type { CommandApi, StoredTargetMap } from "@cursorless/lib-engine"; import { analyzeCommandHistory } from "@cursorless/lib-engine"; +import type { FileSystemTalonSpokenForms } from "@cursorless/lib-node-common"; import { showCheatsheet } from "@cursorless/lib-node-common"; -import type { - CheatSheetCommandArg, - FileSystemTalonSpokenForms, -} from "@cursorless/lib-node-common"; import type { ScopeTestRecorder, TestCaseRecorder, @@ -22,6 +19,7 @@ import { showScopeVisualizerItemDocumentation, } from "./commands"; import { getCheatsheetInfoForCommand } from "./getCheatsheetInfoForCommand"; +import type { CheatSheetCommandArg } from "./ide/CheatSheetCommandArg"; import type { VscodeHats } from "./ide/vscode/hats/VscodeHats"; import type { VscodeIDE } from "./ide/vscode/VscodeIDE"; import type { InstallationDependencies } from "./InstallationDependencies"; @@ -78,7 +76,7 @@ export function registerCommands( arg, talonSpokenForms, ); - return showCheatsheet(vscodeIde, arg, cheatsheetInfo); + return showCheatsheet(vscodeIde, arg.outputPath, cheatsheetInfo); }, // Testcase recorder commands diff --git a/packages/lib-node-common/src/Cheatsheet.ts b/packages/lib-node-common/src/Cheatsheet.ts index d7f6f3206a..59d7627d1a 100644 --- a/packages/lib-node-common/src/Cheatsheet.ts +++ b/packages/lib-node-common/src/Cheatsheet.ts @@ -3,45 +3,11 @@ import path from "node:path"; import { parse } from "node-html-parser"; import type { CheatsheetInfo, IDE } from "@cursorless/lib-common"; -/** - * The argument expected by the cheatsheet command. - */ -interface CheatSheetCommandArgBase { - /** The file to write the cheatsheet to. */ - outputPath: string; -} - -export interface CheatSheetCommandArgV0 extends CheatSheetCommandArgBase { - /** - * The version of the cheatsheet command. - */ - version: 0; - - /** - * A representation of all spoken forms that is used to generate the - * cheatsheet. - */ - spokenFormInfo: CheatsheetInfo; -} - -export interface CheatSheetCommandArgV1 extends CheatSheetCommandArgBase { - /** The extension assembles the cheatsheet from the Talon state file. */ - version: 1; -} - -export type CheatSheetCommandArg = - | CheatSheetCommandArgV0 - | CheatSheetCommandArgV1; - export async function showCheatsheet( ide: IDE, - { version, outputPath }: CheatSheetCommandArg, - spokenFormInfo: CheatsheetInfo, + outputPath: string, + cheatsheetInfo: CheatsheetInfo, ) { - if (version !== 0 && version !== 1) { - throw new Error(`Unsupported cheatsheet api version: ${version}`); - } - const cheatsheetPath = path.join(ide.assetsRoot, "cheatsheet.html"); const cheatsheetContent = await readFile(cheatsheetPath, "utf8"); @@ -49,7 +15,7 @@ export async function showCheatsheet( const root = parse(cheatsheetContent); root.getElementById("cheatsheet-data")!.textContent = - `document.cheatsheetInfo = ${JSON.stringify(spokenFormInfo)};`; + `document.cheatsheetInfo = ${JSON.stringify(cheatsheetInfo)};`; await writeFile(outputPath, root.toString()); } From a7068e5e142d3cf425ffb2c4df70cf367b681bdb Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Mon, 31 Aug 2026 08:54:26 +0200 Subject: [PATCH 04/28] Small cleanup --- packages/app-vscode/src/getCheatsheetInfoForCommand.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/app-vscode/src/getCheatsheetInfoForCommand.ts b/packages/app-vscode/src/getCheatsheetInfoForCommand.ts index 1fed598c47..33f0b3cc12 100644 --- a/packages/app-vscode/src/getCheatsheetInfoForCommand.ts +++ b/packages/app-vscode/src/getCheatsheetInfoForCommand.ts @@ -4,11 +4,10 @@ import { getCheatsheetInfo, } from "@cursorless/lib-common"; import type { FileSystemTalonSpokenForms } from "@cursorless/lib-node-common"; -import type { VscodeApi } from "@cursorless/lib-vscode-common"; import type { CheatSheetCommandArg } from "./ide/CheatSheetCommandArg"; +import { vscodeApi } from "./vscodeApi"; export async function getCheatsheetInfoForCommand( - vscodeApi: VscodeApi, arg: CheatSheetCommandArg, talonSpokenForms: FileSystemTalonSpokenForms, ): Promise { From e7cd77fd43e3487a2e04ab1ecc327166b1b160a8 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Mon, 31 Aug 2026 10:11:08 +0200 Subject: [PATCH 05/28] Added deprecated comment --- cursorless-talon/src/actions/actions.py | 2 +- packages/app-vscode/src/getCheatsheetInfoForCommand.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/cursorless-talon/src/actions/actions.py b/cursorless-talon/src/actions/actions.py index 6364b73e20..18893c1f79 100644 --- a/cursorless-talon/src/actions/actions.py +++ b/cursorless-talon/src/actions/actions.py @@ -117,7 +117,7 @@ def cursorless_vscode_command(command_id: str, target: CursorlessTarget): Deprecated: prefer `cursorless_ide_command` """ - # Deprecated 2026-08-31 + # DEPRECATED: 2026-08-31 actions.app.notify( "Deprecated: cursorless_vscode_command is deprecated, prefer cursorless_ide_command" ) diff --git a/packages/app-vscode/src/getCheatsheetInfoForCommand.ts b/packages/app-vscode/src/getCheatsheetInfoForCommand.ts index 33f0b3cc12..a09ce87b89 100644 --- a/packages/app-vscode/src/getCheatsheetInfoForCommand.ts +++ b/packages/app-vscode/src/getCheatsheetInfoForCommand.ts @@ -14,6 +14,7 @@ export async function getCheatsheetInfoForCommand( const version = arg.version; if (version === 0) { + // DEPRECATED: 2026-08-31 void vscodeApi.window.showWarningMessage( "Cheatsheet command version 0 is deprecated. Please update cursorless-talon", ); From 9f81723829f68dc070a5548396d03260738934d0 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Mon, 31 Aug 2026 11:37:31 +0200 Subject: [PATCH 06/28] Clean up tests --- packages/app-web/src/Cheatsheet.tsx | 6 ++--- packages/lib-cheatsheet-local/package.json | 2 +- packages/lib-cheatsheet-local/src/app.tsx | 2 +- .../src/test/app.spec.tsx | 1 + .../lib-cheatsheet-local/tsconfig.jest.json | 3 ++- packages/lib-cheatsheet-local/tsconfig.json | 5 ++-- packages/lib-cheatsheet-local/vite.config.ts | 2 +- packages/lib-cheatsheet/package.json | 3 ++- packages/lib-cheatsheet/src/index.ts | 1 - .../lib-cheatsheet/src/lib/Cheatsheet.tsx | 2 +- .../src/lib/CheatsheetListSection.tsx | 7 ++++-- .../src/lib/cheatsheet.types.tsx | 6 ----- .../src/test/cheatsheet.spec.tsx | 1 + .../src/test/formatCaptures.spec.tsx | 1 + .../src/test/getDefaultCheatsheetInfo.spec.ts | 3 ++- packages/lib-cheatsheet/tsconfig.jest.json | 3 ++- packages/lib-cheatsheet/tsconfig.json | 5 ++-- packages/lib-common/package.json | 3 +-- .../src/cheatsheet/getCheatsheetInfo.ts | 8 ------- packages/lib-common/src/cheatsheet/index.ts | 3 +++ pnpm-lock.yaml | 23 ++++++++----------- 21 files changed, 41 insertions(+), 49 deletions(-) create mode 100644 packages/lib-common/src/cheatsheet/index.ts diff --git a/packages/app-web/src/Cheatsheet.tsx b/packages/app-web/src/Cheatsheet.tsx index 438422e94c..bc1ed32ef3 100644 --- a/packages/app-web/src/Cheatsheet.tsx +++ b/packages/app-web/src/Cheatsheet.tsx @@ -1,7 +1,5 @@ -import { - Cheatsheet as OriginalCheatsheet, - getDefaultCheatsheetInfo, -} from "@cursorless/lib-cheatsheet"; +import { Cheatsheet as OriginalCheatsheet } from "@cursorless/lib-cheatsheet"; +import { getDefaultCheatsheetInfo } from "@cursorless/lib-common"; import { Title } from "./Title"; export function Cheatsheet() { diff --git a/packages/lib-cheatsheet-local/package.json b/packages/lib-cheatsheet-local/package.json index bb44e99da5..ba096f11a9 100644 --- a/packages/lib-cheatsheet-local/package.json +++ b/packages/lib-cheatsheet-local/package.json @@ -21,7 +21,7 @@ "preact": "^10.29.8" }, "devDependencies": { - "@types/jest": "^30.0.0", + "@jest/globals": "^30.4.1", "@types/node": "^24.13.3", "jest": "^30.4.2", "typescript": "^6.0.3", diff --git a/packages/lib-cheatsheet-local/src/app.tsx b/packages/lib-cheatsheet-local/src/app.tsx index efc338c031..3e05ee56ef 100644 --- a/packages/lib-cheatsheet-local/src/app.tsx +++ b/packages/lib-cheatsheet-local/src/app.tsx @@ -1,5 +1,5 @@ -import type { CheatsheetInfo } from "@cursorless/lib-cheatsheet"; import { Cheatsheet } from "@cursorless/lib-cheatsheet"; +import type { CheatsheetInfo } from "@cursorless/lib-common"; import "./styles.css"; declare global { diff --git a/packages/lib-cheatsheet-local/src/test/app.spec.tsx b/packages/lib-cheatsheet-local/src/test/app.spec.tsx index 05915170ed..97c69a647f 100644 --- a/packages/lib-cheatsheet-local/src/test/app.spec.tsx +++ b/packages/lib-cheatsheet-local/src/test/app.spec.tsx @@ -1,3 +1,4 @@ +import { afterEach, beforeEach, describe, expect, it } from "@jest/globals"; import { render } from "preact"; import { act } from "preact/test-utils"; import { fakeCheatsheetInfo } from "@cursorless/lib-cheatsheet"; diff --git a/packages/lib-cheatsheet-local/tsconfig.jest.json b/packages/lib-cheatsheet-local/tsconfig.jest.json index f7e35d10ae..188d7a5b44 100644 --- a/packages/lib-cheatsheet-local/tsconfig.jest.json +++ b/packages/lib-cheatsheet-local/tsconfig.jest.json @@ -3,6 +3,7 @@ "compilerOptions": { "ignoreDeprecations": "6.0", "module": "commonjs", - "moduleResolution": "bundler" + "moduleResolution": "bundler", + "types": ["node"] } } diff --git a/packages/lib-cheatsheet-local/tsconfig.json b/packages/lib-cheatsheet-local/tsconfig.json index bb689757d2..20c49f2c53 100644 --- a/packages/lib-cheatsheet-local/tsconfig.json +++ b/packages/lib-cheatsheet-local/tsconfig.json @@ -1,12 +1,13 @@ { "extends": "../../tsconfig.web.json", "compilerOptions": { - "types": ["jest"] + "types": [] }, "include": [ "src/**/*.ts", "src/**/*.tsx", "src/**/*.json", "../../resources/typings/**/*.d.ts" - ] + ], + "exclude": ["src/test"] } diff --git a/packages/lib-cheatsheet-local/vite.config.ts b/packages/lib-cheatsheet-local/vite.config.ts index 52bc55a0fd..0ffb901916 100644 --- a/packages/lib-cheatsheet-local/vite.config.ts +++ b/packages/lib-cheatsheet-local/vite.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "vite"; import type { UserConfig } from "vite"; import purgeCss from "vite-plugin-purgecss"; import { viteSingleFile } from "vite-plugin-singlefile"; -import { getDefaultCheatsheetInfo } from "@cursorless/lib-cheatsheet"; +import { getDefaultCheatsheetInfo } from "@cursorless/lib-common"; import { purgeCssOptions, viteHtmlParams, diff --git a/packages/lib-cheatsheet/package.json b/packages/lib-cheatsheet/package.json index b9a07a8485..862692add0 100644 --- a/packages/lib-cheatsheet/package.json +++ b/packages/lib-cheatsheet/package.json @@ -21,7 +21,8 @@ "react-bootstrap-icons": "^1.11.6" }, "devDependencies": { - "@types/jest": "^30.0.0", + "@jest/globals": "^30.4.1", + "@types/node": "^24.13.3", "jest": "^30.4.2", "jest-environment-jsdom": "^30.4.1", "ts-jest": "^29.4.12", diff --git a/packages/lib-cheatsheet/src/index.ts b/packages/lib-cheatsheet/src/index.ts index 5b866b55e5..3a37b41635 100644 --- a/packages/lib-cheatsheet/src/index.ts +++ b/packages/lib-cheatsheet/src/index.ts @@ -1,4 +1,3 @@ export * from "./lib/Cheatsheet"; export * from "./lib/cheatsheet.types"; export * from "./lib/utils/fakeCheatsheetInfo"; -export { getDefaultCheatsheetInfo } from "@cursorless/lib-common/cheatsheet"; diff --git a/packages/lib-cheatsheet/src/lib/Cheatsheet.tsx b/packages/lib-cheatsheet/src/lib/Cheatsheet.tsx index 8b8be08f76..66243d2048 100644 --- a/packages/lib-cheatsheet/src/lib/Cheatsheet.tsx +++ b/packages/lib-cheatsheet/src/lib/Cheatsheet.tsx @@ -1,7 +1,7 @@ import { useEffect } from "preact/hooks"; import { QuestionCircleFill } from "react-bootstrap-icons"; import "./cheatsheet.css"; -import type { CheatsheetInfo } from "./cheatsheet.types"; +import type { CheatsheetInfo } from "@cursorless/lib-common"; import { CheatsheetLegendSection } from "./CheatsheetLegendSection"; import { CheatsheetListSection } from "./CheatsheetListSection"; import { CheatsheetNotesSection } from "./CheatsheetNotesSection"; diff --git a/packages/lib-cheatsheet/src/lib/CheatsheetListSection.tsx b/packages/lib-cheatsheet/src/lib/CheatsheetListSection.tsx index 5d8d33b1e0..654f103fb1 100644 --- a/packages/lib-cheatsheet/src/lib/CheatsheetListSection.tsx +++ b/packages/lib-cheatsheet/src/lib/CheatsheetListSection.tsx @@ -1,5 +1,8 @@ import type { JSX } from "preact"; -import type { CheatsheetSection, Variation } from "./cheatsheet.types"; +import type { + CheatsheetSection, + CheatsheetVariation, +} from "@cursorless/lib-common"; import { formatCaptures } from "./utils/formatCaptures"; import { useIsHighlighted } from "./utils/useIsHighlighted"; @@ -47,7 +50,7 @@ export function CheatsheetListSection({ section }: Props): JSX.Element { } type CheatsheetListEntryProps = { - variation: Variation; + variation: CheatsheetVariation; }; function CheatsheetListEntry({ diff --git a/packages/lib-cheatsheet/src/lib/cheatsheet.types.tsx b/packages/lib-cheatsheet/src/lib/cheatsheet.types.tsx index 738ec4f907..1b3b5f763b 100644 --- a/packages/lib-cheatsheet/src/lib/cheatsheet.types.tsx +++ b/packages/lib-cheatsheet/src/lib/cheatsheet.types.tsx @@ -1,9 +1,3 @@ -export type { - CheatsheetInfo, - CheatsheetSection, - CheatsheetVariation as Variation, -} from "@cursorless/lib-common/cheatsheet"; - interface CheatsheetLegendEntry { term: string; definition: string; diff --git a/packages/lib-cheatsheet/src/test/cheatsheet.spec.tsx b/packages/lib-cheatsheet/src/test/cheatsheet.spec.tsx index a99a265998..0933f7a89f 100644 --- a/packages/lib-cheatsheet/src/test/cheatsheet.spec.tsx +++ b/packages/lib-cheatsheet/src/test/cheatsheet.spec.tsx @@ -1,3 +1,4 @@ +import { afterEach, describe, expect, it } from "@jest/globals"; import { render } from "preact"; import { act } from "preact/test-utils"; import { Cheatsheet } from "../lib/Cheatsheet"; diff --git a/packages/lib-cheatsheet/src/test/formatCaptures.spec.tsx b/packages/lib-cheatsheet/src/test/formatCaptures.spec.tsx index 5cffc56ca1..f5f68d719d 100644 --- a/packages/lib-cheatsheet/src/test/formatCaptures.spec.tsx +++ b/packages/lib-cheatsheet/src/test/formatCaptures.spec.tsx @@ -1,3 +1,4 @@ +import { afterEach, describe, expect, it } from "@jest/globals"; import { render } from "preact"; import { act } from "preact/test-utils"; import { formatCaptures } from "../lib/utils/formatCaptures"; diff --git a/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts b/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts index ccb836de33..170e0b9abf 100644 --- a/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts +++ b/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts @@ -1,9 +1,10 @@ +import { describe, expect, test } from "@jest/globals"; +import type { CheatsheetInfo } from "@cursorless/lib-common/cheatsheet"; import { applyLegacyCheatsheetInfo, getCheatsheetInfo, getDefaultCheatsheetInfo, } from "@cursorless/lib-common/cheatsheet"; -import type { CheatsheetInfo } from "@cursorless/lib-common/cheatsheet"; describe("getDefaultCheatsheetInfo", () => { const cheatsheetInfo = getDefaultCheatsheetInfo(); diff --git a/packages/lib-cheatsheet/tsconfig.jest.json b/packages/lib-cheatsheet/tsconfig.jest.json index f7e35d10ae..188d7a5b44 100644 --- a/packages/lib-cheatsheet/tsconfig.jest.json +++ b/packages/lib-cheatsheet/tsconfig.jest.json @@ -3,6 +3,7 @@ "compilerOptions": { "ignoreDeprecations": "6.0", "module": "commonjs", - "moduleResolution": "bundler" + "moduleResolution": "bundler", + "types": ["node"] } } diff --git a/packages/lib-cheatsheet/tsconfig.json b/packages/lib-cheatsheet/tsconfig.json index bb689757d2..20c49f2c53 100644 --- a/packages/lib-cheatsheet/tsconfig.json +++ b/packages/lib-cheatsheet/tsconfig.json @@ -1,12 +1,13 @@ { "extends": "../../tsconfig.web.json", "compilerOptions": { - "types": ["jest"] + "types": [] }, "include": [ "src/**/*.ts", "src/**/*.tsx", "src/**/*.json", "../../resources/typings/**/*.d.ts" - ] + ], + "exclude": ["src/test"] } diff --git a/packages/lib-common/package.json b/packages/lib-common/package.json index 03594af208..342b599d1d 100644 --- a/packages/lib-common/package.json +++ b/packages/lib-common/package.json @@ -9,9 +9,8 @@ }, "exports": { ".": "./src/index.ts", - "./cheatsheet": "./src/cheatsheet/getCheatsheetInfo.ts", + "./cheatsheet": "./src/cheatsheet/index.ts", "./jest": "./src/tooling/jest.ts", - "./references": "./src/references/index.ts", "./vite": "./src/tooling/vite.ts" }, "scripts": { diff --git a/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts b/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts index 084b38f86a..755c37f276 100644 --- a/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts +++ b/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts @@ -13,14 +13,6 @@ import { import type { TalonSpokenFormListEntry } from "../types/TalonSpokenForms"; import type { CheatsheetInfo, CheatsheetSection } from "./cheatsheet.types"; -export type { - CheatsheetInfo, - CheatsheetItem, - CheatsheetSection, - CheatsheetVariation, -} from "./cheatsheet.types"; -export { applyLegacyCheatsheetInfo } from "./applyLegacyCheatsheetInfo"; - interface CheatsheetReference { defaultSpokenForm?: string; disabledByDefault?: boolean; diff --git a/packages/lib-common/src/cheatsheet/index.ts b/packages/lib-common/src/cheatsheet/index.ts new file mode 100644 index 0000000000..b4489a0062 --- /dev/null +++ b/packages/lib-common/src/cheatsheet/index.ts @@ -0,0 +1,3 @@ +export * from "./applyLegacyCheatsheetInfo"; +export * from "./cheatsheet.types"; +export * from "./getCheatsheetInfo"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aceb98e4cc..b6b852a513 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -310,9 +310,12 @@ importers: specifier: ^1.11.6 version: 1.11.6(react@19.2.8) devDependencies: - '@types/jest': - specifier: ^30.0.0 - version: 30.0.0 + '@jest/globals': + specifier: ^30.4.1 + version: 30.4.1 + '@types/node': + specifier: ^24.13.3 + version: 24.13.3 jest: specifier: ^30.4.2 version: 30.4.2(@types/node@24.13.3)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@24.13.3)(typescript@6.0.3)) @@ -341,9 +344,9 @@ importers: specifier: ^10.29.8 version: 10.29.8 devDependencies: - '@types/jest': - specifier: ^30.0.0 - version: 30.0.0 + '@jest/globals': + specifier: ^30.4.1 + version: 30.4.1 '@types/node': specifier: ^24.13.3 version: 24.13.3 @@ -4925,9 +4928,6 @@ packages: '@types/istanbul-reports@3.0.4': resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} - '@types/jest@30.0.0': - resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==} - '@types/js-yaml@4.0.9': resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} @@ -16955,11 +16955,6 @@ snapshots: dependencies: '@types/istanbul-lib-report': 3.0.3 - '@types/jest@30.0.0': - dependencies: - expect: 30.4.1 - pretty-format: 30.4.1 - '@types/js-yaml@4.0.9': {} '@types/jsdom@21.1.7': From 2ab967bc3ca0db62062cac85a6e6698f70c1ec63 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Mon, 31 Aug 2026 11:53:54 +0200 Subject: [PATCH 07/28] Reuse legacy che cheat info --- .../src/getCheatsheetInfoForCommand.ts | 12 +- .../src/test/getDefaultCheatsheetInfo.spec.ts | 44 ------ .../cheatsheet/applyLegacyCheatsheetInfo.ts | 135 ------------------ packages/lib-common/src/cheatsheet/index.ts | 1 - packages/lib-common/src/index.ts | 4 +- 5 files changed, 4 insertions(+), 192 deletions(-) delete mode 100644 packages/lib-common/src/cheatsheet/applyLegacyCheatsheetInfo.ts diff --git a/packages/app-vscode/src/getCheatsheetInfoForCommand.ts b/packages/app-vscode/src/getCheatsheetInfoForCommand.ts index a09ce87b89..f534cca75c 100644 --- a/packages/app-vscode/src/getCheatsheetInfoForCommand.ts +++ b/packages/app-vscode/src/getCheatsheetInfoForCommand.ts @@ -1,8 +1,5 @@ import type { CheatsheetInfo } from "@cursorless/lib-common"; -import { - applyLegacyCheatsheetInfo, - getCheatsheetInfo, -} from "@cursorless/lib-common"; +import { getCheatsheetInfo } from "@cursorless/lib-common"; import type { FileSystemTalonSpokenForms } from "@cursorless/lib-node-common"; import type { CheatSheetCommandArg } from "./ide/CheatSheetCommandArg"; import { vscodeApi } from "./vscodeApi"; @@ -16,12 +13,9 @@ export async function getCheatsheetInfoForCommand( if (version === 0) { // DEPRECATED: 2026-08-31 void vscodeApi.window.showWarningMessage( - "Cheatsheet command version 0 is deprecated. Please update cursorless-talon", - ); - return applyLegacyCheatsheetInfo( - getCheatsheetInfo({ includeDisabledByDefault: true }), - arg.spokenFormInfo, + "Cheatsheet command version 0 is deprecated. Please update cursorless-talon.", ); + return arg.spokenFormInfo; } if (version === 1) { diff --git a/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts b/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts index 170e0b9abf..20f893d56d 100644 --- a/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts +++ b/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from "@jest/globals"; import type { CheatsheetInfo } from "@cursorless/lib-common/cheatsheet"; import { - applyLegacyCheatsheetInfo, getCheatsheetInfo, getDefaultCheatsheetInfo, } from "@cursorless/lib-common/cheatsheet"; @@ -103,49 +102,6 @@ describe("getDefaultCheatsheetInfo", () => { ).toBeDefined(); }); - test("uses a legacy payload's spoken forms with current syntax and descriptions", () => { - const current = getCheatsheetInfo({ includeDisabledByDefault: true }); - const legacy = getDefaultCheatsheetInfo(); - const legacyItem = getItem("actions", "editNewLineBefore", legacy); - legacyItem.variations = [ - { - spokenForm: "gulp ", - description: "An obsolete description", - }, - ]; - getSection("shapes", legacy).items.push({ - id: "fox", - type: "hatShape", - variations: [{ spokenForm: "animal", description: "Fox" }], - }); - - const result = applyLegacyCheatsheetInfo(current, legacy); - - expect(getItem("actions", "editNewLineBefore", result).variations).toEqual([ - { spokenForm: "gulp ", description: "Edit new line before" }, - { - spokenForm: "gulp ", - description: "Edit new before", - }, - ]); - expect(getItem("shapes", "fox", result).variations).toEqual([ - { spokenForm: "animal", description: "Fox" }, - ]); - }); - - test("does not re-enable items omitted from a legacy payload", () => { - const current = getCheatsheetInfo({ includeDisabledByDefault: true }); - const legacy = getDefaultCheatsheetInfo(); - const legacyScopes = getSection("scopes", legacy); - legacyScopes.items = legacyScopes.items.filter(({ id }) => id !== "token"); - - const result = applyLegacyCheatsheetInfo(current, legacy); - - expect(getSection("scopes", result).items).not.toContainEqual( - expect.objectContaining({ id: "token" }), - ); - }); - // oxlint-disable-next-line unicorn/consistent-function-scoping function getSection( sectionId: string, diff --git a/packages/lib-common/src/cheatsheet/applyLegacyCheatsheetInfo.ts b/packages/lib-common/src/cheatsheet/applyLegacyCheatsheetInfo.ts deleted file mode 100644 index 9359d409af..0000000000 --- a/packages/lib-common/src/cheatsheet/applyLegacyCheatsheetInfo.ts +++ /dev/null @@ -1,135 +0,0 @@ -import type { - CheatsheetInfo, - CheatsheetItem, - CheatsheetVariation, -} from "./cheatsheet.types"; - -const captureRegex = /<[^>]+>/gu; - -/** - * Applies the spoken forms produced by the version-0 Talon cheatsheet payload - * to the current cheatsheet structure and descriptions. - */ -export function applyLegacyCheatsheetInfo( - current: CheatsheetInfo, - legacy: CheatsheetInfo, -): CheatsheetInfo { - const legacySections = new Map( - legacy.sections.map((section) => [section.id, section]), - ); - - return { - sections: current.sections.map((section) => { - const legacySection = legacySections.get(section.id); - if (legacySection == null) { - return section; - } - - const legacyItems = new Map( - legacySection.items.map((item) => [item.id, item]), - ); - - return { - ...section, - items: section.items.flatMap((item) => { - const legacyItem = legacyItems.get( - getLegacyItemId(section.id, item.id), - ); - return legacyItem == null ? [] : [applyLegacyItem(item, legacyItem)]; - }), - }; - }), - }; -} - -function getLegacyItemId(sectionId: string, itemId: string): string { - return sectionId === "actions" && itemId === "rewrapWithPairedDelimiter" - ? "rewrap" - : itemId; -} - -function applyLegacyItem( - current: CheatsheetItem, - legacy: CheatsheetItem, -): CheatsheetItem { - const legacyBySignature = groupByCaptureSignature(legacy.variations); - const signatureIndexes = new Map(); - const replacements: Array = []; - - const matchedSpokenForms = current.variations.map((variation) => { - const signature = captureSignature(variation); - const signatureIndex = signatureIndexes.get(signature) ?? 0; - signatureIndexes.set(signature, signatureIndex + 1); - const legacyVariation = legacyBySignature.get(signature)?.[signatureIndex]; - - if (legacyVariation != null) { - collectLiteralReplacements(variation, legacyVariation, replacements); - } - - return legacyVariation?.spokenForm; - }); - - return { - ...current, - variations: current.variations.map((variation, index) => ({ - ...variation, - spokenForm: - matchedSpokenForms[index] ?? - applyLiteralReplacements(variation.spokenForm, replacements), - })), - }; -} - -function groupByCaptureSignature( - variations: readonly CheatsheetVariation[], -): Map { - const result = new Map(); - for (const variation of variations) { - const signature = captureSignature(variation); - const entries = result.get(signature) ?? []; - entries.push(variation); - result.set(signature, entries); - } - - return result; -} - -function captureSignature({ spokenForm }: CheatsheetVariation): string { - return Array.from( - spokenForm.matchAll(captureRegex), - ([capture]) => capture, - ).join("\0"); -} - -function collectLiteralReplacements( - current: CheatsheetVariation, - legacy: CheatsheetVariation, - replacements: Array, -) { - const currentParts = current.spokenForm.split(captureRegex); - const legacyParts = legacy.spokenForm.split(captureRegex); - - for (let index = 0; index < currentParts.length; index++) { - const currentPart = currentParts[index]?.trim(); - const legacyPart = legacyParts[index]?.trim(); - if ( - currentPart != null && - legacyPart != null && - currentPart.length > 0 && - currentPart !== legacyPart - ) { - replacements.push([currentPart, legacyPart]); - } - } -} - -function applyLiteralReplacements( - spokenForm: string, - replacements: readonly (readonly [string, string])[], -): string { - let result = spokenForm; - for (const [from, to] of replacements) { - result = result.replace(from, to); - } - return result; -} diff --git a/packages/lib-common/src/cheatsheet/index.ts b/packages/lib-common/src/cheatsheet/index.ts index b4489a0062..14a161d2a6 100644 --- a/packages/lib-common/src/cheatsheet/index.ts +++ b/packages/lib-common/src/cheatsheet/index.ts @@ -1,3 +1,2 @@ -export * from "./applyLegacyCheatsheetInfo"; export * from "./cheatsheet.types"; export * from "./getCheatsheetInfo"; diff --git a/packages/lib-common/src/index.ts b/packages/lib-common/src/index.ts index c9ff27ac5e..383ecf228c 100644 --- a/packages/lib-common/src/index.ts +++ b/packages/lib-common/src/index.ts @@ -1,7 +1,5 @@ +export * from "./cheatsheet/index"; export * from "./constants"; -export * from "./cheatsheet/cheatsheet.types"; -export * from "./cheatsheet/applyLegacyCheatsheetInfo"; -export * from "./cheatsheet/getCheatsheetInfo"; export * from "./cursorlessCommandIds"; export * from "./cursorlessSideBarIds"; export * from "./Debouncer"; From fd0349b87eac15a437571623938c4a1253c92c97 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Mon, 31 Aug 2026 12:03:45 +0200 Subject: [PATCH 08/28] Clean up types --- .../src/getCheatsheetInfoForCommand.ts | 8 +++-- .../src/ide/CheatSheetCommandArg.ts | 29 ------------------- packages/app-vscode/src/registerCommands.ts | 2 +- .../src/cheatsheet/cheatsheet.types.ts | 25 ++++++++++++++++ 4 files changed, 31 insertions(+), 33 deletions(-) delete mode 100644 packages/app-vscode/src/ide/CheatSheetCommandArg.ts diff --git a/packages/app-vscode/src/getCheatsheetInfoForCommand.ts b/packages/app-vscode/src/getCheatsheetInfoForCommand.ts index f534cca75c..d2a998f653 100644 --- a/packages/app-vscode/src/getCheatsheetInfoForCommand.ts +++ b/packages/app-vscode/src/getCheatsheetInfoForCommand.ts @@ -1,7 +1,9 @@ -import type { CheatsheetInfo } from "@cursorless/lib-common"; +import type { + CheatSheetCommandArg, + CheatsheetInfo, +} from "@cursorless/lib-common"; import { getCheatsheetInfo } from "@cursorless/lib-common"; import type { FileSystemTalonSpokenForms } from "@cursorless/lib-node-common"; -import type { CheatSheetCommandArg } from "./ide/CheatSheetCommandArg"; import { vscodeApi } from "./vscodeApi"; export async function getCheatsheetInfoForCommand( @@ -13,7 +15,7 @@ export async function getCheatsheetInfoForCommand( if (version === 0) { // DEPRECATED: 2026-08-31 void vscodeApi.window.showWarningMessage( - "Cheatsheet command version 0 is deprecated. Please update cursorless-talon.", + "Cheat sheet command version 0 is deprecated. Please update cursorless-talon.", ); return arg.spokenFormInfo; } diff --git a/packages/app-vscode/src/ide/CheatSheetCommandArg.ts b/packages/app-vscode/src/ide/CheatSheetCommandArg.ts deleted file mode 100644 index 04b7c5b8a6..0000000000 --- a/packages/app-vscode/src/ide/CheatSheetCommandArg.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { CheatsheetInfo } from "@cursorless/lib-common"; - -export interface CheatSheetCommandArgV0 { - /** - * The version of the cheatsheet command. - */ - version: 0; - - /** The file to write the cheatsheet to. */ - outputPath: string; - - /** - * A representation of all spoken forms that is used to generate the - * cheatsheet. - */ - spokenFormInfo: CheatsheetInfo; -} - -export interface CheatSheetCommandArgV1 { - /** The extension assembles the cheatsheet from the Talon state file. */ - version: 1; - - /** The file to write the cheatsheet to. */ - outputPath: string; -} - -export type CheatSheetCommandArg = - | CheatSheetCommandArgV0 - | CheatSheetCommandArgV1; diff --git a/packages/app-vscode/src/registerCommands.ts b/packages/app-vscode/src/registerCommands.ts index b813e397b1..9575421a34 100644 --- a/packages/app-vscode/src/registerCommands.ts +++ b/packages/app-vscode/src/registerCommands.ts @@ -1,5 +1,6 @@ import vscode from "vscode"; import type { + CheatSheetCommandArg, CommandHistoryStorage, CursorlessCommandId, ScopeType, @@ -19,7 +20,6 @@ import { showScopeVisualizerItemDocumentation, } from "./commands"; import { getCheatsheetInfoForCommand } from "./getCheatsheetInfoForCommand"; -import type { CheatSheetCommandArg } from "./ide/CheatSheetCommandArg"; import type { VscodeHats } from "./ide/vscode/hats/VscodeHats"; import type { VscodeIDE } from "./ide/vscode/VscodeIDE"; import type { InstallationDependencies } from "./InstallationDependencies"; diff --git a/packages/lib-common/src/cheatsheet/cheatsheet.types.ts b/packages/lib-common/src/cheatsheet/cheatsheet.types.ts index 9a1c716b52..234b85e81c 100644 --- a/packages/lib-common/src/cheatsheet/cheatsheet.types.ts +++ b/packages/lib-common/src/cheatsheet/cheatsheet.types.ts @@ -18,3 +18,28 @@ export interface CheatsheetSection { export interface CheatsheetInfo { sections: CheatsheetSection[]; } + +interface CheatSheetCommandArgV0 { + version: 0; + + /** The file to write the cheatsheet to. */ + outputPath: string; + + /** + * A representation of all spoken forms that is used to generate the + * cheatsheet. + */ + spokenFormInfo: CheatsheetInfo; +} + +/** The extension assembles the cheatsheet from the Talon state file. */ +interface CheatSheetCommandArgV1 { + version: 1; + + /** The file to write the cheatsheet to. */ + outputPath: string; +} + +export type CheatSheetCommandArg = + | CheatSheetCommandArgV0 + | CheatSheetCommandArgV1; From 12e8ae3c5528c819697c913d8f8d6c2d0a81f012 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Mon, 31 Aug 2026 12:11:00 +0200 Subject: [PATCH 09/28] Clean up --- cursorless-talon-dev/src/cursorless_dev.talon | 3 --- 1 file changed, 3 deletions(-) diff --git a/cursorless-talon-dev/src/cursorless_dev.talon b/cursorless-talon-dev/src/cursorless_dev.talon index 6f2c56a0d0..9f2430fd44 100644 --- a/cursorless-talon-dev/src/cursorless_dev.talon +++ b/cursorless-talon-dev/src/cursorless_dev.talon @@ -29,9 +29,6 @@ tag: user.cursorless user.private_cursorless_record_that_mark_test() {user.cursorless_homophone} record silent: user.cursorless_record_silent_test() -{user.cursorless_homophone} update cheatsheet: - user.private_cursorless_cheat_sheet_update_json() - test snip make : user.private_cursorless_make_snippet_test(cursorless_target) From 9e01428ac14d51b902933a02b42d1fce26637a6f Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Mon, 31 Aug 2026 12:42:17 +0200 Subject: [PATCH 10/28] Remove list entry from state file --- cursorless-talon/src/spoken_forms.py | 16 +- cursorless-talon/src/spoken_forms_output.py | 13 +- .../src/getCheatsheetInfoForCommand.ts | 2 +- .../src/test/getDefaultCheatsheetInfo.spec.ts | 22 ++- .../src/cheatsheet/getCheatsheetInfo.ts | 178 +++++++----------- .../lib-common/src/types/TalonSpokenForms.ts | 8 +- .../src/spokenForms/CustomSpokenForms.ts | 4 +- .../src/FileSystemTalonSpokenForms.ts | 13 +- 8 files changed, 92 insertions(+), 164 deletions(-) diff --git a/cursorless-talon/src/spoken_forms.py b/cursorless-talon/src/spoken_forms.py index 66d91e1e47..d17dd7e539 100644 --- a/cursorless-talon/src/spoken_forms.py +++ b/cursorless-talon/src/spoken_forms.py @@ -18,7 +18,6 @@ ) from .marks.decorated_mark import init_hats from .spoken_forms_output import ( - SpokenFormListOutputEntry, SpokenFormOutputEntry, SpokenFormsOutput, ) @@ -115,17 +114,7 @@ def update(): def update_spoken_forms_output(): spoken_form_entries: list[SpokenFormOutputEntry] = [ { - "type": LIST_TO_TYPE_MAP[entry.list_name], - "id": entry.id, - "spokenForms": entry.spoken_forms, - } - for spoken_form_list in custom_spoken_forms.values() - for entry in spoken_form_list - if entry.list_name in LIST_TO_TYPE_MAP - ] - list_entries: list[SpokenFormListOutputEntry] = [ - { - "listName": entry.list_name, + "type": LIST_TO_TYPE_MAP.get(entry.list_name, entry.list_name), "id": entry.id, "spokenForms": entry.spoken_forms, } @@ -136,8 +125,7 @@ def update_spoken_forms_output(): [ *spoken_form_entries, *get_grapheme_spoken_form_entries(graphemes_talon_list), - ], - list_entries, + ] ) def handle_new_values(csv_name: str, values: Sequence[SpokenFormEntry]): diff --git a/cursorless-talon/src/spoken_forms_output.py b/cursorless-talon/src/spoken_forms_output.py index 6d41783aea..6961c98ec2 100644 --- a/cursorless-talon/src/spoken_forms_output.py +++ b/cursorless-talon/src/spoken_forms_output.py @@ -14,12 +14,6 @@ class SpokenFormOutputEntry(TypedDict): spokenForms: list[str] -class SpokenFormListOutputEntry(TypedDict): - listName: str - id: str - spokenForms: list[str] - - class SpokenFormsOutput: """ Writes spoken forms to a json file for use by the Cursorless vscode extension @@ -35,11 +29,7 @@ def init(self): print(error_message) app.notify(error_message) - def write( - self, - spoken_forms: list[SpokenFormOutputEntry], - lists: list[SpokenFormListOutputEntry], - ): + def write(self, spoken_forms: list[SpokenFormOutputEntry]): with open(SPOKEN_FORMS_OUTPUT_PATH, "w", encoding="UTF-8") as out: try: out.write( @@ -47,7 +37,6 @@ def write( { "version": STATE_JSON_VERSION_NUMBER, "spokenForms": spoken_forms, - "lists": lists, } ) ) diff --git a/packages/app-vscode/src/getCheatsheetInfoForCommand.ts b/packages/app-vscode/src/getCheatsheetInfoForCommand.ts index d2a998f653..b4691bd975 100644 --- a/packages/app-vscode/src/getCheatsheetInfoForCommand.ts +++ b/packages/app-vscode/src/getCheatsheetInfoForCommand.ts @@ -22,7 +22,7 @@ export async function getCheatsheetInfoForCommand( if (version === 1) { return getCheatsheetInfo({ - listEntries: await talonSpokenForms.getSpokenFormLists(), + spokenFormEntries: await talonSpokenForms.getSpokenFormEntries(), }); } diff --git a/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts b/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts index 20f893d56d..a106503068 100644 --- a/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts +++ b/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts @@ -35,27 +35,31 @@ describe("getDefaultCheatsheetInfo", () => { expect(getItem("scopes", "pair")).toBeDefined(); }); - test("applies raw Talon list entries to the current syntax", () => { + test("applies Talon spoken-form entries to the current syntax", () => { const customCheatsheetInfo = getCheatsheetInfo({ - listEntries: [ + spokenFormEntries: [ { - listName: "simple_action", + type: "action", id: "editNewLineBefore", spokenForms: ["gulp"], }, { - listName: "interior_modifier", + type: "interior_modifier", id: "interiorOnly", spokenForms: ["within"], }, - { listName: "scope_type", id: "token", spokenForms: ["word unit"] }, { - listName: "scope_type", + type: "simpleScopeTypeType", + id: "token", + spokenForms: ["word unit"], + }, + { + type: "simpleScopeTypeType", id: "sectionLevelOne", spokenForms: ["one section"], }, { - listName: "range_connective", + type: "range_connective", id: "rangeExcludingStart", spokenForms: ["from end"], }, @@ -91,7 +95,9 @@ describe("getDefaultCheatsheetInfo", () => { test("an empty raw list entry disables only the corresponding item", () => { const customCheatsheetInfo = getCheatsheetInfo({ - listEntries: [{ listName: "scope_type", id: "token", spokenForms: [] }], + spokenFormEntries: [ + { type: "simpleScopeTypeType", id: "token", spokenForms: [] }, + ], }); expect(getSection("scopes", customCheatsheetInfo).items).not.toContainEqual( diff --git a/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts b/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts index 755c37f276..731191f01b 100644 --- a/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts +++ b/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts @@ -10,7 +10,7 @@ import { pairedDelimiterReferences, scopeReferences, } from "../references"; -import type { TalonSpokenFormListEntry } from "../types/TalonSpokenForms"; +import type { TalonSpokenFormEntry } from "../types/TalonSpokenForms"; import type { CheatsheetInfo, CheatsheetSection } from "./cheatsheet.types"; interface CheatsheetReference { @@ -27,25 +27,12 @@ type ReferenceMap = Readonly>; const REFERENCE_SPOKEN_FORM = ""; -const actionListNames = [ - "simple_action", - "callback_action", - "paste_action", - "bring_move_action", - "swap_action", - "wrap_action", - "insert_snippet_action", - "reformat_action", - "call_action", - "experimental_action", -] as const; +const actionTypes = ["action"] as const; type ReferenceKind = "action" | "modifier" | "scope"; export interface GetCheatsheetInfoOptions { - /** Include entries that need Talon state to become active. */ - includeDisabledByDefault?: boolean; - listEntries?: readonly TalonSpokenFormListEntry[]; + spokenFormEntries?: readonly TalonSpokenFormEntry[]; } /** Construct the stock cheatsheet directly from the canonical references. */ @@ -53,12 +40,11 @@ export function getDefaultCheatsheetInfo(): CheatsheetInfo { return getCheatsheetInfo(); } -/** Construct a cheatsheet using optional customized Talon list entries. */ +/** Construct a cheatsheet using optional customized Talon spoken forms. */ export function getCheatsheetInfo({ - includeDisabledByDefault = false, - listEntries = [], + spokenFormEntries = [], }: GetCheatsheetInfoOptions = {}): CheatsheetInfo { - const resolver = new SpokenFormResolver(listEntries); + const resolver = new SpokenFormResolver(spokenFormEntries); return { sections: [ @@ -69,10 +55,9 @@ export function getCheatsheetInfo({ "action", "action", actionReferences, - includeDisabledByDefault, ), - colorsSection(resolver, includeDisabledByDefault), - compoundTargetsSection(resolver, includeDisabledByDefault), + colorsSection(resolver), + compoundTargetsSection(resolver), destinationsSection(resolver), referenceSection( resolver, @@ -81,14 +66,13 @@ export function getCheatsheetInfo({ "modifier", "modifier", modifierReferences, - includeDisabledByDefault, { endOf: "end", everyScope: "every", startOf: "start", }, ), - pairedDelimitersSection(resolver, includeDisabledByDefault), + pairedDelimitersSection(resolver), scopeVisualizerSection(resolver), referenceSection( resolver, @@ -97,12 +81,11 @@ export function getCheatsheetInfo({ "scopeType", "scope", scopeReferences, - includeDisabledByDefault, { surroundingPair: "pair", }, ), - shapesSection(resolver, includeDisabledByDefault), + shapesSection(resolver), specialMarksSection(resolver), tutorialSection, ], @@ -116,7 +99,6 @@ function referenceSection( type: string, referenceKind: ReferenceKind, references: ReferenceMap, - includeDisabledByDefault: boolean, itemIdOverrides: Readonly> = {}, ): CheatsheetSection { return { @@ -130,7 +112,7 @@ function referenceSection( referenceKind, referenceId, reference.defaultSpokenForm, - includeDisabledByDefault || !reference.disabledByDefault, + !reference.disabledByDefault, ); const replacements = getSyntaxReplacements(resolver); @@ -165,23 +147,21 @@ function referenceSection( class SpokenFormResolver { private entries = new Map(); - constructor(listEntries: readonly TalonSpokenFormListEntry[]) { - for (const { listName, id, spokenForms } of listEntries) { - this.entries.set(`${listName}\0${id}`, spokenForms); + constructor(spokenFormEntries: readonly TalonSpokenFormEntry[]) { + for (const { type, id, spokenForms } of spokenFormEntries) { + this.entries.set(`${type}\0${id}`, spokenForms); } } get( - listNames: readonly string[], + types: readonly string[], id: string, defaultSpokenForms: readonly string[], ): readonly string[] { - const matches = listNames.flatMap( - (listName) => this.entries.get(`${listName}\0${id}`) ?? [], - ); - const hasEntry = listNames.some((listName) => - this.entries.has(`${listName}\0${id}`), + const matches = types.flatMap( + (type) => this.entries.get(`${type}\0${id}`) ?? [], ); + const hasEntry = types.some((type) => this.entries.has(`${type}\0${id}`)); return hasEntry ? matches : defaultSpokenForms; } @@ -201,19 +181,16 @@ function getReferenceSpokenForms( if (kind === "action") { const talonId = id === "rewrapWithPairedDelimiter" ? "rewrap" : id; return resolver.get( - actionListNames, + actionTypes, talonId, enabledByDefault ? [defaultSpokenForm] : [], ); } if (kind === "scope") { - const listNames = id === "glyph" ? ["glyph_scope_type"] : ["scope_type"]; - return resolver.get( - listNames, - id, - enabledByDefault ? [defaultSpokenForm] : [], - ); + const types = + id === "glyph" ? ["complexScopeTypeType"] : ["simpleScopeTypeType"]; + return resolver.get(types, id, enabledByDefault ? [defaultSpokenForm] : []); } const definition = modifierSpokenFormDefinitions[id]; @@ -222,40 +199,40 @@ function getReferenceSpokenForms( return defaultSpokenForms; } - return resolver.get(definition.listNames, definition.id, defaultSpokenForms); + return resolver.get(definition.types, definition.id, defaultSpokenForms); } const modifierSpokenFormDefinitions: Readonly< - Record + Record > = { - everyScope: { listNames: ["every_scope_modifier"], id: "every" }, - ancestor: { listNames: ["ancestor_scope_modifier"], id: "ancestor" }, - interiorOnly: { listNames: ["interior_modifier"], id: "interiorOnly" }, - excludeInterior: { listNames: ["simple_modifier"], id: "excludeInterior" }, - leading: { listNames: ["simple_modifier"], id: "leading" }, - trailing: { listNames: ["simple_modifier"], id: "trailing" }, + everyScope: { types: ["every_scope_modifier"], id: "every" }, + ancestor: { types: ["ancestor_scope_modifier"], id: "ancestor" }, + interiorOnly: { types: ["interior_modifier"], id: "interiorOnly" }, + excludeInterior: { types: ["simple_modifier"], id: "excludeInterior" }, + leading: { types: ["simple_modifier"], id: "leading" }, + trailing: { types: ["simple_modifier"], id: "trailing" }, extendThroughStartOf: { - listNames: ["head_tail_modifier"], + types: ["head_tail_modifier"], id: "extendThroughStartOf", }, extendThroughEndOf: { - listNames: ["head_tail_modifier"], + types: ["head_tail_modifier"], id: "extendThroughEndOf", }, - startOf: { listNames: ["position"], id: "start" }, - endOf: { listNames: ["position"], id: "end" }, - visible: { listNames: ["simple_modifier"], id: "visible" }, + startOf: { types: ["position"], id: "start" }, + endOf: { types: ["position"], id: "end" }, + visible: { types: ["simple_modifier"], id: "visible" }, keepContentFilter: { - listNames: ["simple_modifier"], + types: ["simple_modifier"], id: "keepContentFilter", }, keepEmptyFilter: { - listNames: ["simple_modifier"], + types: ["simple_modifier"], id: "keepEmptyFilter", }, - toRawSelection: { listNames: ["simple_modifier"], id: "toRawSelection" }, + toRawSelection: { types: ["simple_modifier"], id: "toRawSelection" }, inferPreviousMark: { - listNames: ["simple_modifier"], + types: ["simple_modifier"], id: "inferPreviousMark", }, }; @@ -317,7 +294,7 @@ function getSyntaxReplacements( replacement( scopeReferences.token.defaultSpokenForm, resolver, - ["scope_type"], + ["simpleScopeTypeType"], "token", ), ]; @@ -326,10 +303,10 @@ function getSyntaxReplacements( function replacement( defaultSpokenForm: string, resolver: SpokenFormResolver, - listNames: readonly string[], + types: readonly string[], id: string, ): SyntaxReplacement { - return [defaultSpokenForm, resolver.get(listNames, id, [defaultSpokenForm])]; + return [defaultSpokenForm, resolver.get(types, id, [defaultSpokenForm])]; } function applyReplacements( @@ -369,7 +346,6 @@ function termRegex(term: string): RegExp { function pairedDelimitersSection( resolver: SpokenFormResolver, - includeDisabledByDefault: boolean, ): CheatsheetSection { return { name: "Paired delimiters", @@ -381,16 +357,9 @@ function pairedDelimitersSection( type: "pairedDelimiter", variations: resolver .get( - [ - "wrapper_only_paired_delimiter", - "wrapper_selectable_paired_delimiter", - "selectable_only_paired_delimiter", - "surrounding_pair_scope_type", - ], + ["pairedDelimiter"], id, - !includeDisabledByDefault && - "disabledByDefault" in reference && - reference.disabledByDefault + "disabledByDefault" in reference && reference.disabledByDefault ? [] : [reference.defaultSpokenForm], ) @@ -407,10 +376,7 @@ function capitalize(value: string): string { return value.charAt(0).toUpperCase() + value.slice(1); } -function colorsSection( - resolver: SpokenFormResolver, - includeDisabledByDefault: boolean, -): CheatsheetSection { +function colorsSection(resolver: SpokenFormResolver): CheatsheetSection { const defaultEnabledIds = new Set(["blue", "green", "pink", "red", "yellow"]); return { name: "Colors", @@ -424,7 +390,7 @@ function colorsSection( .get( ["hat_color"], id, - includeDisabledByDefault || defaultEnabledIds.has(id) + defaultEnabledIds.has(id) ? [requiredSpokenForm(defaultSpokenForm)] : [], ) @@ -447,7 +413,6 @@ function requiredSpokenForm(spokenForm: string | null): string { function compoundTargetsSection( resolver: SpokenFormResolver, - includeDisabledByDefault: boolean, ): CheatsheetSection { return { name: "Compound targets", @@ -455,13 +420,13 @@ function compoundTargetsSection( items: [ { id: "listConnective", - listNames: ["list_connective"], + types: ["list_connective"], spokenForm: connectiveDefaultSpokenForms.listConnective, descriptions: [" and "], }, { id: "rangeExclusive", - listNames: ["range_connective"], + types: ["range_connective"], spokenForm: connectiveDefaultSpokenForms.rangeExclusive, descriptions: [ "between and ", @@ -470,7 +435,7 @@ function compoundTargetsSection( }, { id: "rangeInclusive", - listNames: ["range_connective"], + types: ["range_connective"], spokenForm: connectiveDefaultSpokenForms.rangeInclusive, descriptions: [ " through ", @@ -479,7 +444,7 @@ function compoundTargetsSection( }, { id: "rangeExcludingStart", - listNames: ["range_connective"], + types: ["range_connective"], spokenForm: connectiveDefaultSpokenForms.rangeExcludingStart, descriptions: [ "end of through ", @@ -488,7 +453,7 @@ function compoundTargetsSection( }, { id: "rangeExcludingEnd", - listNames: ["range_connective"], + types: ["range_connective"], spokenForm: connectiveDefaultSpokenForms.rangeExcludingEnd, descriptions: [ " until start of ", @@ -497,7 +462,7 @@ function compoundTargetsSection( }, { id: "verticalRange", - listNames: ["range_type"], + types: ["range_type"], spokenForm: connectiveDefaultSpokenForms.verticalRange, descriptions: [ " vertically through ", @@ -505,12 +470,10 @@ function compoundTargetsSection( ], }, ] - .map(({ id, listNames, spokenForm, descriptions }) => { + .map(({ id, types, spokenForm, descriptions }) => { let defaultSpokenForms: string[]; if (spokenForm != null) { defaultSpokenForms = [spokenForm]; - } else if (includeDisabledByDefault) { - defaultSpokenForms = [id]; } else { defaultSpokenForms = []; } @@ -519,7 +482,7 @@ function compoundTargetsSection( id, type: "compoundTargetConnective", variations: resolver - .get(listNames, id, defaultSpokenForms) + .get(types, id, defaultSpokenForms) .flatMap((customSpokenForm) => descriptions.map((description, index) => ({ spokenForm: @@ -542,31 +505,31 @@ function destinationsSection(resolver: SpokenFormResolver): CheatsheetSection { items: [ { id: "destination_after", - listNames: ["insertion_mode_before_after"], + types: ["insertion_mode_before_after"], valueId: "after", spokenForm: connectiveDefaultSpokenForms.after, description: "Insert after ", }, { id: "destination_before", - listNames: ["insertion_mode_before_after"], + types: ["insertion_mode_before_after"], valueId: "before", spokenForm: connectiveDefaultSpokenForms.before, description: "Insert before ", }, { id: "destination_to", - listNames: ["insertion_mode_to"], + types: ["insertion_mode_to"], valueId: "sourceDestinationConnective", spokenForm: connectiveDefaultSpokenForms.sourceDestinationConnective, description: "Replace ", }, ] - .map(({ id, listNames, valueId, spokenForm, description }) => ({ + .map(({ id, types, valueId, spokenForm, description }) => ({ id, type: "destination", variations: resolver - .get(listNames, valueId, [spokenForm]) + .get(types, valueId, [spokenForm]) .map((customSpokenForm) => ({ spokenForm: `${customSpokenForm} `, description, @@ -626,30 +589,19 @@ function scopeVisualizerSection( }; } -function shapesSection( - resolver: SpokenFormResolver, - includeDisabledByDefault: boolean, -): CheatsheetSection { +function shapesSection(resolver: SpokenFormResolver): CheatsheetSection { return { name: "Shapes", id: "shapes", items: Object.entries(hatShapeDefaultSpokenForms) .filter(([id, spokenForm]) => id !== "default" && spokenForm != null) - .map(([id, defaultSpokenForm]) => ({ + .map(([id]) => ({ id, type: "hatShape", - variations: resolver - .get( - ["hat_shape"], - id, - includeDisabledByDefault - ? [requiredSpokenForm(defaultSpokenForm)] - : [], - ) - .map((spokenForm) => ({ - spokenForm, - description: capitalize(id), - })), + variations: resolver.get(["hat_shape"], id, []).map((spokenForm) => ({ + spokenForm, + description: capitalize(id), + })), })) .filter(({ variations }) => variations.length > 0), }; diff --git a/packages/lib-common/src/types/TalonSpokenForms.ts b/packages/lib-common/src/types/TalonSpokenForms.ts index fe9b719123..5e3c405302 100644 --- a/packages/lib-common/src/types/TalonSpokenForms.ts +++ b/packages/lib-common/src/types/TalonSpokenForms.ts @@ -6,13 +6,13 @@ import type { SpokenFormMapKeyTypes, SpokenFormType } from "./SpokenFormType"; * the user's custom spoken forms to the Cursorless engine. */ export interface TalonSpokenForms { - getSpokenFormEntries(): Promise; + getSpokenFormEntries(): Promise; onDidChange: Notifier["registerListener"]; } -/** A raw customizable Talon list entry, used to assemble user-facing syntax. */ -export interface TalonSpokenFormListEntry { - listName: string; +/** A customizable spoken-form entry received from Talon. */ +export interface TalonSpokenFormEntry { + type: string; id: string; spokenForms: string[]; } diff --git a/packages/lib-engine/src/spokenForms/CustomSpokenForms.ts b/packages/lib-engine/src/spokenForms/CustomSpokenForms.ts index f7af2ac88a..d305e23463 100644 --- a/packages/lib-engine/src/spokenForms/CustomSpokenForms.ts +++ b/packages/lib-engine/src/spokenForms/CustomSpokenForms.ts @@ -3,9 +3,9 @@ import type { CustomRegexScopeType, Disposable, IDE, - SpokenFormEntry, SpokenFormMapKeyTypes, SpokenFormType, + TalonSpokenFormEntry, TalonSpokenForms, } from "@cursorless/lib-common"; import { @@ -77,7 +77,7 @@ export class CustomSpokenForms { onDidChangeCustomSpokenForms = this.notifier.registerListener; private async updateSpokenFormMaps(): Promise { - let allCustomEntries: SpokenFormEntry[]; + let allCustomEntries: TalonSpokenFormEntry[]; // We successfully loaded spoken forms, so any previous "needs update" // state is no longer relevant. diff --git a/packages/lib-node-common/src/FileSystemTalonSpokenForms.ts b/packages/lib-node-common/src/FileSystemTalonSpokenForms.ts index 4a3d88005b..52071f60d6 100644 --- a/packages/lib-node-common/src/FileSystemTalonSpokenForms.ts +++ b/packages/lib-node-common/src/FileSystemTalonSpokenForms.ts @@ -4,8 +4,7 @@ import type { Disposable, FileSystem, Listener, - SpokenFormEntry, - TalonSpokenFormListEntry, + TalonSpokenFormEntry, TalonSpokenForms, } from "@cursorless/lib-common"; import { NeedsInitialTalonUpdateError, Notifier } from "@cursorless/lib-common"; @@ -13,8 +12,7 @@ import { isEnoentError } from "./isError"; interface TalonSpokenFormsPayload { version: number; - spokenForms: SpokenFormEntry[]; - lists?: TalonSpokenFormListEntry[]; + spokenForms: TalonSpokenFormEntry[]; } const LATEST_SPOKEN_FORMS_JSON_VERSION = 0; @@ -39,16 +37,11 @@ export class FileSystemTalonSpokenForms implements TalonSpokenForms { return this.notifier.registerListener(listener); } - async getSpokenFormEntries(): Promise { + async getSpokenFormEntries(): Promise { const payload = await this.getPayload(); return payload.spokenForms; } - async getSpokenFormLists(): Promise { - const payload = await this.getPayload(); - return payload.lists; - } - private async getPayload(): Promise { let payload: TalonSpokenFormsPayload; try { From 9329c92de70f4101bd1e891bdc39130bf77db823 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Mon, 31 Aug 2026 12:53:49 +0200 Subject: [PATCH 11/28] Move gets spoken for logic in to node comment --- .../src/getCheatsheetInfoForCommand.ts | 30 --------- packages/app-vscode/src/registerCommands.ts | 15 ++--- .../src/cheatsheet/cheatsheet.types.ts | 25 ------- packages/lib-node-common/src/Cheatsheet.ts | 66 +++++++++++++++++-- 4 files changed, 67 insertions(+), 69 deletions(-) delete mode 100644 packages/app-vscode/src/getCheatsheetInfoForCommand.ts diff --git a/packages/app-vscode/src/getCheatsheetInfoForCommand.ts b/packages/app-vscode/src/getCheatsheetInfoForCommand.ts deleted file mode 100644 index b4691bd975..0000000000 --- a/packages/app-vscode/src/getCheatsheetInfoForCommand.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { - CheatSheetCommandArg, - CheatsheetInfo, -} from "@cursorless/lib-common"; -import { getCheatsheetInfo } from "@cursorless/lib-common"; -import type { FileSystemTalonSpokenForms } from "@cursorless/lib-node-common"; -import { vscodeApi } from "./vscodeApi"; - -export async function getCheatsheetInfoForCommand( - arg: CheatSheetCommandArg, - talonSpokenForms: FileSystemTalonSpokenForms, -): Promise { - const version = arg.version; - - if (version === 0) { - // DEPRECATED: 2026-08-31 - void vscodeApi.window.showWarningMessage( - "Cheat sheet command version 0 is deprecated. Please update cursorless-talon.", - ); - return arg.spokenFormInfo; - } - - if (version === 1) { - return getCheatsheetInfo({ - spokenFormEntries: await talonSpokenForms.getSpokenFormEntries(), - }); - } - - throw new Error(`Unsupported cheatsheet command version: ${version}`); -} diff --git a/packages/app-vscode/src/registerCommands.ts b/packages/app-vscode/src/registerCommands.ts index 9575421a34..0f9299b79b 100644 --- a/packages/app-vscode/src/registerCommands.ts +++ b/packages/app-vscode/src/registerCommands.ts @@ -1,6 +1,5 @@ import vscode from "vscode"; import type { - CheatSheetCommandArg, CommandHistoryStorage, CursorlessCommandId, ScopeType, @@ -8,7 +7,10 @@ import type { import { CURSORLESS_COMMAND_ID } from "@cursorless/lib-common"; import type { CommandApi, StoredTargetMap } from "@cursorless/lib-engine"; import { analyzeCommandHistory } from "@cursorless/lib-engine"; -import type { FileSystemTalonSpokenForms } from "@cursorless/lib-node-common"; +import type { + CheatSheetCommandArg, + FileSystemTalonSpokenForms, +} from "@cursorless/lib-node-common"; import { showCheatsheet } from "@cursorless/lib-node-common"; import type { ScopeTestRecorder, @@ -19,7 +21,6 @@ import { showQuickPick, showScopeVisualizerItemDocumentation, } from "./commands"; -import { getCheatsheetInfoForCommand } from "./getCheatsheetInfoForCommand"; import type { VscodeHats } from "./ide/vscode/hats/VscodeHats"; import type { VscodeIDE } from "./ide/vscode/VscodeIDE"; import type { InstallationDependencies } from "./InstallationDependencies"; @@ -71,12 +72,8 @@ export function registerCommands( }, // Cheatsheet commands - "cursorless.showCheatsheet": async (arg: CheatSheetCommandArg) => { - const cheatsheetInfo = await getCheatsheetInfoForCommand( - arg, - talonSpokenForms, - ); - return showCheatsheet(vscodeIde, arg.outputPath, cheatsheetInfo); + "cursorless.showCheatsheet": (arg: CheatSheetCommandArg) => { + return showCheatsheet(vscodeIde, talonSpokenForms, arg); }, // Testcase recorder commands diff --git a/packages/lib-common/src/cheatsheet/cheatsheet.types.ts b/packages/lib-common/src/cheatsheet/cheatsheet.types.ts index 234b85e81c..9a1c716b52 100644 --- a/packages/lib-common/src/cheatsheet/cheatsheet.types.ts +++ b/packages/lib-common/src/cheatsheet/cheatsheet.types.ts @@ -18,28 +18,3 @@ export interface CheatsheetSection { export interface CheatsheetInfo { sections: CheatsheetSection[]; } - -interface CheatSheetCommandArgV0 { - version: 0; - - /** The file to write the cheatsheet to. */ - outputPath: string; - - /** - * A representation of all spoken forms that is used to generate the - * cheatsheet. - */ - spokenFormInfo: CheatsheetInfo; -} - -/** The extension assembles the cheatsheet from the Talon state file. */ -interface CheatSheetCommandArgV1 { - version: 1; - - /** The file to write the cheatsheet to. */ - outputPath: string; -} - -export type CheatSheetCommandArg = - | CheatSheetCommandArgV0 - | CheatSheetCommandArgV1; diff --git a/packages/lib-node-common/src/Cheatsheet.ts b/packages/lib-node-common/src/Cheatsheet.ts index 59d7627d1a..69ae3047fc 100644 --- a/packages/lib-node-common/src/Cheatsheet.ts +++ b/packages/lib-node-common/src/Cheatsheet.ts @@ -1,21 +1,77 @@ import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { parse } from "node-html-parser"; +import { getCheatsheetInfo, showWarning } from "@cursorless/lib-common"; import type { CheatsheetInfo, IDE } from "@cursorless/lib-common"; +import type { FileSystemTalonSpokenForms } from "./FileSystemTalonSpokenForms"; + +interface CheatSheetCommandArgV0 { + version: 0; + + /** The file to write the cheatsheet to. */ + outputPath: string; + + /** + * A representation of all spoken forms that is used to generate the + * cheatsheet. + */ + spokenFormInfo: CheatsheetInfo; +} + +/** The extension assembles the cheatsheet from the Talon state file. */ +interface CheatSheetCommandArgV1 { + version: 1; + + /** The file to write the cheatsheet to. */ + outputPath: string; +} + +export type CheatSheetCommandArg = + | CheatSheetCommandArgV0 + | CheatSheetCommandArgV1; export async function showCheatsheet( ide: IDE, - outputPath: string, - cheatsheetInfo: CheatsheetInfo, + talonSpokenForms: FileSystemTalonSpokenForms, + arg: CheatSheetCommandArg, ) { + const cheatsheetInfo = await getCheatsheetInfoForCommand( + ide, + talonSpokenForms, + arg, + ); const cheatsheetPath = path.join(ide.assetsRoot, "cheatsheet.html"); - const cheatsheetContent = await readFile(cheatsheetPath, "utf8"); - const root = parse(cheatsheetContent); root.getElementById("cheatsheet-data")!.textContent = `document.cheatsheetInfo = ${JSON.stringify(cheatsheetInfo)};`; - await writeFile(outputPath, root.toString()); + await writeFile(arg.outputPath, root.toString()); +} + +async function getCheatsheetInfoForCommand( + ide: IDE, + talonSpokenForms: FileSystemTalonSpokenForms, + arg: CheatSheetCommandArg, +): Promise { + const version = arg.version; + + if (version === 0) { + // DEPRECATED: 2026-08-31 + void showWarning( + ide.messages, + "cheatSheetV0Deprecated", + "Cheat sheet command version 0 is deprecated. Please update cursorless-talon.", + ); + return arg.spokenFormInfo; + } + + if (version === 1) { + return getCheatsheetInfo({ + spokenFormEntries: await talonSpokenForms.getSpokenFormEntries(), + }); + } + + throw new Error(`Unsupported cheatsheet command version: ${version}`); } From f2c73d66fce7bd57d60af257fd24a5e86c71ad79 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Mon, 31 Aug 2026 12:57:55 +0200 Subject: [PATCH 12/28] More clean up --- .../lib-common/src/cheatsheet/getCheatsheetInfo.ts | 6 +++--- packages/lib-common/src/types/TalonSpokenForms.ts | 9 +-------- .../lib-engine/src/spokenForms/CustomSpokenForms.ts | 4 ++-- .../src/FileSystemTalonSpokenForms.ts | 13 ++++--------- 4 files changed, 10 insertions(+), 22 deletions(-) diff --git a/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts b/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts index 731191f01b..6489304f97 100644 --- a/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts +++ b/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts @@ -10,7 +10,7 @@ import { pairedDelimiterReferences, scopeReferences, } from "../references"; -import type { TalonSpokenFormEntry } from "../types/TalonSpokenForms"; +import type { SpokenFormEntry } from "../types/TalonSpokenForms"; import type { CheatsheetInfo, CheatsheetSection } from "./cheatsheet.types"; interface CheatsheetReference { @@ -32,7 +32,7 @@ const actionTypes = ["action"] as const; type ReferenceKind = "action" | "modifier" | "scope"; export interface GetCheatsheetInfoOptions { - spokenFormEntries?: readonly TalonSpokenFormEntry[]; + spokenFormEntries?: readonly SpokenFormEntry[]; } /** Construct the stock cheatsheet directly from the canonical references. */ @@ -147,7 +147,7 @@ function referenceSection( class SpokenFormResolver { private entries = new Map(); - constructor(spokenFormEntries: readonly TalonSpokenFormEntry[]) { + constructor(spokenFormEntries: readonly SpokenFormEntry[]) { for (const { type, id, spokenForms } of spokenFormEntries) { this.entries.set(`${type}\0${id}`, spokenForms); } diff --git a/packages/lib-common/src/types/TalonSpokenForms.ts b/packages/lib-common/src/types/TalonSpokenForms.ts index 5e3c405302..a55ab61023 100644 --- a/packages/lib-common/src/types/TalonSpokenForms.ts +++ b/packages/lib-common/src/types/TalonSpokenForms.ts @@ -6,17 +6,10 @@ import type { SpokenFormMapKeyTypes, SpokenFormType } from "./SpokenFormType"; * the user's custom spoken forms to the Cursorless engine. */ export interface TalonSpokenForms { - getSpokenFormEntries(): Promise; + getSpokenFormEntries(): Promise; onDidChange: Notifier["registerListener"]; } -/** A customizable spoken-form entry received from Talon. */ -export interface TalonSpokenFormEntry { - type: string; - id: string; - spokenForms: string[]; -} - /** * The types of entries for which we currently support getting custom spoken * forms from Talon. diff --git a/packages/lib-engine/src/spokenForms/CustomSpokenForms.ts b/packages/lib-engine/src/spokenForms/CustomSpokenForms.ts index d305e23463..f7af2ac88a 100644 --- a/packages/lib-engine/src/spokenForms/CustomSpokenForms.ts +++ b/packages/lib-engine/src/spokenForms/CustomSpokenForms.ts @@ -3,9 +3,9 @@ import type { CustomRegexScopeType, Disposable, IDE, + SpokenFormEntry, SpokenFormMapKeyTypes, SpokenFormType, - TalonSpokenFormEntry, TalonSpokenForms, } from "@cursorless/lib-common"; import { @@ -77,7 +77,7 @@ export class CustomSpokenForms { onDidChangeCustomSpokenForms = this.notifier.registerListener; private async updateSpokenFormMaps(): Promise { - let allCustomEntries: TalonSpokenFormEntry[]; + let allCustomEntries: SpokenFormEntry[]; // We successfully loaded spoken forms, so any previous "needs update" // state is no longer relevant. diff --git a/packages/lib-node-common/src/FileSystemTalonSpokenForms.ts b/packages/lib-node-common/src/FileSystemTalonSpokenForms.ts index 52071f60d6..471374d40a 100644 --- a/packages/lib-node-common/src/FileSystemTalonSpokenForms.ts +++ b/packages/lib-node-common/src/FileSystemTalonSpokenForms.ts @@ -4,7 +4,7 @@ import type { Disposable, FileSystem, Listener, - TalonSpokenFormEntry, + SpokenFormEntry, TalonSpokenForms, } from "@cursorless/lib-common"; import { NeedsInitialTalonUpdateError, Notifier } from "@cursorless/lib-common"; @@ -12,7 +12,7 @@ import { isEnoentError } from "./isError"; interface TalonSpokenFormsPayload { version: number; - spokenForms: TalonSpokenFormEntry[]; + spokenForms: SpokenFormEntry[]; } const LATEST_SPOKEN_FORMS_JSON_VERSION = 0; @@ -37,12 +37,7 @@ export class FileSystemTalonSpokenForms implements TalonSpokenForms { return this.notifier.registerListener(listener); } - async getSpokenFormEntries(): Promise { - const payload = await this.getPayload(); - return payload.spokenForms; - } - - private async getPayload(): Promise { + async getSpokenFormEntries(): Promise { let payload: TalonSpokenFormsPayload; try { payload = JSON.parse( @@ -65,7 +60,7 @@ export class FileSystemTalonSpokenForms implements TalonSpokenForms { ); } - return payload; + return payload.spokenForms; } dispose() { From 6ca661acdbe643bae2446fe696ff1cc295750937 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Mon, 31 Aug 2026 13:00:06 +0200 Subject: [PATCH 13/28] More restoration --- cursorless-talon/src/spoken_forms.py | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/cursorless-talon/src/spoken_forms.py b/cursorless-talon/src/spoken_forms.py index d17dd7e539..2ba20b516a 100644 --- a/cursorless-talon/src/spoken_forms.py +++ b/cursorless-talon/src/spoken_forms.py @@ -17,10 +17,7 @@ grapheme_capture_name, ) from .marks.decorated_mark import init_hats -from .spoken_forms_output import ( - SpokenFormOutputEntry, - SpokenFormsOutput, -) +from .spoken_forms_output import SpokenFormsOutput from .spoken_scope_forms import init_scope_spoken_forms JSON_FILE = Path(__file__).parent / "spoken_forms.json" @@ -112,18 +109,18 @@ def update(): graphemes_talon_list = get_graphemes_talon_list() def update_spoken_forms_output(): - spoken_form_entries: list[SpokenFormOutputEntry] = [ - { - "type": LIST_TO_TYPE_MAP.get(entry.list_name, entry.list_name), - "id": entry.id, - "spokenForms": entry.spoken_forms, - } - for spoken_form_list in custom_spoken_forms.values() - for entry in spoken_form_list - ] spoken_forms_output.write( [ - *spoken_form_entries, + *[ + { + "type": LIST_TO_TYPE_MAP[entry.list_name], + "id": entry.id, + "spokenForms": entry.spoken_forms, + } + for spoken_form_list in custom_spoken_forms.values() + for entry in spoken_form_list + if entry.list_name in LIST_TO_TYPE_MAP + ], *get_grapheme_spoken_form_entries(graphemes_talon_list), ] ) From e7f93f0f49ad89c5f8dddcf2b8c1319a1072f93f Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Mon, 31 Aug 2026 17:19:04 +0200 Subject: [PATCH 14/28] add more spoken forms to state --- cursorless-talon/src/modifiers/position.py | 6 +- cursorless-talon/src/spoken_forms.json | 2 +- cursorless-talon/src/spoken_forms.py | 47 ++- .../src/keyboard/KeyboardCommandHandler.ts | 11 +- .../src/test/getDefaultCheatsheetInfo.spec.ts | 227 ++++++++-- .../src/cheatsheet/getCheatsheetInfo.ts | 394 ++++++++---------- packages/lib-common/src/index.ts | 1 + .../src/references/actionReferences.ts | 20 +- packages/lib-common/src/references/index.ts | 1 + .../src/references/modifierReferences.ts | 47 ++- .../src/references/scopeReferences.ts | 7 +- .../connectiveDefaultSpokenForms.ts | 17 +- .../spokenForms/defaultSpokenFormMapCore.ts | 96 +++-- .../spokenForms/graphemeDefaultSpokenForms.ts | 2 - .../spokenForms/markDefaultSpokenForms.ts | 10 +- .../spokenForms/spokenFormMapUtil.ts | 2 +- .../src/types/DefaultSpokenFormMap.ts} | 16 +- .../lib-common/src/types/SpokenFormType.ts | 47 ++- .../lib-common/src/types/TalonSpokenForms.ts | 9 + .../src/customCommandGrammar/lexer.ts | 7 +- .../CustomSpokenFormGeneratorImpl.test.ts | 5 + .../defaultSpokenForms/marks.ts | 20 - .../defaultSpokenForms/modifiers.ts | 14 +- .../surroundingPairsDelimiters.ts | 26 -- .../generateSpokenForm/generateSpokenForm.ts | 27 +- .../generateSpokenForm/getRangeConnective.ts | 25 +- .../primitiveTargetToSpokenForm.ts | 74 ++-- packages/lib-engine/src/index.ts | 1 - .../src/spokenForms/CustomSpokenForms.ts | 2 +- .../src/spokenForms/defaultSpokenFormMap.ts | 9 +- packages/lib-node-common/src/Cheatsheet.ts | 5 +- .../runCustomSpokenFormScopeInfoTest.ts | 4 +- .../clearEveryFunkNameSkipPastBlueDrum.yml | 4 +- .../clearEveryTokenBatSkipPastEach.yml | 4 +- .../postEveryTokenFunkNameSkipPastToken.yml | 4 +- 35 files changed, 719 insertions(+), 474 deletions(-) rename packages/{lib-engine/src => lib-common/src/references}/spokenForms/defaultSpokenFormMapCore.ts (59%) rename packages/{lib-engine/src => lib-common/src/references}/spokenForms/spokenFormMapUtil.ts (92%) rename packages/{lib-engine/src/spokenForms/defaultSpokenFormMap.types.ts => lib-common/src/types/DefaultSpokenFormMap.ts} (75%) delete mode 100644 packages/lib-engine/src/generateSpokenForm/defaultSpokenForms/marks.ts delete mode 100644 packages/lib-engine/src/generateSpokenForm/defaultSpokenForms/surroundingPairsDelimiters.ts diff --git a/cursorless-talon/src/modifiers/position.py b/cursorless-talon/src/modifiers/position.py index 800ca1f25b..fa864956a0 100644 --- a/cursorless-talon/src/modifiers/position.py +++ b/cursorless-talon/src/modifiers/position.py @@ -4,9 +4,9 @@ mod = Module() -mod.list("cursorless_position", desc='Positions such as "before", "after" etc') +mod.list("cursorless_position_modifier", desc='Positions such as "startOf" and "endOf"') -@mod.capture(rule="{user.cursorless_position}") +@mod.capture(rule="{user.cursorless_position_modifier}") def cursorless_position_modifier(m) -> dict[str, Any]: - return {"type": "startOf" if m.cursorless_position == "start" else "endOf"} + return {"type": "startOf" if m.cursorless_position_modifier == "start" else "endOf"} diff --git a/cursorless-talon/src/spoken_forms.json b/cursorless-talon/src/spoken_forms.json index c11fb9d3d6..ba30376933 100644 --- a/cursorless-talon/src/spoken_forms.json +++ b/cursorless-talon/src/spoken_forms.json @@ -113,7 +113,7 @@ } }, "positions.csv": { - "position": { + "position_modifier": { "start of": "start", "end of": "end" }, diff --git a/cursorless-talon/src/spoken_forms.py b/cursorless-talon/src/spoken_forms.py index 2ba20b516a..2baf11e057 100644 --- a/cursorless-talon/src/spoken_forms.py +++ b/cursorless-talon/src/spoken_forms.py @@ -82,6 +82,30 @@ def ret(filename: str, *args: P.args, **kwargs: P.kwargs) -> R: "scope_type": "simpleScopeTypeType", "glyph_scope_type": "complexScopeTypeType", "custom_regex_scope_type": "customRegex", + "simple_modifier": "simpleModifier", + "interior_modifier": "simpleModifier", + "head_tail_modifier": "simpleModifier", + "position_modifier": "simpleModifier", + "every_scope_modifier": "simpleModifier", + "previous_next_modifier": "modifierExtra", + "forward_backward_modifier": "modifierExtra", + "first_modifier": "modifierExtra", + "last_modifier": "modifierExtra", + "ancestor_scope_modifier": "modifierExtra", + "range_connective": "connective", + "list_connective": "connective", + "swap_connective": "connective", + "range_type": "connective", + "insertion_mode_before_after": "insertionMode", + "insertion_mode_to": "insertionMode", + "simple_mark": "specialMark", + "unknown_symbol": "specialMark", + "line_direction": "specialMark", + "hat_color": "hatColor", + "hat_shape": "hatShape", + "show_scope_visualizer": "scopeVisualizer", + "hide_scope_visualizer": "scopeVisualizer", + "visualization_type": "scopeVisualizer", **{ action_list_name: "action" for action_list_name in ACTION_LIST_NAMES @@ -90,6 +114,19 @@ def ret(filename: str, *args: P.args, **kwargs: P.kwargs) -> R: "custom_action": "customAction", } +ID_REWRITE_MAP = { + "sourceDestinationConnective": "to", + "every": "everyScope", + "start": "startOf", + "end": "endOf", +} + +LITERALS = { + "at": "connective", + "on": "connective", + "bar": "sidebar", +} + def update(): global disposables @@ -114,13 +151,21 @@ def update_spoken_forms_output(): *[ { "type": LIST_TO_TYPE_MAP[entry.list_name], - "id": entry.id, + "id": ID_REWRITE_MAP.get(entry.id, entry.id), "spokenForms": entry.spoken_forms, } for spoken_form_list in custom_spoken_forms.values() for entry in spoken_form_list if entry.list_name in LIST_TO_TYPE_MAP ], + *[ + { + "type": LITERALS[literal], + "id": literal, + "spokenForms": [literal], + } + for literal in LITERALS + ], *get_grapheme_spoken_form_entries(graphemes_talon_list), ] ) diff --git a/packages/app-vscode/src/keyboard/KeyboardCommandHandler.ts b/packages/app-vscode/src/keyboard/KeyboardCommandHandler.ts index 0c920e32dc..0327f6c145 100644 --- a/packages/app-vscode/src/keyboard/KeyboardCommandHandler.ts +++ b/packages/app-vscode/src/keyboard/KeyboardCommandHandler.ts @@ -5,7 +5,7 @@ import type { PartialMark, SurroundingPairName, } from "@cursorless/lib-common"; -import { surroundingPairsDelimiters } from "@cursorless/lib-engine"; +import { pairedDelimiterReferences } from "@cursorless/lib-common"; import type { HatColor, HatShape } from "../ide/vscode/hatStyles.types"; import type { SimpleKeyboardActionDescriptor, @@ -87,13 +87,16 @@ export class KeyboardCommandHandler { actionDescriptor, delimiter, }: WrapActionArg) { - const [left, right] = surroundingPairsDelimiters[delimiter]!; + const delimiters = pairedDelimiterReferences[delimiter].delimiters; + if (delimiters == null) { + throw new Error(`Unknown surrounding pair delimiters for '${delimiter}'`); + } await this.targeted.performActionOnTarget( (target) => ({ name: "wrapWithPairedDelimiter", target, - left, - right, + left: delimiters[0], + right: delimiters[1], }), actionDescriptor, ); diff --git a/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts b/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts index a106503068..2686961ad1 100644 --- a/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts +++ b/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts @@ -35,36 +35,84 @@ describe("getDefaultCheatsheetInfo", () => { expect(getItem("scopes", "pair")).toBeDefined(); }); + test("constructs default destinations", () => { + expect(getSection("destinations").items).toEqual([ + { + id: "destination_after", + type: "destination", + variations: [ + { + spokenForm: "after ", + description: "Insert after ", + }, + ], + }, + { + id: "destination_before", + type: "destination", + variations: [ + { + spokenForm: "before ", + description: "Insert before ", + }, + ], + }, + { + id: "destination_to", + type: "destination", + variations: [ + { + spokenForm: "to ", + description: "Replace ", + }, + ], + }, + ]); + }); + test("applies Talon spoken-form entries to the current syntax", () => { - const customCheatsheetInfo = getCheatsheetInfo({ - spokenFormEntries: [ - { - type: "action", - id: "editNewLineBefore", - spokenForms: ["gulp"], - }, - { - type: "interior_modifier", - id: "interiorOnly", - spokenForms: ["within"], - }, - { - type: "simpleScopeTypeType", - id: "token", - spokenForms: ["word unit"], - }, - { - type: "simpleScopeTypeType", - id: "sectionLevelOne", - spokenForms: ["one section"], - }, - { - type: "range_connective", - id: "rangeExcludingStart", - spokenForms: ["from end"], - }, - ], - }); + const customCheatsheetInfo = getCheatsheetInfo([ + { + type: "action", + id: "editNewLineBefore", + spokenForms: ["gulp"], + }, + { type: "action", id: "swapTargets", spokenForms: ["swap"] }, + { type: "action", id: "applyFormatter", spokenForms: ["format"] }, + { type: "action", id: "callAsFunction", spokenForms: ["call"] }, + { + type: "modifierExtra", + id: "ancestor", + spokenForms: ["parental"], + }, + { + type: "simpleModifier", + id: "interiorOnly", + spokenForms: ["within"], + }, + { type: "connective", id: "at", spokenForms: ["using"] }, + { type: "connective", id: "on", spokenForms: ["onto"] }, + { + type: "simpleScopeTypeType", + id: "token", + spokenForms: ["word unit"], + }, + { + type: "simpleScopeTypeType", + id: "sectionLevelOne", + spokenForms: ["one section"], + }, + { + type: "connective", + id: "rangeExcludingStart", + spokenForms: ["from end"], + }, + { + type: "connective", + id: "swapConnective", + spokenForms: ["versus"], + }, + ]); expect( getItem("actions", "editNewLineBefore", customCheatsheetInfo).variations, @@ -79,6 +127,10 @@ describe("getDefaultCheatsheetInfo", () => { getItem("modifiers", "interiorOnly", customCheatsheetInfo).variations[0] ?.spokenForm, ).toBe("within"); + expect( + getItem("modifiers", "ancestor", customCheatsheetInfo).variations[0] + ?.spokenForm, + ).toBe("parental "); expect( getItem("scopes", "token", customCheatsheetInfo).variations[0] ?.spokenForm, @@ -91,14 +143,30 @@ describe("getDefaultCheatsheetInfo", () => { getItem("compoundTargets", "rangeExcludingStart", customCheatsheetInfo) .variations[0]?.spokenForm, ).toBe(" from end "); + expect( + getItem("actions", "swapTargets", customCheatsheetInfo).variations[0] + ?.spokenForm, + ).toBe("swap versus "); + expect( + getItem("actions", "applyFormatter", customCheatsheetInfo).variations[0] + ?.spokenForm, + ).toBe("format using "); + expect( + getItem("actions", "callAsFunction", customCheatsheetInfo).variations[1] + ?.spokenForm, + ).toBe("call onto "); }); - test("an empty raw list entry disables only the corresponding item", () => { - const customCheatsheetInfo = getCheatsheetInfo({ - spokenFormEntries: [ - { type: "simpleScopeTypeType", id: "token", spokenForms: [] }, - ], - }); + test("an empty spoken-form entry disables only the corresponding item", () => { + const customCheatsheetInfo = getCheatsheetInfo([ + { type: "simpleScopeTypeType", id: "token", spokenForms: [] }, + { type: "action", id: "swapTargets", spokenForms: ["swap"] }, + { + type: "connective", + id: "swapConnective", + spokenForms: ["with"], + }, + ]); expect(getSection("scopes", customCheatsheetInfo).items).not.toContainEqual( expect.objectContaining({ id: "token" }), @@ -108,6 +176,95 @@ describe("getDefaultCheatsheetInfo", () => { ).toBeDefined(); }); + test("omits syntax examples whose spoken form is missing or disabled", () => { + const customCheatsheetInfo = getCheatsheetInfo([ + { type: "action", id: "callAsFunction", spokenForms: ["call"] }, + { type: "action", id: "applyFormatter", spokenForms: ["format"] }, + { type: "connective", id: "at", spokenForms: [] }, + ]); + + expect( + getItem("actions", "callAsFunction", customCheatsheetInfo).variations, + ).toEqual([ + { + spokenForm: "call ", + description: "Insert call to on selection", + }, + ]); + expect( + getSection("actions", customCheatsheetInfo).items, + ).not.toContainEqual(expect.objectContaining({ id: "applyFormatter" })); + }); + + test("constructs destinations only from enabled spoken forms", () => { + const customCheatsheetInfo = getCheatsheetInfo([ + { type: "insertionMode", id: "before", spokenForms: ["ahead of"] }, + { type: "insertionMode", id: "to", spokenForms: ["toward"] }, + ]); + + expect(getSection("destinations", customCheatsheetInfo).items).toEqual([ + { + id: "destination_before", + type: "destination", + variations: [ + { + spokenForm: "ahead of ", + description: "Insert before ", + }, + ], + }, + { + id: "destination_to", + type: "destination", + variations: [ + { + spokenForm: "toward ", + description: "Replace ", + }, + ], + }, + ]); + }); + + test("constructs scope visualizer commands from enabled spoken forms", () => { + const customCheatsheetInfo = getCheatsheetInfo([ + { + type: "scopeVisualizer", + id: "showScopeVisualizer", + spokenForms: ["inspect"], + }, + { + type: "scopeVisualizer", + id: "hideScopeVisualizer", + spokenForms: [], + }, + { + type: "scopeVisualizer", + id: "removal", + spokenForms: ["deletion"], + }, + ]); + + expect( + getItem("scopeVisualizer", "show_scope_visualizer", customCheatsheetInfo) + .variations, + ).toEqual([ + { + spokenForm: "inspect ", + description: "Visualize ", + }, + { + spokenForm: "inspect deletion", + description: "Visualize removal range", + }, + ]); + expect( + getSection("scopeVisualizer", customCheatsheetInfo).items, + ).not.toContainEqual( + expect.objectContaining({ id: "hideScopeVisualizer" }), + ); + }); + // oxlint-disable-next-line unicorn/consistent-function-scoping function getSection( sectionId: string, diff --git a/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts b/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts index 6489304f97..978f76947e 100644 --- a/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts +++ b/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts @@ -1,15 +1,18 @@ import { actionReferences, connectiveDefaultSpokenForms, - graphemeDefaultSpokenForms, + defaultSpokenFormMapCore, hatColorDefaultSpokenForms, hatShapeDefaultSpokenForms, - lineDirectionDefaultSpokenForms, - markDefaultSpokenForms, + modifierExtraReferences, modifierReferences, pairedDelimiterReferences, scopeReferences, } from "../references"; +import type { + DefaultSpokenFormMapDefinition, + DefaultSpokenFormMapEntry, +} from "../types/DefaultSpokenFormMap"; import type { SpokenFormEntry } from "../types/TalonSpokenForms"; import type { CheatsheetInfo, CheatsheetSection } from "./cheatsheet.types"; @@ -31,19 +34,23 @@ const actionTypes = ["action"] as const; type ReferenceKind = "action" | "modifier" | "scope"; -export interface GetCheatsheetInfoOptions { - spokenFormEntries?: readonly SpokenFormEntry[]; -} - /** Construct the stock cheatsheet directly from the canonical references. */ export function getDefaultCheatsheetInfo(): CheatsheetInfo { - return getCheatsheetInfo(); + return constructCheatsheetInfo( + getDefaultSpokenFormEntries(defaultSpokenFormMapCore), + ); } /** Construct a cheatsheet using optional customized Talon spoken forms. */ -export function getCheatsheetInfo({ - spokenFormEntries = [], -}: GetCheatsheetInfoOptions = {}): CheatsheetInfo { +export function getCheatsheetInfo( + spokenFormEntries: readonly SpokenFormEntry[], +): CheatsheetInfo { + return constructCheatsheetInfo(spokenFormEntries); +} + +function constructCheatsheetInfo( + spokenFormEntries: readonly SpokenFormResolverEntry[], +): CheatsheetInfo { const resolver = new SpokenFormResolver(spokenFormEntries); return { @@ -112,7 +119,6 @@ function referenceSection( referenceKind, referenceId, reference.defaultSpokenForm, - !reference.disabledByDefault, ); const replacements = getSyntaxReplacements(resolver); @@ -147,24 +153,46 @@ function referenceSection( class SpokenFormResolver { private entries = new Map(); - constructor(spokenFormEntries: readonly SpokenFormEntry[]) { + constructor(spokenFormEntries: readonly SpokenFormResolverEntry[]) { for (const { type, id, spokenForms } of spokenFormEntries) { this.entries.set(`${type}\0${id}`, spokenForms); } } - get( - types: readonly string[], - id: string, - defaultSpokenForms: readonly string[], - ): readonly string[] { - const matches = types.flatMap( - (type) => this.entries.get(`${type}\0${id}`) ?? [], - ); - const hasEntry = types.some((type) => this.entries.has(`${type}\0${id}`)); - - return hasEntry ? matches : defaultSpokenForms; + get(types: readonly string[], id: string): readonly string[] { + return types.flatMap((type) => this.entries.get(`${type}\0${id}`) ?? []); + } +} + +interface SpokenFormResolverEntry { + type: string; + id: string; + spokenForms: readonly string[]; +} + +function getDefaultSpokenFormEntries( + spokenFormMap: DefaultSpokenFormMapDefinition, +): SpokenFormResolverEntry[] { + return Object.entries(spokenFormMap).flatMap(([type, entries]) => + Object.entries( + entries as Readonly>, + ).map(([id, value]) => ({ + type, + id: + type === "action" && id === "rewrapWithPairedDelimiter" ? "rewrap" : id, + spokenForms: getEnabledDefaultSpokenForms(value), + })), + ); +} + +function getEnabledDefaultSpokenForms( + value: string | DefaultSpokenFormMapEntry, +): readonly string[] { + if (typeof value === "string") { + return [value]; } + + return value.isDisabledByDefault ? [] : value.defaultSpokenForms; } function getReferenceSpokenForms( @@ -172,7 +200,6 @@ function getReferenceSpokenForms( kind: ReferenceKind, id: string, defaultSpokenForm: string | undefined, - enabledByDefault: boolean, ): readonly (string | undefined)[] { if (defaultSpokenForm == null) { return [undefined]; @@ -180,133 +207,133 @@ function getReferenceSpokenForms( if (kind === "action") { const talonId = id === "rewrapWithPairedDelimiter" ? "rewrap" : id; - return resolver.get( - actionTypes, - talonId, - enabledByDefault ? [defaultSpokenForm] : [], - ); + return resolver.get(actionTypes, talonId); } if (kind === "scope") { const types = id === "glyph" ? ["complexScopeTypeType"] : ["simpleScopeTypeType"]; - return resolver.get(types, id, enabledByDefault ? [defaultSpokenForm] : []); + return resolver.get(types, id); } const definition = modifierSpokenFormDefinitions[id]; - const defaultSpokenForms = enabledByDefault ? [defaultSpokenForm] : []; if (definition == null) { - return defaultSpokenForms; + return []; } - return resolver.get(definition.types, definition.id, defaultSpokenForms); + return resolver.get(definition.types, definition.id); } const modifierSpokenFormDefinitions: Readonly< Record > = { - everyScope: { types: ["every_scope_modifier"], id: "every" }, - ancestor: { types: ["ancestor_scope_modifier"], id: "ancestor" }, - interiorOnly: { types: ["interior_modifier"], id: "interiorOnly" }, - excludeInterior: { types: ["simple_modifier"], id: "excludeInterior" }, - leading: { types: ["simple_modifier"], id: "leading" }, - trailing: { types: ["simple_modifier"], id: "trailing" }, + everyScope: { types: ["simpleModifier"], id: "everyScope" }, + ancestor: { types: ["modifierExtra"], id: "ancestor" }, + interiorOnly: { types: ["simpleModifier"], id: "interiorOnly" }, + excludeInterior: { types: ["simpleModifier"], id: "excludeInterior" }, + leading: { types: ["simpleModifier"], id: "leading" }, + trailing: { types: ["simpleModifier"], id: "trailing" }, extendThroughStartOf: { - types: ["head_tail_modifier"], + types: ["simpleModifier"], id: "extendThroughStartOf", }, extendThroughEndOf: { - types: ["head_tail_modifier"], + types: ["simpleModifier"], id: "extendThroughEndOf", }, - startOf: { types: ["position"], id: "start" }, - endOf: { types: ["position"], id: "end" }, - visible: { types: ["simple_modifier"], id: "visible" }, + startOf: { types: ["simpleModifier"], id: "startOf" }, + endOf: { types: ["simpleModifier"], id: "endOf" }, + visible: { types: ["simpleModifier"], id: "visible" }, keepContentFilter: { - types: ["simple_modifier"], + types: ["simpleModifier"], id: "keepContentFilter", }, keepEmptyFilter: { - types: ["simple_modifier"], + types: ["simpleModifier"], id: "keepEmptyFilter", }, - toRawSelection: { types: ["simple_modifier"], id: "toRawSelection" }, + toRawSelection: { types: ["simpleModifier"], id: "toRawSelection" }, inferPreviousMark: { - types: ["simple_modifier"], + types: ["simpleModifier"], id: "inferPreviousMark", }, }; type SyntaxReplacement = readonly [string, readonly string[]]; +const syntaxReplacementDefinitions = { + swapConnective: { + type: "connective", + syntaxTerm: connectiveDefaultSpokenForms.swapConnective, + }, + first: { + type: "modifierExtra", + syntaxTerm: modifierExtraReferences.first.defaultSpokenForm, + }, + last: { + type: "modifierExtra", + syntaxTerm: modifierExtraReferences.last.defaultSpokenForm, + }, + previous: { + type: "modifierExtra", + syntaxTerm: modifierExtraReferences.previous.defaultSpokenForm, + }, + next: { + type: "modifierExtra", + syntaxTerm: modifierExtraReferences.next.defaultSpokenForm, + }, + forward: { + type: "modifierExtra", + syntaxTerm: modifierExtraReferences.forward.defaultSpokenForm, + }, + backward: { + type: "modifierExtra", + syntaxTerm: modifierExtraReferences.backward.defaultSpokenForm, + }, + everyScope: { + type: "simpleModifier", + syntaxTerm: modifierReferences.everyScope.defaultSpokenForm, + }, + token: { + type: "simpleScopeTypeType", + syntaxTerm: scopeReferences.token.defaultSpokenForm, + }, + at: { + type: "connective", + syntaxTerm: connectiveDefaultSpokenForms.at, + }, + on: { + type: "connective", + syntaxTerm: connectiveDefaultSpokenForms.on, + }, +} as const; + +type SyntaxReplacementId = keyof typeof syntaxReplacementDefinitions; + function getSyntaxReplacements( resolver: SpokenFormResolver, ): readonly SyntaxReplacement[] { return [ - replacement( - connectiveDefaultSpokenForms.swapConnective, - resolver, - ["swap_connective"], - "swapConnective", - ), - replacement( - connectiveDefaultSpokenForms.first, - resolver, - ["first_modifier"], - "first", - ), - replacement( - connectiveDefaultSpokenForms.last, - resolver, - ["last_modifier"], - "last", - ), - replacement( - connectiveDefaultSpokenForms.previous, - resolver, - ["previous_next_modifier"], - "previous", - ), - replacement( - connectiveDefaultSpokenForms.next, - resolver, - ["previous_next_modifier"], - "next", - ), - replacement( - connectiveDefaultSpokenForms.forward, - resolver, - ["forward_backward_modifier"], - "forward", - ), - replacement( - connectiveDefaultSpokenForms.backward, - resolver, - ["forward_backward_modifier"], - "backward", - ), - replacement( - modifierReferences.everyScope.defaultSpokenForm, - resolver, - ["every_scope_modifier"], - "every", - ), - replacement( - scopeReferences.token.defaultSpokenForm, - resolver, - ["simpleScopeTypeType"], - "token", - ), + replacement(resolver, "swapConnective"), + replacement(resolver, "first"), + replacement(resolver, "last"), + replacement(resolver, "previous"), + replacement(resolver, "next"), + replacement(resolver, "forward"), + replacement(resolver, "backward"), + replacement(resolver, "everyScope"), + replacement(resolver, "token"), + replacement(resolver, "at"), + replacement(resolver, "on"), ]; } function replacement( - defaultSpokenForm: string, resolver: SpokenFormResolver, - types: readonly string[], - id: string, + id: SyntaxReplacementId, ): SyntaxReplacement { - return [defaultSpokenForm, resolver.get(types, id, [defaultSpokenForm])]; + const { type, syntaxTerm } = syntaxReplacementDefinitions[id]; + return [syntaxTerm, resolver.get([type], id)]; } function applyReplacements( @@ -355,18 +382,10 @@ function pairedDelimitersSection( .map(([id, reference]) => ({ id, type: "pairedDelimiter", - variations: resolver - .get( - ["pairedDelimiter"], - id, - "disabledByDefault" in reference && reference.disabledByDefault - ? [] - : [reference.defaultSpokenForm], - ) - .map((spokenForm) => ({ - spokenForm, - description: capitalize(reference.name), - })), + variations: resolver.get(["pairedDelimiter"], id).map((spokenForm) => ({ + spokenForm, + description: capitalize(reference.name), + })), })) .filter(({ variations }) => variations.length > 0), }; @@ -377,40 +396,23 @@ function capitalize(value: string): string { } function colorsSection(resolver: SpokenFormResolver): CheatsheetSection { - const defaultEnabledIds = new Set(["blue", "green", "pink", "red", "yellow"]); return { name: "Colors", id: "colors", items: Object.entries(hatColorDefaultSpokenForms) .filter(([id, spokenForm]) => id !== "default" && spokenForm != null) - .map(([id, defaultSpokenForm]) => ({ + .map(([id]) => ({ id, type: "hatColor", - variations: resolver - .get( - ["hat_color"], - id, - defaultEnabledIds.has(id) - ? [requiredSpokenForm(defaultSpokenForm)] - : [], - ) - .map((spokenForm) => ({ - spokenForm, - description: capitalize(id), - })), + variations: resolver.get(["hatColor"], id).map((spokenForm) => ({ + spokenForm, + description: capitalize(id), + })), })) .filter(({ variations }) => variations.length > 0), }; } -function requiredSpokenForm(spokenForm: string | null): string { - if (spokenForm == null) { - throw new Error("Expected a default spoken form"); - } - - return spokenForm; -} - function compoundTargetsSection( resolver: SpokenFormResolver, ): CheatsheetSection { @@ -420,14 +422,12 @@ function compoundTargetsSection( items: [ { id: "listConnective", - types: ["list_connective"], - spokenForm: connectiveDefaultSpokenForms.listConnective, + types: ["connective"], descriptions: [" and "], }, { id: "rangeExclusive", - types: ["range_connective"], - spokenForm: connectiveDefaultSpokenForms.rangeExclusive, + types: ["connective"], descriptions: [ "between and ", "between selection and ", @@ -435,8 +435,7 @@ function compoundTargetsSection( }, { id: "rangeInclusive", - types: ["range_connective"], - spokenForm: connectiveDefaultSpokenForms.rangeInclusive, + types: ["connective"], descriptions: [ " through ", "selection through ", @@ -444,8 +443,7 @@ function compoundTargetsSection( }, { id: "rangeExcludingStart", - types: ["range_connective"], - spokenForm: connectiveDefaultSpokenForms.rangeExcludingStart, + types: ["connective"], descriptions: [ "end of through ", "end of selection through ", @@ -453,8 +451,7 @@ function compoundTargetsSection( }, { id: "rangeExcludingEnd", - types: ["range_connective"], - spokenForm: connectiveDefaultSpokenForms.rangeExcludingEnd, + types: ["connective"], descriptions: [ " until start of ", "selection until start of ", @@ -462,36 +459,26 @@ function compoundTargetsSection( }, { id: "verticalRange", - types: ["range_type"], - spokenForm: connectiveDefaultSpokenForms.verticalRange, + types: ["connective"], descriptions: [ " vertically through ", "selection vertically through ", ], }, ] - .map(({ id, types, spokenForm, descriptions }) => { - let defaultSpokenForms: string[]; - if (spokenForm != null) { - defaultSpokenForms = [spokenForm]; - } else { - defaultSpokenForms = []; - } - + .map(({ id, types, descriptions }) => { return { id, type: "compoundTargetConnective", - variations: resolver - .get(types, id, defaultSpokenForms) - .flatMap((customSpokenForm) => - descriptions.map((description, index) => ({ - spokenForm: - index === 0 - ? ` ${customSpokenForm} ` - : `${customSpokenForm} `, - description, - })), - ), + variations: resolver.get(types, id).flatMap((customSpokenForm) => + descriptions.map((description, index) => ({ + spokenForm: + index === 0 + ? ` ${customSpokenForm} ` + : `${customSpokenForm} `, + description, + })), + ), }; }) .filter(({ variations }) => variations.length > 0), @@ -505,31 +492,25 @@ function destinationsSection(resolver: SpokenFormResolver): CheatsheetSection { items: [ { id: "destination_after", - types: ["insertion_mode_before_after"], valueId: "after", - spokenForm: connectiveDefaultSpokenForms.after, description: "Insert after ", }, { id: "destination_before", - types: ["insertion_mode_before_after"], valueId: "before", - spokenForm: connectiveDefaultSpokenForms.before, description: "Insert before ", }, { id: "destination_to", - types: ["insertion_mode_to"], - valueId: "sourceDestinationConnective", - spokenForm: connectiveDefaultSpokenForms.sourceDestinationConnective, + valueId: "to", description: "Replace ", }, ] - .map(({ id, types, valueId, spokenForm, description }) => ({ + .map(({ id, valueId, description }) => ({ id, type: "destination", variations: resolver - .get(types, valueId, [spokenForm]) + .get(["insertionMode"], valueId) .map((customSpokenForm) => ({ spokenForm: `${customSpokenForm} `, description, @@ -543,9 +524,8 @@ function scopeVisualizerSection( resolver: SpokenFormResolver, ): CheatsheetSection { const showSpokenForms = resolver.get( - ["show_scope_visualizer"], + ["scopeVisualizer"], "showScopeVisualizer", - ["visualize"], ); return { name: "Scope visualizer", @@ -554,15 +534,15 @@ function scopeVisualizerSection( items( "hideScopeVisualizer", "command", - resolver.get(["hide_scope_visualizer"], "hideScopeVisualizer", [ - "visualize nothing", - ]), + resolver.get(["scopeVisualizer"], "hideScopeVisualizer"), "Hide scope visualizer", ), - item( + items( "show_scope_sidebar", "command", - "bar cursorless", + resolver + .get(["sidebar"], "bar") + .map((spokenForm) => `${spokenForm} cursorless`), "Show cursorless sidebar", ), { @@ -575,9 +555,7 @@ function scopeVisualizerSection( }, ...["removal", "iteration"].flatMap((visualizationType) => resolver - .get(["visualization_type"], visualizationType, [ - visualizationType, - ]) + .get(["scopeVisualizer"], visualizationType) .map((spokenForm) => ({ spokenForm: `${showSpokenForm} ${spokenForm}`, description: `Visualize ${visualizationType} range`, @@ -598,7 +576,7 @@ function shapesSection(resolver: SpokenFormResolver): CheatsheetSection { .map(([id]) => ({ id, type: "hatShape", - variations: resolver.get(["hat_shape"], id, []).map((spokenForm) => ({ + variations: resolver.get(["hatShape"], id).map((spokenForm) => ({ spokenForm, description: capitalize(id), })), @@ -615,18 +593,14 @@ function specialMarksSection(resolver: SpokenFormResolver): CheatsheetSection { items( "currentSelection", "mark", - resolver.get(["simple_mark"], "currentSelection", [ - markDefaultSpokenForms.cursor, - ]), + resolver.get(["specialMark"], "currentSelection"), "Current selection", ), items( "lineNumberModulo100", "mark", resolver - .get(["line_direction"], "lineNumberModulo100", [ - lineDirectionDefaultSpokenForms.modulo100, - ]) + .get(["specialMark"], "lineNumberModulo100") .map((spokenForm) => `${spokenForm} `), "Line number modulo 100", ), @@ -634,9 +608,7 @@ function specialMarksSection(resolver: SpokenFormResolver): CheatsheetSection { "lineNumberRelativeDown", "mark", resolver - .get(["line_direction"], "lineNumberRelativeDown", [ - lineDirectionDefaultSpokenForms.relativeDown, - ]) + .get(["specialMark"], "lineNumberRelativeDown") .map((spokenForm) => `${spokenForm} `), "Line number down from cursor", ), @@ -644,42 +616,32 @@ function specialMarksSection(resolver: SpokenFormResolver): CheatsheetSection { "lineNumberRelativeUp", "mark", resolver - .get(["line_direction"], "lineNumberRelativeUp", [ - lineDirectionDefaultSpokenForms.relativeUp, - ]) + .get(["specialMark"], "lineNumberRelativeUp") .map((spokenForm) => `${spokenForm} `), "Line number up from cursor", ), items( "nothing", "mark", - resolver.get(["simple_mark"], "nothing", [ - markDefaultSpokenForms.nothing, - ]), + resolver.get(["specialMark"], "nothing"), "Nothing", ), items( "previousSource", "mark", - resolver.get(["simple_mark"], "previousSource", [ - markDefaultSpokenForms.source, - ]), + resolver.get(["specialMark"], "previousSource"), "Previous source", ), items( "previousTarget", "mark", - resolver.get(["simple_mark"], "previousTarget", [ - markDefaultSpokenForms.that, - ]), + resolver.get(["specialMark"], "previousTarget"), "Previous target", ), items( "unknownSymbol", "mark", - resolver.get(["unknown_symbol"], "unknownSymbol", [ - graphemeDefaultSpokenForms["\uFFFD"], - ]), + resolver.get(["specialMark"], "unknownSymbol"), "Unknown symbol", ), ].filter(({ variations }) => variations.length > 0), diff --git a/packages/lib-common/src/index.ts b/packages/lib-common/src/index.ts index 383ecf228c..9fa0e096d6 100644 --- a/packages/lib-common/src/index.ts +++ b/packages/lib-common/src/index.ts @@ -69,6 +69,7 @@ export * from "./types/command/legacy/PartialTargetDescriptorV4.types"; export * from "./types/command/legacy/PartialTargetDescriptorV5.types"; export * from "./types/command/legacy/targetDescriptorV2.types"; export * from "./types/command/PartialTargetDescriptor.types"; +export * from "./types/DefaultSpokenFormMap"; export * from "./types/commandHistory"; export * from "./types/CommandServerApi"; export * from "./types/Edit"; diff --git a/packages/lib-common/src/references/actionReferences.ts b/packages/lib-common/src/references/actionReferences.ts index 884568a41f..b8e9692ee1 100644 --- a/packages/lib-common/src/references/actionReferences.ts +++ b/packages/lib-common/src/references/actionReferences.ts @@ -22,32 +22,38 @@ import { VAR_TARGET_1, VAR_TARGET_2, } from "./constants"; -import { modifierReferences } from "./modifierReferences"; +import { + modifierExtraReferences, + modifierReferences, +} from "./modifierReferences"; import { pairedDelimiterReferences } from "./pairedDelimiterReferences"; import type { ReferenceEntry } from "./ReferenceEntry"; import { scopeReferences } from "./scopeReferences"; -import { connectiveDefaultSpokenForms } from "./spokenForms/connectiveDefaultSpokenForms"; +import { + connectiveDefaultSpokenForms, + insertionModeDefaultSpokenForms, +} from "./spokenForms/connectiveDefaultSpokenForms"; import { markDefaultSpokenForms } from "./spokenForms/markDefaultSpokenForms"; const DEFAULT_PATTERN = `${VAR_SPOKEN_FORM} ${VAR_TARGET}`; const DEFAULT_COMMAND = `${VAR_SPOKEN_FORM} ${TARGET}`; +const CURLY = pairedDelimiterReferences.curlyBrackets.defaultSpokenForm; +const SQUARE = pairedDelimiterReferences.squareBrackets.defaultSpokenForm; const ITEM = scopeReferences.collectionItem.defaultSpokenForm; const VALUE = scopeReferences.value.defaultSpokenForm; const INSTANCE = scopeReferences.instance.defaultSpokenForm; const FUNCTION = scopeReferences.namedFunction.defaultSpokenForm; const TOKEN = scopeReferences.token.defaultSpokenForm; const EVERY = modifierReferences.everyScope.defaultSpokenForm; -const NEXT = connectiveDefaultSpokenForms.next; +const NEXT = modifierExtraReferences.next.defaultSpokenForm; const WITH = connectiveDefaultSpokenForms.swapConnective; -const AFTER = connectiveDefaultSpokenForms.after; -const TO = connectiveDefaultSpokenForms.sourceDestinationConnective; -const CURLY = pairedDelimiterReferences.curlyBrackets.defaultSpokenForm; -const SQUARE = pairedDelimiterReferences.squareBrackets.defaultSpokenForm; const AT = connectiveDefaultSpokenForms.at; const ON = connectiveDefaultSpokenForms.on; const SLICE = connectiveDefaultSpokenForms.verticalRange; const CURRENT_SELECTION = markDefaultSpokenForms.cursor; +const AFTER = insertionModeDefaultSpokenForms.after; +const TO = insertionModeDefaultSpokenForms.to; const MULTI_TARGET_CURSOR_DESCRIPTION = "When used with a list target, this action creates one cursor for each target."; diff --git a/packages/lib-common/src/references/index.ts b/packages/lib-common/src/references/index.ts index 514298b5ae..28da605f09 100644 --- a/packages/lib-common/src/references/index.ts +++ b/packages/lib-common/src/references/index.ts @@ -7,6 +7,7 @@ export * from "./ReferenceEntry"; export * from "./scopeReferenceGroups"; export * from "./scopeReferences"; export * from "./spokenForms/connectiveDefaultSpokenForms"; +export * from "./spokenForms/defaultSpokenFormMapCore"; export * from "./spokenForms/graphemeDefaultSpokenForms"; export * from "./spokenForms/markDefaultSpokenForms"; export * from "./spokenForms/numberDefaultSpokenForms"; diff --git a/packages/lib-common/src/references/modifierReferences.ts b/packages/lib-common/src/references/modifierReferences.ts index 82affa3028..cb4fc6f5ff 100644 --- a/packages/lib-common/src/references/modifierReferences.ts +++ b/packages/lib-common/src/references/modifierReferences.ts @@ -1,4 +1,5 @@ import type { ModifierType } from "../types/command/PartialTargetDescriptor.types"; +import type { SpokenFormMapKeyTypes } from "../types/SpokenFormType"; import { EVERY, ITEM, @@ -16,8 +17,7 @@ import { } from "./constants"; import type { ModifierReferenceGroupId } from "./modifierReferenceGroups"; import { pairedDelimiterReferences } from "./pairedDelimiterReferences"; -import type { ReferenceEntry } from "./ReferenceEntry"; -import { connectiveDefaultSpokenForms } from "./spokenForms/connectiveDefaultSpokenForms"; +import type { ReferenceEntry, SpokenFormReference } from "./ReferenceEntry"; import { graphemeDefaultSpokenForms } from "./spokenForms/graphemeDefaultSpokenForms"; import { ordinalDefaultSpokenForms } from "./spokenForms/numberDefaultSpokenForms"; @@ -26,17 +26,18 @@ const PARENTHESES = pairedDelimiterReferences.parentheses.defaultSpokenForm; const THREE = graphemeDefaultSpokenForms["3"]; const EXTEND_THROUGH_END_OF = "past end of"; const THIRD = ordinalDefaultSpokenForms[3]; -const PREVIOUS = connectiveDefaultSpokenForms.previous; -const NEXT = connectiveDefaultSpokenForms.next; -const BACKWARD = connectiveDefaultSpokenForms.backward; -const FORWARD = connectiveDefaultSpokenForms.forward; -const FIRST = connectiveDefaultSpokenForms.first; -const LAST = connectiveDefaultSpokenForms.last; +const PREVIOUS = "previous"; +const NEXT = "next"; +const BACKWARD = "backward"; +const FORWARD = "forward"; +const FIRST = "first"; +const LAST = "last"; const PASTE = "paste"; const BRING = "bring"; const FROM = "from"; -const AFTER = connectiveDefaultSpokenForms.after; -const TO = connectiveDefaultSpokenForms.sourceDestinationConnective; +const GRAND = "grand"; +const AFTER = "after"; +const TO = "to"; const INSTANCE = "instance"; const VALUE = "value"; @@ -45,6 +46,30 @@ const ITERATION_SCOPE_DESCRIPTION = type AdditionalModifierReferenceType = "ancestor"; +export const modifierExtraReferences = { + first: { + defaultSpokenForm: FIRST, + }, + last: { + defaultSpokenForm: LAST, + }, + previous: { + defaultSpokenForm: PREVIOUS, + }, + next: { + defaultSpokenForm: NEXT, + }, + forward: { + defaultSpokenForm: FORWARD, + }, + backward: { + defaultSpokenForm: BACKWARD, + }, + ancestor: { + defaultSpokenForm: GRAND, + }, +} satisfies Record; + export const modifierReferences = { // Group: containing containingScope: { @@ -88,7 +113,7 @@ export const modifierReferences = { }, ancestor: { name: "Ancestor", - defaultSpokenForm: "grand", + defaultSpokenForm: GRAND, group: { id: "containing", index: 2 }, description: 'Selects the parent of a containing scope. Repeat `"grand"` to walk up more than one parent level.', diff --git a/packages/lib-common/src/references/scopeReferences.ts b/packages/lib-common/src/references/scopeReferences.ts index 682707edb2..3a2aff5735 100644 --- a/packages/lib-common/src/references/scopeReferences.ts +++ b/packages/lib-common/src/references/scopeReferences.ts @@ -18,6 +18,7 @@ import { VAR_PAIR, VAR_SPOKEN_FORM, } from "./constants"; +import { modifierExtraReferences } from "./modifierReferences"; import { pairedDelimiterReferences } from "./pairedDelimiterReferences"; import type { ReferenceEntry } from "./ReferenceEntry"; import type { ScopeReferenceGroupId } from "./scopeReferenceGroups"; @@ -40,9 +41,9 @@ const DEFAULT_PATTERN = VAR_SPOKEN_FORM; const AIR = graphemeDefaultSpokenForms.a; const PARENTHESIS = pairedDelimiterReferences.parentheses.defaultSpokenForm; -const NEXT = connectiveDefaultSpokenForms.next; -const FIRST = connectiveDefaultSpokenForms.first; -const LAST = connectiveDefaultSpokenForms.last; +const NEXT = modifierExtraReferences.next.defaultSpokenForm; +const FIRST = modifierExtraReferences.first.defaultSpokenForm; +const LAST = modifierExtraReferences.last.defaultSpokenForm; const PAST = connectiveDefaultSpokenForms.rangeInclusive; const SECOND = ordinalDefaultSpokenForms[2]; const FOURTH = ordinalDefaultSpokenForms[4]; diff --git a/packages/lib-common/src/references/spokenForms/connectiveDefaultSpokenForms.ts b/packages/lib-common/src/references/spokenForms/connectiveDefaultSpokenForms.ts index 773a61eb7f..9b03d188f6 100644 --- a/packages/lib-common/src/references/spokenForms/connectiveDefaultSpokenForms.ts +++ b/packages/lib-common/src/references/spokenForms/connectiveDefaultSpokenForms.ts @@ -6,18 +6,13 @@ export const connectiveDefaultSpokenForms = { rangeExcludingEnd: "until", listConnective: "and", swapConnective: "with", - sourceDestinationConnective: "to", - before: "before", - after: "after", verticalRange: "slice", - - first: "first", - last: "last", - previous: "previous", - next: "next", - forward: "forward", - backward: "backward", - at: "at", on: "on", } as const; + +export const insertionModeDefaultSpokenForms = { + to: "to", + before: "before", + after: "after", +} as const; diff --git a/packages/lib-engine/src/spokenForms/defaultSpokenFormMapCore.ts b/packages/lib-common/src/references/spokenForms/defaultSpokenFormMapCore.ts similarity index 59% rename from packages/lib-engine/src/spokenForms/defaultSpokenFormMapCore.ts rename to packages/lib-common/src/references/spokenForms/defaultSpokenFormMapCore.ts index ed2045ece2..99f019690c 100644 --- a/packages/lib-engine/src/spokenForms/defaultSpokenFormMapCore.ts +++ b/packages/lib-common/src/references/spokenForms/defaultSpokenFormMapCore.ts @@ -1,21 +1,31 @@ -import { - actionNames, - actionReferences, - graphemeDefaultSpokenForms, - modifierReferences, - pairedDelimiterReferences, - simpleScopeTypeTypes, - scopeReferences, - connectiveDefaultSpokenForms, -} from "@cursorless/lib-common"; -import type { - SpokenFormMapKeyTypes, - SpokenFormReference, -} from "@cursorless/lib-common"; +import { actionNames } from "../../types/command/ActionDescriptor"; +import { simpleScopeTypeTypes } from "../../types/command/PartialTargetDescriptor.types"; import type { DefaultSpokenFormMapDefinition, DefaultSpokenFormMapEntry, -} from "./defaultSpokenFormMap.types"; +} from "../../types/DefaultSpokenFormMap"; +import type { SpokenFormMapKeyTypes } from "../../types/SpokenFormType"; +import { actionReferences } from "../actionReferences"; +import { + modifierExtraReferences, + modifierReferences, +} from "../modifierReferences"; +import { pairedDelimiterReferences } from "../pairedDelimiterReferences"; +import type { SpokenFormReference } from "../ReferenceEntry"; +import { scopeReferences } from "../scopeReferences"; +import { + connectiveDefaultSpokenForms, + insertionModeDefaultSpokenForms, +} from "./connectiveDefaultSpokenForms"; +import { graphemeDefaultSpokenForms } from "./graphemeDefaultSpokenForms"; +import { + hatColorDefaultSpokenForms, + hatShapeDefaultSpokenForms, + lineDirectionDefaultSpokenForms, + markDefaultSpokenForms, + unknownSymbolMarkDefaultSpokenForm, +} from "./markDefaultSpokenForms"; +import { isDisabledByDefault } from "./spokenFormMapUtil"; type DefaultSpokenForm = string | DefaultSpokenFormMapEntry; @@ -46,27 +56,8 @@ const modifierExtraReferenceIds = [ "ancestor", ] as const satisfies readonly SpokenFormMapKeyTypes["modifierExtra"][]; -const modifierExtraReferences = { - first: { - defaultSpokenForm: connectiveDefaultSpokenForms.first, - }, - last: { - defaultSpokenForm: connectiveDefaultSpokenForms.last, - }, - previous: { - defaultSpokenForm: connectiveDefaultSpokenForms.previous, - }, - next: { - defaultSpokenForm: connectiveDefaultSpokenForms.next, - }, - forward: { - defaultSpokenForm: connectiveDefaultSpokenForms.forward, - }, - backward: { - defaultSpokenForm: connectiveDefaultSpokenForms.backward, - }, - ancestor: modifierReferences.ancestor, -} satisfies Record; +const { default: hatShapeDefault, ...hatShapes } = hatShapeDefaultSpokenForms; +const { default: hatColorDefault, ...hatColors } = hatColorDefaultSpokenForms; function getDefaultSpokenForm( reference: SpokenFormReference, @@ -120,7 +111,7 @@ function getCompleteDefaultSpokenFormMap< */ export const defaultSpokenFormMapCore: DefaultSpokenFormMapDefinition = { pairedDelimiter: getCompleteDefaultSpokenFormMap(pairedDelimiterReferences), - + action: getDefaultSpokenFormMap(actionNames, actionReferences), simpleScopeTypeType: getDefaultSpokenFormMap( simpleScopeTypeTypes, scopeReferences, @@ -128,7 +119,6 @@ export const defaultSpokenFormMapCore: DefaultSpokenFormMapDefinition = { complexScopeTypeType: { glyph: getDefaultSpokenForm(scopeReferences.glyph), }, - simpleModifier: getDefaultSpokenFormMap( simpleModifierReferenceIds, modifierReferences, @@ -137,9 +127,33 @@ export const defaultSpokenFormMapCore: DefaultSpokenFormMapDefinition = { modifierExtraReferenceIds, modifierExtraReferences, ), - + grapheme: graphemeDefaultSpokenForms, + insertionMode: insertionModeDefaultSpokenForms, + hatColor: hatColors, + hatShape: hatShapes, + connective: { + ...connectiveDefaultSpokenForms, + rangeExcludingStart: isDisabledByDefault(), + }, + specialMark: { + currentSelection: markDefaultSpokenForms.cursor, + previousTarget: markDefaultSpokenForms.that, + previousSource: markDefaultSpokenForms.source, + nothing: markDefaultSpokenForms.nothing, + lineNumberModulo100: lineDirectionDefaultSpokenForms.modulo100, + lineNumberRelativeUp: lineDirectionDefaultSpokenForms.relativeUp, + lineNumberRelativeDown: lineDirectionDefaultSpokenForms.relativeDown, + unknownSymbol: unknownSymbolMarkDefaultSpokenForm, + }, + scopeVisualizer: { + showScopeVisualizer: "visualize", + hideScopeVisualizer: "visualize nothing", + removal: "removal", + iteration: "iteration", + }, + sidebar: { + bar: "bar", + }, customRegex: {}, - action: getDefaultSpokenFormMap(actionNames, actionReferences), customAction: {}, - grapheme: graphemeDefaultSpokenForms, }; diff --git a/packages/lib-common/src/references/spokenForms/graphemeDefaultSpokenForms.ts b/packages/lib-common/src/references/spokenForms/graphemeDefaultSpokenForms.ts index af68dbbf24..8437cc3ac7 100644 --- a/packages/lib-common/src/references/spokenForms/graphemeDefaultSpokenForms.ts +++ b/packages/lib-common/src/references/spokenForms/graphemeDefaultSpokenForms.ts @@ -84,8 +84,6 @@ const symbols = { "]": "right square", "<": "angle", ">": "right angle", - - "\uFFFD": "special", }; export const graphemeDefaultSpokenForms: Record = { diff --git a/packages/lib-common/src/references/spokenForms/markDefaultSpokenForms.ts b/packages/lib-common/src/references/spokenForms/markDefaultSpokenForms.ts index 9a9fb9b1d3..a22bf732ee 100644 --- a/packages/lib-common/src/references/spokenForms/markDefaultSpokenForms.ts +++ b/packages/lib-common/src/references/spokenForms/markDefaultSpokenForms.ts @@ -2,7 +2,7 @@ import type { PartialMark } from "../../types/command/PartialTargetDescriptor.ty type MarkType = PartialMark["type"]; -export const hatColorDefaultSpokenForms: Record = { +export const hatColorDefaultSpokenForms = { blue: "blue", green: "green", red: "red", @@ -14,9 +14,9 @@ export const hatColorDefaultSpokenForms: Record = { userColor4: "user color four", default: null, -}; +} as const satisfies Record; -export const hatShapeDefaultSpokenForms: Record = { +export const hatShapeDefaultSpokenForms = { ex: "ex", fox: "fox", wing: "wing", @@ -29,7 +29,7 @@ export const hatShapeDefaultSpokenForms: Record = { bolt: "bolt", default: null, -}; +} as const satisfies Record; export const markDefaultSpokenForms = { cursor: "this", @@ -45,6 +45,8 @@ export const markDefaultSpokenForms = { target: null, } as const satisfies Record; +export const unknownSymbolMarkDefaultSpokenForm = "special"; + export const lineDirectionDefaultSpokenForms = { modulo100: "row", relativeUp: "up", diff --git a/packages/lib-engine/src/spokenForms/spokenFormMapUtil.ts b/packages/lib-common/src/references/spokenForms/spokenFormMapUtil.ts similarity index 92% rename from packages/lib-engine/src/spokenForms/spokenFormMapUtil.ts rename to packages/lib-common/src/references/spokenForms/spokenFormMapUtil.ts index 37f260f623..3b99c26e34 100644 --- a/packages/lib-engine/src/spokenForms/spokenFormMapUtil.ts +++ b/packages/lib-common/src/references/spokenForms/spokenFormMapUtil.ts @@ -1,4 +1,4 @@ -import type { DefaultSpokenFormMapEntry } from "./defaultSpokenFormMap.types"; +import type { DefaultSpokenFormMapEntry } from "../../types/DefaultSpokenFormMap"; /** * Used to construct entities that should not be speakable by default. diff --git a/packages/lib-engine/src/spokenForms/defaultSpokenFormMap.types.ts b/packages/lib-common/src/types/DefaultSpokenFormMap.ts similarity index 75% rename from packages/lib-engine/src/spokenForms/defaultSpokenFormMap.types.ts rename to packages/lib-common/src/types/DefaultSpokenFormMap.ts index ffe714d50c..ddb1fc693a 100644 --- a/packages/lib-engine/src/spokenForms/defaultSpokenFormMap.types.ts +++ b/packages/lib-common/src/types/DefaultSpokenFormMap.ts @@ -1,11 +1,4 @@ -import type { SpokenFormMapKeyTypes } from "@cursorless/lib-common"; -import type { SpokenFormMappingType } from "./SpokenFormMap"; - -export type DefaultSpokenFormMapDefinition = { - readonly [K in keyof SpokenFormMapKeyTypes]: Readonly< - Record - >; -}; +import type { SpokenFormMapKeyTypes } from "./SpokenFormType"; export interface DefaultSpokenFormMapEntry { defaultSpokenForms: string[]; @@ -24,5 +17,8 @@ export interface DefaultSpokenFormMapEntry { isPrivate: boolean; } -export type DefaultSpokenFormInfoMap = - SpokenFormMappingType; +export type DefaultSpokenFormMapDefinition = { + readonly [K in keyof SpokenFormMapKeyTypes]: Readonly< + Record + >; +}; diff --git a/packages/lib-common/src/types/SpokenFormType.ts b/packages/lib-common/src/types/SpokenFormType.ts index 683d2234a7..d32b626531 100644 --- a/packages/lib-common/src/types/SpokenFormType.ts +++ b/packages/lib-common/src/types/SpokenFormType.ts @@ -1,10 +1,41 @@ import type { ActionType } from "./command/ActionDescriptor"; +import type { InsertionMode } from "./command/DestinationDescriptor.types"; import type { ModifierType, SimpleScopeTypeType, SurroundingPairName, } from "./command/PartialTargetDescriptor.types"; +type SpecialMark = + | "previousSource" + | "previousTarget" + | "currentSelection" + | "lineNumberRelativeDown" + | "lineNumberModulo100" + | "lineNumberRelativeUp" + | "nothing" + | "unknownSymbol"; + +type Connective = + | "rangeExclusive" + | "rangeInclusive" + | "rangeExcludingStart" + | "rangeExcludingEnd" + | "listConnective" + | "swapConnective" + | "verticalRange" + | "at" + | "on"; + +type ScopeVisualizer = + | "showScopeVisualizer" + | "hideScopeVisualizer" + | "removal" + | "iteration"; + +type ComplexScopeTypeType = "glyph"; +type Sidebar = "bar"; + /** * This interface is the source of truth for the types used in our spoken form * map. The keys of this interface are the types of spoken forms that we @@ -13,9 +44,17 @@ import type { * spoken form. */ export interface SpokenFormMapKeyTypes { + action: ActionType; pairedDelimiter: SpeakableSurroundingPairName; simpleScopeTypeType: SimpleScopeTypeType; - complexScopeTypeType: "glyph"; + complexScopeTypeType: ComplexScopeTypeType; + insertionMode: InsertionMode; + specialMark: SpecialMark; + connective: Connective; + hatColor: string; + hatShape: string; + sidebar: Sidebar; + scopeVisualizer: ScopeVisualizer; /** * These modifier types are spoken by directly saying the spoken form for the @@ -32,9 +71,11 @@ export interface SpokenFormMapKeyTypes { * modifiers, but `next` itself isn't a modifier type. */ modifierExtra: ModifierExtra; - customRegex: string; - action: ActionType; + /** + * These are customizable spoken forms that correspond to a regex pattern. + */ + customRegex: string; /** * These actions correspond to id's of app commands. Eg in VSCode, you can have diff --git a/packages/lib-common/src/types/TalonSpokenForms.ts b/packages/lib-common/src/types/TalonSpokenForms.ts index a55ab61023..0331a9121f 100644 --- a/packages/lib-common/src/types/TalonSpokenForms.ts +++ b/packages/lib-common/src/types/TalonSpokenForms.ts @@ -21,6 +21,15 @@ export const SUPPORTED_ENTRY_TYPES = [ "pairedDelimiter", "action", "customAction", + "simpleModifier", + "modifierExtra", + "connective", + "insertionMode", + "specialMark", + "hatColor", + "hatShape", + "sidebar", + "scopeVisualizer", "grapheme", ] as const; diff --git a/packages/lib-engine/src/customCommandGrammar/lexer.ts b/packages/lib-engine/src/customCommandGrammar/lexer.ts index a8fdab3010..2378dd245b 100644 --- a/packages/lib-engine/src/customCommandGrammar/lexer.ts +++ b/packages/lib-engine/src/customCommandGrammar/lexer.ts @@ -3,7 +3,7 @@ import type { InsertionMode, } from "@cursorless/lib-common"; import { - connectiveDefaultSpokenForms, + insertionModeDefaultSpokenForms, markDefaultSpokenForms, simpleActionNames, simpleScopeTypeTypes, @@ -49,10 +49,7 @@ for (const bringMoveActionName of bringMoveActionNames) { const insertionModes: InsertionMode[] = ["before", "after", "to"]; for (const insertionMode of insertionModes) { - const spokenForm = - connectiveDefaultSpokenForms[ - insertionMode === "to" ? "sourceDestinationConnective" : insertionMode - ]; + const spokenForm = insertionModeDefaultSpokenForms[insertionMode]; tokens[spokenForm] = { type: "insertionMode", value: insertionMode, diff --git a/packages/lib-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.test.ts b/packages/lib-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.test.ts index 648266e30a..498852c801 100644 --- a/packages/lib-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.test.ts +++ b/packages/lib-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.test.ts @@ -24,6 +24,11 @@ suite("CustomSpokenFormGeneratorImpl", () => { id: "a", spokenForms: ["alabaster"], }, + { + type: "specialMark", + id: "currentSelection", + spokenForms: ["this"], + }, ]); }, onDidChange: () => ({ diff --git a/packages/lib-engine/src/generateSpokenForm/defaultSpokenForms/marks.ts b/packages/lib-engine/src/generateSpokenForm/defaultSpokenForms/marks.ts deleted file mode 100644 index 30ddc9a136..0000000000 --- a/packages/lib-engine/src/generateSpokenForm/defaultSpokenForms/marks.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { - hatColorDefaultSpokenForms, - hatShapeDefaultSpokenForms, -} from "@cursorless/lib-common"; - -export function hatColorToSpokenForm(color: string): string { - const result = hatColorDefaultSpokenForms[color]; - if (result == null) { - throw new Error(`Unknown hat color '${color}'`); - } - return result; -} - -export function hatShapeToSpokenForm(shape: string): string { - const result = hatShapeDefaultSpokenForms[shape]; - if (result == null) { - throw new Error(`Unknown hat shape '${shape}'`); - } - return result; -} diff --git a/packages/lib-engine/src/generateSpokenForm/defaultSpokenForms/modifiers.ts b/packages/lib-engine/src/generateSpokenForm/defaultSpokenForms/modifiers.ts index 7d72f2d5a8..984befaf2a 100644 --- a/packages/lib-engine/src/generateSpokenForm/defaultSpokenForms/modifiers.ts +++ b/packages/lib-engine/src/generateSpokenForm/defaultSpokenForms/modifiers.ts @@ -1,18 +1,22 @@ -import { CompositeKeyMap } from "@cursorless/lib-common"; +import { + CompositeKeyMap, + pairedDelimiterReferences, +} from "@cursorless/lib-common"; import type { SpeakableSurroundingPairName } from "@cursorless/lib-common"; import type { SpokenFormComponentMap } from "../getSpokenFormComponentMap"; import type { CustomizableSpokenFormComponentForType } from "../SpokenFormComponent"; -import { surroundingPairsDelimiters } from "./surroundingPairsDelimiters"; const surroundingPairDelimiterToName = new CompositeKeyMap< [string, string], SpeakableSurroundingPairName >((pair) => pair); -for (const [name, pair] of Object.entries(surroundingPairsDelimiters)) { - if (pair != null) { +for (const [name, { delimiters }] of Object.entries( + pairedDelimiterReferences, +)) { + if (delimiters != null) { surroundingPairDelimiterToName.set( - pair, + delimiters, name as SpeakableSurroundingPairName, ); } diff --git a/packages/lib-engine/src/generateSpokenForm/defaultSpokenForms/surroundingPairsDelimiters.ts b/packages/lib-engine/src/generateSpokenForm/defaultSpokenForms/surroundingPairsDelimiters.ts deleted file mode 100644 index f69c631a1e..0000000000 --- a/packages/lib-engine/src/generateSpokenForm/defaultSpokenForms/surroundingPairsDelimiters.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { SpeakableSurroundingPairName } from "@cursorless/lib-common"; - -export const surroundingPairsDelimiters: Record< - SpeakableSurroundingPairName, - [string, string] | null -> = { - curlyBrackets: ["{", "}"], - angleBrackets: ["<", ">"], - escapedDoubleQuotes: [String.raw`\"`, String.raw`\"`], - escapedSingleQuotes: [String.raw`\'`, String.raw`\'`], - escapedParentheses: [String.raw`\(`, String.raw`\)`], - escapedSquareBrackets: [String.raw`\[`, String.raw`\]`], - doubleQuotes: ['"', '"'], - parentheses: ["(", ")"], - backtickQuotes: ["`", "`"], - squareBrackets: ["[", "]"], - singleQuotes: ["'", "'"], - tripleBacktickQuotes: ["```", "```"], - tripleDoubleQuotes: ['"""', '"""'], - tripleSingleQuotes: ["'''", "'''"], - whitespace: [" ", " "], - - any: null, - string: null, - collectionBoundary: null, -}; diff --git a/packages/lib-engine/src/generateSpokenForm/generateSpokenForm.ts b/packages/lib-engine/src/generateSpokenForm/generateSpokenForm.ts index b71300aad4..b156b486ce 100644 --- a/packages/lib-engine/src/generateSpokenForm/generateSpokenForm.ts +++ b/packages/lib-engine/src/generateSpokenForm/generateSpokenForm.ts @@ -9,11 +9,7 @@ import type { SpokenFormMapKeyTypes, SpokenFormType, } from "@cursorless/lib-common"; -import { - camelCaseToAllDown, - connectiveDefaultSpokenForms, - DOCS_URL, -} from "@cursorless/lib-common"; +import { camelCaseToAllDown, DOCS_URL } from "@cursorless/lib-common"; import type { SpokenFormMap } from "../spokenForms/SpokenFormMap"; import { surroundingPairDelimitersToSpokenForm } from "./defaultSpokenForms/modifiers"; import { @@ -139,7 +135,7 @@ export class SpokenFormGenerator { return [ this.spokenFormMap.action[action.name], this.handleTarget(action.target1), - connectiveDefaultSpokenForms.swapConnective, + this.spokenFormMap.connective.swapConnective, this.handleTarget(action.target2), ]; @@ -153,7 +149,7 @@ export class SpokenFormGenerator { return [ this.spokenFormMap.action[action.name], this.handleTarget(action.callee), - connectiveDefaultSpokenForms.on, + this.spokenFormMap.connective.on, this.handleTarget(action.argument), ]; @@ -228,7 +224,7 @@ export class SpokenFormGenerator { i === 0 ? this.handleTarget(element) : [ - connectiveDefaultSpokenForms.listConnective, + this.spokenFormMap.connective.listConnective, this.handleTarget(element), ], ); @@ -237,6 +233,7 @@ export class SpokenFormGenerator { const anchor = this.handleTarget(target.anchor); const active = this.handleTarget(target.active); const connective = getRangeConnective( + this.spokenFormMap, target.excludeAnchor, target.excludeActive, target.rangeType, @@ -267,7 +264,7 @@ export class SpokenFormGenerator { i === 0 ? this.handleDestination(destination) : [ - connectiveDefaultSpokenForms.listConnective, + this.spokenFormMap.connective.listConnective, this.handleDestination(destination), ], ); @@ -285,14 +282,16 @@ export class SpokenFormGenerator { } } - private handleInsertionMode(insertionMode: InsertionMode): string { + private handleInsertionMode( + insertionMode: InsertionMode, + ): SpokenFormComponent { switch (insertionMode) { case "to": - return connectiveDefaultSpokenForms.sourceDestinationConnective; + return this.spokenFormMap.insertionMode.to; case "before": - return connectiveDefaultSpokenForms.before; + return this.spokenFormMap.insertionMode.before; case "after": - return connectiveDefaultSpokenForms.after; + return this.spokenFormMap.insertionMode.after; // No default } } @@ -324,7 +323,7 @@ function constructSpokenForms(component: SpokenFormComponent): string[] { helpInfo = "this is a private spoken form currently only for internal experimentation"; } else if (component.spokenForms.requiresTalonUpdate) { - helpInfo = `please update talon to the latest version (see ${DOCS_URL}/user/updating)`; + helpInfo = `please update cursorless-talon to the latest version (see ${DOCS_URL}/user/updating)`; } else { helpInfo = `please see ${DOCS_URL}/user/customization for more information`; } diff --git a/packages/lib-engine/src/generateSpokenForm/getRangeConnective.ts b/packages/lib-engine/src/generateSpokenForm/getRangeConnective.ts index 00df734b14..0420fd652a 100644 --- a/packages/lib-engine/src/generateSpokenForm/getRangeConnective.ts +++ b/packages/lib-engine/src/generateSpokenForm/getRangeConnective.ts @@ -1,26 +1,33 @@ import type { PartialRangeType } from "@cursorless/lib-common"; -import { connectiveDefaultSpokenForms } from "@cursorless/lib-common"; -import { NoSpokenFormError } from "./NoSpokenFormError"; +import type { SpokenFormComponentMap } from "./getSpokenFormComponentMap"; +import type { SpokenFormComponent } from "./SpokenFormComponent"; export function getRangeConnective( + spokenFormMap: SpokenFormComponentMap, excludeAnchor: boolean, excludeActive: boolean, type?: PartialRangeType, -): string { +): SpokenFormComponent { const prefix = - type === "vertical" ? `${connectiveDefaultSpokenForms.verticalRange} ` : ""; + type === "vertical" ? spokenFormMap.connective.verticalRange : undefined; if (excludeAnchor && excludeActive) { - return prefix + connectiveDefaultSpokenForms.rangeExclusive; + return prefix != null + ? [prefix, spokenFormMap.connective.rangeExclusive] + : spokenFormMap.connective.rangeExclusive; } if (excludeAnchor) { - throw new NoSpokenFormError("Range exclude anchor"); + return prefix != null + ? [prefix, spokenFormMap.connective.rangeExcludingStart] + : spokenFormMap.connective.rangeExcludingStart; } if (excludeActive) { - return prefix + connectiveDefaultSpokenForms.rangeExcludingEnd; + return prefix != null + ? [prefix, spokenFormMap.connective.rangeExcludingEnd] + : spokenFormMap.connective.rangeExcludingEnd; } if (type === "vertical") { // "slice", but could have been "slice past" - return connectiveDefaultSpokenForms.verticalRange; + return spokenFormMap.connective.verticalRange; } - return connectiveDefaultSpokenForms.rangeInclusive; + return spokenFormMap.connective.rangeInclusive; } diff --git a/packages/lib-engine/src/generateSpokenForm/primitiveTargetToSpokenForm.ts b/packages/lib-engine/src/generateSpokenForm/primitiveTargetToSpokenForm.ts index 4b39be7d96..e2a3ab58b6 100644 --- a/packages/lib-engine/src/generateSpokenForm/primitiveTargetToSpokenForm.ts +++ b/packages/lib-engine/src/generateSpokenForm/primitiveTargetToSpokenForm.ts @@ -6,15 +6,6 @@ import type { RelativeScopeModifier, ScopeType, } from "@cursorless/lib-common"; -import { - connectiveDefaultSpokenForms, - lineDirectionDefaultSpokenForms, - markDefaultSpokenForms, -} from "@cursorless/lib-common"; -import { - hatColorToSpokenForm, - hatShapeToSpokenForm, -} from "./defaultSpokenForms/marks"; import { numberToSpokenForm, ordinalToSpokenForm, @@ -142,6 +133,7 @@ export class PrimitiveTargetSpokenFormGenerator { : ordinalToSpokenForm(modifier.anchor.start + 1); const active = this.handleModifier(modifier.active); const connective = getRangeConnective( + this.spokenFormMap, modifier.excludeAnchor, modifier.excludeActive, ); @@ -169,8 +161,8 @@ export class PrimitiveTargetSpokenFormGenerator { if (modifier.length === 1) { const direction = modifier.direction === "forward" - ? connectiveDefaultSpokenForms.forward - : connectiveDefaultSpokenForms.backward; + ? this.spokenFormMap.modifierExtra.forward + : this.spokenFormMap.modifierExtra.backward; // token forward/backward return [isEvery, scope, direction]; @@ -190,7 +182,7 @@ export class PrimitiveTargetSpokenFormGenerator { isEvery, length, scopePlural, - connectiveDefaultSpokenForms.backward, + this.spokenFormMap.modifierExtra.backward, ]; } @@ -200,8 +192,8 @@ export class PrimitiveTargetSpokenFormGenerator { const scope = this.handleScopeType(modifier.scopeType); const direction = modifier.direction === "forward" - ? connectiveDefaultSpokenForms.next - : connectiveDefaultSpokenForms.previous; + ? this.spokenFormMap.modifierExtra.next + : this.spokenFormMap.modifierExtra.previous; const isEvery = modifier.isEvery ? this.spokenFormMap.simpleModifier.everyScope : []; @@ -278,18 +270,30 @@ export class PrimitiveTargetSpokenFormGenerator { const [color, shape] = mark.symbolColor.split("-"); const components: SpokenFormComponent[] = []; if (color !== "default") { - components.push(hatColorToSpokenForm(color)); + const result = this.spokenFormMap.hatColor[color]; + if (result == null) { + throw new Error(`Unknown hat color '${color}'`); + } + components.push(result); } if (shape != null) { - components.push(hatShapeToSpokenForm(shape)); + const result = this.spokenFormMap.hatShape[shape]; + if (result == null) { + throw new Error(`Unknown hat shape '${shape}'`); + } + components.push(result); + } + if (mark.character === "\uFFFD") { + components.push(this.spokenFormMap.specialMark.unknownSymbol); + } else { + components.push( + getSpokenFormStrict( + this.spokenFormMap.grapheme, + "grapheme", + mark.character, + ), + ); } - components.push( - getSpokenFormStrict( - this.spokenFormMap.grapheme, - "grapheme", - mark.character, - ), - ); return components; } @@ -310,6 +314,7 @@ export class PrimitiveTargetSpokenFormGenerator { ); if (typeAnchor === typeActive) { const connective = getRangeConnective( + this.spokenFormMap, mark.excludeAnchor, mark.excludeActive, ); @@ -321,24 +326,35 @@ export class PrimitiveTargetSpokenFormGenerator { // a spoken form for these; we may deprecate this construct entirely throw new Error(`Mark '${mark.type}' is not fully implemented`); } + + case "cursor": + return this.spokenFormMap.specialMark.currentSelection; + case "that": + return this.spokenFormMap.specialMark.previousTarget; + case "source": + return this.spokenFormMap.specialMark.previousSource; + case "nothing": + return this.spokenFormMap.specialMark.nothing; + case "explicit": case "keyboard": case "target": throw new NoSpokenFormError(`Mark '${mark.type}'`); - default: - return [markDefaultSpokenForms[mark.type]]; + // No default } } - private handleLineNumberMark(mark: LineNumberMark): [string, string] { + private handleLineNumberMark( + mark: LineNumberMark, + ): [SpokenFormComponent, string] { switch (mark.lineNumberType) { case "absolute": throw new NoSpokenFormError("Absolute line numbers"); case "modulo100": { // row/ five return [ - lineDirectionDefaultSpokenForms.modulo100, + this.spokenFormMap.specialMark.lineNumberModulo100, numberToSpokenForm(mark.lineNumber + 1), ]; } @@ -346,8 +362,8 @@ export class PrimitiveTargetSpokenFormGenerator { // up/down five return [ mark.lineNumber < 0 - ? lineDirectionDefaultSpokenForms.relativeUp - : lineDirectionDefaultSpokenForms.relativeDown, + ? this.spokenFormMap.specialMark.lineNumberRelativeUp + : this.spokenFormMap.specialMark.lineNumberRelativeDown, numberToSpokenForm(Math.abs(mark.lineNumber)), ]; } diff --git a/packages/lib-engine/src/index.ts b/packages/lib-engine/src/index.ts index b4bcc4cdc1..b5409b402d 100644 --- a/packages/lib-engine/src/index.ts +++ b/packages/lib-engine/src/index.ts @@ -7,7 +7,6 @@ export * from "./core/Snippets"; export * from "./core/StoredTargets"; export * from "./cursorlessEngine"; export * from "./customCommandGrammar/parseCommand"; -export * from "./generateSpokenForm/defaultSpokenForms/surroundingPairsDelimiters"; export * from "./generateSpokenForm/generateSpokenForm"; export * from "./languages/TreeSitterQuery/TreeSitterQueryCache"; export * from "./processTargets/modifiers/scopeHandlers/ScopeHandlerCache"; diff --git a/packages/lib-engine/src/spokenForms/CustomSpokenForms.ts b/packages/lib-engine/src/spokenForms/CustomSpokenForms.ts index f7af2ac88a..32e6fb11f3 100644 --- a/packages/lib-engine/src/spokenForms/CustomSpokenForms.ts +++ b/packages/lib-engine/src/spokenForms/CustomSpokenForms.ts @@ -1,6 +1,7 @@ import { isEqual } from "lodash-es"; import type { CustomRegexScopeType, + DefaultSpokenFormMapEntry, Disposable, IDE, SpokenFormEntry, @@ -20,7 +21,6 @@ import { defaultSpokenFormInfoMap, defaultSpokenFormMap, } from "./defaultSpokenFormMap"; -import type { DefaultSpokenFormMapEntry } from "./defaultSpokenFormMap.types"; import type { SpokenFormMap, SpokenFormMapEntry } from "./SpokenFormMap"; type Writable = { diff --git a/packages/lib-engine/src/spokenForms/defaultSpokenFormMap.ts b/packages/lib-engine/src/spokenForms/defaultSpokenFormMap.ts index d6c6d216a5..1c1620e30c 100644 --- a/packages/lib-engine/src/spokenForms/defaultSpokenFormMap.ts +++ b/packages/lib-engine/src/spokenForms/defaultSpokenFormMap.ts @@ -1,8 +1,11 @@ -import type { DefaultSpokenFormInfoMap } from "./defaultSpokenFormMap.types"; -import { defaultSpokenFormMapCore } from "./defaultSpokenFormMapCore"; -import type { SpokenFormMap } from "./SpokenFormMap"; +import { defaultSpokenFormMapCore } from "@cursorless/lib-common"; +import type { DefaultSpokenFormMapEntry } from "@cursorless/lib-common"; +import type { SpokenFormMap, SpokenFormMappingType } from "./SpokenFormMap"; import { mapSpokenForms } from "./SpokenFormMap"; +export type DefaultSpokenFormInfoMap = + SpokenFormMappingType; + /** * This map contains information about the default spoken forms for all our * speakable entities, including scope types, paired delimiters, etc. Note that diff --git a/packages/lib-node-common/src/Cheatsheet.ts b/packages/lib-node-common/src/Cheatsheet.ts index 69ae3047fc..4e3c67714e 100644 --- a/packages/lib-node-common/src/Cheatsheet.ts +++ b/packages/lib-node-common/src/Cheatsheet.ts @@ -68,9 +68,8 @@ async function getCheatsheetInfoForCommand( } if (version === 1) { - return getCheatsheetInfo({ - spokenFormEntries: await talonSpokenForms.getSpokenFormEntries(), - }); + const spokenFormEntries = await talonSpokenForms.getSpokenFormEntries(); + return getCheatsheetInfo(spokenFormEntries); } throw new Error(`Unsupported cheatsheet command version: ${version}`); diff --git a/packages/test-vscode-e2e/src/suite/scopeProvider/runCustomSpokenFormScopeInfoTest.ts b/packages/test-vscode-e2e/src/suite/scopeProvider/runCustomSpokenFormScopeInfoTest.ts index 2d2eeb4f51..623eb4959e 100644 --- a/packages/test-vscode-e2e/src/suite/scopeProvider/runCustomSpokenFormScopeInfoTest.ts +++ b/packages/test-vscode-e2e/src/suite/scopeProvider/runCustomSpokenFormScopeInfoTest.ts @@ -120,7 +120,7 @@ const squareMissing: ScopeTypeInfo = { scopeType: { type: "surroundingPair", delimiter: "squareBrackets" }, spokenForm: { isPrivate: false, - reason: `paired delimiter with id squareBrackets; please update talon to the latest version (see ${DOCS_URL}/user/updating)`, + reason: `paired delimiter with id squareBrackets; please update cursorless-talon to the latest version (see ${DOCS_URL}/user/updating)`, requiresTalonUpdate: true, type: "error", }, @@ -184,7 +184,7 @@ const statementMissing: ScopeTypeInfo = { scopeType: { type: "statement" }, spokenForm: { isPrivate: false, - reason: `simple scope type type with id statement; please update talon to the latest version (see ${DOCS_URL}/user/updating)`, + reason: `simple scope type type with id statement; please update cursorless-talon to the latest version (see ${DOCS_URL}/user/updating)`, requiresTalonUpdate: true, type: "error", }, diff --git a/resources/fixtures/recorded/everyRange/clearEveryFunkNameSkipPastBlueDrum.yml b/resources/fixtures/recorded/everyRange/clearEveryFunkNameSkipPastBlueDrum.yml index ed39103d74..5fbfb492c3 100644 --- a/resources/fixtures/recorded/everyRange/clearEveryFunkNameSkipPastBlueDrum.yml +++ b/resources/fixtures/recorded/everyRange/clearEveryFunkNameSkipPastBlueDrum.yml @@ -17,7 +17,9 @@ command: excludeAnchor: true excludeActive: false usePrePhraseSnapshot: true -spokenFormError: Range exclude anchor +spokenFormError: >- + connective with id rangeExcludingStart; please see + https://www.cursorless.org/docs/user/customization for more information initialState: documentContents: | def aaa(): diff --git a/resources/fixtures/recorded/everyRange/clearEveryTokenBatSkipPastEach.yml b/resources/fixtures/recorded/everyRange/clearEveryTokenBatSkipPastEach.yml index 4a4eb2c5ee..1a62f7044e 100644 --- a/resources/fixtures/recorded/everyRange/clearEveryTokenBatSkipPastEach.yml +++ b/resources/fixtures/recorded/everyRange/clearEveryTokenBatSkipPastEach.yml @@ -18,7 +18,9 @@ command: excludeAnchor: true excludeActive: false usePrePhraseSnapshot: true -spokenFormError: Range exclude anchor +spokenFormError: >- + connective with id rangeExcludingStart; please see + https://www.cursorless.org/docs/user/customization for more information initialState: documentContents: | aaa bbb ccc ddd eee fff diff --git a/resources/fixtures/recorded/everyRange/postEveryTokenFunkNameSkipPastToken.yml b/resources/fixtures/recorded/everyRange/postEveryTokenFunkNameSkipPastToken.yml index a8dc2dca8f..5e3d3b0c72 100644 --- a/resources/fixtures/recorded/everyRange/postEveryTokenFunkNameSkipPastToken.yml +++ b/resources/fixtures/recorded/everyRange/postEveryTokenFunkNameSkipPastToken.yml @@ -21,7 +21,9 @@ command: excludeAnchor: true excludeActive: false usePrePhraseSnapshot: true -spokenFormError: Range exclude anchor +spokenFormError: >- + connective with id rangeExcludingStart; please see + https://www.cursorless.org/docs/user/customization for more information initialState: documentContents: |- def aaa(): From 3ec39ee658641a276a64c8c6f9c8fd38aaa960e4 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Mon, 31 Aug 2026 17:43:59 +0200 Subject: [PATCH 15/28] Only update spoken form ids for specific lists --- cursorless-talon/src/spoken_forms.py | 10 ++-- .../src/test/getDefaultCheatsheetInfo.spec.ts | 39 +++++++++++++ .../src/cheatsheet/getCheatsheetInfo.ts | 57 ++++++++++++++++--- 3 files changed, 93 insertions(+), 13 deletions(-) diff --git a/cursorless-talon/src/spoken_forms.py b/cursorless-talon/src/spoken_forms.py index 2baf11e057..18da4fdd1a 100644 --- a/cursorless-talon/src/spoken_forms.py +++ b/cursorless-talon/src/spoken_forms.py @@ -115,10 +115,10 @@ def ret(filename: str, *args: P.args, **kwargs: P.kwargs) -> R: } ID_REWRITE_MAP = { - "sourceDestinationConnective": "to", - "every": "everyScope", - "start": "startOf", - "end": "endOf", + ("insertion_mode_to", "sourceDestinationConnective"): "to", + ("every_scope_modifier", "every"): "everyScope", + ("position_modifier", "start"): "startOf", + ("position_modifier", "end"): "endOf", } LITERALS = { @@ -151,7 +151,7 @@ def update_spoken_forms_output(): *[ { "type": LIST_TO_TYPE_MAP[entry.list_name], - "id": ID_REWRITE_MAP.get(entry.id, entry.id), + "id": ID_REWRITE_MAP.get((entry.list_name, entry.id), entry.id), "spokenForms": entry.spoken_forms, } for spoken_form_list in custom_spoken_forms.values() diff --git a/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts b/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts index 2686961ad1..8e03533d6d 100644 --- a/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts +++ b/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts @@ -157,6 +157,45 @@ describe("getDefaultCheatsheetInfo", () => { ).toBe("call onto "); }); + test("includes custom actions", () => { + const customCheatsheetInfo = getCheatsheetInfo([ + { + type: "customAction", + id: "editor.action.moveLinesDownAction", + spokenForms: ["push down", "shove down"], + }, + { + type: "customAction", + id: "disabled.action", + spokenForms: [], + }, + ]); + + expect( + getItem( + "actions", + "editor.action.moveLinesDownAction", + customCheatsheetInfo, + ), + ).toEqual({ + id: "editor.action.moveLinesDownAction", + type: "action", + variations: [ + { + spokenForm: "push down ", + description: "Editor action move lines down action", + }, + { + spokenForm: "shove down ", + description: "Editor action move lines down action", + }, + ], + }); + expect( + getSection("actions", customCheatsheetInfo).items, + ).not.toContainEqual(expect.objectContaining({ id: "disabled.action" })); + }); + test("an empty spoken-form entry disables only the corresponding item", () => { const customCheatsheetInfo = getCheatsheetInfo([ { type: "simpleScopeTypeType", id: "token", spokenForms: [] }, diff --git a/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts b/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts index 978f76947e..014a7025c8 100644 --- a/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts +++ b/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts @@ -55,14 +55,7 @@ function constructCheatsheetInfo( return { sections: [ - referenceSection( - resolver, - "Actions", - "actions", - "action", - "action", - actionReferences, - ), + actionsSection(resolver, spokenFormEntries), colorsSection(resolver), compoundTargetsSection(resolver), destinationsSection(resolver), @@ -99,6 +92,38 @@ function constructCheatsheetInfo( }; } +function actionsSection( + resolver: SpokenFormResolver, + spokenFormEntries: readonly SpokenFormResolverEntry[], +): CheatsheetSection { + const section = referenceSection( + resolver, + "Actions", + "actions", + "action", + "action", + actionReferences, + ); + + return { + ...section, + items: [ + ...section.items, + ...spokenFormEntries + .filter(({ type }) => type === "customAction") + .map(({ id, spokenForms }) => + items( + id, + "action", + spokenForms.map((spokenForm) => `${spokenForm} `), + makeReadable(id), + ), + ) + .filter(({ variations }) => variations.length > 0), + ], + }; +} + function referenceSection( resolver: SpokenFormResolver, name: string, @@ -395,6 +420,22 @@ function capitalize(value: string): string { return value.charAt(0).toUpperCase() + value.slice(1); } +function makeReadable(value: string): string { + const isPrivate = value.startsWith("private."); + const name = isPrivate ? value.slice("private.".length) : value; + const readable = capitalize( + name + .replaceAll(".", " ") + .replaceAll( + /(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|(?<=[a-zA-Z])(?=[0-9])|(?<=[0-9])(?=[a-zA-Z])/gu, + " ", + ) + .toLowerCase(), + ); + + return isPrivate ? `${readable} (PRIVATE)` : readable; +} + function colorsSection(resolver: SpokenFormResolver): CheatsheetSection { return { name: "Colors", From 652f1b9847ae1c9bddcf351354c1ec8184ddfedb Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Mon, 31 Aug 2026 19:51:07 +0200 Subject: [PATCH 16/28] Sort and format she cheat --- cursorless-talon/src/spoken_forms.py | 3 +- .../actions/rewrapWithPairedDelimiter.mdx | 2 +- .../src/test/getDefaultCheatsheetInfo.spec.ts | 110 ++++++++++++------ .../src/cheatsheet/getCheatsheetInfo.ts | 104 +++++++++-------- .../src/references/ReferenceEntry.ts | 1 + .../src/references/actionReferences.ts | 8 +- .../src/references/modifierReferences.ts | 15 +-- .../spokenForms/defaultSpokenFormMapCore.ts | 7 +- .../lib-common/src/types/SpokenFormType.ts | 3 +- .../src/updateReferenceMdx.ts | 2 +- 10 files changed, 153 insertions(+), 102 deletions(-) diff --git a/cursorless-talon/src/spoken_forms.py b/cursorless-talon/src/spoken_forms.py index 18da4fdd1a..8aff20d8f8 100644 --- a/cursorless-talon/src/spoken_forms.py +++ b/cursorless-talon/src/spoken_forms.py @@ -106,12 +106,12 @@ def ret(filename: str, *args: P.args, **kwargs: P.kwargs) -> R: "show_scope_visualizer": "scopeVisualizer", "hide_scope_visualizer": "scopeVisualizer", "visualization_type": "scopeVisualizer", + "custom_action": "customAction", **{ action_list_name: "action" for action_list_name in ACTION_LIST_NAMES if action_list_name != "custom_action" }, - "custom_action": "customAction", } ID_REWRITE_MAP = { @@ -119,6 +119,7 @@ def ret(filename: str, *args: P.args, **kwargs: P.kwargs) -> R: ("every_scope_modifier", "every"): "everyScope", ("position_modifier", "start"): "startOf", ("position_modifier", "end"): "endOf", + ("wrap_action", "rewrap"): "rewrapWithPairedDelimiter", } LITERALS = { diff --git a/packages/app-web-docs/src/docs/user/actions/rewrapWithPairedDelimiter.mdx b/packages/app-web-docs/src/docs/user/actions/rewrapWithPairedDelimiter.mdx index 05b4731282..30da712713 100644 --- a/packages/app-web-docs/src/docs/user/actions/rewrapWithPairedDelimiter.mdx +++ b/packages/app-web-docs/src/docs/user/actions/rewrapWithPairedDelimiter.mdx @@ -6,7 +6,7 @@ sidebar_label: Repack See [paired delimiters](../paired-delimiters.md) for the available pairs. -Cursorless ID: `rewrapWithPairedDelimiter` +Cursorless ID: `rewrap` ## Spoken form diff --git a/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts b/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts index 8e03533d6d..775cc70f00 100644 --- a/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts +++ b/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts @@ -21,18 +21,19 @@ describe("getDefaultCheatsheetInfo", () => { ]); }); - test("omits private and disabled-by-default references", () => { - expect(getSection("actions").items).not.toContainEqual( - expect.objectContaining({ id: "private.showParseTree" }), - ); + test("includes private and omits disabled-by-default references", () => { + expect( + getItem("actions", "private.showParseTree").variations[0]?.description, + ).toMatch(/\(PRIVATE\)$/u); expect(getSection("scopes").items).not.toContainEqual( expect.objectContaining({ id: "sectionLevelOne" }), ); }); - test("maps reference ids to the established cheatsheet ids", () => { - expect(getItem("modifiers", "every")).toBeDefined(); - expect(getItem("scopes", "pair")).toBeDefined(); + test("uses canonical reference ids", () => { + expect(getItem("modifiers", "everyScope")).toBeDefined(); + expect(getItem("scopes", "surroundingPair")).toBeDefined(); + expect(getItem("actions", "rewrapWithPairedDelimiter")).toBeDefined(); }); test("constructs default destinations", () => { @@ -78,24 +79,27 @@ describe("getDefaultCheatsheetInfo", () => { spokenForms: ["gulp"], }, { type: "action", id: "swapTargets", spokenForms: ["swap"] }, - { type: "action", id: "applyFormatter", spokenForms: ["format"] }, + { + type: "action", + id: "pasteFromClipboard", + spokenForms: ["paste", "plop"], + }, { type: "action", id: "callAsFunction", spokenForms: ["call"] }, { type: "modifierExtra", id: "ancestor", - spokenForms: ["parental"], + spokenForms: ["parental", "ancestral"], }, { type: "simpleModifier", id: "interiorOnly", spokenForms: ["within"], }, - { type: "connective", id: "at", spokenForms: ["using"] }, - { type: "connective", id: "on", spokenForms: ["onto"] }, + { type: "connective", id: "on", spokenForms: ["onto", "upon"] }, { type: "simpleScopeTypeType", id: "token", - spokenForms: ["word unit"], + spokenForms: ["word unit", "lexeme"], }, { type: "simpleScopeTypeType", @@ -128,13 +132,15 @@ describe("getDefaultCheatsheetInfo", () => { ?.spokenForm, ).toBe("within"); expect( - getItem("modifiers", "ancestor", customCheatsheetInfo).variations[0] - ?.spokenForm, - ).toBe("parental "); + getItem("modifiers", "ancestor", customCheatsheetInfo).variations.map( + ({ spokenForm }) => spokenForm, + ), + ).toEqual(["parental "]); expect( - getItem("scopes", "token", customCheatsheetInfo).variations[0] - ?.spokenForm, - ).toBe("word unit"); + getItem("scopes", "token", customCheatsheetInfo).variations.map( + ({ spokenForm }) => spokenForm, + ), + ).toEqual(["word unit"]); expect( getItem("scopes", "sectionLevelOne", customCheatsheetInfo).variations[0] ?.spokenForm, @@ -148,16 +154,21 @@ describe("getDefaultCheatsheetInfo", () => { ?.spokenForm, ).toBe("swap versus "); expect( - getItem("actions", "applyFormatter", customCheatsheetInfo).variations[0] - ?.spokenForm, - ).toBe("format using "); + getItem("actions", "pasteFromClipboard", customCheatsheetInfo).variations, + ).toEqual([ + { + spokenForm: "paste ", + description: "Paste from clipboard at ", + }, + ]); expect( - getItem("actions", "callAsFunction", customCheatsheetInfo).variations[1] - ?.spokenForm, - ).toBe("call onto "); + getItem("actions", "callAsFunction", customCheatsheetInfo).variations.map( + ({ spokenForm }) => spokenForm, + ), + ).toEqual(["call ", "call onto "]); }); - test("includes custom actions", () => { + test("includes only the first spoken form for custom actions", () => { const customCheatsheetInfo = getCheatsheetInfo([ { type: "customAction", @@ -185,10 +196,6 @@ describe("getDefaultCheatsheetInfo", () => { spokenForm: "push down ", description: "Editor action move lines down action", }, - { - spokenForm: "shove down ", - description: "Editor action move lines down action", - }, ], }); expect( @@ -196,6 +203,37 @@ describe("getDefaultCheatsheetInfo", () => { ).not.toContainEqual(expect.objectContaining({ id: "disabled.action" })); }); + test("includes custom regex scopes", () => { + const customCheatsheetInfo = getCheatsheetInfo([ + { + type: "customRegex", + id: String.raw`[\w.]+`, + spokenForms: ["dotted", "dotty"], + }, + { + type: "customRegex", + id: "disabled", + spokenForms: [], + }, + ]); + + expect( + getItem("scopes", "customRegex.dotted", customCheatsheetInfo), + ).toEqual({ + id: "customRegex.dotted", + type: "scopeType", + variations: [ + { + spokenForm: "dotted", + description: String.raw`/[\w.]+/`, + }, + ], + }); + expect(getSection("scopes", customCheatsheetInfo).items).not.toContainEqual( + expect.objectContaining({ id: "customRegex.disabled" }), + ); + }); + test("an empty spoken-form entry disables only the corresponding item", () => { const customCheatsheetInfo = getCheatsheetInfo([ { type: "simpleScopeTypeType", id: "token", spokenForms: [] }, @@ -218,8 +256,8 @@ describe("getDefaultCheatsheetInfo", () => { test("omits syntax examples whose spoken form is missing or disabled", () => { const customCheatsheetInfo = getCheatsheetInfo([ { type: "action", id: "callAsFunction", spokenForms: ["call"] }, - { type: "action", id: "applyFormatter", spokenForms: ["format"] }, - { type: "connective", id: "at", spokenForms: [] }, + { type: "action", id: "swapTargets", spokenForms: ["swap"] }, + { type: "connective", id: "swapConnective", spokenForms: [] }, ]); expect( @@ -232,12 +270,16 @@ describe("getDefaultCheatsheetInfo", () => { ]); expect( getSection("actions", customCheatsheetInfo).items, - ).not.toContainEqual(expect.objectContaining({ id: "applyFormatter" })); + ).not.toContainEqual(expect.objectContaining({ id: "swapTargets" })); }); test("constructs destinations only from enabled spoken forms", () => { const customCheatsheetInfo = getCheatsheetInfo([ - { type: "insertionMode", id: "before", spokenForms: ["ahead of"] }, + { + type: "insertionMode", + id: "before", + spokenForms: ["ahead of", "prior to"], + }, { type: "insertionMode", id: "to", spokenForms: ["toward"] }, ]); @@ -270,7 +312,7 @@ describe("getDefaultCheatsheetInfo", () => { { type: "scopeVisualizer", id: "showScopeVisualizer", - spokenForms: ["inspect"], + spokenForms: ["inspect", "visualize"], }, { type: "scopeVisualizer", diff --git a/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts b/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts index 014a7025c8..3c8ca342b4 100644 --- a/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts +++ b/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts @@ -14,6 +14,7 @@ import type { DefaultSpokenFormMapEntry, } from "../types/DefaultSpokenFormMap"; import type { SpokenFormEntry } from "../types/TalonSpokenForms"; +import { camelCaseToAllDown, capitalize } from "../util/stringUtils"; import type { CheatsheetInfo, CheatsheetSection } from "./cheatsheet.types"; interface CheatsheetReference { @@ -56,9 +57,9 @@ function constructCheatsheetInfo( return { sections: [ actionsSection(resolver, spokenFormEntries), - colorsSection(resolver), - compoundTargetsSection(resolver), destinationsSection(resolver), + scopesSection(resolver, spokenFormEntries), + scopeVisualizerSection(resolver), referenceSection( resolver, "Modifiers", @@ -66,27 +67,12 @@ function constructCheatsheetInfo( "modifier", "modifier", modifierReferences, - { - endOf: "end", - everyScope: "every", - startOf: "start", - }, ), pairedDelimitersSection(resolver), - scopeVisualizerSection(resolver), - referenceSection( - resolver, - "Scopes", - "scopes", - "scopeType", - "scope", - scopeReferences, - { - surroundingPair: "pair", - }, - ), - shapesSection(resolver), specialMarksSection(resolver), + compoundTargetsSection(resolver), + colorsSection(resolver), + shapesSection(resolver), tutorialSection, ], }; @@ -115,7 +101,9 @@ function actionsSection( items( id, "action", - spokenForms.map((spokenForm) => `${spokenForm} `), + spokenForms + .slice(0, 1) + .map((spokenForm) => `${spokenForm} `), makeReadable(id), ), ) @@ -124,6 +112,43 @@ function actionsSection( }; } +function scopesSection( + resolver: SpokenFormResolver, + spokenFormEntries: readonly SpokenFormResolverEntry[], +): CheatsheetSection { + const section = referenceSection( + resolver, + "Scopes", + "scopes", + "scopeType", + "scope", + scopeReferences, + ); + + return { + ...section, + items: [ + ...section.items, + ...spokenFormEntries + .filter(({ type }) => type === "customRegex") + .flatMap(({ id }) => { + const spokenForms = resolver.get(["customRegex"], id); + if (spokenForms.length === 0) { + return []; + } + return [ + items( + `customRegex.${spokenForms[0]}`, + "scopeType", + spokenForms, + `/${id}/`, + ), + ]; + }), + ], + }; +} + function referenceSection( resolver: SpokenFormResolver, name: string, @@ -131,13 +156,11 @@ function referenceSection( type: string, referenceKind: ReferenceKind, references: ReferenceMap, - itemIdOverrides: Readonly> = {}, ): CheatsheetSection { return { name, id, items: Object.entries(references) - .filter(([, reference]) => !reference.private) .map(([referenceId, reference]) => { const spokenForms = getReferenceSpokenForms( resolver, @@ -148,7 +171,7 @@ function referenceSection( const replacements = getSyntaxReplacements(resolver); return { - id: itemIdOverrides[referenceId] ?? referenceId, + id: referenceId, type, variations: reference.syntaxes.flatMap(({ pattern, cheatsheet }) => spokenForms.flatMap((spokenForm) => @@ -165,7 +188,9 @@ function referenceSection( ], ).map((customPattern) => ({ spokenForm: customPattern, - description: cheatsheet, + description: reference.private + ? `${cheatsheet} (PRIVATE)` + : cheatsheet, })), ), ), @@ -185,7 +210,9 @@ class SpokenFormResolver { } get(types: readonly string[], id: string): readonly string[] { - return types.flatMap((type) => this.entries.get(`${type}\0${id}`) ?? []); + return types + .flatMap((type) => this.entries.get(`${type}\0${id}`) ?? []) + .slice(0, 1); } } @@ -203,8 +230,7 @@ function getDefaultSpokenFormEntries( entries as Readonly>, ).map(([id, value]) => ({ type, - id: - type === "action" && id === "rewrapWithPairedDelimiter" ? "rewrap" : id, + id, spokenForms: getEnabledDefaultSpokenForms(value), })), ); @@ -231,8 +257,7 @@ function getReferenceSpokenForms( } if (kind === "action") { - const talonId = id === "rewrapWithPairedDelimiter" ? "rewrap" : id; - return resolver.get(actionTypes, talonId); + return resolver.get(actionTypes, id); } if (kind === "scope") { @@ -403,7 +428,6 @@ function pairedDelimitersSection( name: "Paired delimiters", id: "pairedDelimiters", items: Object.entries(pairedDelimiterReferences) - .filter(([, reference]) => !("private" in reference && reference.private)) .map(([id, reference]) => ({ id, type: "pairedDelimiter", @@ -416,24 +440,8 @@ function pairedDelimitersSection( }; } -function capitalize(value: string): string { - return value.charAt(0).toUpperCase() + value.slice(1); -} - function makeReadable(value: string): string { - const isPrivate = value.startsWith("private."); - const name = isPrivate ? value.slice("private.".length) : value; - const readable = capitalize( - name - .replaceAll(".", " ") - .replaceAll( - /(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|(?<=[a-zA-Z])(?=[0-9])|(?<=[0-9])(?=[a-zA-Z])/gu, - " ", - ) - .toLowerCase(), - ); - - return isPrivate ? `${readable} (PRIVATE)` : readable; + return capitalize(camelCaseToAllDown(value.replaceAll(".", " "))); } function colorsSection(resolver: SpokenFormResolver): CheatsheetSection { diff --git a/packages/lib-common/src/references/ReferenceEntry.ts b/packages/lib-common/src/references/ReferenceEntry.ts index bb1d09d627..2952481738 100644 --- a/packages/lib-common/src/references/ReferenceEntry.ts +++ b/packages/lib-common/src/references/ReferenceEntry.ts @@ -6,6 +6,7 @@ export interface SpokenFormReference { export interface ReferenceEntry extends SpokenFormReference { name: string; + csv_id?: string; legacySpokenForms?: string[]; description?: string; group: GroupDefinition; diff --git a/packages/lib-common/src/references/actionReferences.ts b/packages/lib-common/src/references/actionReferences.ts index b8e9692ee1..cba9dcda86 100644 --- a/packages/lib-common/src/references/actionReferences.ts +++ b/packages/lib-common/src/references/actionReferences.ts @@ -66,7 +66,12 @@ const REORDER_DESCRIPTION = const PAIRED_DELIMITER_DESCRIPTION = "See [paired delimiters](../paired-delimiters.md) for the available pairs."; -type TalonSideActionType = "applyFormatter" | "nextHomophone"; +export const talonSideActionNames = [ + "applyFormatter", + "nextHomophone", +] as const; + +export type TalonSideActionType = (typeof talonSideActionNames)[number]; export const actionReferences = { // Group: cursor @@ -732,6 +737,7 @@ export const actionReferences = { }, rewrapWithPairedDelimiter: { name: "Rewrap with paired delimiter", + csv_id: "rewrap", defaultSpokenForm: "repack", group: { id: "wrap", index: 1 }, description: PAIRED_DELIMITER_DESCRIPTION, diff --git a/packages/lib-common/src/references/modifierReferences.ts b/packages/lib-common/src/references/modifierReferences.ts index cb4fc6f5ff..8c220a0521 100644 --- a/packages/lib-common/src/references/modifierReferences.ts +++ b/packages/lib-common/src/references/modifierReferences.ts @@ -654,19 +654,8 @@ Relative modifiers select scopes before or after the input target. Without \`"ev name: "Preferred scope", private: true, group: { id: "private", index: 0 }, - syntaxes: [ - { - pattern: VAR_SCOPE, - description: `Preferred instance of ${VAR_SCOPE}.`, - cheatsheet: `Preferred instance of ${VAR_SCOPE}`, - }, - ], - examples: [ - { - command: `${SET_SELECTION} ${ITEM} ${TARGET}`, - description: `Selects the closest item to the ${TARGET_DESC}.`, - }, - ], + syntaxes: [], + examples: [], }, modifyIfUntyped: { name: "Modify if untyped", diff --git a/packages/lib-common/src/references/spokenForms/defaultSpokenFormMapCore.ts b/packages/lib-common/src/references/spokenForms/defaultSpokenFormMapCore.ts index 99f019690c..7698f5796a 100644 --- a/packages/lib-common/src/references/spokenForms/defaultSpokenFormMapCore.ts +++ b/packages/lib-common/src/references/spokenForms/defaultSpokenFormMapCore.ts @@ -5,7 +5,7 @@ import type { DefaultSpokenFormMapEntry, } from "../../types/DefaultSpokenFormMap"; import type { SpokenFormMapKeyTypes } from "../../types/SpokenFormType"; -import { actionReferences } from "../actionReferences"; +import { actionReferences, talonSideActionNames } from "../actionReferences"; import { modifierExtraReferences, modifierReferences, @@ -111,7 +111,10 @@ function getCompleteDefaultSpokenFormMap< */ export const defaultSpokenFormMapCore: DefaultSpokenFormMapDefinition = { pairedDelimiter: getCompleteDefaultSpokenFormMap(pairedDelimiterReferences), - action: getDefaultSpokenFormMap(actionNames, actionReferences), + action: getDefaultSpokenFormMap( + [...actionNames, ...talonSideActionNames], + actionReferences, + ), simpleScopeTypeType: getDefaultSpokenFormMap( simpleScopeTypeTypes, scopeReferences, diff --git a/packages/lib-common/src/types/SpokenFormType.ts b/packages/lib-common/src/types/SpokenFormType.ts index d32b626531..77889c73ae 100644 --- a/packages/lib-common/src/types/SpokenFormType.ts +++ b/packages/lib-common/src/types/SpokenFormType.ts @@ -1,3 +1,4 @@ +import type { TalonSideActionType } from "../references/actionReferences"; import type { ActionType } from "./command/ActionDescriptor"; import type { InsertionMode } from "./command/DestinationDescriptor.types"; import type { @@ -44,7 +45,7 @@ type Sidebar = "bar"; * spoken form. */ export interface SpokenFormMapKeyTypes { - action: ActionType; + action: ActionType | TalonSideActionType; pairedDelimiter: SpeakableSurroundingPairName; simpleScopeTypeType: SimpleScopeTypeType; complexScopeTypeType: ComplexScopeTypeType; diff --git a/packages/tool-meta-updater/src/updateReferenceMdx.ts b/packages/tool-meta-updater/src/updateReferenceMdx.ts index 8c061538f7..5ebd0bc11f 100644 --- a/packages/tool-meta-updater/src/updateReferenceMdx.ts +++ b/packages/tool-meta-updater/src/updateReferenceMdx.ts @@ -49,7 +49,7 @@ export function updateReferenceMdx( expected.push(entry.description, ""); } - expected.push(`Cursorless ID: ${code(id)}`, ""); + expected.push(`Cursorless ID: ${code(entry.csv_id ?? id)}`, ""); const spokenFormLines: string[] = []; From 5dd8eecd434fa9cfa8a67cdca966467b42b512a5 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Mon, 31 Aug 2026 19:59:00 +0200 Subject: [PATCH 17/28] Change set selection to selects --- packages/app-web-docs/src/docs/user/actions/README.md | 2 +- .../app-web-docs/src/docs/user/actions/setSelection.mdx | 4 ++-- packages/lib-common/src/references/actionReferences.ts | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/app-web-docs/src/docs/user/actions/README.md b/packages/app-web-docs/src/docs/user/actions/README.md index bf9020f03d..9c9f107067 100644 --- a/packages/app-web-docs/src/docs/user/actions/README.md +++ b/packages/app-web-docs/src/docs/user/actions/README.md @@ -2,7 +2,7 @@ ## Cursor movement -- [`"take "`](./setSelection.mdx) - Set selection to ``. +- [`"take "`](./setSelection.mdx) - Select ``. - [`"pre "`](./setSelectionBefore.mdx) - Set empty selection before ``. - [`"post "`](./setSelectionAfter.mdx) - Set empty selection after ``. - [`"append "`](./addSelection.mdx) - Adds `` to the current selection set. diff --git a/packages/app-web-docs/src/docs/user/actions/setSelection.mdx b/packages/app-web-docs/src/docs/user/actions/setSelection.mdx index 0b5d7909b0..9facbe794c 100644 --- a/packages/app-web-docs/src/docs/user/actions/setSelection.mdx +++ b/packages/app-web-docs/src/docs/user/actions/setSelection.mdx @@ -2,7 +2,7 @@ sidebar_label: Take --- -# Take (Set selection) +# Take (Select) When used with a list target, this action creates one cursor for each target. @@ -14,7 +14,7 @@ Default: `take` ## Syntax -- take `` - Set selection to ``. +- take `` - Select ``. ## Examples diff --git a/packages/lib-common/src/references/actionReferences.ts b/packages/lib-common/src/references/actionReferences.ts index cba9dcda86..e9a572afba 100644 --- a/packages/lib-common/src/references/actionReferences.ts +++ b/packages/lib-common/src/references/actionReferences.ts @@ -76,15 +76,15 @@ export type TalonSideActionType = (typeof talonSideActionNames)[number]; export const actionReferences = { // Group: cursor setSelection: { - name: "Set selection", + name: "Select", defaultSpokenForm: SET_SELECTION, group: { id: "cursor", index: 0 }, description: MULTI_TARGET_CURSOR_DESCRIPTION, syntaxes: [ { pattern: DEFAULT_PATTERN, - description: `Set selection to ${VAR_TARGET}.`, - cheatsheet: "Set selection", + description: `Select ${VAR_TARGET}.`, + cheatsheet: "Select", }, ], examples: [ From 05cce204d7149f139a5b87eb09ba457e62aee5a7 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Mon, 31 Aug 2026 20:48:46 +0200 Subject: [PATCH 18/28] Added references for hat shapes and colors --- .../src/cheatsheet/getCheatsheetInfo.ts | 18 ++-- .../lib-common/src/references/constants.ts | 8 +- .../src/references/hatStyleReferences.ts | 102 ++++++++++++++++++ packages/lib-common/src/references/index.ts | 1 + .../spokenForms/defaultSpokenFormMapCore.ts | 10 +- .../spokenForms/markDefaultSpokenForms.ts | 29 ----- 6 files changed, 118 insertions(+), 50 deletions(-) create mode 100644 packages/lib-common/src/references/hatStyleReferences.ts diff --git a/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts b/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts index 3c8ca342b4..42a649405c 100644 --- a/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts +++ b/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts @@ -2,8 +2,8 @@ import { actionReferences, connectiveDefaultSpokenForms, defaultSpokenFormMapCore, - hatColorDefaultSpokenForms, - hatShapeDefaultSpokenForms, + hatColorReferences, + hatShapeReferences, modifierExtraReferences, modifierReferences, pairedDelimiterReferences, @@ -448,14 +448,13 @@ function colorsSection(resolver: SpokenFormResolver): CheatsheetSection { return { name: "Colors", id: "colors", - items: Object.entries(hatColorDefaultSpokenForms) - .filter(([id, spokenForm]) => id !== "default" && spokenForm != null) - .map(([id]) => ({ + items: Object.entries(hatColorReferences) + .map(([id, reference]) => ({ id, type: "hatColor", variations: resolver.get(["hatColor"], id).map((spokenForm) => ({ spokenForm, - description: capitalize(id), + description: reference.name, })), })) .filter(({ variations }) => variations.length > 0), @@ -620,14 +619,13 @@ function shapesSection(resolver: SpokenFormResolver): CheatsheetSection { return { name: "Shapes", id: "shapes", - items: Object.entries(hatShapeDefaultSpokenForms) - .filter(([id, spokenForm]) => id !== "default" && spokenForm != null) - .map(([id]) => ({ + items: Object.entries(hatShapeReferences) + .map(([id, reference]) => ({ id, type: "hatShape", variations: resolver.get(["hatShape"], id).map((spokenForm) => ({ spokenForm, - description: capitalize(id), + description: reference.name, })), })) .filter(({ variations }) => variations.length > 0), diff --git a/packages/lib-common/src/references/constants.ts b/packages/lib-common/src/references/constants.ts index 6c1a14397f..823d664a45 100644 --- a/packages/lib-common/src/references/constants.ts +++ b/packages/lib-common/src/references/constants.ts @@ -1,5 +1,5 @@ +import { hatColorReferences } from "./hatStyleReferences"; import { graphemeDefaultSpokenForms } from "./spokenForms/graphemeDefaultSpokenForms"; -import { hatColorDefaultSpokenForms } from "./spokenForms/markDefaultSpokenForms"; // Template variables export const VAR_SPOKEN_FORM = ""; @@ -17,11 +17,11 @@ export const VAR_MODIFIER = ""; export const VAR_CHARACTER = ""; // Targets -export const TARGET = `${hatColorDefaultSpokenForms.blue} ${graphemeDefaultSpokenForms.a}`; +export const TARGET = `${hatColorReferences.blue.defaultSpokenForm} ${graphemeDefaultSpokenForms.a}`; export const TARGET_DESC = "token containing letter 'a' with a blue hat"; -export const TARGET_2 = `${hatColorDefaultSpokenForms.green} ${graphemeDefaultSpokenForms.b}`; +export const TARGET_2 = `${hatColorReferences.green.defaultSpokenForm} ${graphemeDefaultSpokenForms.b}`; export const TARGET_2_DESC = "token containing letter 'b' with a green hat"; -export const TARGET_NUMBER = `${hatColorDefaultSpokenForms.blue} ${graphemeDefaultSpokenForms["5"]}`; +export const TARGET_NUMBER = `${hatColorReferences.blue.defaultSpokenForm} ${graphemeDefaultSpokenForms["5"]}`; export const TARGET_NUMBER_DESC = "token containing number '5' with a blue hat"; // Actions diff --git a/packages/lib-common/src/references/hatStyleReferences.ts b/packages/lib-common/src/references/hatStyleReferences.ts new file mode 100644 index 0000000000..352291dd2f --- /dev/null +++ b/packages/lib-common/src/references/hatStyleReferences.ts @@ -0,0 +1,102 @@ +import type { SpokenFormReference } from "./ReferenceEntry"; + +type HatStyleReference = SpokenFormReference & { + name: string; + defaultSpokenForm: string; +}; + +export const hatColorReferences = { + blue: { + name: "Blue", + defaultSpokenForm: "blue", + }, + green: { + name: "Green", + defaultSpokenForm: "green", + }, + red: { + name: "Red", + defaultSpokenForm: "red", + }, + pink: { + name: "Pink", + defaultSpokenForm: "pink", + }, + yellow: { + name: "Yellow", + defaultSpokenForm: "yellow", + }, + userColor1: { + name: "User color 1", + defaultSpokenForm: "navy", + disabledByDefault: true, + }, + userColor2: { + name: "User color 2", + defaultSpokenForm: "apricot", + disabledByDefault: true, + }, + userColor3: { + name: "User color 3", + defaultSpokenForm: "user color three", + disabledByDefault: true, + }, + userColor4: { + name: "User color 4", + defaultSpokenForm: "user color four", + disabledByDefault: true, + }, +} as const satisfies Record; + +export const hatShapeReferences = { + ex: { + name: "Ex", + defaultSpokenForm: "ex", + disabledByDefault: true, + }, + fox: { + name: "Fox", + defaultSpokenForm: "fox", + disabledByDefault: true, + }, + wing: { + name: "Wing", + defaultSpokenForm: "wing", + disabledByDefault: true, + }, + hole: { + name: "Hole", + defaultSpokenForm: "hole", + disabledByDefault: true, + }, + frame: { + name: "Frame", + defaultSpokenForm: "frame", + disabledByDefault: true, + }, + curve: { + name: "Curve", + defaultSpokenForm: "curve", + disabledByDefault: true, + }, + eye: { + name: "Eye", + defaultSpokenForm: "eye", + disabledByDefault: true, + }, + play: { + name: "Play", + defaultSpokenForm: "play", + disabledByDefault: true, + }, + crosshairs: { + name: "Crosshairs", + defaultSpokenForm: "cross", + disabledByDefault: true, + }, + bolt: { + name: "Bolt", + defaultSpokenForm: "bolt", + disabledByDefault: true, + }, +} as const satisfies Record; diff --git a/packages/lib-common/src/references/index.ts b/packages/lib-common/src/references/index.ts index 28da605f09..f8874fde02 100644 --- a/packages/lib-common/src/references/index.ts +++ b/packages/lib-common/src/references/index.ts @@ -1,5 +1,6 @@ export * from "./actionReferenceGroups"; export * from "./actionReferences"; +export * from "./hatStyleReferences"; export * from "./modifierReferenceGroups"; export * from "./modifierReferences"; export * from "./pairedDelimiterReferences"; diff --git a/packages/lib-common/src/references/spokenForms/defaultSpokenFormMapCore.ts b/packages/lib-common/src/references/spokenForms/defaultSpokenFormMapCore.ts index 7698f5796a..bccab97c9e 100644 --- a/packages/lib-common/src/references/spokenForms/defaultSpokenFormMapCore.ts +++ b/packages/lib-common/src/references/spokenForms/defaultSpokenFormMapCore.ts @@ -6,6 +6,7 @@ import type { } from "../../types/DefaultSpokenFormMap"; import type { SpokenFormMapKeyTypes } from "../../types/SpokenFormType"; import { actionReferences, talonSideActionNames } from "../actionReferences"; +import { hatColorReferences, hatShapeReferences } from "../hatStyleReferences"; import { modifierExtraReferences, modifierReferences, @@ -19,8 +20,6 @@ import { } from "./connectiveDefaultSpokenForms"; import { graphemeDefaultSpokenForms } from "./graphemeDefaultSpokenForms"; import { - hatColorDefaultSpokenForms, - hatShapeDefaultSpokenForms, lineDirectionDefaultSpokenForms, markDefaultSpokenForms, unknownSymbolMarkDefaultSpokenForm, @@ -56,9 +55,6 @@ const modifierExtraReferenceIds = [ "ancestor", ] as const satisfies readonly SpokenFormMapKeyTypes["modifierExtra"][]; -const { default: hatShapeDefault, ...hatShapes } = hatShapeDefaultSpokenForms; -const { default: hatColorDefault, ...hatColors } = hatColorDefaultSpokenForms; - function getDefaultSpokenForm( reference: SpokenFormReference, ): DefaultSpokenForm { @@ -130,10 +126,10 @@ export const defaultSpokenFormMapCore: DefaultSpokenFormMapDefinition = { modifierExtraReferenceIds, modifierExtraReferences, ), + hatColor: getCompleteDefaultSpokenFormMap(hatColorReferences), + hatShape: getCompleteDefaultSpokenFormMap(hatShapeReferences), grapheme: graphemeDefaultSpokenForms, insertionMode: insertionModeDefaultSpokenForms, - hatColor: hatColors, - hatShape: hatShapes, connective: { ...connectiveDefaultSpokenForms, rangeExcludingStart: isDisabledByDefault(), diff --git a/packages/lib-common/src/references/spokenForms/markDefaultSpokenForms.ts b/packages/lib-common/src/references/spokenForms/markDefaultSpokenForms.ts index a22bf732ee..42a4daee3a 100644 --- a/packages/lib-common/src/references/spokenForms/markDefaultSpokenForms.ts +++ b/packages/lib-common/src/references/spokenForms/markDefaultSpokenForms.ts @@ -2,35 +2,6 @@ import type { PartialMark } from "../../types/command/PartialTargetDescriptor.ty type MarkType = PartialMark["type"]; -export const hatColorDefaultSpokenForms = { - blue: "blue", - green: "green", - red: "red", - pink: "pink", - yellow: "yellow", - userColor1: "navy", - userColor2: "apricot", - userColor3: "user color three", - userColor4: "user color four", - - default: null, -} as const satisfies Record; - -export const hatShapeDefaultSpokenForms = { - ex: "ex", - fox: "fox", - wing: "wing", - hole: "hole", - frame: "frame", - curve: "curve", - eye: "eye", - play: "play", - crosshairs: "cross", - bolt: "bolt", - - default: null, -} as const satisfies Record; - export const markDefaultSpokenForms = { cursor: "this", that: "that", From 29147e7b8e16922d05a25d37dcfcaffd013a7ebb Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Mon, 31 Aug 2026 21:25:52 +0200 Subject: [PATCH 19/28] Added visibility to references --- .../src/test/getDefaultCheatsheetInfo.spec.ts | 46 +++++++++++++++++-- .../src/cheatsheet/getCheatsheetInfo.ts | 15 +++--- packages/lib-common/src/index.ts | 3 +- .../src/references/ReferenceEntry.ts | 5 +- .../src/references/actionReferences.ts | 18 ++++---- .../src/references/hatStyleReferences.ts | 28 +++++------ .../src/references/modifierReferences.ts | 8 ++-- .../references/pairedDelimiterReferences.ts | 12 ++--- .../src/references/scopeReferences.ts | 28 +++++------ .../spokenForms/defaultSpokenFormMapCore.ts | 8 ++-- .../spokenForms/spokenFormMapUtil.ts | 14 ++---- .../src/types/DefaultSpokenFormMap.ts | 15 +----- .../src/types/SpokenFormVisibility.ts | 14 ++++++ .../generateSpokenForm.test.ts | 17 ++++--- .../src/spokenForms/CustomSpokenForms.ts | 4 +- .../src/spokenForms/defaultSpokenFormMap.ts | 19 ++++---- packages/tool-meta-updater/src/metaUpdater.ts | 2 +- .../src/scopeFixtureGroups.ts | 3 +- .../src/updatePairedDelimitersMd.ts | 4 +- .../src/updateReferenceMdx.ts | 2 +- .../src/updateReferenceReadmeMd.ts | 7 +-- 21 files changed, 154 insertions(+), 118 deletions(-) create mode 100644 packages/lib-common/src/types/SpokenFormVisibility.ts diff --git a/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts b/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts index 775cc70f00..8c30c7a43e 100644 --- a/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts +++ b/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts @@ -21,15 +21,42 @@ describe("getDefaultCheatsheetInfo", () => { ]); }); - test("includes private and omits disabled-by-default references", () => { - expect( - getItem("actions", "private.showParseTree").variations[0]?.description, - ).toMatch(/\(PRIVATE\)$/u); + test("omits private and disabled-by-default references", () => { + expect(getSection("actions").items).not.toContainEqual( + expect.objectContaining({ id: "private.showParseTree" }), + ); + expect(getSection("scopes").items).not.toContainEqual( + expect.objectContaining({ id: "private.fieldAccess" }), + ); expect(getSection("scopes").items).not.toContainEqual( expect.objectContaining({ id: "sectionLevelOne" }), ); }); + test("includes private references with actual spoken forms", () => { + const customCheatsheetInfo = getCheatsheetInfo([ + { + type: "action", + id: "private.showParseTree", + spokenForms: ["inspect parse tree"], + }, + { + type: "simpleScopeTypeType", + id: "private.fieldAccess", + spokenForms: ["access"], + }, + ]); + + expect( + getItem("actions", "private.showParseTree", customCheatsheetInfo) + .variations[0]?.description, + ).toMatch(/\(PRIVATE\)$/u); + expect( + getItem("scopes", "private.fieldAccess", customCheatsheetInfo) + .variations[0]?.description, + ).toMatch(/\(PRIVATE\)$/u); + }); + test("uses canonical reference ids", () => { expect(getItem("modifiers", "everyScope")).toBeDefined(); expect(getItem("scopes", "surroundingPair")).toBeDefined(); @@ -71,6 +98,17 @@ describe("getDefaultCheatsheetInfo", () => { ]); }); + test("includes only colors and shapes that are enabled by default", () => { + expect(getSection("colors").items.map(({ id }) => id)).toEqual([ + "blue", + "green", + "red", + "pink", + "yellow", + ]); + expect(getSection("shapes").items).toEqual([]); + }); + test("applies Talon spoken-form entries to the current syntax", () => { const customCheatsheetInfo = getCheatsheetInfo([ { diff --git a/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts b/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts index 42a649405c..253a7e33c2 100644 --- a/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts +++ b/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts @@ -9,6 +9,7 @@ import { pairedDelimiterReferences, scopeReferences, } from "../references"; +import type { SpokenFormReference } from "../references"; import type { DefaultSpokenFormMapDefinition, DefaultSpokenFormMapEntry, @@ -17,10 +18,7 @@ import type { SpokenFormEntry } from "../types/TalonSpokenForms"; import { camelCaseToAllDown, capitalize } from "../util/stringUtils"; import type { CheatsheetInfo, CheatsheetSection } from "./cheatsheet.types"; -interface CheatsheetReference { - defaultSpokenForm?: string; - disabledByDefault?: boolean; - private?: boolean; +interface CheatsheetReference extends SpokenFormReference { syntaxes: readonly { pattern: string; cheatsheet: string; @@ -188,9 +186,10 @@ function referenceSection( ], ).map((customPattern) => ({ spokenForm: customPattern, - description: reference.private - ? `${cheatsheet} (PRIVATE)` - : cheatsheet, + description: + reference.visibility === "private" + ? `${cheatsheet} (PRIVATE)` + : cheatsheet, })), ), ), @@ -243,7 +242,7 @@ function getEnabledDefaultSpokenForms( return [value]; } - return value.isDisabledByDefault ? [] : value.defaultSpokenForms; + return value.visibility == null ? value.defaultSpokenForms : []; } function getReferenceSpokenForms( diff --git a/packages/lib-common/src/index.ts b/packages/lib-common/src/index.ts index 9fa0e096d6..a003910b68 100644 --- a/packages/lib-common/src/index.ts +++ b/packages/lib-common/src/index.ts @@ -69,9 +69,9 @@ export * from "./types/command/legacy/PartialTargetDescriptorV4.types"; export * from "./types/command/legacy/PartialTargetDescriptorV5.types"; export * from "./types/command/legacy/targetDescriptorV2.types"; export * from "./types/command/PartialTargetDescriptor.types"; -export * from "./types/DefaultSpokenFormMap"; export * from "./types/commandHistory"; export * from "./types/CommandServerApi"; +export * from "./types/DefaultSpokenFormMap"; export * from "./types/Edit"; export * from "./types/GeneralizedRange"; export * from "./types/HatTokenMap"; @@ -86,6 +86,7 @@ export * from "./types/ScopeProvider"; export * from "./types/Selection"; export * from "./types/SpokenForm"; export * from "./types/SpokenFormType"; +export * from "./types/SpokenFormVisibility"; export * from "./types/StringRecord"; export * from "./types/TalonSpokenForms"; export * from "./types/TestCaseFixture"; diff --git a/packages/lib-common/src/references/ReferenceEntry.ts b/packages/lib-common/src/references/ReferenceEntry.ts index 2952481738..b8a11c435b 100644 --- a/packages/lib-common/src/references/ReferenceEntry.ts +++ b/packages/lib-common/src/references/ReferenceEntry.ts @@ -1,7 +1,8 @@ +import type { SpokenFormVisibility } from "../types/SpokenFormVisibility"; + export interface SpokenFormReference { defaultSpokenForm?: string; - private?: boolean; - disabledByDefault?: boolean; + visibility?: SpokenFormVisibility; } export interface ReferenceEntry extends SpokenFormReference { diff --git a/packages/lib-common/src/references/actionReferences.ts b/packages/lib-common/src/references/actionReferences.ts index e9a572afba..8b91c4b2bb 100644 --- a/packages/lib-common/src/references/actionReferences.ts +++ b/packages/lib-common/src/references/actionReferences.ts @@ -1426,7 +1426,7 @@ Older Cursorless installations may have \`"from"\` disabled. Remove the leading name: "Show parse tree", defaultSpokenForm: "parse tree", group: { id: "private", index: 0 }, - private: true, + visibility: "private", syntaxes: [ { pattern: DEFAULT_PATTERN, @@ -1444,7 +1444,7 @@ Older Cursorless installations may have \`"from"\` disabled. Remove the leading parsed: { name: "Parsed", defaultSpokenForm: "parsed", - private: true, + visibility: "private", group: { id: "private", index: 1 }, syntaxes: [], examples: [], @@ -1453,7 +1453,7 @@ Older Cursorless installations may have \`"from"\` disabled. Remove the leading name: "Get targets", defaultSpokenForm: "get targets", group: { id: "private", index: 2 }, - private: true, + visibility: "private", syntaxes: [], examples: [], }, @@ -1461,7 +1461,7 @@ Older Cursorless installations may have \`"from"\` disabled. Remove the leading name: "Set keyboard target", defaultSpokenForm: "set keyboard target", group: { id: "private", index: 3 }, - private: true, + visibility: "private", syntaxes: [], examples: [], }, @@ -1469,7 +1469,7 @@ Older Cursorless installations may have \`"from"\` disabled. Remove the leading name: "Execute command", defaultSpokenForm: "execute command", group: { id: "private", index: 4 }, - private: true, + visibility: "private", syntaxes: [], examples: [], }, @@ -1477,7 +1477,7 @@ Older Cursorless installations may have \`"from"\` disabled. Remove the leading name: "Edit new", defaultSpokenForm: "edit new", group: { id: "private", index: 5 }, - private: true, + visibility: "private", syntaxes: [], examples: [], }, @@ -1485,7 +1485,7 @@ Older Cursorless installations may have \`"from"\` disabled. Remove the leading name: "Get text", defaultSpokenForm: "get text", group: { id: "private", index: 6 }, - private: true, + visibility: "private", syntaxes: [], examples: [], }, @@ -1493,7 +1493,7 @@ Older Cursorless installations may have \`"from"\` disabled. Remove the leading name: "Replace", defaultSpokenForm: "replace", group: { id: "private", index: 7 }, - private: true, + visibility: "private", syntaxes: [], examples: [], }, @@ -1501,7 +1501,7 @@ Older Cursorless installations may have \`"from"\` disabled. Remove the leading name: "Wrap with snippet", defaultSpokenForm: "wrap", group: { id: "private", index: 8 }, - private: true, + visibility: "private", syntaxes: [], examples: [], }, diff --git a/packages/lib-common/src/references/hatStyleReferences.ts b/packages/lib-common/src/references/hatStyleReferences.ts index 352291dd2f..d1227a7383 100644 --- a/packages/lib-common/src/references/hatStyleReferences.ts +++ b/packages/lib-common/src/references/hatStyleReferences.ts @@ -29,22 +29,22 @@ export const hatColorReferences = { userColor1: { name: "User color 1", defaultSpokenForm: "navy", - disabledByDefault: true, + visibility: "disabledByDefault", }, userColor2: { name: "User color 2", defaultSpokenForm: "apricot", - disabledByDefault: true, + visibility: "disabledByDefault", }, userColor3: { name: "User color 3", defaultSpokenForm: "user color three", - disabledByDefault: true, + visibility: "disabledByDefault", }, userColor4: { name: "User color 4", defaultSpokenForm: "user color four", - disabledByDefault: true, + visibility: "disabledByDefault", }, } as const satisfies Record; @@ -52,51 +52,51 @@ export const hatShapeReferences = { ex: { name: "Ex", defaultSpokenForm: "ex", - disabledByDefault: true, + visibility: "disabledByDefault", }, fox: { name: "Fox", defaultSpokenForm: "fox", - disabledByDefault: true, + visibility: "disabledByDefault", }, wing: { name: "Wing", defaultSpokenForm: "wing", - disabledByDefault: true, + visibility: "disabledByDefault", }, hole: { name: "Hole", defaultSpokenForm: "hole", - disabledByDefault: true, + visibility: "disabledByDefault", }, frame: { name: "Frame", defaultSpokenForm: "frame", - disabledByDefault: true, + visibility: "disabledByDefault", }, curve: { name: "Curve", defaultSpokenForm: "curve", - disabledByDefault: true, + visibility: "disabledByDefault", }, eye: { name: "Eye", defaultSpokenForm: "eye", - disabledByDefault: true, + visibility: "disabledByDefault", }, play: { name: "Play", defaultSpokenForm: "play", - disabledByDefault: true, + visibility: "disabledByDefault", }, crosshairs: { name: "Crosshairs", defaultSpokenForm: "cross", - disabledByDefault: true, + visibility: "disabledByDefault", }, bolt: { name: "Bolt", defaultSpokenForm: "bolt", - disabledByDefault: true, + visibility: "disabledByDefault", }, } as const satisfies Record; diff --git a/packages/lib-common/src/references/modifierReferences.ts b/packages/lib-common/src/references/modifierReferences.ts index 8c220a0521..2c9fac5300 100644 --- a/packages/lib-common/src/references/modifierReferences.ts +++ b/packages/lib-common/src/references/modifierReferences.ts @@ -652,28 +652,28 @@ Relative modifiers select scopes before or after the input target. Without \`"ev // Group: private preferredScope: { name: "Preferred scope", - private: true, + visibility: "private", group: { id: "private", index: 0 }, syntaxes: [], examples: [], }, modifyIfUntyped: { name: "Modify if untyped", - private: true, + visibility: "private", group: { id: "private", index: 1 }, syntaxes: [], examples: [], }, fallback: { name: "Fallback", - private: true, + visibility: "private", group: { id: "private", index: 2 }, syntaxes: [], examples: [], }, range: { name: "Range", - private: true, + visibility: "private", group: { id: "private", index: 3 }, syntaxes: [], examples: [], diff --git a/packages/lib-common/src/references/pairedDelimiterReferences.ts b/packages/lib-common/src/references/pairedDelimiterReferences.ts index 44f59605e5..db900df61d 100644 --- a/packages/lib-common/src/references/pairedDelimiterReferences.ts +++ b/packages/lib-common/src/references/pairedDelimiterReferences.ts @@ -177,8 +177,7 @@ export const pairedDelimiterReferences = { tripleDoubleQuotes: { name: "triple double quotes", defaultSpokenForm: "triple quad", - private: true, - disabledByDefault: true, + visibility: "private", index: null, isSingleLine: false, delimiters: ['"""', '"""'], @@ -195,8 +194,7 @@ export const pairedDelimiterReferences = { tripleSingleQuotes: { name: "triple single quotes", defaultSpokenForm: "triple twin", - private: true, - disabledByDefault: true, + visibility: "private", index: null, isSingleLine: false, delimiters: ["'''", "'''"], @@ -210,8 +208,7 @@ export const pairedDelimiterReferences = { tripleBacktickQuotes: { name: "triple backtick quotes", defaultSpokenForm: "triple skis", - private: true, - disabledByDefault: true, + visibility: "private", index: null, isSingleLine: false, delimiters: ["```", "```"], @@ -233,8 +230,7 @@ export const pairedDelimiterReferences = { collectionBoundary: { name: "collection boundary", defaultSpokenForm: "collection boundary", - private: true, - disabledByDefault: true, + visibility: "private", index: null, isSingleLine: false, delimiters: null, diff --git a/packages/lib-common/src/references/scopeReferences.ts b/packages/lib-common/src/references/scopeReferences.ts index 3a2aff5735..67f3232394 100644 --- a/packages/lib-common/src/references/scopeReferences.ts +++ b/packages/lib-common/src/references/scopeReferences.ts @@ -1163,7 +1163,7 @@ For a range target, the entire range becomes the search text. Without an explici name: "Section level one", group: { id: "sections", index: 1 }, defaultSpokenForm: "one section", - disabledByDefault: true, + visibility: "disabledByDefault", isLanguageSpecific: true, defaultInsertionDelimiter: "\n\n", syntaxes: [ @@ -1184,7 +1184,7 @@ For a range target, the entire range becomes the search text. Without an explici name: "Section level two", group: { id: "sections", index: 2 }, defaultSpokenForm: "two section", - disabledByDefault: true, + visibility: "disabledByDefault", isLanguageSpecific: true, defaultInsertionDelimiter: "\n\n", syntaxes: [ @@ -1205,7 +1205,7 @@ For a range target, the entire range becomes the search text. Without an explici name: "Section level three", group: { id: "sections", index: 3 }, defaultSpokenForm: "three section", - disabledByDefault: true, + visibility: "disabledByDefault", isLanguageSpecific: true, defaultInsertionDelimiter: "\n\n", syntaxes: [ @@ -1226,7 +1226,7 @@ For a range target, the entire range becomes the search text. Without an explici name: "Section level four", group: { id: "sections", index: 4 }, defaultSpokenForm: "four section", - disabledByDefault: true, + visibility: "disabledByDefault", isLanguageSpecific: true, defaultInsertionDelimiter: "\n\n", syntaxes: [ @@ -1247,7 +1247,7 @@ For a range target, the entire range becomes the search text. Without an explici name: "Section level five", group: { id: "sections", index: 5 }, defaultSpokenForm: "five section", - disabledByDefault: true, + visibility: "disabledByDefault", isLanguageSpecific: true, defaultInsertionDelimiter: "\n\n", syntaxes: [ @@ -1268,7 +1268,7 @@ For a range target, the entire range becomes the search text. Without an explici name: "Section level six", group: { id: "sections", index: 6 }, defaultSpokenForm: "six section", - disabledByDefault: true, + visibility: "disabledByDefault", isLanguageSpecific: true, defaultInsertionDelimiter: "\n\n", syntaxes: [ @@ -1312,7 +1312,7 @@ For a range target, the entire range becomes the search text. Without an explici name: "Field access", group: { id: "private", index: 0 }, defaultSpokenForm: "access", - private: true, + visibility: "private", isLanguageSpecific: true, syntaxes: [ { @@ -1332,7 +1332,7 @@ For a range target, the entire range becomes the search text. Without an explici name: "String", group: { id: "private", index: 1 }, defaultSpokenForm: "parse tree string", - private: true, + visibility: "private", isLanguageSpecific: true, syntaxes: [ { @@ -1352,7 +1352,7 @@ For a range target, the entire range becomes the search text. Without an explici name: "Text fragment", group: { id: "private", index: 2 }, defaultSpokenForm: "text fragment", - private: true, + visibility: "private", isLanguageSpecific: true, syntaxes: [ { @@ -1372,7 +1372,7 @@ For a range target, the entire range becomes the search text. Without an explici name: "Disqualify delimiter", group: { id: "private", index: 3 }, defaultSpokenForm: "disqualify delimiter", - private: true, + visibility: "private", isLanguageSpecific: true, syntaxes: [ { @@ -1392,7 +1392,7 @@ For a range target, the entire range becomes the search text. Without an explici name: "Pair delimiter", group: { id: "private", index: 4 }, defaultSpokenForm: "pair delimiter", - private: true, + visibility: "private", isLanguageSpecific: true, syntaxes: [ { @@ -1412,7 +1412,7 @@ For a range target, the entire range becomes the search text. Without an explici name: "Interior", group: { id: "private", index: 5 }, defaultSpokenForm: "interior", - private: true, + visibility: "private", isLanguageSpecific: true, syntaxes: [ { @@ -1431,7 +1431,7 @@ For a range target, the entire range becomes the search text. Without an explici surroundingPairInterior: { name: "Surrounding pair interior", group: { id: "private", index: 6 }, - private: true, + visibility: "private", isLanguageSpecific: false, syntaxes: [], examples: [], @@ -1439,7 +1439,7 @@ For a range target, the entire range becomes the search text. Without an explici customRegex: { name: "Custom regex", group: { id: "private", index: 7 }, - private: true, + visibility: "private", isLanguageSpecific: false, syntaxes: [], examples: [], diff --git a/packages/lib-common/src/references/spokenForms/defaultSpokenFormMapCore.ts b/packages/lib-common/src/references/spokenForms/defaultSpokenFormMapCore.ts index bccab97c9e..afac7289c2 100644 --- a/packages/lib-common/src/references/spokenForms/defaultSpokenFormMapCore.ts +++ b/packages/lib-common/src/references/spokenForms/defaultSpokenFormMapCore.ts @@ -64,17 +64,15 @@ function getDefaultSpokenForm( throw new Error("Reference has no default spoken form"); } - const isDisabledByDefault = reference.disabledByDefault ?? false; - const isPrivate = reference.private ?? false; + const { visibility } = reference; - if (!isDisabledByDefault && !isPrivate) { + if (visibility == null) { return defaultSpokenForm; } return { defaultSpokenForms: [defaultSpokenForm], - isDisabledByDefault, - isPrivate, + visibility, }; } diff --git a/packages/lib-common/src/references/spokenForms/spokenFormMapUtil.ts b/packages/lib-common/src/references/spokenForms/spokenFormMapUtil.ts index 3b99c26e34..dea609265d 100644 --- a/packages/lib-common/src/references/spokenForms/spokenFormMapUtil.ts +++ b/packages/lib-common/src/references/spokenForms/spokenFormMapUtil.ts @@ -4,17 +4,14 @@ import type { DefaultSpokenFormMapEntry } from "../../types/DefaultSpokenFormMap * Used to construct entities that should not be speakable by default. * * @param spokenForms The default spoken forms for this entity - * @returns A DefaultSpokenFormMapEntry with the given spoken forms, and - * {@link DefaultSpokenFormMapEntry.isDisabledByDefault|isDisabledByDefault} set - * to true + * @returns A disabled-by-default entry with the given spoken forms */ export function isDisabledByDefault( ...spokenForms: string[] ): DefaultSpokenFormMapEntry { return { defaultSpokenForms: spokenForms, - isDisabledByDefault: true, - isPrivate: false, + visibility: "disabledByDefault", }; } @@ -22,14 +19,11 @@ export function isDisabledByDefault( * Used to construct entities that are only for internal experimentation. * * @param spokenForms The default spoken forms for this entity - * @returns A DefaultSpokenFormMapEntry with the given spoken forms, and - * {@link DefaultSpokenFormMapEntry.isDisabledByDefault|isDisabledByDefault} and - * {@link DefaultSpokenFormMapEntry.isPrivate|isPrivate} set to true + * @returns A private entry with the given spoken forms */ export function isPrivate(...spokenForms: string[]): DefaultSpokenFormMapEntry { return { defaultSpokenForms: spokenForms, - isDisabledByDefault: true, - isPrivate: true, + visibility: "private", }; } diff --git a/packages/lib-common/src/types/DefaultSpokenFormMap.ts b/packages/lib-common/src/types/DefaultSpokenFormMap.ts index ddb1fc693a..935a3d7353 100644 --- a/packages/lib-common/src/types/DefaultSpokenFormMap.ts +++ b/packages/lib-common/src/types/DefaultSpokenFormMap.ts @@ -1,20 +1,9 @@ import type { SpokenFormMapKeyTypes } from "./SpokenFormType"; +import type { SpokenFormVisibility } from "./SpokenFormVisibility"; export interface DefaultSpokenFormMapEntry { defaultSpokenForms: string[]; - - /** - * If `true`, indicates that the entry may have a default spoken form, but - * it should not be enabled by default. These will show up in user csv's with - * a `-` at the beginning. - */ - isDisabledByDefault: boolean; - - /** - * If `true`, indicates that the entry is only for internal experimentation, - * and should not be exposed to users except within a targeted working group. - */ - isPrivate: boolean; + visibility?: SpokenFormVisibility; } export type DefaultSpokenFormMapDefinition = { diff --git a/packages/lib-common/src/types/SpokenFormVisibility.ts b/packages/lib-common/src/types/SpokenFormVisibility.ts new file mode 100644 index 0000000000..c4baf37f3d --- /dev/null +++ b/packages/lib-common/src/types/SpokenFormVisibility.ts @@ -0,0 +1,14 @@ +/** + * Controls whether a spoken form is exposed and enabled by default. + * + * - `"disabledByDefault"` indicates that the spoken form is available for + * customization but is not enabled by default. It appears in user CSV files + * with a `-` prefix. + * - `"private"` indicates that the spoken form is intended only for internal + * experimentation or a targeted working group. Private spoken forms are + * also disabled by default. + * + * An omitted visibility indicates a public spoken form that is enabled by + * default. + */ +export type SpokenFormVisibility = "disabledByDefault" | "private"; diff --git a/packages/lib-engine/src/generateSpokenForm/generateSpokenForm.test.ts b/packages/lib-engine/src/generateSpokenForm/generateSpokenForm.test.ts index f944fba623..a23e1d5c1e 100644 --- a/packages/lib-engine/src/generateSpokenForm/generateSpokenForm.test.ts +++ b/packages/lib-engine/src/generateSpokenForm/generateSpokenForm.test.ts @@ -18,13 +18,16 @@ import { getHatMapCommand } from "./getHatMapCommand"; */ const spokenFormMap: SpokenFormMap = mapSpokenForms( defaultSpokenFormInfoMap, - ({ defaultSpokenForms, isPrivate }) => ({ - spokenForms: isPrivate ? [] : defaultSpokenForms, - isCustom: false, - defaultSpokenForms, - requiresTalonUpdate: false, - isPrivate, - }), + ({ defaultSpokenForms, visibility }) => { + const isPrivate = visibility === "private"; + return { + spokenForms: isPrivate ? [] : defaultSpokenForms, + isCustom: false, + defaultSpokenForms, + requiresTalonUpdate: false, + isPrivate, + }; + }, ); suite("Generate spoken forms", () => { diff --git a/packages/lib-engine/src/spokenForms/CustomSpokenForms.ts b/packages/lib-engine/src/spokenForms/CustomSpokenForms.ts index 32e6fb11f3..6cb2c15197 100644 --- a/packages/lib-engine/src/spokenForms/CustomSpokenForms.ts +++ b/packages/lib-engine/src/spokenForms/CustomSpokenForms.ts @@ -159,8 +159,8 @@ function updateEntriesForType( const obj: Partial> = {}; for (const id of ids) { - const { defaultSpokenForms = [], isPrivate = false } = - defaultEntries[id] ?? {}; + const { defaultSpokenForms = [], visibility } = defaultEntries[id] ?? {}; + const isPrivate = visibility === "private"; const customSpokenForms = customEntries[id]; // No entry for the given id. This either means that the user needs to diff --git a/packages/lib-engine/src/spokenForms/defaultSpokenFormMap.ts b/packages/lib-engine/src/spokenForms/defaultSpokenFormMap.ts index 1c1620e30c..aeb3c7a229 100644 --- a/packages/lib-engine/src/spokenForms/defaultSpokenFormMap.ts +++ b/packages/lib-engine/src/spokenForms/defaultSpokenFormMap.ts @@ -17,8 +17,6 @@ export const defaultSpokenFormInfoMap: DefaultSpokenFormInfoMap = typeof value === "string" ? { defaultSpokenForms: [value], - isDisabledByDefault: false, - isPrivate: false, } : value, ); @@ -29,11 +27,14 @@ export const defaultSpokenFormInfoMap: DefaultSpokenFormInfoMap = */ export const defaultSpokenFormMap: SpokenFormMap = mapSpokenForms( defaultSpokenFormInfoMap, - ({ defaultSpokenForms, isDisabledByDefault, isPrivate }) => ({ - spokenForms: isDisabledByDefault ? [] : defaultSpokenForms, - isCustom: false, - defaultSpokenForms, - requiresTalonUpdate: false, - isPrivate, - }), + ({ defaultSpokenForms, visibility }) => { + const isPrivate = visibility === "private"; + return { + spokenForms: visibility == null ? defaultSpokenForms : [], + isCustom: false, + defaultSpokenForms, + requiresTalonUpdate: false, + isPrivate, + }; + }, ); diff --git a/packages/tool-meta-updater/src/metaUpdater.ts b/packages/tool-meta-updater/src/metaUpdater.ts index d781ab02d9..e6e7eb535c 100644 --- a/packages/tool-meta-updater/src/metaUpdater.ts +++ b/packages/tool-meta-updater/src/metaUpdater.ts @@ -138,5 +138,5 @@ export const updater = async (workspaceDir: string) => { }; function isPrivate(entry: object): boolean { - return "private" in entry && entry.private === true; + return "visibility" in entry && entry.visibility === "private"; } diff --git a/packages/tool-meta-updater/src/scopeFixtureGroups.ts b/packages/tool-meta-updater/src/scopeFixtureGroups.ts index cf8bde4419..402e7f069c 100644 --- a/packages/tool-meta-updater/src/scopeFixtureGroups.ts +++ b/packages/tool-meta-updater/src/scopeFixtureGroups.ts @@ -185,7 +185,8 @@ function groupFixtures(fixtures: FixtureMetadata[]): ScopeFixtureGroup[] { name: prettifyScopeType( getFacetInfo(fixture.languageId, fixture.facet).scopeType, ), - private: "private" in reference && reference.private === true, + private: + "visibility" in reference && reference.visibility === "private", scopeTypeType: fixture.scopeTypeType, }; scopeMap.set(fixture.scopeTypeType, scope); diff --git a/packages/tool-meta-updater/src/updatePairedDelimitersMd.ts b/packages/tool-meta-updater/src/updatePairedDelimitersMd.ts index 4242c09385..a672046ef2 100644 --- a/packages/tool-meta-updater/src/updatePairedDelimitersMd.ts +++ b/packages/tool-meta-updater/src/updatePairedDelimitersMd.ts @@ -23,12 +23,12 @@ export function updatePairedDelimitersMd( const rows = Object.values(entries) .filter( (entry): entry is PairedDelimiterReference & { index: number } => - !entry.private && entry.index != null, + entry.visibility !== "private" && entry.index != null, ) .toSorted((a, b) => a.index - b.index) .map((entry) => { const [before, after] = entry.delimiters ?? [null, null]; - const spokenForm = `${code(`"${entry.defaultSpokenForm}"`)}${entry.disabledByDefault ? ` (${DISABLED_BY_DEFAULT})` : ""}`; + const spokenForm = `${code(`"${entry.defaultSpokenForm}"`)}${entry.visibility === "disabledByDefault" ? ` (${DISABLED_BY_DEFAULT})` : ""}`; return [ spokenForm, diff --git a/packages/tool-meta-updater/src/updateReferenceMdx.ts b/packages/tool-meta-updater/src/updateReferenceMdx.ts index 5ebd0bc11f..74aeeba485 100644 --- a/packages/tool-meta-updater/src/updateReferenceMdx.ts +++ b/packages/tool-meta-updater/src/updateReferenceMdx.ts @@ -63,7 +63,7 @@ export function updateReferenceMdx( ); } - if (entry.disabledByDefault) { + if (entry.visibility === "disabledByDefault") { spokenFormLines.push(DISABLED_BY_DEFAULT); } diff --git a/packages/tool-meta-updater/src/updateReferenceReadmeMd.ts b/packages/tool-meta-updater/src/updateReferenceReadmeMd.ts index 2062977c15..81b71846e0 100644 --- a/packages/tool-meta-updater/src/updateReferenceReadmeMd.ts +++ b/packages/tool-meta-updater/src/updateReferenceReadmeMd.ts @@ -31,9 +31,10 @@ export function updateReferenceReadmeMd( for (const [rawId, entry] of groupEntries) { const id = cleanId(rawId); - const disabledByDefault = entry.disabledByDefault - ? ` (${DISABLED_BY_DEFAULT})` - : ""; + const disabledByDefault = + entry.visibility === "disabledByDefault" + ? ` (${DISABLED_BY_DEFAULT})` + : ""; for (const syntax of entry.syntaxes) { const pattern = injectSpokenForm( syntax.pattern, From 21d17dcb52b00ea2d7759b46ee64986cf3f6f2f6 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Mon, 31 Aug 2026 21:31:22 +0200 Subject: [PATCH 20/28] Made cheat sheet replacement more robust --- .../src/test/getDefaultCheatsheetInfo.spec.ts | 17 +++++++ .../src/cheatsheet/getCheatsheetInfo.ts | 48 ++++++++++++++----- 2 files changed, 53 insertions(+), 12 deletions(-) diff --git a/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts b/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts index 8c30c7a43e..c47eabee6d 100644 --- a/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts +++ b/packages/lib-cheatsheet/src/test/getDefaultCheatsheetInfo.spec.ts @@ -206,6 +206,23 @@ describe("getDefaultCheatsheetInfo", () => { ).toEqual(["call ", "call onto "]); }); + test("does not apply replacements to customized spoken forms", () => { + const customCheatsheetInfo = getCheatsheetInfo([ + { type: "action", id: "swapTargets", spokenForms: ["swap"] }, + { + type: "connective", + id: "swapConnective", + spokenForms: ["next"], + }, + { type: "modifierExtra", id: "next", spokenForms: ["afterward"] }, + ]); + + expect( + getItem("actions", "swapTargets", customCheatsheetInfo).variations[0] + ?.spokenForm, + ).toBe("swap next "); + }); + test("includes only the first spoken form for custom actions", () => { const customCheatsheetInfo = getCheatsheetInfo([ { diff --git a/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts b/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts index 253a7e33c2..87049b715c 100644 --- a/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts +++ b/packages/lib-common/src/cheatsheet/getCheatsheetInfo.ts @@ -389,30 +389,54 @@ function applyReplacements( pattern: string, replacements: readonly SyntaxReplacement[], ): string[] { - let patterns = [pattern]; + let protectedPattern: (string | number)[] = [pattern]; + + for (const [ + replacementIndex, + [defaultSpokenForm], + ] of replacements.entries()) { + protectedPattern = protectedPattern.flatMap((part) => + typeof part === "string" + ? intersperse( + part.split(termRegex(defaultSpokenForm)), + replacementIndex, + ) + : [part], + ); + } - for (const [defaultSpokenForm, spokenForms] of replacements) { - const replacedPatterns: string[] = []; - for (const currentPattern of patterns) { - if (!termRegex(defaultSpokenForm).test(currentPattern)) { - replacedPatterns.push(currentPattern); - continue; - } + let patterns = [protectedPattern]; + + for (const [replacementIndex, [, spokenForms]] of replacements.entries()) { + if (!protectedPattern.includes(replacementIndex)) { + continue; + } + const replacedPatterns: (string | number)[][] = []; + for (const currentPattern of patterns) { for (const spokenForm of spokenForms) { replacedPatterns.push( - replaceTerm(currentPattern, defaultSpokenForm, spokenForm), + currentPattern.map((part) => + part === replacementIndex ? spokenForm : part, + ), ); } } patterns = replacedPatterns; } - return patterns; + return patterns.map((parts) => parts.join("")); } -function replaceTerm(pattern: string, from: string, to: string): string { - return pattern.replaceAll(termRegex(from), to); +function intersperse(items: readonly T[], separator: U): (T | U)[] { + const result: (T | U)[] = []; + for (const [index, item] of items.entries()) { + result.push(item); + if (index < items.length - 1) { + result.push(separator); + } + } + return result; } function termRegex(term: string): RegExp { From e5273a087a3687ee64e6ebb6ce701bd607cd34b7 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Mon, 31 Aug 2026 21:44:56 +0200 Subject: [PATCH 21/28] Tutorials should properly show error about update needed --- packages/lib-tutorial/src/setupStep.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lib-tutorial/src/setupStep.ts b/packages/lib-tutorial/src/setupStep.ts index 0379d84607..61c4d8b319 100644 --- a/packages/lib-tutorial/src/setupStep.ts +++ b/packages/lib-tutorial/src/setupStep.ts @@ -41,7 +41,7 @@ export async function setupStep( state: TutorialState, currentTutorial: TutorialContent | undefined, ): Promise { - if (state.type !== "doingTutorial") { + if (state.type !== "doingTutorial" || state.hasErrors) { return { editor: undefined, highlightRanges: [] }; } From 026ab5190cd30239c5156a04941ba4b52a83bd5f Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Mon, 31 Aug 2026 21:57:18 +0200 Subject: [PATCH 22/28] Added version to state file --- cursorless-talon/src/spoken_forms_output.py | 2 +- .../lib-common/src/types/TalonSpokenForms.ts | 7 +- .../DisabledTalonSpokenForms.ts | 7 +- .../CustomSpokenFormGeneratorImpl.test.ts | 120 ++++++++++++++---- .../src/spokenForms/CustomSpokenForms.ts | 22 +++- packages/lib-node-common/src/Cheatsheet.ts | 4 +- .../src/FileSystemTalonSpokenForms.ts | 18 +-- 7 files changed, 134 insertions(+), 46 deletions(-) diff --git a/cursorless-talon/src/spoken_forms_output.py b/cursorless-talon/src/spoken_forms_output.py index 6961c98ec2..283825fd29 100644 --- a/cursorless-talon/src/spoken_forms_output.py +++ b/cursorless-talon/src/spoken_forms_output.py @@ -5,7 +5,7 @@ from talon import app SPOKEN_FORMS_OUTPUT_PATH = Path.home() / ".cursorless" / "state.json" -STATE_JSON_VERSION_NUMBER = 0 +STATE_JSON_VERSION_NUMBER = 1 class SpokenFormOutputEntry(TypedDict): diff --git a/packages/lib-common/src/types/TalonSpokenForms.ts b/packages/lib-common/src/types/TalonSpokenForms.ts index 0331a9121f..477311cf38 100644 --- a/packages/lib-common/src/types/TalonSpokenForms.ts +++ b/packages/lib-common/src/types/TalonSpokenForms.ts @@ -6,7 +6,7 @@ import type { SpokenFormMapKeyTypes, SpokenFormType } from "./SpokenFormType"; * the user's custom spoken forms to the Cursorless engine. */ export interface TalonSpokenForms { - getSpokenFormEntries(): Promise; + getSpokenForms(): Promise; onDidChange: Notifier["registerListener"]; } @@ -45,6 +45,11 @@ export type SpokenFormEntry = { [K in SpokenFormType]: SpokenFormEntryForType; }[SupportedEntryType]; +export interface TalonSpokenFormsPayload { + version: number; + spokenForms: SpokenFormEntry[]; +} + export class NeedsInitialTalonUpdateError extends Error { constructor(message: string) { super(message); diff --git a/packages/lib-engine/src/disabledComponents/DisabledTalonSpokenForms.ts b/packages/lib-engine/src/disabledComponents/DisabledTalonSpokenForms.ts index f9fe109464..a4e4cf299b 100644 --- a/packages/lib-engine/src/disabledComponents/DisabledTalonSpokenForms.ts +++ b/packages/lib-engine/src/disabledComponents/DisabledTalonSpokenForms.ts @@ -1,8 +1,11 @@ -import type { SpokenFormEntry, TalonSpokenForms } from "@cursorless/lib-common"; +import type { + TalonSpokenForms, + TalonSpokenFormsPayload, +} from "@cursorless/lib-common"; import { DisabledCustomSpokenFormsError } from "@cursorless/lib-common"; export class DisabledTalonSpokenForms implements TalonSpokenForms { - getSpokenFormEntries(): Promise { + getSpokenForms(): Promise { throw new DisabledCustomSpokenFormsError(); } diff --git a/packages/lib-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.test.ts b/packages/lib-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.test.ts index 498852c801..50fad298d2 100644 --- a/packages/lib-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.test.ts +++ b/packages/lib-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.test.ts @@ -4,32 +4,35 @@ import { CustomSpokenFormGeneratorImpl } from "./CustomSpokenFormGeneratorImpl"; suite("CustomSpokenFormGeneratorImpl", () => { test( - "basic", + "uses custom spoken forms and defaults for types omitted by Talon", asyncSafety(async () => { const generator = new CustomSpokenFormGeneratorImpl(new FakeIDE(), { - getSpokenFormEntries() { - return Promise.resolve([ - { - type: "complexScopeTypeType", - id: "glyph", - spokenForms: ["foo"], - }, - { - type: "action", - id: "setSelection", - spokenForms: ["bar"], - }, - { - type: "grapheme", - id: "a", - spokenForms: ["alabaster"], - }, - { - type: "specialMark", - id: "currentSelection", - spokenForms: ["this"], - }, - ]); + getSpokenForms() { + return Promise.resolve({ + version: 0, + spokenForms: [ + { + type: "complexScopeTypeType", + id: "glyph", + spokenForms: ["foo"], + }, + { + type: "action", + id: "setSelection", + spokenForms: ["bar"], + }, + { + type: "grapheme", + id: "a", + spokenForms: ["alabaster"], + }, + { + type: "specialMark", + id: "currentSelection", + spokenForms: ["this"], + }, + ], + }); }, onDidChange: () => ({ dispose() { @@ -69,6 +72,75 @@ suite("CustomSpokenFormGeneratorImpl", () => { spokenForms: ["bar this"], }, ); + + assert.deepEqual( + generator.commandToSpokenForm({ + version: LATEST_VERSION, + action: { + name: "setSelection", + target: { + type: "primitive", + mark: { + type: "decoratedSymbol", + symbolColor: "blue", + character: "a", + }, + }, + }, + usePrePhraseSnapshot: false, + }), + { + type: "success", + spokenForms: ["bar blue alabaster"], + }, + ); + }), + ); + + test( + "requires a Talon update when version 1 omits an entry type", + asyncSafety(async () => { + const generator = new CustomSpokenFormGeneratorImpl(new FakeIDE(), { + getSpokenForms() { + return Promise.resolve({ + version: 1, + spokenForms: [ + { + type: "action", + id: "setSelection", + spokenForms: ["take"], + }, + ], + }); + }, + onDidChange: () => ({ + dispose() { + // no-op + }, + }), + }); + + await generator.customSpokenFormsInitialized; + + const spokenForm = generator.commandToSpokenForm({ + version: LATEST_VERSION, + action: { + name: "setSelection", + target: { + type: "primitive", + mark: { + type: "decoratedSymbol", + symbolColor: "blue", + character: "a", + }, + }, + }, + usePrePhraseSnapshot: false, + }); + + assert.equal(spokenForm.type, "error"); + assert.equal(spokenForm.requiresTalonUpdate, true); + assert.match(spokenForm.reason, /hat color with id blue/u); }), ); }); diff --git a/packages/lib-engine/src/spokenForms/CustomSpokenForms.ts b/packages/lib-engine/src/spokenForms/CustomSpokenForms.ts index 6cb2c15197..a4b68deaf7 100644 --- a/packages/lib-engine/src/spokenForms/CustomSpokenForms.ts +++ b/packages/lib-engine/src/spokenForms/CustomSpokenForms.ts @@ -78,13 +78,16 @@ export class CustomSpokenForms { private async updateSpokenFormMaps(): Promise { let allCustomEntries: SpokenFormEntry[]; + let spokenFormsVersion: number; // We successfully loaded spoken forms, so any previous "needs update" // state is no longer relevant. this.needsInitialTalonUpdate_ = false; try { - allCustomEntries = await this.talonSpokenForms.getSpokenFormEntries(); + const payload = await this.talonSpokenForms.getSpokenForms(); + allCustomEntries = payload.spokenForms; + spokenFormsVersion = payload.version; if (allCustomEntries.length === 0) { throw new Error("Custom spoken forms list empty"); } @@ -110,15 +113,26 @@ export class CustomSpokenForms { return; } + this.spokenFormMap_ = { ...defaultSpokenFormMap }; + for (const entryType of SUPPORTED_ENTRY_TYPES) { + const entriesForType = allCustomEntries.filter( + (entry) => entry.type === entryType, + ); + + // Older Talon versions don't provide every entry type. In that case, + // retain the defaults for the entire type instead of treating each entry + // as a newly added spoken form that requires a Talon update. + if (entriesForType.length === 0 && spokenFormsVersion === 0) { + continue; + } + updateEntriesForType( this.spokenFormMap_, entryType, defaultSpokenFormInfoMap[entryType], Object.fromEntries( - allCustomEntries - .filter((entry) => entry.type === entryType) - .map(({ id, spokenForms }) => [id, spokenForms]), + entriesForType.map(({ id, spokenForms }) => [id, spokenForms]), ), ); } diff --git a/packages/lib-node-common/src/Cheatsheet.ts b/packages/lib-node-common/src/Cheatsheet.ts index 4e3c67714e..99865985d7 100644 --- a/packages/lib-node-common/src/Cheatsheet.ts +++ b/packages/lib-node-common/src/Cheatsheet.ts @@ -68,8 +68,8 @@ async function getCheatsheetInfoForCommand( } if (version === 1) { - const spokenFormEntries = await talonSpokenForms.getSpokenFormEntries(); - return getCheatsheetInfo(spokenFormEntries); + const { spokenForms } = await talonSpokenForms.getSpokenForms(); + return getCheatsheetInfo(spokenForms); } throw new Error(`Unsupported cheatsheet command version: ${version}`); diff --git a/packages/lib-node-common/src/FileSystemTalonSpokenForms.ts b/packages/lib-node-common/src/FileSystemTalonSpokenForms.ts index 471374d40a..f3ebce6fc8 100644 --- a/packages/lib-node-common/src/FileSystemTalonSpokenForms.ts +++ b/packages/lib-node-common/src/FileSystemTalonSpokenForms.ts @@ -4,18 +4,13 @@ import type { Disposable, FileSystem, Listener, - SpokenFormEntry, TalonSpokenForms, + TalonSpokenFormsPayload, } from "@cursorless/lib-common"; import { NeedsInitialTalonUpdateError, Notifier } from "@cursorless/lib-common"; import { isEnoentError } from "./isError"; -interface TalonSpokenFormsPayload { - version: number; - spokenForms: SpokenFormEntry[]; -} - -const LATEST_SPOKEN_FORMS_JSON_VERSION = 0; +const LATEST_SPOKEN_FORMS_JSON_VERSION = 1; export class FileSystemTalonSpokenForms implements TalonSpokenForms { private disposable: Disposable; @@ -37,7 +32,7 @@ export class FileSystemTalonSpokenForms implements TalonSpokenForms { return this.notifier.registerListener(listener); } - async getSpokenFormEntries(): Promise { + async getSpokenForms(): Promise { let payload: TalonSpokenFormsPayload; try { payload = JSON.parse( @@ -53,14 +48,13 @@ export class FileSystemTalonSpokenForms implements TalonSpokenForms { throw error; } - if (payload.version !== LATEST_SPOKEN_FORMS_JSON_VERSION) { - // In the future, we'll need to handle migrations. Not sure exactly how yet. + if (payload.version > LATEST_SPOKEN_FORMS_JSON_VERSION) { throw new Error( - `Invalid spoken forms version. Expected ${LATEST_SPOKEN_FORMS_JSON_VERSION} but got ${payload.version}`, + `Unsupported spoken forms version ${payload.version}. Supported versions: 0-${LATEST_SPOKEN_FORMS_JSON_VERSION}`, ); } - return payload.spokenForms; + return payload; } dispose() { From 52b663679aad1636966c31e7693ad76d3c7412bf Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Mon, 31 Aug 2026 22:01:35 +0200 Subject: [PATCH 23/28] Properly unsubscribe to decorated mark listener events --- cursorless-talon/src/marks/decorated_mark.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cursorless-talon/src/marks/decorated_mark.py b/cursorless-talon/src/marks/decorated_mark.py index 043807ba1c..f8abb3abf1 100644 --- a/cursorless-talon/src/marks/decorated_mark.py +++ b/cursorless-talon/src/marks/decorated_mark.py @@ -188,6 +188,11 @@ def on_watch(path, flags): fs.watch(vscode_settings_path, on_watch) def unsubscribe(): + global fast_reload_job, slow_reload_job + cron.cancel(fast_reload_job) + cron.cancel(slow_reload_job) + fast_reload_job = None + slow_reload_job = None if vscode_settings_path is not None: fs.unwatch(vscode_settings_path, on_watch) if unsubscribe_hat_styles is not None: From 59f7f3be69d01dfc458ba6c8f78c64d6e5ece2ba Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Mon, 31 Aug 2026 22:03:15 +0200 Subject: [PATCH 24/28] Fall back when state file cannot be read for the cheatsheet --- packages/lib-node-common/src/Cheatsheet.ts | 25 +++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/lib-node-common/src/Cheatsheet.ts b/packages/lib-node-common/src/Cheatsheet.ts index 99865985d7..b31b6124f7 100644 --- a/packages/lib-node-common/src/Cheatsheet.ts +++ b/packages/lib-node-common/src/Cheatsheet.ts @@ -1,8 +1,17 @@ import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { parse } from "node-html-parser"; -import { getCheatsheetInfo, showWarning } from "@cursorless/lib-common"; -import type { CheatsheetInfo, IDE } from "@cursorless/lib-common"; +import { + getCheatsheetInfo, + getDefaultCheatsheetInfo, + getErrorMessage, + showWarning, +} from "@cursorless/lib-common"; +import type { + CheatsheetInfo, + IDE, + SpokenFormEntry, +} from "@cursorless/lib-common"; import type { FileSystemTalonSpokenForms } from "./FileSystemTalonSpokenForms"; interface CheatSheetCommandArgV0 { @@ -68,7 +77,17 @@ async function getCheatsheetInfoForCommand( } if (version === 1) { - const { spokenForms } = await talonSpokenForms.getSpokenForms(); + let spokenForms: SpokenFormEntry[]; + try { + ({ spokenForms } = await talonSpokenForms.getSpokenForms()); + } catch (error) { + void showWarning( + ide.messages, + "cheatsheetSpokenFormsFallback", + `Unable to load custom spoken forms: ${getErrorMessage(error)}. Using default spoken forms.`, + ); + return getDefaultCheatsheetInfo(); + } return getCheatsheetInfo(spokenForms); } From 551d06c2a1c93523e4d69f07c043cb92e6f1c074 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Mon, 31 Aug 2026 22:04:57 +0200 Subject: [PATCH 25/28] Small cleanup --- packages/lib-node-common/src/Cheatsheet.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/lib-node-common/src/Cheatsheet.ts b/packages/lib-node-common/src/Cheatsheet.ts index b31b6124f7..41de9ce0ab 100644 --- a/packages/lib-node-common/src/Cheatsheet.ts +++ b/packages/lib-node-common/src/Cheatsheet.ts @@ -77,9 +77,9 @@ async function getCheatsheetInfoForCommand( } if (version === 1) { - let spokenForms: SpokenFormEntry[]; try { - ({ spokenForms } = await talonSpokenForms.getSpokenForms()); + const { spokenForms } = await talonSpokenForms.getSpokenForms(); + return getCheatsheetInfo(spokenForms); } catch (error) { void showWarning( ide.messages, @@ -88,7 +88,6 @@ async function getCheatsheetInfoForCommand( ); return getDefaultCheatsheetInfo(); } - return getCheatsheetInfo(spokenForms); } throw new Error(`Unsupported cheatsheet command version: ${version}`); From 8738e75bb6fbf67ea034df8aef7e42afae80a520 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Mon, 31 Aug 2026 22:05:57 +0200 Subject: [PATCH 26/28] Remove unused import --- packages/lib-node-common/src/Cheatsheet.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/lib-node-common/src/Cheatsheet.ts b/packages/lib-node-common/src/Cheatsheet.ts index 41de9ce0ab..9b7295b71d 100644 --- a/packages/lib-node-common/src/Cheatsheet.ts +++ b/packages/lib-node-common/src/Cheatsheet.ts @@ -1,17 +1,13 @@ import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { parse } from "node-html-parser"; +import type { CheatsheetInfo, IDE } from "@cursorless/lib-common"; import { getCheatsheetInfo, getDefaultCheatsheetInfo, getErrorMessage, showWarning, } from "@cursorless/lib-common"; -import type { - CheatsheetInfo, - IDE, - SpokenFormEntry, -} from "@cursorless/lib-common"; import type { FileSystemTalonSpokenForms } from "./FileSystemTalonSpokenForms"; interface CheatSheetCommandArgV0 { From ceb29a6411884d7514ae89a35d4e1c99622f4652 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Mon, 31 Aug 2026 22:18:21 +0200 Subject: [PATCH 27/28] Added link to online cheat sheet --- packages/app-web-docs/src/docs/user/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app-web-docs/src/docs/user/README.md b/packages/app-web-docs/src/docs/user/README.md index 2816ad1059..fda68ebe80 100644 --- a/packages/app-web-docs/src/docs/user/README.md +++ b/packages/app-web-docs/src/docs/user/README.md @@ -8,7 +8,7 @@ Welcome to Cursorless! If you're new, start with the [tutorial videos](https://w This guide assumes you've already [installed Cursorless](./installation.md). -Once you understand the concepts, you can pull up a cheatsheet by saying `"cursorless reference"` or `"cursorless cheatsheet"` with VSCode focused. You can return to these docs by saying `"cursorless docs"` or `"cursorless help"` within VSCode. +Once you understand the concepts, you can browse the [online cheat sheet](https://www.cursorless.org/cheatsheet), or pull up a local cheat sheet with your custom spoken forms by saying `"cursorless cheat sheet"` or `"cursorless reference"` with VSCode focused. You can return to these docs by saying `"cursorless docs"` or `"cursorless help"` within VSCode. To change any spoken forms, see [Customization](./customization.md). From d2170de5b6b8ad47d8f7b3119b41c722e38e8ed8 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Mon, 31 Aug 2026 22:31:13 +0200 Subject: [PATCH 28/28] write cheat sheet as one word --- packages/app-web-docs/src/docs/user/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app-web-docs/src/docs/user/README.md b/packages/app-web-docs/src/docs/user/README.md index fda68ebe80..99dbb49563 100644 --- a/packages/app-web-docs/src/docs/user/README.md +++ b/packages/app-web-docs/src/docs/user/README.md @@ -8,7 +8,7 @@ Welcome to Cursorless! If you're new, start with the [tutorial videos](https://w This guide assumes you've already [installed Cursorless](./installation.md). -Once you understand the concepts, you can browse the [online cheat sheet](https://www.cursorless.org/cheatsheet), or pull up a local cheat sheet with your custom spoken forms by saying `"cursorless cheat sheet"` or `"cursorless reference"` with VSCode focused. You can return to these docs by saying `"cursorless docs"` or `"cursorless help"` within VSCode. +Once you understand the concepts, you can browse the [online cheatsheet](https://www.cursorless.org/cheatsheet), or pull up a local cheatsheet with your custom spoken forms by saying `"cursorless cheatsheet"` or `"cursorless reference"` with VSCode focused. You can return to these docs by saying `"cursorless docs"` or `"cursorless help"` within VSCode. To change any spoken forms, see [Customization](./customization.md).