Skip to content

Commit a4dfb8a

Browse files
AndreasArvidssonpre-commit-ci-lite[bot]pokey
authored
Added command history (#2115)
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]>
1 parent 7341d0f commit a4dfb8a

File tree

20 files changed

+472
-5
lines changed

20 files changed

+472
-5
lines changed

docs/user/localCommandHIstory.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Local command history
2+
3+
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.
4+
5+
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.
6+
7+
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`:
8+
9+
```json
10+
"cursorless.commandHistory": true
11+
```
12+
13+
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.

packages/common/src/ide/PassthroughIDEBase.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,10 @@ export default class PassthroughIDEBase implements IDE {
123123
return this.original.visibleTextEditors;
124124
}
125125

126+
public get cursorlessVersion(): string {
127+
return this.original.cursorlessVersion;
128+
}
129+
126130
public get assetsRoot(): string {
127131
return this.original.assetsRoot;
128132
}

packages/common/src/ide/fake/FakeIDE.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export default class FakeIDE implements IDE {
3131
capabilities: FakeCapabilities = new FakeCapabilities();
3232

3333
runMode: RunMode = "test";
34+
cursorlessVersion: string = "0.0.0";
3435
workspaceFolders: readonly WorkspaceFolder[] | undefined = undefined;
3536
private disposables: Disposable[] = [];
3637
private assetsRoot_: string | undefined;

packages/common/src/ide/types/Configuration.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export type CursorlessConfiguration = {
88
wordSeparators: string[];
99
experimental: { snippetsDir: string | undefined; hatStability: HatStability };
1010
decorationDebounceDelayMs: number;
11+
commandHistory: boolean;
1112
debug: boolean;
1213
};
1314

@@ -26,6 +27,7 @@ export const CONFIGURATION_DEFAULTS: CursorlessConfiguration = {
2627
snippetsDir: undefined,
2728
hatStability: HatStability.balanced,
2829
},
30+
commandHistory: false,
2931
debug: false,
3032
};
3133

packages/common/src/ide/types/FileSystem.types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,9 @@ export interface FileSystem {
2121
* The path to the Cursorless talon state JSON file.
2222
*/
2323
readonly cursorlessTalonStateJsonPath: string;
24+
25+
/**
26+
* The path to the Cursorless command history directory.
27+
*/
28+
readonly cursorlessCommandHistoryDirPath: string;
2429
}

packages/common/src/ide/types/ide.types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ export interface IDE {
4141
*/
4242
disposeOnExit(...disposables: Disposable[]): () => void;
4343

44+
/**
45+
* The version of the cursorless extension
46+
*/
47+
readonly cursorlessVersion: string;
48+
4449
/**
4550
* The root directory of this shipped code. Can be used to access bundled
4651
* assets.

packages/common/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export * from "./types/Token";
4747
export * from "./types/HatTokenMap";
4848
export * from "./types/ScopeProvider";
4949
export * from "./types/SpokenForm";
50+
export * from "./types/commandHistory";
5051
export * from "./util/textFormatters";
5152
export * from "./types/snippet.types";
5253
export * from "./testUtil/fromPlainObject";
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { Command } from "./command/command.types";
2+
3+
/**
4+
* Represents a single line in a command history jsonl file.
5+
*/
6+
export interface CommandHistoryEntry {
7+
// UUID of the log entry.
8+
id: string;
9+
10+
// Date of the log entry. eg: "2023-09-05"
11+
date: string;
12+
13+
// Version of the Cursorless extension. eg: "0.28.0-c7bcf64d".
14+
cursorlessVersion: string;
15+
16+
// Name of thrown error. eg: "NoContainingScopeError".
17+
error?: string;
18+
19+
// UUID of the phrase.
20+
phraseId: string | undefined;
21+
22+
// The command that was executed.
23+
command: Command;
24+
}

packages/cursorless-engine/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"lodash": "^4.17.21",
2424
"node-html-parser": "^6.1.11",
2525
"sbd": "^1.0.19",
26+
"uuid": "^9.0.0",
2627
"zod": "3.22.3"
2728
},
2829
"devDependencies": {
@@ -31,6 +32,7 @@
3132
"@types/mocha": "^10.0.3",
3233
"@types/sbd": "^1.0.3",
3334
"@types/sinon": "^10.0.2",
35+
"@types/uuid": "^8.3.4",
3436
"js-yaml": "^4.1.0",
3537
"mocha": "^10.2.0",
3638
"sinon": "^11.1.1"
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import {
2+
ActionDescriptor,
3+
CommandComplete,
4+
CommandHistoryEntry,
5+
CommandServerApi,
6+
FileSystem,
7+
IDE,
8+
ReadOnlyHatMap,
9+
} from "@cursorless/common";
10+
import type {
11+
CommandRunner,
12+
CommandRunnerDecorator,
13+
} from "@cursorless/cursorless-engine";
14+
import produce from "immer";
15+
import * as fs from "node:fs/promises";
16+
import * as path from "node:path";
17+
import { v4 as uuid } from "uuid";
18+
19+
const filePrefix = "cursorlessCommandHistory";
20+
21+
/**
22+
* When user opts in, this class sanitizes and appends each Cursorless command
23+
* to a local log file in `.cursorless/commandHistory` dir.
24+
*/
25+
export class CommandHistory implements CommandRunnerDecorator {
26+
private readonly dirPath: string;
27+
private currentPhraseSignal = "";
28+
private currentPhraseId = "";
29+
30+
constructor(
31+
private ide: IDE,
32+
private commandServerApi: CommandServerApi | null,
33+
fileSystem: FileSystem,
34+
) {
35+
this.dirPath = fileSystem.cursorlessCommandHistoryDirPath;
36+
}
37+
38+
wrapCommandRunner(
39+
_readableHatMap: ReadOnlyHatMap,
40+
runner: CommandRunner,
41+
): CommandRunner {
42+
if (!this.isActive()) {
43+
return runner;
44+
}
45+
46+
return {
47+
run: async (commandComplete: CommandComplete) => {
48+
try {
49+
const returnValue = await runner.run(commandComplete);
50+
51+
await this.appendToLog(commandComplete);
52+
53+
return returnValue;
54+
} catch (e) {
55+
await this.appendToLog(commandComplete, e as Error);
56+
throw e;
57+
}
58+
},
59+
};
60+
}
61+
62+
private async appendToLog(
63+
command: CommandComplete,
64+
thrownError?: Error,
65+
): Promise<void> {
66+
const date = new Date();
67+
const fileName = `${filePrefix}_${getMonthDate(date)}.jsonl`;
68+
const file = path.join(this.dirPath, fileName);
69+
70+
const historyItem: CommandHistoryEntry = {
71+
id: uuid(),
72+
date: getDayDate(date),
73+
cursorlessVersion: this.ide.cursorlessVersion,
74+
error: thrownError?.name,
75+
phraseId: await this.getPhraseId(),
76+
command: produce(command, sanitizeCommandInPlace),
77+
};
78+
const data = JSON.stringify(historyItem) + "\n";
79+
80+
await fs.mkdir(this.dirPath, { recursive: true });
81+
await fs.appendFile(file, data, "utf8");
82+
}
83+
84+
private async getPhraseId(): Promise<string | undefined> {
85+
const phraseStartSignal = this.commandServerApi?.signals?.prePhrase;
86+
87+
if (phraseStartSignal == null) {
88+
return undefined;
89+
}
90+
91+
const newSignal = await phraseStartSignal.getVersion();
92+
93+
if (newSignal == null) {
94+
return undefined;
95+
}
96+
97+
if (newSignal !== this.currentPhraseSignal) {
98+
this.currentPhraseSignal = newSignal;
99+
this.currentPhraseId = uuid();
100+
}
101+
102+
return this.currentPhraseId;
103+
}
104+
105+
private isActive(): boolean {
106+
return this.ide.configuration.getOwnConfiguration("commandHistory");
107+
}
108+
}
109+
110+
// Remove spoken form and sanitize action
111+
function sanitizeCommandInPlace(command: CommandComplete): void {
112+
delete command.spokenForm;
113+
sanitizeActionInPlace(command.action);
114+
}
115+
116+
function sanitizeActionInPlace(action: ActionDescriptor): void {
117+
switch (action.name) {
118+
// Remove replace with text
119+
case "replace":
120+
if (Array.isArray(action.replaceWith)) {
121+
action.replaceWith = [];
122+
}
123+
break;
124+
125+
// Remove substitutions and custom body
126+
case "insertSnippet":
127+
delete action.snippetDescription.substitutions;
128+
if (action.snippetDescription.type === "custom") {
129+
action.snippetDescription.body = "";
130+
}
131+
break;
132+
133+
case "wrapWithSnippet":
134+
if (action.snippetDescription.type === "custom") {
135+
action.snippetDescription.body = "";
136+
}
137+
break;
138+
139+
case "executeCommand":
140+
delete action.options?.commandArgs;
141+
break;
142+
143+
case "breakLine":
144+
case "clearAndSetSelection":
145+
case "copyToClipboard":
146+
case "cutToClipboard":
147+
case "deselect":
148+
case "editNewLineAfter":
149+
case "editNewLineBefore":
150+
case "experimental.setInstanceReference":
151+
case "extractVariable":
152+
case "findInWorkspace":
153+
case "foldRegion":
154+
case "followLink":
155+
case "indentLine":
156+
case "insertCopyAfter":
157+
case "insertCopyBefore":
158+
case "insertEmptyLineAfter":
159+
case "insertEmptyLineBefore":
160+
case "insertEmptyLinesAround":
161+
case "joinLines":
162+
case "outdentLine":
163+
case "randomizeTargets":
164+
case "remove":
165+
case "rename":
166+
case "revealDefinition":
167+
case "revealTypeDefinition":
168+
case "reverseTargets":
169+
case "scrollToBottom":
170+
case "scrollToCenter":
171+
case "scrollToTop":
172+
case "setSelection":
173+
case "setSelectionAfter":
174+
case "setSelectionBefore":
175+
case "showDebugHover":
176+
case "showHover":
177+
case "showQuickFix":
178+
case "showReferences":
179+
case "sortTargets":
180+
case "toggleLineBreakpoint":
181+
case "toggleLineComment":
182+
case "unfoldRegion":
183+
case "private.showParseTree":
184+
case "private.getTargets":
185+
case "callAsFunction":
186+
case "editNew":
187+
case "generateSnippet":
188+
case "getText":
189+
case "highlight":
190+
case "moveToTarget":
191+
case "pasteFromClipboard":
192+
case "replaceWithTarget":
193+
case "rewrapWithPairedDelimiter":
194+
case "swapTargets":
195+
case "wrapWithPairedDelimiter":
196+
case "findInDocument":
197+
break;
198+
199+
default: {
200+
// Ensure we don't miss any new actions
201+
const _exhaustiveCheck: never = action;
202+
}
203+
}
204+
}
205+
206+
function getMonthDate(date: Date): string {
207+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}`;
208+
}
209+
210+
function getDayDate(date: Date): string {
211+
return `${getMonthDate(date)}-${pad(date.getDate())}`;
212+
}
213+
214+
function pad(num: number): string {
215+
return num.toString().padStart(2, "0");
216+
}

0 commit comments

Comments
 (0)