Skip to content
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
150 changes: 134 additions & 16 deletions src/documentdb/shell/DocumentDBShellPty.ts
Comment thread
tnaum-ms marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { callWithTelemetryAndErrorHandling, UserCancelledError } from '@microsof
import * as l10n from '@vscode/l10n';
import { randomUUID } from 'crypto';
import * as vscode from 'vscode';
import { type CompletionCategory } from '../../telemetry/completionCategories';
import { callWithAccumulatingTelemetry } from '../../utils/callWithAccumulatingTelemetry';
import { classifyCommand, extractRunCommandName } from '../../utils/classifyCommand';
import { ClustersClient } from '../ClustersClient';
import { CredentialCache } from '../CredentialCache';
Expand All @@ -18,7 +20,7 @@ import { addDomainInfoToProperties } from '../utils/getClusterMetadata';
import { getClosingBrackets } from './bracketDepthCounter';
import { colorizeShellInput } from './highlighting/colorizeShellInput';
import { SettingsHintError } from './SettingsHintError';
import { type CompletionResult, ShellCompletionProvider } from './ShellCompletionProvider';
import { type CompletionCandidate, type CompletionResult, ShellCompletionProvider } from './ShellCompletionProvider';
import { findCommonPrefix, renderCompletionList } from './ShellCompletionRenderer';
import { ShellGhostText } from './ShellGhostText';
import { ShellInputHandler } from './ShellInputHandler';
Expand Down Expand Up @@ -95,6 +97,10 @@ export class DocumentDBShellPty implements vscode.Pseudoterminal {
private _completionListVisible: boolean = false;
/** Whether the current ghost text is an informational hint (not insertable). */
private _ghostTextIsHint: boolean = false;
/** Whether the current ghost text is a closing-brackets suggestion. */
private _ghostTextIsClosingBrackets: boolean = false;
/** The kind of the completion candidate shown as ghost text (for telemetry). */
private _ghostCandidateKind: CompletionCandidate['kind'] | undefined;
/** Optional initial input to pre-fill after initialization. */
private _initialInput: string | undefined;

Expand Down Expand Up @@ -269,8 +275,7 @@ export class DocumentDBShellPty implements vscode.Pseudoterminal {
// Clear ghost text before processing input (except for Right Arrow and Tab
// which are handled by the input handler's escape sequence processing)
if (data !== '\x1b[C' && data !== '\x09') {
this._ghostText.clear((d) => this._writeEmitter.fire(d));
this._ghostTextIsHint = false;
this.clearGhostState();
}

// Dismiss completion list on any input
Expand Down Expand Up @@ -437,8 +442,7 @@ export class DocumentDBShellPty implements vscode.Pseudoterminal {
* completion, forward to input handler).
*/
private processInputDirectly(data: string): void {
this._ghostText.clear((d) => this._writeEmitter.fire(d));
this._ghostTextIsHint = false;
this.clearGhostState();
this._completionListVisible = false;
this._inputHandler.handleInput(data);
}
Expand Down Expand Up @@ -939,8 +943,7 @@ export class DocumentDBShellPty implements vscode.Pseudoterminal {

// Clear hint ghost text if visible (hints are not insertable)
if (this._ghostText.isVisible) {
this._ghostText.clear((d) => this._writeEmitter.fire(d));
this._ghostTextIsHint = false;
this.clearGhostState();
}

const result = this.getCompletionResult(buffer, cursor);
Expand All @@ -966,6 +969,9 @@ export class DocumentDBShellPty implements vscode.Pseudoterminal {
this._writeEmitter.fire(listOutput);
this._completionListVisible = true;

// ── Telemetry: track completion list shown ───────────────
this.trackCompletionListShown(result.candidates);

// Rewrite the prompt + buffer so the user continues editing
this._writeEmitter.fire('\r\n');
this.rewriteCurrentLine();
Expand All @@ -986,12 +992,14 @@ export class DocumentDBShellPty implements vscode.Pseudoterminal {
// special-char collections (sto → ['stores (10)']).
if (result.prefix.length > 0 && !candidate.insertText.startsWith(result.prefix)) {
this._inputHandler.replaceText(result.prefix.length, candidate.insertText);
this.trackCompletionAccepted(candidate.kind, 'tab');
return;
}

const remaining = candidate.insertText.slice(result.prefix.length);
if (remaining.length > 0) {
this._inputHandler.insertText(remaining);
this.trackCompletionAccepted(candidate.kind, 'tab');
}
}

Expand All @@ -1018,6 +1026,16 @@ export class DocumentDBShellPty implements vscode.Pseudoterminal {

// ─── Private: Ghost text ─────────────────────────────────────────────────

/**
* Clear ghost text rendering and reset all associated state fields.
*/
private clearGhostState(): void {
this._ghostText.clear((d) => this._writeEmitter.fire(d));
this._ghostTextIsHint = false;
this._ghostTextIsClosingBrackets = false;
this._ghostCandidateKind = undefined;
}

/**
* Handle buffer changes for ghost text evaluation.
* Called after every character insertion or deletion.
Expand All @@ -1035,7 +1053,7 @@ export class DocumentDBShellPty implements vscode.Pseudoterminal {

// Don't show ghost text if cursor is not at end of buffer
if (cursor < buffer.length) {
this._ghostText.clear((d) => this._writeEmitter.fire(d));
this.clearGhostState();
return;
}

Expand All @@ -1056,7 +1074,7 @@ export class DocumentDBShellPty implements vscode.Pseudoterminal {

// Need at least 1 character to show ghost text
if (buffer.trim().length === 0) {
this._ghostText.clear((d) => this._writeEmitter.fire(d));
this.clearGhostState();
return;
}

Expand All @@ -1069,15 +1087,20 @@ export class DocumentDBShellPty implements vscode.Pseudoterminal {
// (e.g., bracket notation, quoted field paths, special-char collections).
// The visual would be misleading since the insertion replaces the prefix.
if (!candidate.insertText.startsWith(result.prefix)) {
this._ghostText.clear((d) => this._writeEmitter.fire(d));
this.clearGhostState();
return;
}

// Single match with a typed prefix — show ghost text
const remaining = candidate.insertText.slice(result.prefix.length);
if (remaining.length > 0) {
this._ghostTextIsHint = false;
this._ghostText.show(remaining, (d) => this._writeEmitter.fire(d));
this._ghostTextIsClosingBrackets = false;
this._ghostCandidateKind = candidate.kind;
const rendered = this._ghostText.show(remaining, (d) => this._writeEmitter.fire(d));
if (rendered) {
this.trackCompletionGhostShown(candidate.kind);
}
return;
}
}
Expand Down Expand Up @@ -1113,13 +1136,18 @@ export class DocumentDBShellPty implements vscode.Pseudoterminal {
const closing = getClosingBrackets(buffer);
if (closing.length > 0) {
this._ghostTextIsHint = false;
this._ghostText.show(closing, (d) => this._writeEmitter.fire(d));
this._ghostTextIsClosingBrackets = true;
this._ghostCandidateKind = undefined;
const closingRendered = this._ghostText.show(closing, (d) => this._writeEmitter.fire(d));
if (closingRendered) {
this.trackClosingBracketsShown();
}
return;
}
}
}

this._ghostText.clear((d) => this._writeEmitter.fire(d));
this.clearGhostState();
}

/**
Expand All @@ -1145,16 +1173,26 @@ export class DocumentDBShellPty implements vscode.Pseudoterminal {

// Don't accept hint ghost text — it's informational only
if (this._ghostTextIsHint) {
this._ghostText.clear((d) => this._writeEmitter.fire(d));
this._ghostTextIsHint = false;
this.clearGhostState();
return undefined;
}

const ghostText = this._ghostText.currentText;
const wasClosingBrackets = this._ghostTextIsClosingBrackets;
const candidateKind = this._ghostCandidateKind;

// Clear ghost state and erase the dim rendering
this._ghostText.clear((d) => this._writeEmitter.fire(d));
this.clearGhostState();

if (ghostText) {
// ── Telemetry: track ghost text acceptance ───────────────
if (wasClosingBrackets) {
this.trackClosingBracketsAccepted();
} else if (candidateKind) {
this.trackCompletionAccepted(candidateKind, 'ghostText');
this.trackCompletionGhostAccepted(candidateKind);
}

// Insert the ghost text into the buffer through the input handler.
// insertText handles buffer update + terminal echo in normal color.
this._inputHandler.insertText(ghostText);
Expand Down Expand Up @@ -1188,4 +1226,84 @@ export class DocumentDBShellPty implements vscode.Pseudoterminal {
// Domain info is best-effort — don't fail telemetry if parsing fails
}
}

/**
* Map shell CompletionCandidate.kind to the standard CompletionCategory.
*/
private static shellKindToCategory(kind: CompletionCandidate['kind']): CompletionCategory {
switch (kind) {
case 'field':
return 'field';
case 'operator':
return 'operator';
case 'bson':
return 'bsonConstructor';
case 'collection':
case 'database':
return 'collectionName';
case 'method':
case 'command':
return 'other';
}
}

/**
* Track a completion acceptance (Tab or ghost text) in the shell.
*/
private trackCompletionAccepted(kind: CompletionCandidate['kind'], trigger: 'tab' | 'ghostText'): void {
const category = DocumentDBShellPty.shellKindToCategory(kind);
void callWithAccumulatingTelemetry('completion.accepted', (ctx) => {
ctx.telemetry.measurements[`cat_${category}_src_shell`] = 1;
ctx.telemetry.measurements[`trigger_${trigger}`] = 1;
});
}

/**
* Track that a closing-brackets ghost text suggestion was shown.
*/
private trackClosingBracketsShown(): void {
void callWithAccumulatingTelemetry('shell.closingBrackets', (ctx) => {
ctx.telemetry.measurements.shown = 1;
});
}

/**
* Track that a closing-brackets ghost text suggestion was accepted.
*/
private trackClosingBracketsAccepted(): void {
void callWithAccumulatingTelemetry('shell.closingBrackets', (ctx) => {
ctx.telemetry.measurements.accepted = 1;
});
}

/**
* Track that a completion list (multi-match picker) was shown.
*/
private trackCompletionListShown(candidates: readonly CompletionCandidate[]): void {
const kind = candidates[0]?.kind ?? 'command';
const category = DocumentDBShellPty.shellKindToCategory(kind);
void callWithAccumulatingTelemetry('shell.completionList', (ctx) => {
ctx.telemetry.measurements[`shown_${category}`] = 1;
});
Comment thread
tnaum-ms marked this conversation as resolved.
}

Comment thread
tnaum-ms marked this conversation as resolved.
/**
* Track that a completion ghost text suggestion was shown.
*/
private trackCompletionGhostShown(kind: CompletionCandidate['kind']): void {
const category = DocumentDBShellPty.shellKindToCategory(kind);
void callWithAccumulatingTelemetry('shell.completionGhost', (ctx) => {
ctx.telemetry.measurements[`shown_${category}`] = 1;
});
}

/**
* Track that a completion ghost text suggestion was accepted.
*/
private trackCompletionGhostAccepted(kind: CompletionCandidate['kind']): void {
const category = DocumentDBShellPty.shellKindToCategory(kind);
void callWithAccumulatingTelemetry('shell.completionGhost', (ctx) => {
ctx.telemetry.measurements[`accepted_${category}`] = 1;
});
}
}
9 changes: 6 additions & 3 deletions src/documentdb/shell/ShellGhostText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,17 @@ export class ShellGhostText {
*
* @param text - the suggestion text to display (the part NOT yet typed)
* @param write - function to write ANSI data to the terminal
* @returns `true` if new ghost text was rendered, `false` if skipped (empty or unchanged)
*/
show(text: string, write: (data: string) => void): void {
show(text: string, write: (data: string) => void): boolean {
if (!text || text.length === 0) {
this.clear(write);
return;
return false;
}

// If the same ghost text is already showing, don't re-render
if (this._visible && this._currentGhost === text) {
return;
return false;
}

// Clear any existing ghost text first
Expand All @@ -84,6 +85,8 @@ export class ShellGhostText {
if (displayWidth > 0) {
write(`\x1b[${String(displayWidth)}D`);
}

return true;
}

/**
Expand Down
3 changes: 2 additions & 1 deletion src/telemetry/completionCategories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,12 @@ export function normalizeCompletionCategory(raw: string | undefined): Completion
// Sources — which surface produced the completion
// ---------------------------------------------------------------------------

export type CompletionSource = 'playground' | 'collectionView';
export type CompletionSource = 'playground' | 'collectionView' | 'shell';

export const CompletionSources = {
Playground: 'playground',
CollectionView: 'collectionView',
Shell: 'shell',
} as const satisfies Record<string, CompletionSource>;

const validSources: ReadonlySet<string> = new Set<string>(Object.values(CompletionSources));
Expand Down