Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add simple command history analyzer #2163

Merged
merged 9 commits into from
Feb 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -134,6 +134,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.

Loading