From be6ab53a468ce311c55b2cd0133083b55bf6a91a Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Thu, 1 Feb 2024 13:41:14 +0100 Subject: [PATCH] Add simple command history analyzer (#2163) image ## Checklist - [ ] I have added [tests](https://www.cursorless.org/docs/contributing/test-case-recorder/) - [ ] 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: Pokey Rule <755842+pokey@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- cursorless-talon/src/cursorless.py | 8 +- cursorless-talon/src/cursorless.talon | 3 + docs/user/localCommandHIstory.md | 2 + packages/common/src/cursorlessCommandIds.ts | 4 + packages/cursorless-engine/package.json | 2 + .../src/CommandHistoryAnalyzer.ts | 140 ++++++++++++++++++ .../src/asyncIteratorToList.ts | 9 ++ .../src/generateCommandHistoryEntries.ts | 25 ++++ packages/cursorless-engine/src/index.ts | 1 + .../src/util/getScopeType.ts | 33 +++++ packages/cursorless-vscode/package.json | 7 +- packages/cursorless-vscode/src/extension.ts | 1 + .../cursorless-vscode/src/registerCommands.ts | 7 + pnpm-lock.yaml | 6 + 14 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 packages/cursorless-engine/src/CommandHistoryAnalyzer.ts create mode 100644 packages/cursorless-engine/src/asyncIteratorToList.ts create mode 100644 packages/cursorless-engine/src/generateCommandHistoryEntries.ts create mode 100644 packages/cursorless-engine/src/util/getScopeType.ts diff --git a/cursorless-talon/src/cursorless.py b/cursorless-talon/src/cursorless.py index d64791219d..9617f51593 100644 --- a/cursorless-talon/src/cursorless.py +++ b/cursorless-talon/src/cursorless.py @@ -1,4 +1,4 @@ -from talon import Module +from talon import Module, actions mod = Module() @@ -15,3 +15,9 @@ def private_cursorless_show_settings_in_ide(): def private_cursorless_show_sidebar(): """Show Cursorless-specific settings in ide""" + + def private_cursorless_show_command_statistics(): + """Show Cursorless command statistics""" + actions.user.private_cursorless_run_rpc_command_no_wait( + "cursorless.analyzeCommandHistory" + ) diff --git a/cursorless-talon/src/cursorless.talon b/cursorless-talon/src/cursorless.talon index d2fa383a64..f12bab8a3e 100644 --- a/cursorless-talon/src/cursorless.talon +++ b/cursorless-talon/src/cursorless.talon @@ -43,3 +43,6 @@ tag: user.cursorless bar {user.cursorless_homophone}: user.private_cursorless_show_sidebar() + +{user.cursorless_homophone} stats: + user.private_cursorless_show_command_statistics() diff --git a/docs/user/localCommandHIstory.md b/docs/user/localCommandHIstory.md index b99d2a4a78..3aab613a88 100644 --- a/docs/user/localCommandHIstory.md +++ b/docs/user/localCommandHIstory.md @@ -11,3 +11,5 @@ To enable local, sanitized command logging, enable the `cursorless.commandHistor ``` 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. + +We currently offer very basic command statistics via the `"cursorless stats"` voice command. Expect more in the future! diff --git a/packages/common/src/cursorlessCommandIds.ts b/packages/common/src/cursorlessCommandIds.ts index 031698e479..ecbda32f31 100644 --- a/packages/common/src/cursorlessCommandIds.ts +++ b/packages/common/src/cursorlessCommandIds.ts @@ -47,6 +47,7 @@ export const cursorlessCommandIds = [ "cursorless.toggleDecorations", "cursorless.showScopeVisualizer", "cursorless.hideScopeVisualizer", + "cursorless.analyzeCommandHistory", ] as const satisfies readonly `cursorless.${string}`[]; export type CursorlessCommandId = (typeof cursorlessCommandIds)[number]; @@ -76,6 +77,9 @@ export const cursorlessCommandDescriptions: Record< ["cursorless.hideScopeVisualizer"]: new VisibleCommand( "Hide the scope visualizer", ), + ["cursorless.analyzeCommandHistory"]: new VisibleCommand( + "Analyze collected command history", + ), ["cursorless.command"]: new HiddenCommand("The core cursorless command"), ["cursorless.showQuickPick"]: new HiddenCommand( diff --git a/packages/cursorless-engine/package.json b/packages/cursorless-engine/package.json index 1bd9a71c5d..c348c3f08c 100644 --- a/packages/cursorless-engine/package.json +++ b/packages/cursorless-engine/package.json @@ -17,6 +17,7 @@ "license": "MIT", "dependencies": { "@cursorless/common": "workspace:*", + "glob": "^7.1.7", "immer": "^9.0.15", "immutability-helper": "^3.1.1", "itertools": "^2.1.1", @@ -27,6 +28,7 @@ "zod": "3.22.3" }, "devDependencies": { + "@types/glob": "^7.1.3", "@types/js-yaml": "^4.0.2", "@types/lodash": "4.14.181", "@types/mocha": "^10.0.3", diff --git a/packages/cursorless-engine/src/CommandHistoryAnalyzer.ts b/packages/cursorless-engine/src/CommandHistoryAnalyzer.ts new file mode 100644 index 0000000000..bf467cc25b --- /dev/null +++ b/packages/cursorless-engine/src/CommandHistoryAnalyzer.ts @@ -0,0 +1,140 @@ +import { + CommandHistoryEntry, + Modifier, + PartialPrimitiveTargetDescriptor, + ScopeType, + showWarning, +} from "@cursorless/common"; +import { groupBy, map, sum } from "lodash"; +import { asyncIteratorToList } from "./asyncIteratorToList"; +import { canonicalizeAndValidateCommand } from "./core/commandVersionUpgrades/canonicalizeAndValidateCommand"; +import { generateCommandHistoryEntries } from "./generateCommandHistoryEntries"; +import { ide } from "./singletons/ide.singleton"; +import { getPartialTargetDescriptors } from "./util/getPartialTargetDescriptors"; +import { getPartialPrimitiveTargets } from "./util/getPrimitiveTargets"; +import { getScopeType } from "./util/getScopeType"; + +/** + * Analyzes the command history for a given time period, and outputs a report + */ +class Period { + private readonly period: string; + private readonly actions: Record = {}; + private readonly modifiers: Record = {}; + private readonly scopeTypes: Record = {}; + private count: number = 0; + + constructor(period: string, entries: CommandHistoryEntry[]) { + this.period = period; + for (const entry of entries) { + this.append(entry); + } + } + + toString(): string { + return [ + `# ${this.period}`, + `Total command count: ${this.count}`, + this.serializeMap("Actions", this.actions), + this.serializeMap("Modifiers", this.modifiers), + this.serializeMap("Scope types", this.scopeTypes), + ].join("\n\n"); + } + + private serializeMap(name: string, map: Record) { + const total = sum(Object.values(map)); + const entries = Object.entries(map); + entries.sort((a, b) => b[1] - a[1]); + const entriesSerialized = entries + .map(([key, value]) => ` ${key}: ${value} (${toPercent(value / total)})`) + .join("\n"); + return `${name}:\n${entriesSerialized}`; + } + + private append(entry: CommandHistoryEntry) { + this.count++; + const command = canonicalizeAndValidateCommand(entry.command); + this.incrementAction(command.action.name); + + this.parsePrimitiveTargets( + getPartialPrimitiveTargets(getPartialTargetDescriptors(command.action)), + ); + } + + private parsePrimitiveTargets( + partialPrimitiveTargets: PartialPrimitiveTargetDescriptor[], + ) { + for (const target of partialPrimitiveTargets) { + if (target.modifiers == null) { + continue; + } + for (const modifier of target.modifiers) { + this.incrementModifier(modifier); + + const scopeType = getScopeType(modifier); + if (scopeType != null) { + this.incrementScope(scopeType); + } + } + } + } + + private incrementAction(actionName: string) { + this.actions[actionName] = (this.actions[actionName] ?? 0) + 1; + } + + private incrementModifier(modifier: Modifier) { + this.modifiers[modifier.type] = (this.modifiers[modifier.type] ?? 0) + 1; + } + + private incrementScope(scopeType: ScopeType) { + this.scopeTypes[scopeType.type] = + (this.scopeTypes[scopeType.type] ?? 0) + 1; + } +} + +function getMonth(entry: CommandHistoryEntry): string { + return entry.date.slice(0, 7); +} + +export async function analyzeCommandHistory(dir: string) { + const entries = await asyncIteratorToList(generateCommandHistoryEntries(dir)); + + if (entries.length === 0) { + const TAKE_ME_THERE = "Show me"; + const result = await showWarning( + ide().messages, + "noHistory", + "No command history entries found. Please enable the command history in the settings.", + TAKE_ME_THERE, + ); + + if (result === TAKE_ME_THERE) { + // FIXME: This is VSCode-specific + await ide().executeCommand( + "workbench.action.openSettings", + "cursorless.commandHistory", + ); + } + + return; + } + + const content = [ + new Period("Totals", entries).toString(), + + ...map(Object.entries(groupBy(entries, getMonth)), ([key, entries]) => + new Period(key, entries).toString(), + ), + ].join("\n\n\n"); + + await ide().openUntitledTextDocument({ content }); +} + +function toPercent(value: number) { + return Intl.NumberFormat(undefined, { + style: "percent", + minimumFractionDigits: 0, + maximumFractionDigits: 1, + }).format(value); +} diff --git a/packages/cursorless-engine/src/asyncIteratorToList.ts b/packages/cursorless-engine/src/asyncIteratorToList.ts new file mode 100644 index 0000000000..7e8d0a5e56 --- /dev/null +++ b/packages/cursorless-engine/src/asyncIteratorToList.ts @@ -0,0 +1,9 @@ +export async function asyncIteratorToList( + iterator: AsyncIterable, +): Promise { + const list: T[] = []; + for await (const item of iterator) { + list.push(item); + } + return list; +} diff --git a/packages/cursorless-engine/src/generateCommandHistoryEntries.ts b/packages/cursorless-engine/src/generateCommandHistoryEntries.ts new file mode 100644 index 0000000000..9794b0d2c6 --- /dev/null +++ b/packages/cursorless-engine/src/generateCommandHistoryEntries.ts @@ -0,0 +1,25 @@ +import { CommandHistoryEntry } from "@cursorless/common"; +import globRaw from "glob"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; + +export async function* generateCommandHistoryEntries(dir: string) { + const files = await glob("*.jsonl", { cwd: dir }); + + for (const file of files) { + const filePath = path.join(dir, file); + const content = await readFile(filePath, "utf8"); + const lines = content.split("\n"); + + for (const line of lines) { + if (line.length === 0) { + continue; + } + + yield JSON.parse(line) as CommandHistoryEntry; + } + } +} + +const glob = promisify(globRaw); diff --git a/packages/cursorless-engine/src/index.ts b/packages/cursorless-engine/src/index.ts index c68a23dc48..9087d6a60c 100644 --- a/packages/cursorless-engine/src/index.ts +++ b/packages/cursorless-engine/src/index.ts @@ -9,3 +9,4 @@ export * from "./generateSpokenForm/defaultSpokenForms/surroundingPairsDelimiter export * from "./api/CursorlessEngineApi"; export * from "./CommandRunner"; export * from "./CommandHistory"; +export * from "./CommandHistoryAnalyzer"; diff --git a/packages/cursorless-engine/src/util/getScopeType.ts b/packages/cursorless-engine/src/util/getScopeType.ts new file mode 100644 index 0000000000..5a4bfeaa16 --- /dev/null +++ b/packages/cursorless-engine/src/util/getScopeType.ts @@ -0,0 +1,33 @@ +import type { Modifier, ScopeType } from "@cursorless/common"; + +export function getScopeType(modifier: Modifier): ScopeType | undefined { + switch (modifier.type) { + case "containingScope": + case "everyScope": + case "ordinalScope": + case "relativeScope": + return modifier.scopeType; + + case "interiorOnly": + case "excludeInterior": + case "visible": + case "toRawSelection": + case "inferPreviousMark": + case "keepContentFilter": + case "keepEmptyFilter": + case "leading": + case "trailing": + case "startOf": + case "endOf": + case "extendThroughStartOf": + case "extendThroughEndOf": + case "cascading": + case "range": + case "modifyIfUntyped": + return undefined; + + default: { + const _exhaustiveCheck: never = modifier; + } + } +} diff --git a/packages/cursorless-vscode/package.json b/packages/cursorless-vscode/package.json index e7b2164db4..8d20bf0ee1 100644 --- a/packages/cursorless-vscode/package.json +++ b/packages/cursorless-vscode/package.json @@ -70,7 +70,8 @@ "onCommand:cursorless.takeSnapshot", "onCommand:cursorless.toggleDecorations", "onCommand:cursorless.showScopeVisualizer", - "onCommand:cursorless.hideScopeVisualizer" + "onCommand:cursorless.hideScopeVisualizer", + "onCommand:cursorless.analyzeCommandHistory" ], "main": "./extension.cjs", "capabilities": { @@ -124,6 +125,10 @@ "command": "cursorless.hideScopeVisualizer", "title": "Cursorless: Hide the scope visualizer" }, + { + "command": "cursorless.analyzeCommandHistory", + "title": "Cursorless: Analyze collected command history" + }, { "command": "cursorless.command", "title": "Cursorless: The core cursorless command", diff --git a/packages/cursorless-vscode/src/extension.ts b/packages/cursorless-vscode/src/extension.ts index 512381777e..9b51839c86 100644 --- a/packages/cursorless-vscode/src/extension.ts +++ b/packages/cursorless-vscode/src/extension.ts @@ -133,6 +133,7 @@ export async function activate( context, vscodeIDE, commandApi, + fileSystem, testCaseRecorder, scopeVisualizer, keyboardCommands, diff --git a/packages/cursorless-vscode/src/registerCommands.ts b/packages/cursorless-vscode/src/registerCommands.ts index cbaae38ed8..c070e296a6 100644 --- a/packages/cursorless-vscode/src/registerCommands.ts +++ b/packages/cursorless-vscode/src/registerCommands.ts @@ -1,11 +1,13 @@ import { CURSORLESS_COMMAND_ID, CursorlessCommandId, + FileSystem, isTesting, } from "@cursorless/common"; import { CommandApi, TestCaseRecorder, + analyzeCommandHistory, showCheatsheet, updateDefaults, } from "@cursorless/cursorless-engine"; @@ -21,6 +23,7 @@ export function registerCommands( extensionContext: vscode.ExtensionContext, vscodeIde: VscodeIDE, commandApi: CommandApi, + fileSystem: FileSystem, testCaseRecorder: TestCaseRecorder, scopeVisualizer: ScopeVisualizer, keyboardCommands: KeyboardCommands, @@ -67,6 +70,10 @@ export function registerCommands( ["cursorless.showScopeVisualizer"]: scopeVisualizer.start, ["cursorless.hideScopeVisualizer"]: scopeVisualizer.stop, + // Command history + ["cursorless.analyzeCommandHistory"]: () => + analyzeCommandHistory(fileSystem.cursorlessCommandHistoryDirPath), + // General keyboard commands ["cursorless.keyboard.escape"]: keyboardCommands.keyboardHandler.cancelActiveListener, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce6553e9e0..38f0b6343b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -238,6 +238,9 @@ importers: '@cursorless/common': specifier: workspace:* version: link:../common + glob: + specifier: ^7.1.7 + version: 7.2.3 immer: specifier: ^9.0.15 version: 9.0.19 @@ -263,6 +266,9 @@ importers: specifier: 3.22.3 version: 3.22.3 devDependencies: + '@types/glob': + specifier: ^7.1.3 + version: 7.2.0 '@types/js-yaml': specifier: ^4.0.2 version: 4.0.5