diff --git a/cursorless-talon/src/apps/cursorless_vscode.py b/cursorless-talon/src/apps/cursorless_vscode.py
index 7fda2ac7973..01451bb5e5a 100644
--- a/cursorless-talon/src/apps/cursorless_vscode.py
+++ b/cursorless-talon/src/apps/cursorless_vscode.py
@@ -32,3 +32,9 @@ def private_cursorless_show_settings_in_ide():
)
actions.sleep("250ms")
actions.insert("cursorless")
+
+ def private_cursorless_show_sidebar():
+ """Show Cursorless sidebar"""
+ actions.user.private_cursorless_run_rpc_command_and_wait(
+ "workbench.view.extension.cursorless"
+ )
diff --git a/cursorless-talon/src/cursorless.py b/cursorless-talon/src/cursorless.py
index 86147fb1ebe..d64791219d5 100644
--- a/cursorless-talon/src/cursorless.py
+++ b/cursorless-talon/src/cursorless.py
@@ -12,3 +12,6 @@
class Actions:
def private_cursorless_show_settings_in_ide():
"""Show Cursorless-specific settings in ide"""
+
+ def private_cursorless_show_sidebar():
+ """Show Cursorless-specific settings in ide"""
diff --git a/cursorless-talon/src/cursorless.talon b/cursorless-talon/src/cursorless.talon
index 9b784f684c0..d2fa383a64a 100644
--- a/cursorless-talon/src/cursorless.talon
+++ b/cursorless-talon/src/cursorless.talon
@@ -40,3 +40,6 @@ tag: user.cursorless
{user.cursorless_homophone} settings:
user.private_cursorless_show_settings_in_ide()
+
+bar {user.cursorless_homophone}:
+ user.private_cursorless_show_sidebar()
diff --git a/images/icon.svg b/images/icon.svg
new file mode 100644
index 00000000000..e7fb1c351db
--- /dev/null
+++ b/images/icon.svg
@@ -0,0 +1,6 @@
+
diff --git a/packages/common/src/cursorlessCommandIds.ts b/packages/common/src/cursorlessCommandIds.ts
index f1c69de9f55..fb9b0fc7dec 100644
--- a/packages/common/src/cursorlessCommandIds.ts
+++ b/packages/common/src/cursorlessCommandIds.ts
@@ -69,6 +69,12 @@ export const cursorlessCommandDescriptions: Record<
"Resume test case recording",
),
["cursorless.showDocumentation"]: new VisibleCommand("Show documentation"),
+ ["cursorless.showScopeVisualizer"]: new VisibleCommand(
+ "Show the scope visualizer",
+ ),
+ ["cursorless.hideScopeVisualizer"]: new VisibleCommand(
+ "Hide the scope visualizer",
+ ),
["cursorless.command"]: new HiddenCommand("The core cursorless command"),
["cursorless.showQuickPick"]: new HiddenCommand(
@@ -110,10 +116,4 @@ export const cursorlessCommandDescriptions: Record<
["cursorless.keyboard.modal.modeToggle"]: new HiddenCommand(
"Toggle the cursorless modal mode",
),
- ["cursorless.showScopeVisualizer"]: new HiddenCommand(
- "Show the scope visualizer",
- ),
- ["cursorless.hideScopeVisualizer"]: new HiddenCommand(
- "Hide the scope visualizer",
- ),
};
diff --git a/packages/cursorless-vscode/package.json b/packages/cursorless-vscode/package.json
index 1f09ca8c2ec..711770b31d5 100644
--- a/packages/cursorless-vscode/package.json
+++ b/packages/cursorless-vscode/package.json
@@ -46,6 +46,7 @@
],
"activationEvents": [
"onLanguage",
+ "onView:cursorless.scopes",
"onCommand:cursorless.command",
"onCommand:cursorless.internal.updateCheatsheetDefaults",
"onCommand:cursorless.keyboard.escape",
@@ -77,6 +78,14 @@
}
},
"contributes": {
+ "views": {
+ "cursorless": [
+ {
+ "id": "cursorless.scopes",
+ "name": "Scopes"
+ }
+ ]
+ },
"commands": [
{
"command": "cursorless.toggleDecorations",
@@ -106,6 +115,14 @@
"command": "cursorless.showDocumentation",
"title": "Cursorless: Show documentation"
},
+ {
+ "command": "cursorless.showScopeVisualizer",
+ "title": "Cursorless: Show the scope visualizer"
+ },
+ {
+ "command": "cursorless.hideScopeVisualizer",
+ "title": "Cursorless: Hide the scope visualizer"
+ },
{
"command": "cursorless.command",
"title": "Cursorless: The core cursorless command",
@@ -175,16 +192,6 @@
"command": "cursorless.keyboard.modal.modeToggle",
"title": "Cursorless: Toggle the cursorless modal mode",
"enablement": "false"
- },
- {
- "command": "cursorless.showScopeVisualizer",
- "title": "Cursorless: Show the scope visualizer",
- "enablement": "false"
- },
- {
- "command": "cursorless.hideScopeVisualizer",
- "title": "Cursorless: Hide the scope visualizer",
- "enablement": "false"
}
],
"colors": [
@@ -1032,6 +1039,15 @@
"fontCharacter": "\\E900"
}
}
+ },
+ "viewsContainers": {
+ "activitybar": [
+ {
+ "id": "cursorless",
+ "title": "Cursorless",
+ "icon": "images/icon.svg"
+ }
+ ]
}
},
"sponsor": {
diff --git a/packages/cursorless-vscode/src/ScopeTreeProvider.ts b/packages/cursorless-vscode/src/ScopeTreeProvider.ts
new file mode 100644
index 00000000000..def69ab0d33
--- /dev/null
+++ b/packages/cursorless-vscode/src/ScopeTreeProvider.ts
@@ -0,0 +1,311 @@
+import {
+ CursorlessCommandId,
+ Disposer,
+ ScopeProvider,
+ ScopeSupport,
+ ScopeSupportLevels,
+ ScopeTypeInfo,
+} from "@cursorless/common";
+import { CustomSpokenFormGenerator } from "@cursorless/cursorless-engine";
+import {
+ CURSORLESS_SCOPE_TREE_VIEW_ID,
+ VscodeApi,
+} from "@cursorless/vscode-common";
+import { isEqual } from "lodash";
+import type {
+ Event,
+ ExtensionContext,
+ TreeDataProvider,
+ TreeItemLabel,
+ TreeView,
+ TreeViewVisibilityChangeEvent,
+} from "vscode";
+import {
+ EventEmitter,
+ MarkdownString,
+ ThemeIcon,
+ TreeItem,
+ TreeItemCollapsibleState,
+} from "vscode";
+import { URI } from "vscode-uri";
+import {
+ ScopeVisualizer,
+ VisualizationType,
+} from "./ScopeVisualizerCommandApi";
+
+export const DONT_SHOW_TALON_UPDATE_MESSAGE_KEY = "dontShowUpdateTalonMessage";
+
+export class ScopeTreeProvider implements TreeDataProvider {
+ private visibleDisposable: Disposer | undefined;
+ private treeView: TreeView;
+ private supportLevels: ScopeSupportLevels = [];
+ private shownUpdateTalonMessage = false;
+
+ private _onDidChangeTreeData: EventEmitter<
+ MyTreeItem | undefined | null | void
+ > = new EventEmitter();
+ readonly onDidChangeTreeData: Event =
+ this._onDidChangeTreeData.event;
+
+ constructor(
+ private vscodeApi: VscodeApi,
+ private context: ExtensionContext,
+ private scopeProvider: ScopeProvider,
+ private scopeVisualizer: ScopeVisualizer,
+ private customSpokenFormGenerator: CustomSpokenFormGenerator,
+ private hasCommandServer: boolean,
+ ) {
+ this.treeView = vscodeApi.window.createTreeView(
+ CURSORLESS_SCOPE_TREE_VIEW_ID,
+ {
+ treeDataProvider: this,
+ },
+ );
+
+ this.context.subscriptions.push(
+ this.treeView,
+ this.treeView.onDidChangeVisibility(this.onDidChangeVisible, this),
+ this,
+ );
+ }
+
+ static create(
+ vscodeApi: VscodeApi,
+ context: ExtensionContext,
+ scopeProvider: ScopeProvider,
+ scopeVisualizer: ScopeVisualizer,
+ customSpokenFormGenerator: CustomSpokenFormGenerator,
+ hasCommandServer: boolean,
+ ): ScopeTreeProvider {
+ const treeProvider = new ScopeTreeProvider(
+ vscodeApi,
+ context,
+ scopeProvider,
+ scopeVisualizer,
+ customSpokenFormGenerator,
+ hasCommandServer,
+ );
+ treeProvider.init();
+ return treeProvider;
+ }
+
+ init() {
+ if (this.treeView.visible) {
+ this.registerScopeSupportListener();
+ }
+ }
+
+ onDidChangeVisible(e: TreeViewVisibilityChangeEvent) {
+ if (e.visible) {
+ if (this.visibleDisposable != null) {
+ return;
+ }
+
+ this.registerScopeSupportListener();
+ } else {
+ if (this.visibleDisposable == null) {
+ return;
+ }
+
+ this.visibleDisposable.dispose();
+ this.visibleDisposable = undefined;
+ }
+ }
+
+ private registerScopeSupportListener() {
+ this.visibleDisposable = new Disposer();
+ this.visibleDisposable.push(
+ this.scopeProvider.onDidChangeScopeSupport((supportLevels) => {
+ this.supportLevels = supportLevels;
+ this._onDidChangeTreeData.fire();
+ }),
+ this.scopeVisualizer.onDidChangeScopeType(() => {
+ this._onDidChangeTreeData.fire();
+ }),
+ );
+ }
+
+ getTreeItem(element: MyTreeItem): MyTreeItem {
+ return element;
+ }
+
+ getChildren(element?: MyTreeItem): MyTreeItem[] {
+ if (element == null) {
+ this.possiblyShowUpdateTalonMessage();
+ return getSupportCategories();
+ }
+
+ if (element instanceof SupportCategoryTreeItem) {
+ return this.getScopeTypesWithSupport(element.scopeSupport);
+ }
+
+ throw new Error("Unexpected element");
+ }
+
+ private async possiblyShowUpdateTalonMessage() {
+ if (
+ !this.customSpokenFormGenerator.needsInitialTalonUpdate ||
+ this.shownUpdateTalonMessage ||
+ !this.hasCommandServer ||
+ (await this.context.globalState.get(DONT_SHOW_TALON_UPDATE_MESSAGE_KEY))
+ ) {
+ return;
+ }
+
+ this.shownUpdateTalonMessage = true;
+
+ const HOW_BUTTON_TEXT = "How?";
+ const DONT_SHOW_AGAIN_BUTTON_TEXT = "Don't show again";
+ const result = await this.vscodeApi.window.showInformationMessage(
+ "In order to see your custom spoken forms in the sidebar, you'll need to update your Cursorless Talon files.",
+ HOW_BUTTON_TEXT,
+ DONT_SHOW_AGAIN_BUTTON_TEXT,
+ );
+
+ if (result === HOW_BUTTON_TEXT) {
+ await this.vscodeApi.env.openExternal(
+ URI.parse(
+ "https://www.cursorless.org/docs/user/updating/#updating-the-talon-side",
+ ),
+ );
+ } else if (result === DONT_SHOW_AGAIN_BUTTON_TEXT) {
+ await this.context.globalState.update(
+ DONT_SHOW_TALON_UPDATE_MESSAGE_KEY,
+ true,
+ );
+ }
+ }
+
+ getScopeTypesWithSupport(scopeSupport: ScopeSupport): ScopeSupportTreeItem[] {
+ return this.supportLevels
+ .filter(
+ (supportLevel) =>
+ supportLevel.support === scopeSupport &&
+ (supportLevel.spokenForm.type !== "error" ||
+ !supportLevel.spokenForm.isPrivate),
+ )
+ .map(
+ (supportLevel) =>
+ new ScopeSupportTreeItem(
+ supportLevel,
+ isEqual(supportLevel.scopeType, this.scopeVisualizer.scopeType),
+ ),
+ )
+ .sort((a, b) => {
+ if (
+ a.scopeTypeInfo.spokenForm.type !== b.scopeTypeInfo.spokenForm.type
+ ) {
+ return a.scopeTypeInfo.spokenForm.type === "error" ? 1 : -1;
+ }
+
+ if (
+ a.scopeTypeInfo.isLanguageSpecific !==
+ b.scopeTypeInfo.isLanguageSpecific
+ ) {
+ return a.scopeTypeInfo.isLanguageSpecific ? -1 : 1;
+ }
+
+ return a.label.label.localeCompare(b.label.label);
+ });
+ }
+
+ dispose() {
+ this.visibleDisposable?.dispose();
+ }
+}
+
+function getSupportCategories(): SupportCategoryTreeItem[] {
+ return [
+ new SupportCategoryTreeItem(
+ "Present",
+ ScopeSupport.supportedAndPresentInEditor,
+ TreeItemCollapsibleState.Expanded,
+ ),
+ new SupportCategoryTreeItem(
+ "Not present",
+ ScopeSupport.supportedButNotPresentInEditor,
+ TreeItemCollapsibleState.Expanded,
+ ),
+ new SupportCategoryTreeItem(
+ "Legacy",
+ ScopeSupport.supportedLegacy,
+ TreeItemCollapsibleState.Expanded,
+ ),
+ new SupportCategoryTreeItem(
+ "Unsupported",
+ ScopeSupport.unsupported,
+ TreeItemCollapsibleState.Collapsed,
+ ),
+ ];
+}
+
+class ScopeSupportTreeItem extends TreeItem {
+ public label!: TreeItemLabel;
+
+ /**
+ * @param scopeTypeInfo The scope type info
+ * @param isVisualized Whether the scope type is currently being visualized
+ with the scope visualizer
+ */
+ constructor(
+ public scopeTypeInfo: ScopeTypeInfo,
+ isVisualized: boolean,
+ ) {
+ let label: string;
+ let tooltip: string | undefined;
+
+ if (scopeTypeInfo.spokenForm.type === "success") {
+ label = scopeTypeInfo.spokenForm.spokenForms
+ .map((spokenForm) => `"${spokenForm}"`)
+ .join(" | ");
+ } else {
+ label = "-";
+ tooltip = scopeTypeInfo.spokenForm.requiresTalonUpdate
+ ? "Requires Talon update"
+ : "Spoken form disabled; see [customization docs](https://www.cursorless.org/docs/user/customization/#talon-side-settings)";
+ }
+
+ super(
+ {
+ label,
+ highlights: isVisualized ? [[0, label.length]] : [],
+ },
+ TreeItemCollapsibleState.None,
+ );
+
+ this.tooltip = tooltip == null ? tooltip : new MarkdownString(tooltip);
+ this.description = scopeTypeInfo.humanReadableName;
+
+ this.command = isVisualized
+ ? {
+ command:
+ "cursorless.hideScopeVisualizer" satisfies CursorlessCommandId,
+ title: "Hide the scope visualizer",
+ }
+ : {
+ command:
+ "cursorless.showScopeVisualizer" satisfies CursorlessCommandId,
+ arguments: [
+ scopeTypeInfo.scopeType,
+ "content" satisfies VisualizationType,
+ ],
+ title: `Visualize ${scopeTypeInfo.humanReadableName}`,
+ };
+
+ if (scopeTypeInfo.isLanguageSpecific) {
+ this.iconPath = new ThemeIcon("code");
+ }
+ }
+}
+
+class SupportCategoryTreeItem extends TreeItem {
+ constructor(
+ label: string,
+ public readonly scopeSupport: ScopeSupport,
+ collapsibleState: TreeItemCollapsibleState,
+ ) {
+ super(label, collapsibleState);
+ }
+}
+
+type MyTreeItem = ScopeSupportTreeItem | SupportCategoryTreeItem;
diff --git a/packages/cursorless-vscode/src/extension.ts b/packages/cursorless-vscode/src/extension.ts
index 67d8233f578..af3affa0caf 100644
--- a/packages/cursorless-vscode/src/extension.ts
+++ b/packages/cursorless-vscode/src/extension.ts
@@ -35,6 +35,7 @@ import {
import { KeyboardCommands } from "./keyboard/KeyboardCommands";
import { registerCommands } from "./registerCommands";
import { ReleaseNotes } from "./ReleaseNotes";
+import { ScopeTreeProvider } from "./ScopeTreeProvider";
import {
ScopeVisualizer,
ScopeVisualizerListener,
@@ -97,6 +98,15 @@ export async function activate(
const keyboardCommands = KeyboardCommands.create(context, statusBarItem);
const scopeVisualizer = createScopeVisualizer(normalizedIde, scopeProvider);
+ ScopeTreeProvider.create(
+ vscodeApi,
+ context,
+ scopeProvider,
+ scopeVisualizer,
+ customSpokenFormGenerator,
+ commandServerApi != null,
+ );
+
registerCommands(
context,
vscodeIDE,
diff --git a/packages/cursorless-vscode/src/ide/vscode/VscodeGlobalState.ts b/packages/cursorless-vscode/src/ide/vscode/VscodeGlobalState.ts
index 760524d893a..6e0f2744c97 100644
--- a/packages/cursorless-vscode/src/ide/vscode/VscodeGlobalState.ts
+++ b/packages/cursorless-vscode/src/ide/vscode/VscodeGlobalState.ts
@@ -1,7 +1,8 @@
-import type { ExtensionContext } from "vscode";
import type { State, StateData, StateKey } from "@cursorless/common";
import { STATE_DEFAULTS } from "@cursorless/common";
+import type { ExtensionContext } from "vscode";
import { VERSION_KEY } from "../../ReleaseNotes";
+import { DONT_SHOW_TALON_UPDATE_MESSAGE_KEY } from "../../ScopeTreeProvider";
export default class VscodeGlobalState implements State {
constructor(private extensionContext: ExtensionContext) {
@@ -9,6 +10,7 @@ export default class VscodeGlobalState implements State {
extensionContext.globalState.setKeysForSync([
...Object.keys(STATE_DEFAULTS),
VERSION_KEY,
+ DONT_SHOW_TALON_UPDATE_MESSAGE_KEY,
]);
}
diff --git a/packages/cursorless-vscode/src/scripts/populateDist/assets.ts b/packages/cursorless-vscode/src/scripts/populateDist/assets.ts
index b628120496c..d84df78ed22 100644
--- a/packages/cursorless-vscode/src/scripts/populateDist/assets.ts
+++ b/packages/cursorless-vscode/src/scripts/populateDist/assets.ts
@@ -25,6 +25,7 @@ export const assets: Asset[] = [
},
{ source: "../../images/hats", destination: "images/hats" },
{ source: "../../images/icon.png", destination: "images/icon.png" },
+ { source: "../../images/icon.svg", destination: "images/icon.svg" },
{ source: "../../schemas", destination: "schemas" },
{
source: "../../third-party-licenses.csv",
diff --git a/packages/meta-updater/src/getCursorlessVscodeFields.ts b/packages/meta-updater/src/getCursorlessVscodeFields.ts
index 2abf53d185a..424eaf36342 100644
--- a/packages/meta-updater/src/getCursorlessVscodeFields.ts
+++ b/packages/meta-updater/src/getCursorlessVscodeFields.ts
@@ -21,6 +21,10 @@ export function getCursorlessVscodeFields(input: PackageJson) {
// Causes extension to activate whenever any text editor is opened
"onLanguage",
+ // Causes extension to activate when the Cursorless scope support side bar
+ // is opened
+ "onView:cursorless.scopes",
+
// Causes extension to activate when any Cursorless command is run.
// Technically we don't need to do this since VSCode 1.74.0, but we support
// older versions
diff --git a/packages/vscode-common/src/cursorlessSideBarIds.ts b/packages/vscode-common/src/cursorlessSideBarIds.ts
new file mode 100644
index 00000000000..c51bb171474
--- /dev/null
+++ b/packages/vscode-common/src/cursorlessSideBarIds.ts
@@ -0,0 +1 @@
+export const CURSORLESS_SCOPE_TREE_VIEW_ID = "cursorless.scopes";
diff --git a/packages/vscode-common/src/index.ts b/packages/vscode-common/src/index.ts
index c6a679e3bc0..1b1db4c637d 100644
--- a/packages/vscode-common/src/index.ts
+++ b/packages/vscode-common/src/index.ts
@@ -5,3 +5,4 @@ export * from "./vscodeUtil";
export * from "./runCommand";
export * from "./VscodeApi";
export * from "./ScopeVisualizerColorConfig";
+export * from "./cursorlessSideBarIds";