Skip to content

Commit

Permalink
Add simple command history analyzer (#2163)
Browse files Browse the repository at this point in the history
<img width="525" alt="image"
src="https://github.com/cursorless-dev/cursorless/assets/755842/fae1bdd3-470a-4c28-b2ef-e47818943b59">


## 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 <[email protected]>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
  • Loading branch information
3 people authored Feb 1, 2024
1 parent 09c28c8 commit be6ab53
Show file tree
Hide file tree
Showing 14 changed files with 246 additions and 2 deletions.
8 changes: 7 additions & 1 deletion cursorless-talon/src/cursorless.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from talon import Module
from talon import Module, actions

mod = Module()

Expand All @@ -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"
)
3 changes: 3 additions & 0 deletions cursorless-talon/src/cursorless.talon
Original file line number Diff line number Diff line change
Expand Up @@ -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()
2 changes: 2 additions & 0 deletions docs/user/localCommandHIstory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!
4 changes: 4 additions & 0 deletions packages/common/src/cursorlessCommandIds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions packages/cursorless-engine/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
140 changes: 140 additions & 0 deletions packages/cursorless-engine/src/CommandHistoryAnalyzer.ts
Original file line number Diff line number Diff line change
@@ -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<string, number> = {};
private readonly modifiers: Record<string, number> = {};
private readonly scopeTypes: Record<string, number> = {};
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<string, number>) {
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);
}
9 changes: 9 additions & 0 deletions packages/cursorless-engine/src/asyncIteratorToList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export async function asyncIteratorToList<T>(
iterator: AsyncIterable<T>,
): Promise<T[]> {
const list: T[] = [];
for await (const item of iterator) {
list.push(item);
}
return list;
}
25 changes: 25 additions & 0 deletions packages/cursorless-engine/src/generateCommandHistoryEntries.ts
Original file line number Diff line number Diff line change
@@ -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);
1 change: 1 addition & 0 deletions packages/cursorless-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ export * from "./generateSpokenForm/defaultSpokenForms/surroundingPairsDelimiter
export * from "./api/CursorlessEngineApi";
export * from "./CommandRunner";
export * from "./CommandHistory";
export * from "./CommandHistoryAnalyzer";
33 changes: 33 additions & 0 deletions packages/cursorless-engine/src/util/getScopeType.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
7 changes: 6 additions & 1 deletion packages/cursorless-vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/cursorless-vscode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ export async function activate(
context,
vscodeIDE,
commandApi,
fileSystem,
testCaseRecorder,
scopeVisualizer,
keyboardCommands,
Expand Down
7 changes: 7 additions & 0 deletions packages/cursorless-vscode/src/registerCommands.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import {
CURSORLESS_COMMAND_ID,
CursorlessCommandId,
FileSystem,
isTesting,
} from "@cursorless/common";
import {
CommandApi,
TestCaseRecorder,
analyzeCommandHistory,
showCheatsheet,
updateDefaults,
} from "@cursorless/cursorless-engine";
Expand All @@ -21,6 +23,7 @@ export function registerCommands(
extensionContext: vscode.ExtensionContext,
vscodeIde: VscodeIDE,
commandApi: CommandApi,
fileSystem: FileSystem,
testCaseRecorder: TestCaseRecorder,
scopeVisualizer: ScopeVisualizer,
keyboardCommands: KeyboardCommands,
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit be6ab53

Please sign in to comment.