Skip to content

Commit 0f51f42

Browse files
authored
Log typing speed (microsoft#253656)
* log typing speed * include character count as reference for reliable speed computation and include more characters if not many in recent session window * call it typing interval
1 parent 28738da commit 0f51f42

File tree

6 files changed

+250
-0
lines changed

6 files changed

+250
-0
lines changed

src/vs/editor/common/languages.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1002,6 +1002,8 @@ export type LifetimeSummary = {
10021002
characterCountModified?: number;
10031003
disjointReplacements?: number;
10041004
sameShapeReplacements?: boolean;
1005+
typingInterval: number;
1006+
typingIntervalCharacterCount: number;
10051007
};
10061008

10071009
export interface CodeAction {

src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { TextModelEditReason, EditReasons } from '../../../../common/textModelEd
4747
import { ICodeEditorService } from '../../../../browser/services/codeEditorService.js';
4848
import { InlineCompletionViewData, InlineCompletionViewKind } from '../view/inlineEdits/inlineEditsViewInterface.js';
4949
import { IInlineCompletionsService } from '../../../../browser/services/inlineCompletionsService.js';
50+
import { TypingInterval } from './typingSpeed.js';
5051

5152
export class InlineCompletionsModel extends Disposable {
5253
private readonly _source;
@@ -84,6 +85,8 @@ export class InlineCompletionsModel extends Disposable {
8485

8586
private readonly _editorObs;
8687

88+
private readonly _typing: TypingInterval;
89+
8790
private readonly _suggestPreviewEnabled;
8891
private readonly _suggestPreviewMode;
8992
private readonly _inlineSuggestMode;
@@ -120,6 +123,7 @@ export class InlineCompletionsModel extends Disposable {
120123
this._inlineEditsEnabled = this._editorObs.getOption(EditorOption.inlineSuggest).map(v => !!v.edits.enabled);
121124
this._inlineEditsShowCollapsedEnabled = this._editorObs.getOption(EditorOption.inlineSuggest).map(s => s.edits.showCollapsed);
122125
this._triggerCommandOnProviderChange = this._editorObs.getOption(EditorOption.inlineSuggest).map(s => s.experimental.triggerCommandOnProviderChange);
126+
this._typing = this._register(new TypingInterval(this.textModel));
123127

124128
this._register(this._inlineCompletionsService.onDidChangeIsSnoozing((isSnoozing) => {
125129
if (isSnoozing) {
@@ -360,11 +364,14 @@ export class InlineCompletionsModel extends Disposable {
360364
reason += reason.length > 0 ? `:${changeSummary.changeReason}` : changeSummary.changeReason;
361365
}
362366

367+
const typingInterval = this._typing.getTypingInterval();
363368
const requestInfo: InlineSuggestRequestInfo = {
364369
editorType: this.editorType,
365370
startTime: Date.now(),
366371
languageId: this.textModel.getLanguageId(),
367372
reason,
373+
typingInterval: typingInterval.averageInterval,
374+
typingIntervalCharacterCount: typingInterval.characterCount,
368375
};
369376

370377
let context: InlineCompletionContextWithoutUuid = {

src/vs/editor/contrib/inlineCompletions/browser/model/provideInlineCompletions.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,8 @@ export type InlineSuggestRequestInfo = {
245245
editorType: InlineCompletionEditorType;
246246
languageId: string;
247247
reason: string;
248+
typingInterval: number;
249+
typingIntervalCharacterCount: number;
248250
};
249251

250252
export type InlineSuggestViewData = {
@@ -351,6 +353,8 @@ export class InlineSuggestData {
351353
requestReason: this._requestInfo.reason,
352354
viewKind: this._viewData.viewKind,
353355
error: this._viewData.error,
356+
typingInterval: this._requestInfo.typingInterval,
357+
typingIntervalCharacterCount: this._requestInfo.typingIntervalCharacterCount,
354358
...this._viewData.renderData,
355359
};
356360
this.source.provider.handleEndOfLifetime(this.source.inlineSuggestions, this.sourceInlineCompletion, reason, summary);
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { sum } from '../../../../../base/common/arrays.js';
7+
import { Disposable } from '../../../../../base/common/lifecycle.js';
8+
import { ITextModel } from '../../../../common/model.js';
9+
import { IModelContentChangedEvent } from '../../../../common/textModelEvents.js';
10+
11+
interface TypingSession {
12+
startTime: number;
13+
endTime: number;
14+
characterCount: number; // Effective character count for typing interval calculation
15+
}
16+
17+
interface TypingIntervalResult {
18+
averageInterval: number; // Average milliseconds between keystrokes
19+
characterCount: number; // Number of characters involved in the computation
20+
}
21+
22+
/**
23+
* Tracks typing speed as average milliseconds between keystrokes.
24+
* Higher values indicate slower typing.
25+
*/
26+
export class TypingInterval extends Disposable {
27+
28+
private readonly _typingSessions: TypingSession[] = [];
29+
private _currentSession: TypingSession | null = null;
30+
private _lastChangeTime = 0;
31+
private _cachedTypingIntervalResult: TypingIntervalResult | null = null;
32+
private _cacheInvalidated = true;
33+
34+
// Configuration constants
35+
private static readonly MAX_SESSION_GAP_MS = 3_000; // 3 seconds max gap between keystrokes in a session
36+
private static readonly MIN_SESSION_DURATION_MS = 1_000; // Minimum session duration to consider
37+
private static readonly SESSION_HISTORY_LIMIT = 50; // Keep last 50 sessions for calculation
38+
private static readonly TYPING_SPEED_WINDOW_MS = 300_000; // 5 minutes window for speed calculation
39+
private static readonly MIN_CHARS_FOR_RELIABLE_SPEED = 20; // Minimum characters needed for reliable speed calculation
40+
41+
/**
42+
* Gets the current typing interval as average milliseconds between keystrokes
43+
* and the number of characters involved in the computation.
44+
* Higher interval values indicate slower typing.
45+
* Returns { interval: 0, characterCount: 0 } if no typing data is available.
46+
*/
47+
public getTypingInterval(): TypingIntervalResult {
48+
if (this._cacheInvalidated || this._cachedTypingIntervalResult === null) {
49+
this._cachedTypingIntervalResult = this._calculateTypingInterval();
50+
this._cacheInvalidated = false;
51+
}
52+
return this._cachedTypingIntervalResult;
53+
}
54+
55+
constructor(private readonly _textModel: ITextModel) {
56+
super();
57+
58+
this._register(this._textModel.onDidChangeContent(e => this._updateTypingSpeed(e)));
59+
}
60+
61+
private _updateTypingSpeed(change: IModelContentChangedEvent): void {
62+
const now = Date.now();
63+
const characterCount = this._calculateEffectiveCharacterCount(change);
64+
65+
// If too much time has passed since last change, start a new session
66+
if (this._currentSession && (now - this._lastChangeTime) > TypingInterval.MAX_SESSION_GAP_MS) {
67+
this._finalizeCurrentSession();
68+
}
69+
70+
// Start new session if none exists
71+
if (!this._currentSession) {
72+
this._currentSession = {
73+
startTime: now,
74+
endTime: now,
75+
characterCount: 0
76+
};
77+
}
78+
79+
// Update current session
80+
this._currentSession.endTime = now;
81+
this._currentSession.characterCount += characterCount;
82+
83+
this._lastChangeTime = now;
84+
this._cacheInvalidated = true;
85+
}
86+
87+
private _calculateEffectiveCharacterCount(change: IModelContentChangedEvent): number {
88+
const actualCharCount = this._getActualCharacterCount(change);
89+
90+
// If this is actual user typing, count all characters
91+
if (this._isUserTyping(change)) {
92+
return actualCharCount;
93+
}
94+
95+
// For all other actions (paste, suggestions, etc.), count as 1 regardless of size
96+
return actualCharCount > 0 ? 1 : 0;
97+
}
98+
99+
private _getActualCharacterCount(change: IModelContentChangedEvent): number {
100+
let totalChars = 0;
101+
for (const c of change.changes) {
102+
// Count characters added or removed (use the larger of the two)
103+
totalChars += Math.max(c.text.length, c.rangeLength);
104+
}
105+
return totalChars;
106+
}
107+
108+
private _isUserTyping(change: IModelContentChangedEvent): boolean {
109+
// If no detailed reasons, assume user typing
110+
if (!change.detailedReasons || change.detailedReasons.length === 0) {
111+
return true;
112+
}
113+
114+
// Check if any of the reasons indicate actual user typing
115+
for (const reason of change.detailedReasons) {
116+
if (this._isUserTypingReason(reason)) {
117+
return true;
118+
}
119+
}
120+
121+
return false;
122+
}
123+
124+
private _isUserTypingReason(reason: any): boolean {
125+
// Handle undo/redo - not considered user typing
126+
if (reason.metadata.isUndoing || reason.metadata.isRedoing) {
127+
return false;
128+
}
129+
130+
// Handle different source types
131+
switch (reason.metadata.source) {
132+
case 'cursor': {
133+
// Direct user input via cursor
134+
const kind = reason.metadata.kind;
135+
return kind === 'type' || kind === 'compositionType' || kind === 'compositionEnd';
136+
}
137+
138+
default:
139+
// All other sources (paste, suggestions, code actions, etc.) are not user typing
140+
return false;
141+
}
142+
}
143+
144+
private _finalizeCurrentSession(): void {
145+
if (!this._currentSession) {
146+
return;
147+
}
148+
149+
const sessionDuration = this._currentSession.endTime - this._currentSession.startTime;
150+
151+
// Only keep sessions that meet minimum duration and have actual content
152+
if (sessionDuration >= TypingInterval.MIN_SESSION_DURATION_MS && this._currentSession.characterCount > 0) {
153+
this._typingSessions.push(this._currentSession);
154+
155+
// Limit session history
156+
if (this._typingSessions.length > TypingInterval.SESSION_HISTORY_LIMIT) {
157+
this._typingSessions.shift();
158+
}
159+
}
160+
161+
this._currentSession = null;
162+
}
163+
164+
private _calculateTypingInterval(): TypingIntervalResult {
165+
// Finalize current session for calculation
166+
if (this._currentSession) {
167+
const tempSession = { ...this._currentSession };
168+
const sessionDuration = tempSession.endTime - tempSession.startTime;
169+
if (sessionDuration >= TypingInterval.MIN_SESSION_DURATION_MS && tempSession.characterCount > 0) {
170+
const allSessions = [...this._typingSessions, tempSession];
171+
return this._calculateSpeedFromSessions(allSessions);
172+
}
173+
}
174+
175+
return this._calculateSpeedFromSessions(this._typingSessions);
176+
}
177+
178+
private _calculateSpeedFromSessions(sessions: TypingSession[]): TypingIntervalResult {
179+
if (sessions.length === 0) {
180+
return { averageInterval: 0, characterCount: 0 };
181+
}
182+
183+
// Sort sessions by recency (most recent first) to ensure we get the most recent sessions
184+
const sortedSessions = [...sessions].sort((a, b) => b.endTime - a.endTime);
185+
186+
// First, try the standard window
187+
const cutoffTime = Date.now() - TypingInterval.TYPING_SPEED_WINDOW_MS;
188+
const recentSessions = sortedSessions.filter(session => session.endTime > cutoffTime);
189+
const olderSessions = sortedSessions.splice(recentSessions.length);
190+
191+
let totalChars = sum(recentSessions.map(session => session.characterCount));
192+
193+
// If we don't have enough characters in the standard window, expand to include older sessions
194+
for (let i = 0; i < olderSessions.length && totalChars < TypingInterval.MIN_CHARS_FOR_RELIABLE_SPEED; i++) {
195+
recentSessions.push(olderSessions[i]);
196+
totalChars += olderSessions[i].characterCount;
197+
}
198+
199+
const totalTime = sum(recentSessions.map(session => session.endTime - session.startTime));
200+
if (totalTime === 0 || totalChars <= 1) {
201+
return { averageInterval: 0, characterCount: totalChars };
202+
}
203+
204+
// Calculate average milliseconds between keystrokes
205+
const keystrokeIntervals = Math.max(1, totalChars - 1);
206+
const avgMsBetweenKeystrokes = totalTime / keystrokeIntervals;
207+
208+
return {
209+
averageInterval: Math.round(avgMsBetweenKeystrokes),
210+
characterCount: totalChars
211+
};
212+
}
213+
214+
/**
215+
* Reset all typing speed data
216+
*/
217+
public reset(): void {
218+
this._typingSessions.length = 0;
219+
this._currentSession = null;
220+
this._lastChangeTime = 0;
221+
this._cachedTypingIntervalResult = null;
222+
this._cacheInvalidated = true;
223+
}
224+
225+
public override dispose(): void {
226+
this._finalizeCurrentSession();
227+
super.dispose();
228+
}
229+
}

src/vs/monaco.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7562,6 +7562,8 @@ declare namespace monaco.languages {
75627562
characterCountModified?: number;
75637563
disjointReplacements?: number;
75647564
sameShapeReplacements?: boolean;
7565+
typingInterval: number;
7566+
typingIntervalCharacterCount: number;
75657567
};
75667568

75677569
export interface CodeAction {

src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,8 @@ export class MainThreadLanguageFeatures extends Disposable implements MainThread
657657
viewKind: lifetimeSummary.viewKind,
658658
requestReason: lifetimeSummary.requestReason,
659659
error: lifetimeSummary.error,
660+
typingInterval: lifetimeSummary.typingInterval,
661+
typingIntervalCharacterCount: lifetimeSummary.typingIntervalCharacterCount,
660662
languageId: lifetimeSummary.languageId,
661663
cursorColumnDistance: lifetimeSummary.cursorColumnDistance,
662664
cursorLineDistance: lifetimeSummary.cursorLineDistance,
@@ -1309,6 +1311,8 @@ type InlineCompletionEndOfLifeEvent = {
13091311
requestReason: string;
13101312
languageId: string;
13111313
error: string | undefined;
1314+
typingInterval: number;
1315+
typingIntervalCharacterCount: number;
13121316
superseded: boolean;
13131317
editorType: string;
13141318
viewKind: string | undefined;
@@ -1337,6 +1341,8 @@ type InlineCompletionsEndOfLifeClassification = {
13371341
languageId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The language ID of the document where the inline completion was shown' };
13381342
requestReason: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The reason for the inline completion request' };
13391343
error: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The error message if the inline completion failed' };
1344+
typingInterval: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The average typing interval of the user at the moment the inline completion was requested' };
1345+
typingIntervalCharacterCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The character count involved in the typing interval calculation' };
13401346
superseded: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the inline completion was superseded by another one' };
13411347
editorType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The type of the editor where the inline completion was shown' };
13421348
viewKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The kind of the view where the inline completion was shown' };

0 commit comments

Comments
 (0)