diff --git a/docs/user/localCommandHIstory.md b/docs/user/localCommandHIstory.md new file mode 100644 index 0000000000..b99d2a4a78 --- /dev/null +++ b/docs/user/localCommandHIstory.md @@ -0,0 +1,13 @@ +# Local command history + +By default, Cursorless doesn't capture anything about your usage. However, we do have a way to opt in to a local, sanitized command history. This history is never sent to our servers, and any commands that may contain text will be sanitized. + +The idea is that these statistics can be used in the future for doing local analyses to determine ways you can improve your Cursorless efficiency. We may also support a way for you to send your statistics to us for analysis in the future, but this will be opt-in only. + +To enable local, sanitized command logging, enable the `cursorless.commandHistory` VSCode setting. You should see a checkbox in the settings UI when you say `"cursorless settings"`. You can also set it manually in your `settings.json`: + +```json + "cursorless.commandHistory": true +``` + +The logged commands can be found in your user directory, under `.cursorless/commandHistory`. You can delete this directory at any time to clear your history. Please don't delete the parent `.cursorless` directory, as this contains other files for use by Cursorless. diff --git a/packages/common/src/ide/PassthroughIDEBase.ts b/packages/common/src/ide/PassthroughIDEBase.ts index fda08de066..788a632ee5 100644 --- a/packages/common/src/ide/PassthroughIDEBase.ts +++ b/packages/common/src/ide/PassthroughIDEBase.ts @@ -123,6 +123,10 @@ export default class PassthroughIDEBase implements IDE { return this.original.visibleTextEditors; } + public get cursorlessVersion(): string { + return this.original.cursorlessVersion; + } + public get assetsRoot(): string { return this.original.assetsRoot; } diff --git a/packages/common/src/ide/fake/FakeIDE.ts b/packages/common/src/ide/fake/FakeIDE.ts index 7fd3917a52..0ea12b1765 100644 --- a/packages/common/src/ide/fake/FakeIDE.ts +++ b/packages/common/src/ide/fake/FakeIDE.ts @@ -31,6 +31,7 @@ export default class FakeIDE implements IDE { capabilities: FakeCapabilities = new FakeCapabilities(); runMode: RunMode = "test"; + cursorlessVersion: string = "0.0.0"; workspaceFolders: readonly WorkspaceFolder[] | undefined = undefined; private disposables: Disposable[] = []; private assetsRoot_: string | undefined; diff --git a/packages/common/src/ide/types/Configuration.ts b/packages/common/src/ide/types/Configuration.ts index 6ee3a7bc87..eb40fdfc22 100644 --- a/packages/common/src/ide/types/Configuration.ts +++ b/packages/common/src/ide/types/Configuration.ts @@ -8,6 +8,7 @@ export type CursorlessConfiguration = { wordSeparators: string[]; experimental: { snippetsDir: string | undefined; hatStability: HatStability }; decorationDebounceDelayMs: number; + commandHistory: boolean; debug: boolean; }; @@ -26,6 +27,7 @@ export const CONFIGURATION_DEFAULTS: CursorlessConfiguration = { snippetsDir: undefined, hatStability: HatStability.balanced, }, + commandHistory: false, debug: false, }; diff --git a/packages/common/src/ide/types/FileSystem.types.ts b/packages/common/src/ide/types/FileSystem.types.ts index eef51b7730..79ea2e2f07 100644 --- a/packages/common/src/ide/types/FileSystem.types.ts +++ b/packages/common/src/ide/types/FileSystem.types.ts @@ -21,4 +21,9 @@ export interface FileSystem { * The path to the Cursorless talon state JSON file. */ readonly cursorlessTalonStateJsonPath: string; + + /** + * The path to the Cursorless command history directory. + */ + readonly cursorlessCommandHistoryDirPath: string; } diff --git a/packages/common/src/ide/types/ide.types.ts b/packages/common/src/ide/types/ide.types.ts index 54436751bd..860eaf9e08 100644 --- a/packages/common/src/ide/types/ide.types.ts +++ b/packages/common/src/ide/types/ide.types.ts @@ -41,6 +41,11 @@ export interface IDE { */ disposeOnExit(...disposables: Disposable[]): () => void; + /** + * The version of the cursorless extension + */ + readonly cursorlessVersion: string; + /** * The root directory of this shipped code. Can be used to access bundled * assets. diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index b35bd962f7..5f34326ce8 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -47,6 +47,7 @@ export * from "./types/Token"; export * from "./types/HatTokenMap"; export * from "./types/ScopeProvider"; export * from "./types/SpokenForm"; +export * from "./types/commandHistory"; export * from "./util/textFormatters"; export * from "./types/snippet.types"; export * from "./testUtil/fromPlainObject"; diff --git a/packages/common/src/types/commandHistory.ts b/packages/common/src/types/commandHistory.ts new file mode 100644 index 0000000000..5c72fcc5d7 --- /dev/null +++ b/packages/common/src/types/commandHistory.ts @@ -0,0 +1,24 @@ +import type { Command } from "./command/command.types"; + +/** + * Represents a single line in a command history jsonl file. + */ +export interface CommandHistoryEntry { + // UUID of the log entry. + id: string; + + // Date of the log entry. eg: "2023-09-05" + date: string; + + // Version of the Cursorless extension. eg: "0.28.0-c7bcf64d". + cursorlessVersion: string; + + // Name of thrown error. eg: "NoContainingScopeError". + error?: string; + + // UUID of the phrase. + phraseId: string | undefined; + + // The command that was executed. + command: Command; +} diff --git a/packages/cursorless-engine/package.json b/packages/cursorless-engine/package.json index 6978b91348..1bd9a71c5d 100644 --- a/packages/cursorless-engine/package.json +++ b/packages/cursorless-engine/package.json @@ -23,6 +23,7 @@ "lodash": "^4.17.21", "node-html-parser": "^6.1.11", "sbd": "^1.0.19", + "uuid": "^9.0.0", "zod": "3.22.3" }, "devDependencies": { @@ -31,6 +32,7 @@ "@types/mocha": "^10.0.3", "@types/sbd": "^1.0.3", "@types/sinon": "^10.0.2", + "@types/uuid": "^8.3.4", "js-yaml": "^4.1.0", "mocha": "^10.2.0", "sinon": "^11.1.1" diff --git a/packages/cursorless-engine/src/CommandHistory.ts b/packages/cursorless-engine/src/CommandHistory.ts new file mode 100644 index 0000000000..dd7c969121 --- /dev/null +++ b/packages/cursorless-engine/src/CommandHistory.ts @@ -0,0 +1,216 @@ +import { + ActionDescriptor, + CommandComplete, + CommandHistoryEntry, + CommandServerApi, + FileSystem, + IDE, + ReadOnlyHatMap, +} from "@cursorless/common"; +import type { + CommandRunner, + CommandRunnerDecorator, +} from "@cursorless/cursorless-engine"; +import produce from "immer"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { v4 as uuid } from "uuid"; + +const filePrefix = "cursorlessCommandHistory"; + +/** + * When user opts in, this class sanitizes and appends each Cursorless command + * to a local log file in `.cursorless/commandHistory` dir. + */ +export class CommandHistory implements CommandRunnerDecorator { + private readonly dirPath: string; + private currentPhraseSignal = ""; + private currentPhraseId = ""; + + constructor( + private ide: IDE, + private commandServerApi: CommandServerApi | null, + fileSystem: FileSystem, + ) { + this.dirPath = fileSystem.cursorlessCommandHistoryDirPath; + } + + wrapCommandRunner( + _readableHatMap: ReadOnlyHatMap, + runner: CommandRunner, + ): CommandRunner { + if (!this.isActive()) { + return runner; + } + + return { + run: async (commandComplete: CommandComplete) => { + try { + const returnValue = await runner.run(commandComplete); + + await this.appendToLog(commandComplete); + + return returnValue; + } catch (e) { + await this.appendToLog(commandComplete, e as Error); + throw e; + } + }, + }; + } + + private async appendToLog( + command: CommandComplete, + thrownError?: Error, + ): Promise { + const date = new Date(); + const fileName = `${filePrefix}_${getMonthDate(date)}.jsonl`; + const file = path.join(this.dirPath, fileName); + + const historyItem: CommandHistoryEntry = { + id: uuid(), + date: getDayDate(date), + cursorlessVersion: this.ide.cursorlessVersion, + error: thrownError?.name, + phraseId: await this.getPhraseId(), + command: produce(command, sanitizeCommandInPlace), + }; + const data = JSON.stringify(historyItem) + "\n"; + + await fs.mkdir(this.dirPath, { recursive: true }); + await fs.appendFile(file, data, "utf8"); + } + + private async getPhraseId(): Promise { + const phraseStartSignal = this.commandServerApi?.signals?.prePhrase; + + if (phraseStartSignal == null) { + return undefined; + } + + const newSignal = await phraseStartSignal.getVersion(); + + if (newSignal == null) { + return undefined; + } + + if (newSignal !== this.currentPhraseSignal) { + this.currentPhraseSignal = newSignal; + this.currentPhraseId = uuid(); + } + + return this.currentPhraseId; + } + + private isActive(): boolean { + return this.ide.configuration.getOwnConfiguration("commandHistory"); + } +} + +// Remove spoken form and sanitize action +function sanitizeCommandInPlace(command: CommandComplete): void { + delete command.spokenForm; + sanitizeActionInPlace(command.action); +} + +function sanitizeActionInPlace(action: ActionDescriptor): void { + switch (action.name) { + // Remove replace with text + case "replace": + if (Array.isArray(action.replaceWith)) { + action.replaceWith = []; + } + break; + + // Remove substitutions and custom body + case "insertSnippet": + delete action.snippetDescription.substitutions; + if (action.snippetDescription.type === "custom") { + action.snippetDescription.body = ""; + } + break; + + case "wrapWithSnippet": + if (action.snippetDescription.type === "custom") { + action.snippetDescription.body = ""; + } + break; + + case "executeCommand": + delete action.options?.commandArgs; + break; + + case "breakLine": + case "clearAndSetSelection": + case "copyToClipboard": + case "cutToClipboard": + case "deselect": + case "editNewLineAfter": + case "editNewLineBefore": + case "experimental.setInstanceReference": + case "extractVariable": + case "findInWorkspace": + case "foldRegion": + case "followLink": + case "indentLine": + case "insertCopyAfter": + case "insertCopyBefore": + case "insertEmptyLineAfter": + case "insertEmptyLineBefore": + case "insertEmptyLinesAround": + case "joinLines": + case "outdentLine": + case "randomizeTargets": + case "remove": + case "rename": + case "revealDefinition": + case "revealTypeDefinition": + case "reverseTargets": + case "scrollToBottom": + case "scrollToCenter": + case "scrollToTop": + case "setSelection": + case "setSelectionAfter": + case "setSelectionBefore": + case "showDebugHover": + case "showHover": + case "showQuickFix": + case "showReferences": + case "sortTargets": + case "toggleLineBreakpoint": + case "toggleLineComment": + case "unfoldRegion": + case "private.showParseTree": + case "private.getTargets": + case "callAsFunction": + case "editNew": + case "generateSnippet": + case "getText": + case "highlight": + case "moveToTarget": + case "pasteFromClipboard": + case "replaceWithTarget": + case "rewrapWithPairedDelimiter": + case "swapTargets": + case "wrapWithPairedDelimiter": + case "findInDocument": + break; + + default: { + // Ensure we don't miss any new actions + const _exhaustiveCheck: never = action; + } + } +} + +function getMonthDate(date: Date): string { + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}`; +} + +function getDayDate(date: Date): string { + return `${getMonthDate(date)}-${pad(date.getDate())}`; +} + +function pad(num: number): string { + return num.toString().padStart(2, "0"); +} diff --git a/packages/cursorless-engine/src/index.ts b/packages/cursorless-engine/src/index.ts index 9348e847e2..37648cf84c 100644 --- a/packages/cursorless-engine/src/index.ts +++ b/packages/cursorless-engine/src/index.ts @@ -6,3 +6,5 @@ export * from "./core/StoredTargets"; export * from "./typings/TreeSitter"; export * from "./cursorlessEngine"; export * from "./api/CursorlessEngineApi"; +export * from "./CommandRunner"; +export * from "./CommandHistory"; diff --git a/packages/cursorless-vscode-e2e/package.json b/packages/cursorless-vscode-e2e/package.json index 69be076237..97255b135e 100644 --- a/packages/cursorless-vscode-e2e/package.json +++ b/packages/cursorless-vscode-e2e/package.json @@ -21,6 +21,7 @@ "dependencies": { "@cursorless/common": "workspace:*", "@cursorless/vscode-common": "workspace:*", + "immer": "^9.0.15", "lodash": "^4.17.21" }, "devDependencies": { diff --git a/packages/cursorless-vscode-e2e/src/suite/commandHistory.vscode.test.ts b/packages/cursorless-vscode-e2e/src/suite/commandHistory.vscode.test.ts new file mode 100644 index 0000000000..c7234aa6bf --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/commandHistory.vscode.test.ts @@ -0,0 +1,155 @@ +import { + CommandComplete, + CommandHistoryEntry, + LATEST_VERSION, + ReplaceActionDescriptor, +} from "@cursorless/common"; +import { + getCursorlessApi, + openNewEditor, + runCursorlessCommand, +} from "@cursorless/vscode-common"; +import { assert } from "chai"; +import { existsSync } from "node:fs"; +import { readFile, readdir, rm } from "node:fs/promises"; +import path from "node:path"; +import * as vscode from "vscode"; +import { endToEndTestSetup } from "../endToEndTestSetup"; +import produce from "immer"; + +/* + * All tests in this file are running against the latest version of the command + * and needs to be manually updated on every command migration. + */ + +suite("commandHistory", function () { + endToEndTestSetup(this); + + let tmpdir = ""; + + suiteSetup(async () => { + tmpdir = (await getCursorlessApi()).testHelpers! + .cursorlessCommandHistoryDirPath; + }); + + this.afterEach(async () => { + await rm(tmpdir, { recursive: true, force: true }); + }); + + test("active", () => testActive(tmpdir)); + test("sanitization", () => testSanitization(tmpdir)); + test("inactive", () => testInactive(tmpdir)); + test("error", () => testError(tmpdir)); +}); + +async function testActive(tmpdir: string) { + await injectFakeIsActive(true); + await initalizeEditor(); + const command = takeCommand("h"); + await runCursorlessCommand(command); + + const content = await getLogEntry(tmpdir); + delete command.spokenForm; + assert.deepEqual(content.command, command); +} + +async function testSanitization(tmpdir: string) { + await injectFakeIsActive(true); + await initalizeEditor(); + const command = replaceWithTextCommand(); + await runCursorlessCommand(command); + + const content = await getLogEntry(tmpdir); + assert.deepEqual( + content.command, + produce(command, (draft) => { + (draft.action as ReplaceActionDescriptor).replaceWith = []; + }), + ); +} + +async function testInactive(tmpdir: string) { + await injectFakeIsActive(false); + await initalizeEditor(); + await runCursorlessCommand(takeCommand("h")); + + assert.notOk(existsSync(tmpdir)); +} + +async function testError(tmpdir: string) { + await injectFakeIsActive(true); + await initalizeEditor(); + const command = takeCommand("a"); + + try { + await runCursorlessCommand(command); + } catch (error) { + // Do nothing + } + + const content = await getLogEntry(tmpdir); + assert.containsAllKeys(content, ["error"]); + delete command.spokenForm; + assert.deepEqual(content.command, command); +} + +async function getLogEntry(tmpdir: string) { + assert.ok(existsSync(tmpdir)); + const paths = await readdir(tmpdir); + assert.lengthOf(paths, 1); + assert.ok(/cursorlessCommandHistory_.*\.jsonl/.test(paths[0])); + + return JSON.parse( + await readFile(path.join(tmpdir, paths[0]), "utf8"), + ) as CommandHistoryEntry; +} + +async function injectFakeIsActive(isActive: boolean): Promise { + (await getCursorlessApi()).testHelpers!.ide.configuration.mockConfiguration( + "commandHistory", + isActive, + ); +} + +async function initalizeEditor() { + const { hatTokenMap } = (await getCursorlessApi()).testHelpers!; + + const editor = await openNewEditor("hello world"); + + editor.selections = [new vscode.Selection(0, 11, 0, 11)]; + + await hatTokenMap.allocateHats(); +} + +function takeCommand(character: string): CommandComplete { + return { + version: LATEST_VERSION, + spokenForm: `take <${character}>`, + usePrePhraseSnapshot: false, + action: { + name: "setSelection", + target: { + type: "primitive", + mark: { + type: "decoratedSymbol", + symbolColor: "default", + character, + }, + }, + }, + }; +} + +function replaceWithTextCommand(): CommandComplete { + return { + version: LATEST_VERSION, + usePrePhraseSnapshot: false, + action: { + name: "replace", + destination: { + type: "implicit", + }, + replaceWith: ["hello world"], + }, + }; +} diff --git a/packages/cursorless-vscode/package.json b/packages/cursorless-vscode/package.json index 0600bd9d8d..2c80dbe818 100644 --- a/packages/cursorless-vscode/package.json +++ b/packages/cursorless-vscode/package.json @@ -285,6 +285,12 @@ "default": true, "description": "Whether to show decorations on vscode start." }, + "cursorless.commandHistory": { + "type": "boolean", + "default": false, + "order": 2, + "description": "Keep a local, sanitized command history. This history is never sent to our servers, and any commands that may contain text will be sanitized. These statistics can be used in the future for doing local analyses to determine ways you can improve your Cursorless efficiency. We may also support a way for you to send your statistics to us for analysis in the future, but this will be opt-in only." + }, "cursorless.tokenHatSplittingMode.preserveCase": { "type": "boolean", "default": false, @@ -1146,6 +1152,7 @@ "@cursorless/common": "workspace:*", "@cursorless/cursorless-engine": "workspace:*", "@cursorless/vscode-common": "workspace:*", + "immer": "^9.0.15", "itertools": "^2.1.1", "lodash": "^4.17.21", "nearley": "2.20.1", diff --git a/packages/cursorless-vscode/src/constructTestHelpers.ts b/packages/cursorless-vscode/src/constructTestHelpers.ts index 106a131386..f415ab7efc 100644 --- a/packages/cursorless-vscode/src/constructTestHelpers.ts +++ b/packages/cursorless-vscode/src/constructTestHelpers.ts @@ -22,6 +22,7 @@ import * as vscode from "vscode"; import { VscodeIDE } from "./ide/vscode/VscodeIDE"; import { toVscodeEditor } from "./ide/vscode/toVscodeEditor"; import { vscodeApi } from "./vscodeApi"; +import { VscodeFileSystem } from "./ide/vscode/VscodeFileSystem"; export function constructTestHelpers( commandServerApi: CommandServerApi | null, @@ -29,7 +30,7 @@ export function constructTestHelpers( hatTokenMap: HatTokenMap, vscodeIDE: VscodeIDE, normalizedIde: NormalizedIDE, - cursorlessTalonStateJsonPath: string, + fileSystem: VscodeFileSystem, scopeProvider: ScopeProvider, injectIde: (ide: IDE) => void, runIntegrationTests: () => Promise, @@ -65,7 +66,8 @@ export function constructTestHelpers( ); }, - cursorlessTalonStateJsonPath, + cursorlessTalonStateJsonPath: fileSystem.cursorlessTalonStateJsonPath, + cursorlessCommandHistoryDirPath: fileSystem.cursorlessCommandHistoryDirPath, setStoredTarget( editor: vscode.TextEditor, diff --git a/packages/cursorless-vscode/src/extension.ts b/packages/cursorless-vscode/src/extension.ts index 50104316c0..33d45c75e4 100644 --- a/packages/cursorless-vscode/src/extension.ts +++ b/packages/cursorless-vscode/src/extension.ts @@ -11,7 +11,9 @@ import { TextDocument, } from "@cursorless/common"; import { + CommandHistory, createCursorlessEngine, + TestCaseRecorder, TreeSitter, } from "@cursorless/cursorless-engine"; import { @@ -22,6 +24,7 @@ import { toVscodeRange, } from "@cursorless/vscode-common"; import * as crypto from "crypto"; +import { mkdir } from "fs/promises"; import * as os from "os"; import * as path from "path"; import * as vscode from "vscode"; @@ -47,8 +50,6 @@ import { } from "./ScopeVisualizerCommandApi"; import { StatusBarItem } from "./StatusBarItem"; import { vscodeApi } from "./vscodeApi"; -import { mkdir } from "fs/promises"; -import { TestCaseRecorder } from "@cursorless/cursorless-engine"; /** * Extension entrypoint called by VSCode on Cursorless startup. @@ -99,6 +100,10 @@ export async function activate( fileSystem, ); + addCommandRunnerDecorator( + new CommandHistory(normalizedIde, commandServerApi, fileSystem), + ); + const testCaseRecorder = new TestCaseRecorder(hatTokenMap, storedTargets); addCommandRunnerDecorator(testCaseRecorder); @@ -142,7 +147,7 @@ export async function activate( hatTokenMap, vscodeIDE, normalizedIde as NormalizedIDE, - fileSystem.cursorlessTalonStateJsonPath, + fileSystem, scopeProvider, injectIde, runIntegrationTests, diff --git a/packages/cursorless-vscode/src/ide/vscode/VscodeFileSystem.ts b/packages/cursorless-vscode/src/ide/vscode/VscodeFileSystem.ts index fed9699b11..6ce0f0c68e 100644 --- a/packages/cursorless-vscode/src/ide/vscode/VscodeFileSystem.ts +++ b/packages/cursorless-vscode/src/ide/vscode/VscodeFileSystem.ts @@ -4,12 +4,17 @@ import * as path from "path"; export class VscodeFileSystem implements FileSystem { public readonly cursorlessTalonStateJsonPath: string; + public readonly cursorlessCommandHistoryDirPath: string; constructor(public readonly cursorlessDir: string) { this.cursorlessTalonStateJsonPath = path.join( this.cursorlessDir, "state.json", ); + this.cursorlessCommandHistoryDirPath = path.join( + this.cursorlessDir, + "commandHistory", + ); } watchDir(path: string, onDidChange: PathChangeListener): Disposable { diff --git a/packages/cursorless-vscode/src/ide/vscode/VscodeIDE.ts b/packages/cursorless-vscode/src/ide/vscode/VscodeIDE.ts index 66800c07a9..9709cf4b57 100644 --- a/packages/cursorless-vscode/src/ide/vscode/VscodeIDE.ts +++ b/packages/cursorless-vscode/src/ide/vscode/VscodeIDE.ts @@ -86,6 +86,10 @@ export class VscodeIDE implements IDE { return this.extensionContext.extensionPath; } + get cursorlessVersion(): string { + return this.extensionContext.extension.packageJSON.version; + } + get runMode(): RunMode { return vscodeRunMode(this.extensionContext); } diff --git a/packages/vscode-common/src/TestHelpers.ts b/packages/vscode-common/src/TestHelpers.ts index 467c8d2782..4028ea5673 100644 --- a/packages/vscode-common/src/TestHelpers.ts +++ b/packages/vscode-common/src/TestHelpers.ts @@ -46,6 +46,7 @@ export interface TestHelpers { runIntegrationTests(): Promise; cursorlessTalonStateJsonPath: string; + cursorlessCommandHistoryDirPath: string; /** * A thin wrapper around the VSCode API that allows us to mock it for testing. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1b34e9a87..7c67a8a4fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -256,6 +256,9 @@ importers: sbd: specifier: ^1.0.19 version: 1.0.19 + uuid: + specifier: ^9.0.0 + version: 9.0.0 zod: specifier: 3.22.3 version: 3.22.3 @@ -275,6 +278,9 @@ importers: '@types/sinon': specifier: ^10.0.2 version: 10.0.13 + '@types/uuid': + specifier: ^8.3.4 + version: 8.3.4 js-yaml: specifier: ^4.1.0 version: 4.1.0 @@ -427,6 +433,9 @@ importers: '@cursorless/vscode-common': specifier: workspace:* version: link:../vscode-common + immer: + specifier: ^9.0.15 + version: 9.0.21 itertools: specifier: ^2.1.1 version: 2.1.1 @@ -521,6 +530,9 @@ importers: '@cursorless/vscode-common': specifier: workspace:* version: link:../vscode-common + immer: + specifier: ^9.0.15 + version: 9.0.21 lodash: specifier: ^4.17.21 version: 4.17.21