From 260639086eff7517e2b046a7e8410a45a9fc29fe Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Wed, 3 Jan 2024 13:27:41 +0000 Subject: [PATCH 1/8] Add simple command history analyzer --- packages/common/src/cursorlessCommandIds.ts | 4 + packages/cursorless-engine/package.json | 2 + .../src/CommandHistoryAnalyzer.ts | 140 ++++++++++++++++++ packages/cursorless-engine/src/index.ts | 1 + .../src/util/getScopeType.ts | 31 ++++ packages/cursorless-vscode/package.json | 7 +- packages/cursorless-vscode/src/extension.ts | 1 + .../cursorless-vscode/src/registerCommands.ts | 7 + pnpm-lock.yaml | 6 + 9 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 packages/cursorless-engine/src/CommandHistoryAnalyzer.ts create mode 100644 packages/cursorless-engine/src/util/getScopeType.ts 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..a13b93e9e9 --- /dev/null +++ b/packages/cursorless-engine/src/CommandHistoryAnalyzer.ts @@ -0,0 +1,140 @@ +import { + CommandHistoryEntry, + Modifier, + PartialPrimitiveTargetDescriptor, + ScopeType, +} from "@cursorless/common"; +import globRaw from "glob"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; +import { canonicalizeAndValidateCommand } from "./core/commandVersionUpgrades/canonicalizeAndValidateCommand"; +import { ide } from "./singletons/ide.singleton"; +import { getPartialTargetDescriptors } from "./util/getPartialTargetDescriptors"; +import { getPartialPrimitiveTargets } from "./util/getPrimitiveTargets"; +import { getScopeType } from "./util/getScopeType"; + +const glob = promisify(globRaw); + +class Period { + readonly period: string; + readonly actions: Record = {}; + readonly modifiers: Record = {}; + readonly scopeTypes: Record = {}; + count: number = 0; + + constructor(period: string) { + this.period = period; + } + + toString(): string { + return [ + `[${this.period}]`, + `Total commands: ${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 entries = Object.entries(map); + entries.sort((a, b) => b[1] - a[1]); + const entriesSerialized = entries + .map(([key, value]) => ` ${key}: ${value}`) + .join("\n"); + return `${name} (${entries.length}):\n${entriesSerialized}`; + } + + append(entry: CommandHistoryEntry) { + this.count++; + const command = canonicalizeAndValidateCommand(entry.command); + this.incrementAction(command.action.name); + + const partialTargets = getPartialTargetDescriptors(command.action); + const partialPrimitiveTargets = getPartialPrimitiveTargets(partialTargets); + this.parsePrimitiveTargets(partialPrimitiveTargets); + } + + 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; + } +} + +class Periods { + readonly periods: Record = {}; + + getMonth(entry: CommandHistoryEntry) { + const date = entry.date.slice(0, 7); + + if (!this.periods[date]) { + this.periods[date] = new Period(date); + } + + return this.periods[date]; + } + + getPeriods() { + const periods = Object.values(this.periods); + periods.sort((a, b) => a.period.localeCompare(b.period)); + return periods; + } +} + +async function analyzeFilesAndReturnMonths(dir: string): Promise { + const files = await glob("*.jsonl", { cwd: dir }); + const periods = new Periods(); + + 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; + } + + const entry = JSON.parse(line) as CommandHistoryEntry; + const month = periods.getMonth(entry); + month.append(entry); + } + } + + return periods.getPeriods(); +} + +export async function analyzeCommandHistory(dir: string) { + const months = await analyzeFilesAndReturnMonths(dir); + const monthTexts = months.map((month) => month.toString()); + const text = monthTexts.join("\n\n") + "\n"; + + await ide().openUntitledTextDocument({ content: text }); +} diff --git a/packages/cursorless-engine/src/index.ts b/packages/cursorless-engine/src/index.ts index 37648cf84c..a16afbc058 100644 --- a/packages/cursorless-engine/src/index.ts +++ b/packages/cursorless-engine/src/index.ts @@ -8,3 +8,4 @@ export * from "./cursorlessEngine"; 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..b112e8f0dc --- /dev/null +++ b/packages/cursorless-engine/src/util/getScopeType.ts @@ -0,0 +1,31 @@ +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": + return undefined; + + case "cascading": + case "range": + case "modifyIfUntyped": + throw Error(`Unexpected modifier type: ${modifier.type}`); + } +} diff --git a/packages/cursorless-vscode/package.json b/packages/cursorless-vscode/package.json index 2c80dbe818..7724817e7d 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 33d45c75e4..6d6110d62d 100644 --- a/packages/cursorless-vscode/src/extension.ts +++ b/packages/cursorless-vscode/src/extension.ts @@ -131,6 +131,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 7c67a8a4fc..0d55481223 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 From 05fd46a0630b6d76b196820d02799942e8aef146 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Fri, 26 Jan 2024 15:37:57 +0000 Subject: [PATCH 2/8] Simplification --- .../src/CommandHistoryAnalyzer.ts | 81 ++++++------------- .../src/asyncIteratorToList.ts | 9 +++ .../src/generateCommandHistoryEntries.ts | 22 +++++ .../cursorless-engine/src/groupby.test.ts | 39 +++++++++ packages/cursorless-engine/src/groupby.ts | 49 +++++++++++ packages/cursorless-engine/src/mapAsync.ts | 16 ++++ .../src/util/getScopeType.ts | 8 +- 7 files changed, 166 insertions(+), 58 deletions(-) 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/groupby.test.ts create mode 100644 packages/cursorless-engine/src/groupby.ts create mode 100644 packages/cursorless-engine/src/mapAsync.ts diff --git a/packages/cursorless-engine/src/CommandHistoryAnalyzer.ts b/packages/cursorless-engine/src/CommandHistoryAnalyzer.ts index a13b93e9e9..8b59fbcd11 100644 --- a/packages/cursorless-engine/src/CommandHistoryAnalyzer.ts +++ b/packages/cursorless-engine/src/CommandHistoryAnalyzer.ts @@ -5,26 +5,31 @@ import { ScopeType, } from "@cursorless/common"; import globRaw from "glob"; -import { readFile } from "node:fs/promises"; -import path from "node:path"; import { promisify } from "node:util"; import { canonicalizeAndValidateCommand } from "./core/commandVersionUpgrades/canonicalizeAndValidateCommand"; import { ide } from "./singletons/ide.singleton"; import { getPartialTargetDescriptors } from "./util/getPartialTargetDescriptors"; import { getPartialPrimitiveTargets } from "./util/getPrimitiveTargets"; import { getScopeType } from "./util/getScopeType"; +import { generateCommandHistoryEntries } from "./generateCommandHistoryEntries"; +import groupby from "./groupby"; +import { mapAsync } from "./mapAsync"; +import { asyncIteratorToList } from "./asyncIteratorToList"; -const glob = promisify(globRaw); +export const glob = promisify(globRaw); class Period { - readonly period: string; - readonly actions: Record = {}; - readonly modifiers: Record = {}; - readonly scopeTypes: Record = {}; - count: number = 0; + private readonly period: string; + private readonly actions: Record = {}; + private readonly modifiers: Record = {}; + private readonly scopeTypes: Record = {}; + private count: number = 0; - constructor(period: string) { + constructor(period: string, entries: CommandHistoryEntry[]) { this.period = period; + for (const entry of entries) { + this.append(entry); + } } toString(): string { @@ -46,14 +51,14 @@ class Period { return `${name} (${entries.length}):\n${entriesSerialized}`; } - append(entry: CommandHistoryEntry) { + private append(entry: CommandHistoryEntry) { this.count++; const command = canonicalizeAndValidateCommand(entry.command); this.incrementAction(command.action.name); - const partialTargets = getPartialTargetDescriptors(command.action); - const partialPrimitiveTargets = getPartialPrimitiveTargets(partialTargets); - this.parsePrimitiveTargets(partialPrimitiveTargets); + this.parsePrimitiveTargets( + getPartialPrimitiveTargets(getPartialTargetDescriptors(command.action)), + ); } private parsePrimitiveTargets( @@ -88,53 +93,19 @@ class Period { } } -class Periods { - readonly periods: Record = {}; - - getMonth(entry: CommandHistoryEntry) { - const date = entry.date.slice(0, 7); - - if (!this.periods[date]) { - this.periods[date] = new Period(date); - } - - return this.periods[date]; - } - - getPeriods() { - const periods = Object.values(this.periods); - periods.sort((a, b) => a.period.localeCompare(b.period)); - return periods; - } +function getMonth(entry: CommandHistoryEntry): string { + return entry.date.slice(0, 7); } -async function analyzeFilesAndReturnMonths(dir: string): Promise { - const files = await glob("*.jsonl", { cwd: dir }); - const periods = new Periods(); - - 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; - } - - const entry = JSON.parse(line) as CommandHistoryEntry; - const month = periods.getMonth(entry); - month.append(entry); - } - } - - return periods.getPeriods(); +function generatePeriods(dir: string): AsyncIterable { + return mapAsync( + groupby(generateCommandHistoryEntries(dir), getMonth), + ([key, entries]) => new Period(key, entries).toString(), + ); } export async function analyzeCommandHistory(dir: string) { - const months = await analyzeFilesAndReturnMonths(dir); - const monthTexts = months.map((month) => month.toString()); - const text = monthTexts.join("\n\n") + "\n"; + const text = (await asyncIteratorToList(generatePeriods(dir))).join("\n\n"); await ide().openUntitledTextDocument({ content: text }); } diff --git a/packages/cursorless-engine/src/asyncIteratorToList.ts b/packages/cursorless-engine/src/asyncIteratorToList.ts new file mode 100644 index 0000000000..f68321641a --- /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..11261c6538 --- /dev/null +++ b/packages/cursorless-engine/src/generateCommandHistoryEntries.ts @@ -0,0 +1,22 @@ +import { CommandHistoryEntry } from "@cursorless/common"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { glob } from "./CommandHistoryAnalyzer"; + +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; + } + } +} diff --git a/packages/cursorless-engine/src/groupby.test.ts b/packages/cursorless-engine/src/groupby.test.ts new file mode 100644 index 0000000000..147df2c09f --- /dev/null +++ b/packages/cursorless-engine/src/groupby.test.ts @@ -0,0 +1,39 @@ +import assert from "assert"; +import groupby from "./groupby"; + +describe("groupby", () => { + it("should group entries based on the callback function", async () => { + const generator = async function* () { + yield 1; + yield 2; + yield 3; + yield 4; + yield 5; + }; + + const callback = (entry: number) => (entry % 2 === 0 ? "even" : "odd"); + + const result = []; + for await (const group of groupby(generator(), callback)) { + result.push(group); + } + + assert.equal(result, [ + [1, 3, 5], + [2, 4], + ]); + }); + + it("should handle empty generator", async () => { + const generator = async function* () {}; + + const callback = (entry: number) => (entry % 2 === 0 ? "even" : "odd"); + + const result = []; + for await (const group of groupby(generator(), callback)) { + result.push(group); + } + + assert.equal(result, []); + }); +}); diff --git a/packages/cursorless-engine/src/groupby.ts b/packages/cursorless-engine/src/groupby.ts new file mode 100644 index 0000000000..2ba5185ac0 --- /dev/null +++ b/packages/cursorless-engine/src/groupby.ts @@ -0,0 +1,49 @@ +/** + * Given an async generator and a callback, returns an async generator that + * yields groups of items from the original generator, where each group contains + * those entries that have the same value for the callback. + * + * For example, if the original generator yields the following entries: + * + * { date: "2021-01-01", command: "foo" } + * { date: "2021-01-01", command: "bar" } + * { date: "2021-01-02", command: "baz" } + * { date: "2021-01-02", command: "qux" } + * + * and the callback is `entry => entry.date`, then the returned generator will + * yield the following groups: + * + * [ + * { date: "2021-01-01", command: "foo" }, + * { date: "2021-01-01", command: "bar" }, + * ], + * [ + * { date: "2021-01-02", command: "baz" }, + * { date: "2021-01-02", command: "qux" }, + * ] + * + * @param generator The generator to group + * @param callback The callback to use to determine which entries are in the same group + * @returns An async generator that yields groups of entries + */ +export default async function* groupby( + generator: AsyncGenerator, + callback: (entry: T) => K, +): AsyncGenerator<[K, T[]]> { + const groups = new Map(); + const keys: K[] = []; + + for await (const entry of generator) { + const key = callback(entry); + const group = groups.get(key) ?? []; + group.push(entry); + if (!groups.has(key)) { + groups.set(key, group); + keys.push(key); + } + } + + for (const pair of groups.entries()) { + yield pair; + } +} diff --git a/packages/cursorless-engine/src/mapAsync.ts b/packages/cursorless-engine/src/mapAsync.ts new file mode 100644 index 0000000000..d9f8eae0ba --- /dev/null +++ b/packages/cursorless-engine/src/mapAsync.ts @@ -0,0 +1,16 @@ +/** + * Given an async generator and a callback, returns an async generator that + * applies the callback to each entry in the original generator and yields the + * result. + * @param generator The generator to map + * @param callback The callback to apply to each entry + * @returns An async generator that yields the result of applying the callback + */ +export async function* mapAsync( + generator: AsyncGenerator, + callback: (entry: T) => K, +): AsyncGenerator { + for await (const entry of generator) { + yield callback(entry); + } +} diff --git a/packages/cursorless-engine/src/util/getScopeType.ts b/packages/cursorless-engine/src/util/getScopeType.ts index b112e8f0dc..5a4bfeaa16 100644 --- a/packages/cursorless-engine/src/util/getScopeType.ts +++ b/packages/cursorless-engine/src/util/getScopeType.ts @@ -21,11 +21,13 @@ export function getScopeType(modifier: Modifier): ScopeType | undefined { case "endOf": case "extendThroughStartOf": case "extendThroughEndOf": - return undefined; - case "cascading": case "range": case "modifyIfUntyped": - throw Error(`Unexpected modifier type: ${modifier.type}`); + return undefined; + + default: { + const _exhaustiveCheck: never = modifier; + } } } From d4b5aa1740b9563f4b50eb5d036303e3bdb277c6 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Fri, 26 Jan 2024 15:41:24 +0000 Subject: [PATCH 3/8] simplify --- .../src/CommandHistoryAnalyzer.ts | 18 +++---- .../cursorless-engine/src/groupby.test.ts | 39 --------------- packages/cursorless-engine/src/groupby.ts | 49 ------------------- packages/cursorless-engine/src/mapAsync.ts | 16 ------ 4 files changed, 9 insertions(+), 113 deletions(-) delete mode 100644 packages/cursorless-engine/src/groupby.test.ts delete mode 100644 packages/cursorless-engine/src/groupby.ts delete mode 100644 packages/cursorless-engine/src/mapAsync.ts diff --git a/packages/cursorless-engine/src/CommandHistoryAnalyzer.ts b/packages/cursorless-engine/src/CommandHistoryAnalyzer.ts index 8b59fbcd11..ac1c8b8e3a 100644 --- a/packages/cursorless-engine/src/CommandHistoryAnalyzer.ts +++ b/packages/cursorless-engine/src/CommandHistoryAnalyzer.ts @@ -5,16 +5,15 @@ import { ScopeType, } from "@cursorless/common"; import globRaw from "glob"; +import { groupBy, map } from "lodash"; import { promisify } from "node:util"; +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"; -import { generateCommandHistoryEntries } from "./generateCommandHistoryEntries"; -import groupby from "./groupby"; -import { mapAsync } from "./mapAsync"; -import { asyncIteratorToList } from "./asyncIteratorToList"; export const glob = promisify(globRaw); @@ -97,15 +96,16 @@ function getMonth(entry: CommandHistoryEntry): string { return entry.date.slice(0, 7); } -function generatePeriods(dir: string): AsyncIterable { - return mapAsync( - groupby(generateCommandHistoryEntries(dir), getMonth), - ([key, entries]) => new Period(key, entries).toString(), +async function generatePeriods(dir: string): Promise { + const entries = await asyncIteratorToList(generateCommandHistoryEntries(dir)); + + return map(Object.entries(groupBy(entries, getMonth)), ([key, entries]) => + new Period(key, entries).toString(), ); } export async function analyzeCommandHistory(dir: string) { - const text = (await asyncIteratorToList(generatePeriods(dir))).join("\n\n"); + const text = (await generatePeriods(dir)).join("\n\n"); await ide().openUntitledTextDocument({ content: text }); } diff --git a/packages/cursorless-engine/src/groupby.test.ts b/packages/cursorless-engine/src/groupby.test.ts deleted file mode 100644 index 147df2c09f..0000000000 --- a/packages/cursorless-engine/src/groupby.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import assert from "assert"; -import groupby from "./groupby"; - -describe("groupby", () => { - it("should group entries based on the callback function", async () => { - const generator = async function* () { - yield 1; - yield 2; - yield 3; - yield 4; - yield 5; - }; - - const callback = (entry: number) => (entry % 2 === 0 ? "even" : "odd"); - - const result = []; - for await (const group of groupby(generator(), callback)) { - result.push(group); - } - - assert.equal(result, [ - [1, 3, 5], - [2, 4], - ]); - }); - - it("should handle empty generator", async () => { - const generator = async function* () {}; - - const callback = (entry: number) => (entry % 2 === 0 ? "even" : "odd"); - - const result = []; - for await (const group of groupby(generator(), callback)) { - result.push(group); - } - - assert.equal(result, []); - }); -}); diff --git a/packages/cursorless-engine/src/groupby.ts b/packages/cursorless-engine/src/groupby.ts deleted file mode 100644 index 2ba5185ac0..0000000000 --- a/packages/cursorless-engine/src/groupby.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Given an async generator and a callback, returns an async generator that - * yields groups of items from the original generator, where each group contains - * those entries that have the same value for the callback. - * - * For example, if the original generator yields the following entries: - * - * { date: "2021-01-01", command: "foo" } - * { date: "2021-01-01", command: "bar" } - * { date: "2021-01-02", command: "baz" } - * { date: "2021-01-02", command: "qux" } - * - * and the callback is `entry => entry.date`, then the returned generator will - * yield the following groups: - * - * [ - * { date: "2021-01-01", command: "foo" }, - * { date: "2021-01-01", command: "bar" }, - * ], - * [ - * { date: "2021-01-02", command: "baz" }, - * { date: "2021-01-02", command: "qux" }, - * ] - * - * @param generator The generator to group - * @param callback The callback to use to determine which entries are in the same group - * @returns An async generator that yields groups of entries - */ -export default async function* groupby( - generator: AsyncGenerator, - callback: (entry: T) => K, -): AsyncGenerator<[K, T[]]> { - const groups = new Map(); - const keys: K[] = []; - - for await (const entry of generator) { - const key = callback(entry); - const group = groups.get(key) ?? []; - group.push(entry); - if (!groups.has(key)) { - groups.set(key, group); - keys.push(key); - } - } - - for (const pair of groups.entries()) { - yield pair; - } -} diff --git a/packages/cursorless-engine/src/mapAsync.ts b/packages/cursorless-engine/src/mapAsync.ts deleted file mode 100644 index d9f8eae0ba..0000000000 --- a/packages/cursorless-engine/src/mapAsync.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Given an async generator and a callback, returns an async generator that - * applies the callback to each entry in the original generator and yields the - * result. - * @param generator The generator to map - * @param callback The callback to apply to each entry - * @returns An async generator that yields the result of applying the callback - */ -export async function* mapAsync( - generator: AsyncGenerator, - callback: (entry: T) => K, -): AsyncGenerator { - for await (const entry of generator) { - yield callback(entry); - } -} From 2c5f5018186947aa854f6aae9f355f8e836b520c Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Fri, 26 Jan 2024 15:45:38 +0000 Subject: [PATCH 4/8] cleanup --- .../src/CommandHistoryAnalyzer.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/cursorless-engine/src/CommandHistoryAnalyzer.ts b/packages/cursorless-engine/src/CommandHistoryAnalyzer.ts index ac1c8b8e3a..f3c078a07f 100644 --- a/packages/cursorless-engine/src/CommandHistoryAnalyzer.ts +++ b/packages/cursorless-engine/src/CommandHistoryAnalyzer.ts @@ -17,6 +17,9 @@ import { getScopeType } from "./util/getScopeType"; export const glob = promisify(globRaw); +/** + * Analyzes the command history for a given time period, and outputs a report + */ class Period { private readonly period: string; private readonly actions: Record = {}; @@ -96,16 +99,13 @@ function getMonth(entry: CommandHistoryEntry): string { return entry.date.slice(0, 7); } -async function generatePeriods(dir: string): Promise { +export async function analyzeCommandHistory(dir: string) { const entries = await asyncIteratorToList(generateCommandHistoryEntries(dir)); - return map(Object.entries(groupBy(entries, getMonth)), ([key, entries]) => - new Period(key, entries).toString(), - ); -} - -export async function analyzeCommandHistory(dir: string) { - const text = (await generatePeriods(dir)).join("\n\n"); + const content = map( + Object.entries(groupBy(entries, getMonth)), + ([key, entries]) => new Period(key, entries).toString(), + ).join("\n\n"); - await ide().openUntitledTextDocument({ content: text }); + await ide().openUntitledTextDocument({ content }); } From 3de5578e9fa026c01aea5581a0e7991a85279e44 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Fri, 26 Jan 2024 15:49:31 +0000 Subject: [PATCH 5/8] [pre-commit.ci lite] apply automatic fixes --- packages/cursorless-engine/src/asyncIteratorToList.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cursorless-engine/src/asyncIteratorToList.ts b/packages/cursorless-engine/src/asyncIteratorToList.ts index f68321641a..7e8d0a5e56 100644 --- a/packages/cursorless-engine/src/asyncIteratorToList.ts +++ b/packages/cursorless-engine/src/asyncIteratorToList.ts @@ -1,5 +1,5 @@ export async function asyncIteratorToList( - iterator: AsyncIterable + iterator: AsyncIterable, ): Promise { const list: T[] = []; for await (const item of iterator) { From c6c1e25ce55bcae755e36f7b2bb1eb6b8d2737a4 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Wed, 31 Jan 2024 17:11:18 +0000 Subject: [PATCH 6/8] Add totals and percentages --- .../src/CommandHistoryAnalyzer.ts | 34 ++++++++++++------- .../src/generateCommandHistoryEntries.ts | 5 ++- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/packages/cursorless-engine/src/CommandHistoryAnalyzer.ts b/packages/cursorless-engine/src/CommandHistoryAnalyzer.ts index f3c078a07f..f423f3d157 100644 --- a/packages/cursorless-engine/src/CommandHistoryAnalyzer.ts +++ b/packages/cursorless-engine/src/CommandHistoryAnalyzer.ts @@ -4,9 +4,7 @@ import { PartialPrimitiveTargetDescriptor, ScopeType, } from "@cursorless/common"; -import globRaw from "glob"; -import { groupBy, map } from "lodash"; -import { promisify } from "node:util"; +import { groupBy, map, sum } from "lodash"; import { asyncIteratorToList } from "./asyncIteratorToList"; import { canonicalizeAndValidateCommand } from "./core/commandVersionUpgrades/canonicalizeAndValidateCommand"; import { generateCommandHistoryEntries } from "./generateCommandHistoryEntries"; @@ -15,8 +13,6 @@ import { getPartialTargetDescriptors } from "./util/getPartialTargetDescriptors" import { getPartialPrimitiveTargets } from "./util/getPrimitiveTargets"; import { getScopeType } from "./util/getScopeType"; -export const glob = promisify(globRaw); - /** * Analyzes the command history for a given time period, and outputs a report */ @@ -36,8 +32,8 @@ class Period { toString(): string { return [ - `[${this.period}]`, - `Total commands: ${this.count}`, + `# ${this.period}`, + `Total command count: ${this.count}`, this.serializeMap("Actions", this.actions), this.serializeMap("Modifiers", this.modifiers), this.serializeMap("Scope types", this.scopeTypes), @@ -45,12 +41,13 @@ class Period { } 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}`) + .map(([key, value]) => ` ${key}: ${value} (${toPercent(value / total)})`) .join("\n"); - return `${name} (${entries.length}):\n${entriesSerialized}`; + return `${name}:\n${entriesSerialized}`; } private append(entry: CommandHistoryEntry) { @@ -102,10 +99,21 @@ function getMonth(entry: CommandHistoryEntry): string { export async function analyzeCommandHistory(dir: string) { const entries = await asyncIteratorToList(generateCommandHistoryEntries(dir)); - const content = map( - Object.entries(groupBy(entries, getMonth)), - ([key, entries]) => new Period(key, entries).toString(), - ).join("\n\n"); + 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/generateCommandHistoryEntries.ts b/packages/cursorless-engine/src/generateCommandHistoryEntries.ts index 11261c6538..9794b0d2c6 100644 --- a/packages/cursorless-engine/src/generateCommandHistoryEntries.ts +++ b/packages/cursorless-engine/src/generateCommandHistoryEntries.ts @@ -1,7 +1,8 @@ import { CommandHistoryEntry } from "@cursorless/common"; +import globRaw from "glob"; import { readFile } from "node:fs/promises"; import path from "node:path"; -import { glob } from "./CommandHistoryAnalyzer"; +import { promisify } from "node:util"; export async function* generateCommandHistoryEntries(dir: string) { const files = await glob("*.jsonl", { cwd: dir }); @@ -20,3 +21,5 @@ export async function* generateCommandHistoryEntries(dir: string) { } } } + +const glob = promisify(globRaw); From 39bfc139141690b4745afd1b75a9c6a292e8699b Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Wed, 31 Jan 2024 17:18:21 +0000 Subject: [PATCH 7/8] Add docs / voice command --- cursorless-talon/src/cursorless.py | 8 +++++++- cursorless-talon/src/cursorless.talon | 3 +++ docs/user/localCommandHIstory.md | 2 ++ 3 files changed, 12 insertions(+), 1 deletion(-) 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! From dbb3351cdc819851140778e8f17d37dc0348873c Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Wed, 31 Jan 2024 17:28:18 +0000 Subject: [PATCH 8/8] Handle case with no history --- .../src/CommandHistoryAnalyzer.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/cursorless-engine/src/CommandHistoryAnalyzer.ts b/packages/cursorless-engine/src/CommandHistoryAnalyzer.ts index f423f3d157..bf467cc25b 100644 --- a/packages/cursorless-engine/src/CommandHistoryAnalyzer.ts +++ b/packages/cursorless-engine/src/CommandHistoryAnalyzer.ts @@ -3,6 +3,7 @@ import { Modifier, PartialPrimitiveTargetDescriptor, ScopeType, + showWarning, } from "@cursorless/common"; import { groupBy, map, sum } from "lodash"; import { asyncIteratorToList } from "./asyncIteratorToList"; @@ -99,6 +100,26 @@ function getMonth(entry: CommandHistoryEntry): string { 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(),