Skip to content

Commit 4f19990

Browse files
amazonq: habit building features (#4732)
* amazonq: right click context menu for current line Problem: If the user has not highlighted any text in the editor, a right click will not show anything in the context menu. But if they select some text when right click it will show the Amazon Q Chat context commands. Solution: If the user right clicks and there is no highlight, show the context menu and it will automatically use the current line Signed-off-by: Nikolas Komonen <[email protected]> * open Q chat with `cmd` + `i` Opens the Q chat window with the `cmd` + `i` key command. Telemetry metric is also included specifically for this keycommand Signed-off-by: Nikolas Komonen <[email protected]> * amazonq: codelens to prompt user to try chat For a set amount of times we will show the user a codelens above the currently selected line that prompts them to try and use Q chat. We are doing this as a way to advertise the chat feature. The codelens will show for a specific amount of times and then never show again, so we do not annoy users. Clicking the codelens opens Q chat Signed-off-by: Nikolas Komonen <[email protected]> * lint fix Signed-off-by: Nikolas Komonen <[email protected]> * changelog items Signed-off-by: Nikolas Komonen <[email protected]> --------- Signed-off-by: Nikolas Komonen <[email protected]>
1 parent bf38919 commit 4f19990

File tree

9 files changed

+264
-13
lines changed

9 files changed

+264
-13
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"type": "Feature",
3+
"description": "Amazon Q: cmd + i to open chat"
4+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"type": "Feature",
3+
"description": "Amazon Q: Right Click + no code selected shows Q context menu"
4+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"type": "Feature",
3+
"description": "Amazon Q: brief CodeLens to advertise chat"
4+
}

