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 LLM instructions file suggestion #669

Open
wants to merge 40 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
7734636
wip
karreiro Dec 11, 2024
75ccb49
(wip 2) calling the language model api
karreiro Dec 11, 2024
a8c2f53
(wip 3) sidefix
karreiro Dec 11, 2024
23df2cd
(wip 4) make sidekick a bit less ugly
karreiro Dec 11, 2024
b650451
(wip 5) make sidekick a bit less ugly (2)
karreiro Dec 11, 2024
8b20827
Move loading state to title button (#670)
frandiox Dec 12, 2024
94e4e8d
Support multiple suggestions from a single request (#671)
frandiox Dec 12, 2024
031ede8
Bring hover back
karreiro Dec 12, 2024
43fe134
Support suggestions on selected code (#672)
frandiox Dec 12, 2024
2a64066
Hide suggestions on code change (#673)
frandiox Dec 12, 2024
0ebba9d
(wip) updated prompt
karreiro Dec 12, 2024
b5ce907
(wip) prompt
karreiro Dec 12, 2024
7a45e04
(wip) prompt 2
karreiro Dec 12, 2024
66bd2d8
Add LLM instructions file suggestion
madmath Dec 11, 2024
c011447
update llm instructions
madmath Dec 11, 2024
8f9606a
New llm instruction file
benjaminsehl Dec 12, 2024
299cb4f
Add LLM instructions file suggestion
madmath Dec 11, 2024
ec0aedb
update llm instructions
madmath Dec 11, 2024
184c622
Use diff view for sidefix
frandiox Dec 13, 2024
9e5c6eb
Improve the timing for removing suggestions
frandiox Dec 13, 2024
dbb4157
Try to improve the prompt
frandiox Dec 13, 2024
15cb244
Update prompt to use tag convention
karreiro Dec 13, 2024
9207a50
Remove duplicated functions; extract file creation to a method because
karreiro Dec 13, 2024
f603423
Fine tuning prompt
karreiro Dec 13, 2024
288f786
Remove unused items
karreiro Dec 13, 2024
ef42904
Setup 'vitest' on 'packages/vscode-extension'
karreiro Jan 24, 2025
8c13465
Extract 'isCursor' and 'hasShopifyThemeLoaded' to 'utils.ts'
karreiro Jan 24, 2025
c310c2e
Extract the logic for showing/hiding the 'Shopify Magic' button to 'u…
karreiro Jan 24, 2025
1b37f04
Removing 'inline completions' support as we are leaning towards 'text…
karreiro Jan 24, 2025
f1eaa7a
Fix fs tests on Windows in 'utils.spec.ts'
karreiro Jan 24, 2025
e6c843c
- Extract `.cursorrules/copilot-instructions.md` files creation to 'l…
karreiro Jan 27, 2025
43b327c
Extract 'RefactorProvider' from 'extensions'
karreiro Jan 27, 2025
f631900
Extract, improve, and breakdown prompts in 'shopify-magic-prompts'
karreiro Jan 28, 2025
19a61e1
Remove 'llm-instructions.template' and make tests run faster
karreiro Jan 28, 2025
5a7a209
Rename 'Sidekick' to 'Shopify Magic' everywhere
karreiro Jan 28, 2025
e8b4711
RefactorProvider.ts -> ShopifyMagicCodeActionProvider.ts
karreiro Jan 28, 2025
c6e77ec
Former llm-instructions.template and live prompt now share the same l…
karreiro Jan 28, 2025
5f03b6c
- Adjust file create + refine prompt
karreiro Jan 28, 2025
5b658f2
Improve Dialogs
karreiro Jan 28, 2025
eb39fa4
Add changeset
karreiro Jan 28, 2025
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
189 changes: 67 additions & 122 deletions packages/vscode-extension/src/node/extension.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import { FileStat, FileTuple, path as pathUtils } from '@shopify/theme-check-common';
import * as path from 'node:path';
import {
CancellationToken,
CodeAction,
CodeActionContext,
CodeActionKind,
CodeActionProvider,
commands,
ExtensionContext,
languages,
Position,
ProviderResult,
Range,
TextDocument,
TextEditor,
TextEditorDecorationType,
Uri,
window,
workspace,
WorkspaceEdit,
} from 'vscode';
Expand All @@ -23,14 +29,11 @@ import {
import { documentSelectors } from '../common/constants';
import LiquidFormatter from '../common/formatter';
import { vscodePrettierFormat } from './formatter';
import { getSidekickAnalysis, LiquidSuggestion, log, SidekickDecoration } from './sidekick';
import { getSidekickAnalysis, LiquidSuggestion, log, SidekickDecoration } from './shopify-magic';
import { showShopifyMagicButton, showShopifyMagicLoadingButton } from './ui';
import { createInstructionsFiles } from './llm-instructions';

type LiquidSuggestionWithDecorationKey = LiquidSuggestion & { key: string };
interface ConfigFile {
path: string;
templateName: string;
prompt: string;
}

let $client: LanguageClient | undefined;
let $editor: TextEditor | undefined;
Expand All @@ -43,7 +46,8 @@ const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms));
export async function activate(context: ExtensionContext) {
const runChecksCommand = 'themeCheck/runChecks';

await createInstructionsFileIfNeeded(context);
await createInstructionsFiles(context);
await showShopifyMagicButton();

context.subscriptions.push(
commands.registerCommand('shopifyLiquid.restart', () => restartServer(context)),
Expand All @@ -60,40 +64,71 @@ export async function activate(context: ExtensionContext) {
),
);
context.subscriptions.push(
commands.registerTextEditorCommand('shopifyLiquid.sidekick', async (textEditor: TextEditor) => {
$editor = textEditor;

log('Sidekick is analyzing...');
await Promise.all([
commands.executeCommand('setContext', 'shopifyLiquid.sidekick.isLoading', true),
]);

try {
// Show sidekick decorations
applyDecorations(await getSidekickAnalysis(textEditor));
} finally {
await Promise.all([
commands.executeCommand('setContext', 'shopifyLiquid.sidekick.isLoading', false),
]);
}
}),
commands.registerTextEditorCommand(
'shopifyLiquid.shopifyMagic',
async (textEditor: TextEditor) => {
$editor = textEditor;

log('Sidekick is analyzing...');

await showShopifyMagicLoadingButton();

try {
// Show sidekick decorations
applyDecorations(await getSidekickAnalysis(textEditor));
} finally {
await showShopifyMagicButton();
}
},
),
);
context.subscriptions.push(
commands.registerCommand(
'shopifyLiquid.sidefix',
async (suggestion: LiquidSuggestionWithDecorationKey) => {
log('Sidekick is fixing...');

applySuggestion(suggestion);
},
),
);
// context.subscriptions.push(
// languages.registerInlineCompletionItemProvider(
// [{ language: 'liquid' }],
// new LiquidCompletionProvider(),
// ),
// );

class RefactorProvider implements CodeActionProvider {
public static readonly providedCodeActionKinds = [CodeActionKind.Refactor];

public provideCodeActions(
document: TextDocument,
range: Range,
// eslint-disable-next-line no-unused-vars
_context: CodeActionContext,
// eslint-disable-next-line no-unused-vars
_token: CancellationToken,
): ProviderResult<CodeAction[]> {
return [this.createRefactorCodeAction(8, document, range, CodeActionKind.RefactorRewrite)];
}

private createRefactorCodeAction(
n: number,
document: TextDocument,
range: Range,
kind: CodeActionKind,
): CodeAction {
const refactorAction = new CodeAction(`Refactor using Sidekick`, CodeActionKind.Refactor);
refactorAction.command = {
command: 'shopifyLiquid.shopifyMagic',
title: `Refactor Code ${n}`,
arguments: [document, range],
tooltip: `This is the tooltip for the refactor code ${n}`,
};
refactorAction.kind = kind;
return refactorAction;
}
}

context.subscriptions.push(
languages.registerCodeActionsProvider([{ language: 'liquid' }], new RefactorProvider(), {
providedCodeActionKinds: RefactorProvider.providedCodeActionKinds,
}),
);

context.subscriptions.push(
workspace.onDidChangeTextDocument(({ contentChanges, reason, document }) => {
Expand Down Expand Up @@ -263,93 +298,3 @@ async function applySuggestion({ key, range, newCode }: LiquidSuggestionWithDeco

$isApplyingSuggestion = false;
}

async function isShopifyTheme(workspaceRoot: string): Promise<boolean> {
try {
// Check for typical Shopify theme folders
const requiredFolders = ['sections', 'templates', 'assets', 'config'];
for (const folder of requiredFolders) {
const folderUri = Uri.file(path.join(workspaceRoot, folder));
try {
await workspace.fs.stat(folderUri);
} catch {
return false;
}
}
return true;
} catch {
return false;
}
}

function isCursor(): boolean {
try {
// Check if we're running in Cursor's electron process
const processTitle = process.title.toLowerCase();
const isElectronCursor =
processTitle.includes('cursor') && process.versions.electron !== undefined;

// Check for Cursor-specific environment variables that are set by Cursor itself
const hasCursorEnv =
process.env.CURSOR_CHANNEL !== undefined || process.env.CURSOR_VERSION !== undefined;

return isElectronCursor || hasCursorEnv;
} catch {
return false;
}
}

async function getConfigFileDetails(workspaceRoot: string): Promise<ConfigFile> {
if (isCursor()) {
return {
path: path.join(workspaceRoot, '.cursorrules'),
templateName: 'llm-instructions.template',
prompt:
'Detected Shopify theme project in Cursor. Do you want a .cursorrules file to be created?',
};
}
return {
path: path.join(workspaceRoot, '.github', 'copilot-instructions.md'),
templateName: 'llm-instructions.template',
prompt:
'Detected Shopify theme project in VSCode. Do you want a Copilot instructions file to be created?',
};
}

async function createInstructionsFileIfNeeded(context: ExtensionContext) {
if (!workspace.workspaceFolders?.length) {
return;
}

const workspaceRoot = workspace.workspaceFolders[0].uri.fsPath;
const instructionsConfig = await getConfigFileDetails(workspaceRoot);

// Don't do anything if the file already exists
try {
await workspace.fs.stat(Uri.file(instructionsConfig.path));
return;
} catch {
// File doesn't exist, continue
}

if (await isShopifyTheme(workspaceRoot)) {
const response = await window.showInformationMessage(instructionsConfig.prompt, 'Yes', 'No');

if (response === 'Yes') {
// Create directory if it doesn't exist (needed for .github case)
const dir = path.dirname(instructionsConfig.path);
try {
await workspace.fs.createDirectory(Uri.file(dir));
} catch {
// Directory might already exist, continue
}

// Read the template file from the extension's resources
const templateContent = await workspace.fs.readFile(
Uri.file(context.asAbsolutePath(`resources/${instructionsConfig.templateName}`)),
);
await workspace.fs.writeFile(Uri.file(instructionsConfig.path), templateContent);
log(`Wrote instructions file to ${instructionsConfig.path}`);
}
}
}
10 changes: 10 additions & 0 deletions packages/vscode-extension/src/node/fs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Uri, workspace } from 'vscode';

export async function fileExists(path: string): Promise<boolean> {
try {
await workspace.fs.stat(Uri.file(path));
return true;
} catch (e) {
return false;
}
}
134 changes: 134 additions & 0 deletions packages/vscode-extension/src/node/llm-instructions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { it, expect, describe, vi, afterEach, afterAll, beforeEach } from 'vitest';
import { createInstructionsFiles } from './llm-instructions';
import { window, ExtensionContext } from 'vscode';
import { getShopifyThemeRootDirs, isCursor } from './utils';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as os from 'node:os';
import { fileExists } from './fs';

vi.mock('./utils');
vi.mock('vscode', async () => {
return {
window: {
showInformationMessage: vi.fn().mockResolvedValue('Yes'),
},
workspace: {
fs: {
writeFile: async (uri: { path: string }, content: Uint8Array) => {
return fs.writeFile(uri.path, content);
},
createDirectory: async (uri: { path: string }) => {
return fs.mkdir(uri.path, { recursive: true });
},
stat: async (uri: { path: string }) => {
return fs.stat(uri.path);
},
readFile: async (uri: { path: string }) => {
return fs.readFile(uri.path);
},
},
},
// eslint-disable-next-line @typescript-eslint/naming-convention
Uri: {
file: (path: string) => ({ path }),
},
};
});

describe('createInstructionsFiles', async () => {
const nullLogger = () => {};
const templateFile = path.join(__dirname, '..', '..', 'resources', 'llm-instructions.template');
const ctx = {
globalState: {
get: vi.fn(),
update: vi.fn(),
},
asAbsolutePath: () => templateFile,
} as unknown as ExtensionContext;

let tmpDir: string;
let themeDirs: string[];

beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'shopify-test-'));
themeDirs = [path.join(tmpDir, 'theme1'), path.join(tmpDir, 'theme2')];

await Promise.all(themeDirs.map((dir) => fs.mkdir(dir, { recursive: true })));
});

afterEach(async () => {
vi.clearAllMocks();
await fs.rm(tmpDir, { recursive: true, force: true });
});

afterAll(() => {
vi.unstubAllGlobals();
});

it('should create Cursor instructions files for each theme directory when user accepts', async () => {
vi.mocked(getShopifyThemeRootDirs).mockResolvedValue(themeDirs);
vi.mocked(isCursor).mockReturnValue(true);

await createInstructionsFiles(ctx, nullLogger);

expect(window.showInformationMessage).toHaveBeenCalledTimes(2);

for (const themeDir of themeDirs) {
const rulesPath = path.join(themeDir, '.cursorrules');
expect(await fileExists(rulesPath)).toBeTruthy();
}
});

it('should not create files when user declines', async () => {
vi.mocked(getShopifyThemeRootDirs).mockResolvedValue(themeDirs);
vi.mocked(isCursor).mockReturnValue(true);
vi.mocked(window.showInformationMessage).mockResolvedValue('No' as any);

await createInstructionsFiles(ctx, nullLogger);

expect(window.showInformationMessage).toHaveBeenCalledTimes(2);

for (const themeDir of themeDirs) {
const rulesPath = path.join(themeDir, '.cursorrules');
expect(await fileExists(rulesPath)).toBeFalsy();
}

expect(ctx.globalState.update).toHaveBeenCalledTimes(2);
});

it('should create Copilot instructions when not using Cursor', async () => {
vi.mocked(getShopifyThemeRootDirs).mockResolvedValue(themeDirs);
vi.mocked(isCursor).mockReturnValue(false);
vi.mocked(window.showInformationMessage).mockResolvedValue('Yes' as any);

await createInstructionsFiles(ctx, nullLogger);

for (const themeDir of themeDirs) {
const copilotPath = path.join(themeDir, '.github', 'copilot-instructions.md');
expect(await fileExists(copilotPath)).toBeTruthy();
}
});

it('should prompt for update when file exists', async () => {
vi.mocked(getShopifyThemeRootDirs).mockResolvedValue(themeDirs);
vi.mocked(isCursor).mockReturnValue(true);
vi.mocked(window.showInformationMessage).mockResolvedValue('Yes' as any);

for (const themeDir of themeDirs) {
const rulesPath = path.join(themeDir, '.cursorrules');
await fs.mkdir(path.dirname(rulesPath), { recursive: true });
await fs.writeFile(rulesPath, 'old content');
}

await createInstructionsFiles(ctx, nullLogger);

expect(window.showInformationMessage).toHaveBeenCalledTimes(2);

for (const themeDir of themeDirs) {
const rulesPath = path.join(themeDir, '.cursorrules');
const content = await fs.readFile(rulesPath, 'utf-8');
expect(content).not.toBe('old content');
}
});
});
Loading