Skip to content

Commit be6ab53

Browse files
AndreasArvidssonpokeypre-commit-ci-lite[bot]
authored
Add simple command history analyzer (#2163)
<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>
1 parent 09c28c8 commit be6ab53

File tree

14 files changed

+246
-2
lines changed

14 files changed

+246
-2
lines changed

cursorless-talon/src/cursorless.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from talon import Module
1+
from talon import Module, actions
22

33
mod = Module()
44

@@ -15,3 +15,9 @@ def private_cursorless_show_settings_in_ide():
1515

1616
def private_cursorless_show_sidebar():
1717
"""Show Cursorless-specific settings in ide"""
18+
19+
def private_cursorless_show_command_statistics():
20+
"""Show Cursorless command statistics"""
21+
actions.user.private_cursorless_run_rpc_command_no_wait(
22+
"cursorless.analyzeCommandHistory"
23+
)

cursorless-talon/src/cursorless.talon

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,6 @@ tag: user.cursorless
4343

4444
bar {user.cursorless_homophone}:
4545
user.private_cursorless_show_sidebar()
46+
47+
{user.cursorless_homophone} stats:
48+
user.private_cursorless_show_command_statistics()

docs/user/localCommandHIstory.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,5 @@ To enable local, sanitized command logging, enable the `cursorless.commandHistor
1111
```
1212

1313
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.
14+
15+
We currently offer very basic command statistics via the `"cursorless stats"` voice command. Expect more in the future!

packages/common/src/cursorlessCommandIds.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export const cursorlessCommandIds = [
4747
"cursorless.toggleDecorations",
4848
"cursorless.showScopeVisualizer",
4949
"cursorless.hideScopeVisualizer",
50+
"cursorless.analyzeCommandHistory",
5051
] as const satisfies readonly `cursorless.${string}`[];
5152

5253
export type CursorlessCommandId = (typeof cursorlessCommandIds)[number];
@@ -76,6 +77,9 @@ export const cursorlessCommandDescriptions: Record<
7677
["cursorless.hideScopeVisualizer"]: new VisibleCommand(
7778
"Hide the scope visualizer",
7879
),
80+
["cursorless.analyzeCommandHistory"]: new VisibleCommand(
81+
"Analyze collected command history",
82+
),
7983

8084
["cursorless.command"]: new HiddenCommand("The core cursorless command"),
8185
["cursorless.showQuickPick"]: new HiddenCommand(

packages/cursorless-engine/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"license": "MIT",
1818
"dependencies": {
1919
"@cursorless/common": "workspace:*",
20+
"glob": "^7.1.7",
2021
"immer": "^9.0.15",
2122
"immutability-helper": "^3.1.1",
2223
"itertools": "^2.1.1",
@@ -27,6 +28,7 @@
2728
"zod": "3.22.3"
2829
},
2930
"devDependencies": {
31+
"@types/glob": "^7.1.3",
3032
"@types/js-yaml": "^4.0.2",
3133
"@types/lodash": "4.14.181",
3234
"@types/mocha": "^10.0.3",
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import {
2+
CommandHistoryEntry,
3+
Modifier,
4+
PartialPrimitiveTargetDescriptor,
5+
ScopeType,
6+
showWarning,
7+
} from "@cursorless/common";
8+
import { groupBy, map, sum } from "lodash";
9+
import { asyncIteratorToList } from "./asyncIteratorToList";
10+
import { canonicalizeAndValidateCommand } from "./core/commandVersionUpgrades/canonicalizeAndValidateCommand";
11+
import { generateCommandHistoryEntries } from "./generateCommandHistoryEntries";
12+
import { ide } from "./singletons/ide.singleton";
13+
import { getPartialTargetDescriptors } from "./util/getPartialTargetDescriptors";
14+
import { getPartialPrimitiveTargets } from "./util/getPrimitiveTargets";
15+
import { getScopeType } from "./util/getScopeType";
16+
17+
/**
18+
* Analyzes the command history for a given time period, and outputs a report
19+
*/
20+
class Period {
21+
private readonly period: string;
22+
private readonly actions: Record<string, number> = {};
23+
private readonly modifiers: Record<string, number> = {};
24+
private readonly scopeTypes: Record<string, number> = {};
25+
private count: number = 0;
26+
27+
constructor(period: string, entries: CommandHistoryEntry[]) {
28+
this.period = period;
29+
for (const entry of entries) {
30+
this.append(entry);
31+
}
32+
}
33+
34+
toString(): string {
35+
return [
36+
`# ${this.period}`,
37+
`Total command count: ${this.count}`,
38+
this.serializeMap("Actions", this.actions),
39+
this.serializeMap("Modifiers", this.modifiers),
40+
this.serializeMap("Scope types", this.scopeTypes),
41+
].join("\n\n");
42+
}
43+
44+
private serializeMap(name: string, map: Record<string, number>) {
45+
const total = sum(Object.values(map));
46+
const entries = Object.entries(map);
47+
entries.sort((a, b) => b[1] - a[1]);
48+
const entriesSerialized = entries
49+
.map(([key, value]) => ` ${key}: ${value} (${toPercent(value / total)})`)
50+
.join("\n");
51+
return `${name}:\n${entriesSerialized}`;
52+
}
53+
54+
private append(entry: CommandHistoryEntry) {
55+
this.count++;
56+
const command = canonicalizeAndValidateCommand(entry.command);
57+
this.incrementAction(command.action.name);
58+
59+
this.parsePrimitiveTargets(
60+
getPartialPrimitiveTargets(getPartialTargetDescriptors(command.action)),
61+
);
62+
}
63+
64+
private parsePrimitiveTargets(
65+
partialPrimitiveTargets: PartialPrimitiveTargetDescriptor[],
66+
) {
67+
for (const target of partialPrimitiveTargets) {
68+
if (target.modifiers == null) {
69+
continue;
70+
}
71+
for (const modifier of target.modifiers) {
72+
this.incrementModifier(modifier);
73+
74+
const scopeType = getScopeType(modifier);
75+
if (scopeType != null) {
76+
this.incrementScope(scopeType);
77+
}
78+
}
79+
}
80+
}
81+
82+
private incrementAction(actionName: string) {
83+
this.actions[actionName] = (this.actions[actionName] ?? 0) + 1;
84+
}
85+
86+
private incrementModifier(modifier: Modifier) {
87+
this.modifiers[modifier.type] = (this.modifiers[modifier.type] ?? 0) + 1;
88+
}
89+
90+
private incrementScope(scopeType: ScopeType) {
91+
this.scopeTypes[scopeType.type] =
92+
(this.scopeTypes[scopeType.type] ?? 0) + 1;
93+
}
94+
}
95+
96+
function getMonth(entry: CommandHistoryEntry): string {
97+
return entry.date.slice(0, 7);
98+
}
99+
100+
export async function analyzeCommandHistory(dir: string) {
101+
const entries = await asyncIteratorToList(generateCommandHistoryEntries(dir));
102+
103+
if (entries.length === 0) {
104+
const TAKE_ME_THERE = "Show me";
105+
const result = await showWarning(
106+
ide().messages,
107+
"noHistory",
108+
"No command history entries found. Please enable the command history in the settings.",
109+
TAKE_ME_THERE,
110+
);
111+
112+
if (result === TAKE_ME_THERE) {
113+
// FIXME: This is VSCode-specific
114+
await ide().executeCommand(
115+
"workbench.action.openSettings",
116+
"cursorless.commandHistory",
117+
);
118+
}
119+
120+
return;
121+
}
122+
123+
const content = [
124+
new Period("Totals", entries).toString(),
125+
126+
...map(Object.entries(groupBy(entries, getMonth)), ([key, entries]) =>
127+
new Period(key, entries).toString(),
128+
),
129+
].join("\n\n\n");
130+
131+
await ide().openUntitledTextDocument({ content });
132+
}
133+
134+
function toPercent(value: number) {
135+
return Intl.NumberFormat(undefined, {
136+
style: "percent",
137+
minimumFractionDigits: 0,
138+
maximumFractionDigits: 1,
139+
}).format(value);
140+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export async function asyncIteratorToList<T>(
2+
iterator: AsyncIterable<T>,
3+
): Promise<T[]> {
4+
const list: T[] = [];
5+
for await (const item of iterator) {
6+
list.push(item);
7+
}
8+
return list;
9+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { CommandHistoryEntry } from "@cursorless/common";
2+
import globRaw from "glob";
3+
import { readFile } from "node:fs/promises";
4+
import path from "node:path";
5+
import { promisify } from "node:util";
6+
7+
export async function* generateCommandHistoryEntries(dir: string) {
8+
const files = await glob("*.jsonl", { cwd: dir });
9+
10+
for (const file of files) {
11+
const filePath = path.join(dir, file);
12+
const content = await readFile(filePath, "utf8");
13+
const lines = content.split("\n");
14+
15+
for (const line of lines) {
16+
if (line.length === 0) {
17+
continue;
18+
}
19+
20+
yield JSON.parse(line) as CommandHistoryEntry;
21+
}
22+
}
23+
}
24+
25+
const glob = promisify(globRaw);

packages/cursorless-engine/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ export * from "./generateSpokenForm/defaultSpokenForms/surroundingPairsDelimiter
99
export * from "./api/CursorlessEngineApi";
1010
export * from "./CommandRunner";
1111
export * from "./CommandHistory";
12+
export * from "./CommandHistoryAnalyzer";
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { Modifier, ScopeType } from "@cursorless/common";
2+
3+
export function getScopeType(modifier: Modifier): ScopeType | undefined {
4+
switch (modifier.type) {
5+
case "containingScope":
6+
case "everyScope":
7+
case "ordinalScope":
8+
case "relativeScope":
9+
return modifier.scopeType;
10+
11+
case "interiorOnly":
12+
case "excludeInterior":
13+
case "visible":
14+
case "toRawSelection":
15+
case "inferPreviousMark":
16+
case "keepContentFilter":
17+
case "keepEmptyFilter":
18+
case "leading":
19+
case "trailing":
20+
case "startOf":
21+
case "endOf":
22+
case "extendThroughStartOf":
23+
case "extendThroughEndOf":
24+
case "cascading":
25+
case "range":
26+
case "modifyIfUntyped":
27+
return undefined;
28+
29+
default: {
30+
const _exhaustiveCheck: never = modifier;
31+
}
32+
}
33+
}

0 commit comments

Comments
 (0)