Skip to content

Commit

Permalink
Added command history (#2115)
Browse files Browse the repository at this point in the history
setting:
```json
"cursorless.commandHistory": true
```

Monthly log file: `cursorlessCommandHistory_2023-12.jsonl`
```
{"date":"2023-12-10","cursorlessVersion":"0.28.0-40a6fee4","command":{"version":6,"action":{"name":"setSelection","target":{"type":"primitive","modifiers":[{"type":"containingScope","scopeType":{"type":"line"}}]}},"usePrePhraseSnapshot":true}}
```

Voice command: `"Cursorless analyze history"` opens a new untitled
document:
```
[2023-12]

Total commands: 24

Actions (7):
  setSelection: 18
  clearAndSetSelection: 1
  remove: 1
  wrapWithPairedDelimiter: 1
  getText: 1
  replace: 1
  copyToClipboard: 1

Modifiers (2):
  containingScope: 20
  relativeScope: 2

Scope types (4):
  line: 18
  surroundingPair: 2
  comment: 1
  document: 1

```

## Checklist

- [x] I have added
[tests](https://www.cursorless.org/docs/contributing/test-case-recorder/)
- [x] I have updated the
[docs](https://github.com/cursorless-dev/cursorless/tree/main/docs) and
[cheatsheet](https://github.com/cursorless-dev/cursorless/tree/main/cursorless-talon/src/cheatsheet)
- [-] I have not broken the cheatsheet

---------

Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Pokey Rule <[email protected]>
  • Loading branch information
3 people authored Jan 3, 2024
1 parent 7341d0f commit a4dfb8a
Show file tree
Hide file tree
Showing 20 changed files with 472 additions and 5 deletions.
13 changes: 13 additions & 0 deletions docs/user/localCommandHIstory.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions packages/common/src/ide/PassthroughIDEBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions packages/common/src/ide/fake/FakeIDE.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions packages/common/src/ide/types/Configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type CursorlessConfiguration = {
wordSeparators: string[];
experimental: { snippetsDir: string | undefined; hatStability: HatStability };
decorationDebounceDelayMs: number;
commandHistory: boolean;
debug: boolean;
};

Expand All @@ -26,6 +27,7 @@ export const CONFIGURATION_DEFAULTS: CursorlessConfiguration = {
snippetsDir: undefined,
hatStability: HatStability.balanced,
},
commandHistory: false,
debug: false,
};

Expand Down
5 changes: 5 additions & 0 deletions packages/common/src/ide/types/FileSystem.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
5 changes: 5 additions & 0 deletions packages/common/src/ide/types/ide.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions packages/common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
24 changes: 24 additions & 0 deletions packages/common/src/types/commandHistory.ts
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 2 additions & 0 deletions packages/cursorless-engine/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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"
Expand Down
216 changes: 216 additions & 0 deletions packages/cursorless-engine/src/CommandHistory.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<string | undefined> {
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");
}
2 changes: 2 additions & 0 deletions packages/cursorless-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
1 change: 1 addition & 0 deletions packages/cursorless-vscode-e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"dependencies": {
"@cursorless/common": "workspace:*",
"@cursorless/vscode-common": "workspace:*",
"immer": "^9.0.15",
"lodash": "^4.17.21"
},
"devDependencies": {
Expand Down
Loading

0 comments on commit a4dfb8a

Please sign in to comment.