packages/core/package.json

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1503,8 +1503,7 @@
15031503
"editor/context": [
15041504
{
15051505
"submenu": "amazonqEditorContextSubmenu",
1506-
"group": "cw_chat",
1507-
"when": "editorHasSelection"
1506+
"group": "cw_chat"
15081507
}
15091508
],
15101509
"view/item/context": [
@@ -3831,33 +3830,35 @@
38313830
}
38323831
],
38333832
"keybindings": [
3833+
{
3834+
"command": "_aws.amazonq.focusChat.keybinding",
3835+
"win": "win+i",
3836+
"mac": "cmd+i",
3837+
"linux": "meta+i"
3838+
},
38343839
{
38353840
"command": "aws.amazonq.explainCode",
38363841
"win": "win+alt+e",
38373842
"mac": "cmd+alt+e",
3838-
"linux": "meta+alt+e",
3839-
"when": "editorHasSelection"
3843+
"linux": "meta+alt+e"
38403844
},
38413845
{
38423846
"command": "aws.amazonq.refactorCode",
38433847
"win": "win+alt+u",
38443848
"mac": "cmd+alt+u",
3845-
"linux": "meta+alt+u",
3846-
"when": "editorHasSelection"
3849+
"linux": "meta+alt+u"
38473850
},
38483851
{
38493852
"command": "aws.amazonq.fixCode",
38503853
"win": "win+alt+y",
38513854
"mac": "cmd+alt+y",
3852-
"linux": "meta+alt+y",
3853-
"when": "editorHasSelection"
3855+
"linux": "meta+alt+y"
38543856
},
38553857
{
38563858
"command": "aws.amazonq.optimizeCode",
38573859
"win": "win+alt+a",
38583860
"mac": "cmd+alt+a",
3859-
"linux": "meta+alt+a",
3860-
"when": "editorHasSelection"
3861+
"linux": "meta+alt+a"
38613862
},
38623863
{
38633864
"command": "aws.amazonq.sendToPrompt",

packages/core/src/amazonq/activation.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ import { welcome } from './onboardingPage'
1515
import { learnMoreAmazonQCommand, switchToAmazonQCommand } from './explorer/amazonQChildrenNodes'
1616
import { activateBadge } from './util/viewBadgeHandler'
1717
import { telemetry } from '../shared/telemetry/telemetry'
18-
import { focusAmazonQPanel } from '../codewhispererChat/commands/registerCommands'
18+
import { focusAmazonQPanel, focusAmazonQPanelKeybinding } from '../codewhispererChat/commands/registerCommands'
19+
import { TryChatCodeLensProvider, tryChatCodeLensCommand } from '../codewhispererChat/editor/codelens'
1920

2021
export async function activate(context: ExtensionContext) {
2122
const appInitContext = DefaultAmazonQAppInitContext.instance
@@ -31,13 +32,17 @@ export async function activate(context: ExtensionContext) {
3132

3233
const cwcWebViewToAppsPublisher = appInitContext.getWebViewToAppsMessagePublishers().get('cwc')!
3334

35+
await TryChatCodeLensProvider.register()
36+
3437
context.subscriptions.push(
3538
window.registerWebviewViewProvider(AmazonQChatViewProvider.viewType, provider, {
3639
webviewOptions: {
3740
retainContextWhenHidden: true,
3841
},
3942
}),
40-
focusAmazonQPanel.register()
43+
focusAmazonQPanel.register(),
44+
focusAmazonQPanelKeybinding.register(),
45+
tryChatCodeLensCommand.register()
4146
)
4247

4348
amazonQWelcomeCommand.register(context, cwcWebViewToAppsPublisher)

packages/core/src/codewhispererChat/commands/registerCommands.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ export const focusAmazonQPanel = Commands.declare(
1717
}
1818
)
1919

20+
/**
21+
* {@link focusAmazonQPanel} but only used for the keybinding since we cannot
22+
* explicitly set the `source` in the package.json definition
23+
*/
24+
export const focusAmazonQPanelKeybinding = Commands.declare('_aws.amazonq.focusChat.keybinding', () => async () => {
25+
await focusAmazonQPanel.execute(placeholder, 'keybinding')
26+
})
27+
2028
const getCommandTriggerType = (data: any): EditorContextCommandTriggerType => {
2129
// data is undefined when commands triggered from keybinding or command palette. Currently no
2230
// way to differentiate keybinding and command palette, so both interactions are recorded as keybinding
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
import vscode from 'vscode'
6+
import globals from '../../shared/extensionGlobals'
7+
import { ToolkitError } from '../../shared/errors'
8+
import { Commands, placeholder } from '../../shared/vscode/commands2'
9+
import { platform } from 'os'
10+
import { focusAmazonQPanel } from '../commands/registerCommands'
11+
12+
/** When the user clicks the CodeLens that prompts user to try Amazon Q chat */
13+
export const tryChatCodeLensCommand = Commands.declare(`_aws.amazonq.tryChatCodeLens`, () => async () => {
14+
await focusAmazonQPanel.execute(placeholder, 'codeLens')
15+
})
16+
17+
/**
18+
* As part of hinting at users to use Amazon Q Chat, we will show codelenses
19+
* prompting them a certain amount of time/uses. Then after
20+
* a certain amount of times we will never show it again.
21+
*
22+
* This codelens appears above every clicked line.
23+
*/
24+
export class TryChatCodeLensProvider implements vscode.CodeLensProvider {
25+
private _onDidChangeCodeLenses = new vscode.EventEmitter<void>()
26+
onDidChangeCodeLenses: vscode.Event<void> = this._onDidChangeCodeLenses.event
27+
28+
/** How many times we've shown the CodeLens */
29+
private count: number = 0
30+
/** How many times we want to show the CodeLens */
31+
static readonly maxCount = 10
32+
static readonly debounceMillis: number = 700
33+
static readonly showCodeLensId = `aws.amazonq.showTryChatCodeLens`
34+
35+
private static providerDisposable: vscode.Disposable | undefined = undefined
36+
private disposables: vscode.Disposable[] = []
37+
38+
constructor(private readonly cursorPositionIfValid = () => TryChatCodeLensProvider._resolveCursorPosition()) {
39+
// when we want to recalculate the codelens
40+
this.disposables.push(
41+
vscode.window.onDidChangeActiveTextEditor(() => this._onDidChangeCodeLenses.fire()),
42+
vscode.window.onDidChangeTextEditorSelection(() => this._onDidChangeCodeLenses.fire())
43+
)
44+
}
45+
46+
static async register(): Promise<boolean> {
47+
const shouldShow = globals.context.globalState.get(this.showCodeLensId, true)
48+
if (!shouldShow) {
49+
return false
50+
}
51+
52+
if (this.providerDisposable) {
53+
throw new ToolkitError(`${this.name} can only be registered once.`)
54+
}
55+
56+
const provider = new TryChatCodeLensProvider()
57+
this.providerDisposable = vscode.languages.registerCodeLensProvider({ scheme: 'file' }, provider)
58+
globals.context.subscriptions.push(provider)
59+
return true
60+
}
61+
62+
provideCodeLenses(
63+
document: vscode.TextDocument,
64+
token: vscode.CancellationToken
65+
): vscode.ProviderResult<vscode.CodeLens[]> {
66+
return new Promise(async resolve => {
67+
token.onCancellationRequested(() => resolve([]))
68+
69+
if (this.count >= TryChatCodeLensProvider.maxCount) {
70+
// We only want to show this code lens a certain amount of times
71+
// to not annoy customers. The following ensures it is never shown again.
72+
this.dispose()
73+
return resolve([])
74+
}
75+
76+
// We use a timeout as a leading debounce so that the user must
77+
// wait on a specific line for a certain amount of time until we show the codelens.
78+
// This prevents spamming code lenses if the user changes multiple lines quickly.
79+
globals.clock.setTimeout(() => {
80+
const position = this.cursorPositionIfValid()
81+
if (token.isCancellationRequested || position === undefined) {
82+
return resolve([])
83+
}
84+
85+
resolve([
86+
{
87+
range: new vscode.Range(position, position),
88+
isResolved: true,
89+
command: {
90+
command: tryChatCodeLensCommand.id,
91+
title: `Amazon Q: open chat with (${resolveModifierKey()} + i) - showing ${
92+
TryChatCodeLensProvider.maxCount - this.count
93+
} more times`,
94+
},
95+
},
96+
])
97+
98+
this.count++
99+
}, TryChatCodeLensProvider.debounceMillis)
100+
})
101+
}
102+
103+
/**
104+
* Resolves the current cursor position in the active document
105+
* if the criteria are met.
106+
*/
107+
private static _resolveCursorPosition(): vscode.Position | undefined {
108+
const activeEditor = vscode.window.activeTextEditor
109+
const activeDocument = activeEditor?.document
110+
const textSelection = activeEditor?.selection
111+
if (
112+
!activeEditor ||
113+
!activeDocument ||
114+
activeEditor.selections.length > 1 || // is multi-cursor select
115+
!textSelection?.isSingleLine ||
116+
activeDocument.lineAt(textSelection.start.line).text.length === 0 // is empty line
117+
) {
118+
return undefined
119+
}
120+
121+
return textSelection.start
122+
}
123+
124+
dispose() {
125+
void globals.context.globalState.update(TryChatCodeLensProvider.showCodeLensId, false)
126+
TryChatCodeLensProvider.providerDisposable?.dispose()
127+
this.disposables.forEach(d => d.dispose())
128+
}
129+
}
130+
131+
export function resolveModifierKey() {
132+
const platformName = platform()
133+
switch (platformName) {
134+
case 'win32':
135+
return 'ctrl'
136+
case 'linux':
137+
return 'meta'
138+
case 'darwin':
139+
return 'cmd'
140+
default:
141+
return 'ctrl'
142+
}
143+
}

packages/core/src/codewhispererChat/editor/context/focusArea/focusAreaExtractor.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ export class FocusAreaContextExtractor {
3636

3737
// It means we don't really have a selection, but cursor position only
3838
if (!this.isCodeBlockSelected(editor)) {
39-
importantRange = editor.visibleRanges[0]
39+
// Select the whole line
40+
importantRange = editor.document.lineAt(importantRange.start.line).range
4041
}
4142

4243
const names = await this.findNamesInRange(editor.document.getText(), importantRange, editor.document.languageId)
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import assert from 'assert'
7+
import vscode from 'vscode'
8+
import {
9+
TryChatCodeLensProvider,
10+
resolveModifierKey,
11+
tryChatCodeLensCommand,
12+
} from '../../../codewhispererChat/editor/codelens'
13+
import { assertTelemetry, installFakeClock } from '../../testUtil'
14+
import { InstalledClock } from '@sinonjs/fake-timers'
15+
import globals from '../../../shared/extensionGlobals'
16+
import { focusAmazonQPanel } from '../../../codewhispererChat/commands/registerCommands'
17+
18+
describe('TryChatCodeLensProvider', () => {
19+
let instance: TryChatCodeLensProvider = new TryChatCodeLensProvider()
20+
let cancellationTokenSource: vscode.CancellationTokenSource
21+
let clock: InstalledClock
22+
const codeLensPosition = new vscode.Position(1, 2)
23+
24+
beforeEach(function () {
25+
instance = new TryChatCodeLensProvider(() => codeLensPosition)
26+
clock = installFakeClock()
27+
})
28+
29+
afterEach(function () {
30+
instance.dispose()
31+
cancellationTokenSource?.dispose()
32+
clock.uninstall()
33+
})
34+
35+
it('keeps returning a code lense until it hits the max times it should show', async function () {
36+
let codeLensCount = 0
37+
const modifierKey = resolveModifierKey()
38+
while (codeLensCount < 10) {
39+
cancellationTokenSource = new vscode.CancellationTokenSource()
40+
const resultPromise = instance.provideCodeLenses({} as any, cancellationTokenSource.token)
41+
clock.tick(TryChatCodeLensProvider.debounceMillis) // skip debounce
42+
43+
assert.deepStrictEqual(await resultPromise, [
44+
{
45+
range: new vscode.Range(codeLensPosition, codeLensPosition),
46+
command: {
47+
title: `Amazon Q: open chat with (${modifierKey} + i) - showing ${
48+
TryChatCodeLensProvider.maxCount - codeLensCount
49+
} more times`,
50+
command: tryChatCodeLensCommand.id,
51+
},
52+
isResolved: true,
53+
},
54+
])
55+
56+
codeLensCount++
57+
}
58+
const emptyResult = await instance.provideCodeLenses({} as any, new vscode.CancellationTokenSource().token)
59+
assert.deepStrictEqual(emptyResult, [])
60+
})
61+
62+
it('does not register the provider if we do not want to show the code lens', async function () {
63+
// indicate we do not want to show it
64+
await globals.context.globalState.update(TryChatCodeLensProvider.showCodeLensId, false)
65+
// ensure we do not show it
66+
assert.deepStrictEqual(await TryChatCodeLensProvider.register(), false)
67+
68+
// indicate we want to show it
69+
await globals.context.globalState.update(TryChatCodeLensProvider.showCodeLensId, true)
70+
// The general toolkit activation will have already registered this provider, so it throws when we try again
71+
// But if it throws it implies it tried to register it.
72+
await assert.rejects(TryChatCodeLensProvider.register(), {
73+
message: `${TryChatCodeLensProvider.name} can only be registered once.`,
74+
})
75+
})
76+
77+
it('outputs expected telemetry', async function () {
78+
await tryChatCodeLensCommand.execute()
79+
assertTelemetry('vscode_executeCommand', { command: focusAmazonQPanel.id, source: 'codeLens' })
80+
})
81+
})

0 commit comments

Comments
 (0)