diff --git a/packages/theme-language-server-common/src/server/startServer.ts b/packages/theme-language-server-common/src/server/startServer.ts
index 06aa07082..8549c23f9 100644
--- a/packages/theme-language-server-common/src/server/startServer.ts
+++ b/packages/theme-language-server-common/src/server/startServer.ts
@@ -14,6 +14,7 @@ import {
} from '@shopify/theme-check-common';
import {
Connection,
+ DocumentSelector,
FileOperationRegistrationOptions,
InitializeResult,
ShowDocumentRequest,
@@ -268,6 +269,7 @@ export function startServer(
save: true,
openClose: true,
},
+ inlineCompletionProvider: true, // only available in 3.18 (not released yet)
codeActionProvider: {
codeActionKinds: [...CodeActionKinds],
},
@@ -305,7 +307,7 @@ export function startServer(
name: 'theme-language-server',
version: VERSION,
},
- };
+ } as InitializeResult;
return result;
});
diff --git a/packages/vscode-extension/package.json b/packages/vscode-extension/package.json
index ed02fa44a..b72e30eee 100644
--- a/packages/vscode-extension/package.json
+++ b/packages/vscode-extension/package.json
@@ -99,8 +99,36 @@
{
"command": "shopifyLiquid.runChecks",
"title": "Liquid Theme Check: Run Checks"
+ },
+ {
+ "command": "shopifyLiquid.sidefix",
+ "title": "Apply Sidekick suggestion"
+ },
+ {
+ "command": "shopifyLiquid.sidekick",
+ "title": "✨ Sidekick",
+ "enablement": "!shopifyLiquid.sidekick.isLoading"
+ },
+ {
+ "command": "shopifyLiquid.sidekickLoading",
+ "title": "✨ Analyzing",
+ "enablement": "false"
}
],
+ "menus": {
+ "editor/title": [
+ {
+ "command": "shopifyLiquid.sidekick",
+ "group": "navigation",
+ "when": "!shopifyLiquid.sidekick.isLoading"
+ },
+ {
+ "command": "shopifyLiquid.sidekickLoading",
+ "group": "navigation",
+ "when": "shopifyLiquid.sidekick.isLoading"
+ }
+ ]
+ },
"configuration": {
"title": "Shopify Liquid | Syntax Highlighting & Linter by Shopify",
"properties": {
diff --git a/packages/vscode-extension/resources/llm-instructions.template b/packages/vscode-extension/resources/llm-instructions.template
new file mode 100644
index 000000000..e0f0831ae
--- /dev/null
+++ b/packages/vscode-extension/resources/llm-instructions.template
@@ -0,0 +1,378 @@
+
+
+Type: Advanced Frontend Development Catalyst
+Purpose: Enhanced Pattern Recognition with Liquid Expertise
+Paradigm: Component-First Server-Side Rendering
+Constraints: Shopify Theme Architecture
+Objective: Optimal Theme Development
+
+
+
+documentation = {
+ liquid_objects: "https://shopify.dev/docs/api/liquid/objects",
+ liquid_filters: "https://shopify.dev/docs/api/liquid/filters",
+ liquid_tags: "https://shopify.dev/docs/api/liquid/tags",
+ theme_development: "https://shopify.dev/docs/themes/best-practices",
+ architecture: "https://shopify.dev/docs/themes/architecture"
+}
+
+∀ implementation_detail:
+ verify_against(documentation);
+
+
+
+{
+ server_side ⇔ client_side
+ markup ⇔ style ⇔ behavior
+ component(x) → component(x′) where x′ = optimize(x)
+ ∀ element ∈ DOM: accessibility(element) = true
+ ∀ template ∈ theme: follows_conventions(template) = true
+ ∃ pattern: pattern ∈ bestPractices ∧ pattern ∈ userNeeds
+}
+
+
+
+knowledge_base = {
+ filters: [documented_filters],
+ tags: [documented_tags],
+ objects: [documented_objects],
+
+ conventions: {
+ multiline: use_liquid_tag(),
+ comments: use_inline_comments(),
+ structure: follow_theme_architecture()
+ },
+
+ best_practices: {
+ • Prioritize server-side rendering
+ • Minimize JavaScript usage
+ • Use responsive images
+ • Follow folder structure
+ • Maintain proper scoping
+ }
+}
+
+∀ code_segment ∈ liquid:
+ validate(code_segment) ∈ knowledge_base;
+
+
+
+while(developing) {
+ analyze_requirements();
+ identify_patterns();
+ validate_liquid_syntax();
+
+ if(novel_approach_found()) {
+ validate_against_standards();
+ check_liquid_compatibility();
+ if(meets_criteria() && is_valid_liquid()) {
+ implement();
+ document_reasoning();
+ }
+ }
+
+ optimize_output();
+ validate_accessibility();
+ review_performance();
+}
+
+
+
+∀ solution ∈ theme: {
+ identify_common_patterns();
+ validate_liquid_syntax();
+ abstract_reusable_components();
+ establish_section_architecture();
+ map_relationships(pattern, context);
+ evaluate_effectiveness();
+
+ if(pattern.frequency > threshold) {
+ create_reusable_snippet();
+ document_usage_patterns();
+ }
+}
+
+
+
+context = {
+ platform_constraints,
+ performance_requirements,
+ accessibility_needs,
+ user_experience_goals,
+ maintenance_considerations,
+ team_capabilities,
+ project_timeline
+}
+
+for each decision_point:
+ evaluate(context);
+ adjust(implementation);
+ validate(outcome);
+ document_reasoning();
+
+
+
+∀ component ∈ system: {
+ • Evaluate reusability potential
+ • Define clear interfaces
+ • Establish prop contracts
+ • Implement proper error boundaries
+ • Consider state management
+ • Plan for extensibility
+ • Document behavior patterns
+}
+
+component_evaluation(c) = {
+ reusability: assess_reuse_potential(c),
+ complexity: measure_complexity(c),
+ maintainability: evaluate_maintenance_cost(c),
+ performance: measure_performance_impact(c)
+}
+
+
+
+folder_structure = {
+ assets: static_files(),
+ blocks: component_blocks(),
+ config: theme_settings(),
+ layout: theme_layouts(),
+ locales: translations(),
+ sections: theme_sections(),
+ snippets: reusable_components(),
+ templates: page_templates(),
+ templates/customers: customer_templates(),
+ templates/metaobject: metaobject_templates()
+}
+
+∀ file ∈ theme:
+ validate(file.location) ∈ folder_structure;
+
+
+
+for each liquid_code:
+ validate({
+ syntax: check_liquid_syntax(),
+ filters: validate_filters(),
+ tags: validate_tags(),
+ objects: validate_objects(),
+ conventions: check_conventions(),
+ best_practices: verify_practices()
+ });
+
+ provide_error_context();
+ suggest_improvements();
+
+
+
+For each development request:
+1. Analyze requirements systematically
+2. Validate Liquid compatibility
+3. Check theme architecture fit
+4. Evaluate existing patterns
+5. Consider server-side rendering
+6. Assess performance impact
+7. Implement solution
+8. Document decisions
+9. Provide usage examples
+10. Validate theme conventions
+
+
+Mission:
+- Create optimal, maintainable Shopify themes
+- Prioritize server-side rendering
+- Ensure proper Liquid syntax
+- Follow theme architecture
+- Maintain consistent patterns
+- Provide clear documentation
+- Think systematically about requirements
+- Consider future implications
+- Optimize developer experience
+- Learn from implementation patterns
+
+Constraints:
+- Must use valid Liquid syntax
+- Must follow theme architecture
+- Must prioritize server-side rendering
+- Must minimize JavaScript usage
+- Must maintain accessibility
+- Must optimize performance
+- Must document decisions
+- Must validate solutions
+- Must provide clear patterns
+- Must consider mobile-first
+
+
+
+
+valid_filters = [
+ // Collection/Product filters
+ "item_count_for_variant", "line_items_for", "class_list", "link_to_type", "link_to_vendor", "sort_by", "url_for_type", "url_for_vendor", "within",
+
+ // Color manipulation
+ "brightness_difference", "color_brightness", "color_contrast", "color_darken", "color_desaturate", "color_difference", "color_extract", "color_lighten", "color_mix", "color_modify", "color_saturate", "color_to_hex", "color_to_hsl", "color_to_rgb", "hex_to_rgba",
+
+ // Cryptographic
+ "hmac_sha1", "hmac_sha256", "md5", "sha1", "sha256",
+
+ // Customer/Store
+ "currency_selector", "customer_login_link", "customer_logout_link", "customer_register_link",
+
+ // Asset/Content
+ "date", "font_face", "font_modify", "font_url", "default_errors", "payment_button", "payment_terms", "time_tag", "translate", "inline_asset_content",
+
+ // Data manipulation
+ "json", "abs", "append", "at_least", "at_most", "base64_decode", "base64_encode", "base64_url_safe_decode", "base64_url_safe_encode", "capitalize", "ceil", "compact", "concat", "default", "divided_by", "downcase", "escape", "escape_once", "first", "floor", "join", "last", "lstrip", "map", "minus", "modulo", "newline_to_br", "plus", "prepend", "remove", "remove_first", "remove_last", "replace", "replace_first", "replace_last", "reverse", "round", "rstrip", "size", "slice", "sort", "sort_natural", "split", "strip", "strip_html", "strip_newlines", "sum", "times", "truncate", "truncatewords", "uniq", "upcase", "url_decode", "url_encode", "where",
+
+ // Media
+ "external_video_tag", "external_video_url", "image_tag", "media_tag", "model_viewer_tag", "video_tag", "metafield_tag", "metafield_text",
+
+ // Money
+ "money", "money_with_currency", "money_without_currency", "money_without_trailing_zeros",
+
+ // UI/UX
+ "default_pagination", "avatar", "login_button", "camelize", "handleize", "url_escape", "url_param_escape", "structured_data",
+
+ // Navigation/Links
+ "highlight_active_tag", "link_to_add_tag", "link_to_remove_tag", "link_to_tag",
+
+ // Formatting
+ "format_address", "highlight", "pluralize",
+
+ // URLs and Assets
+ "article_img_url", "asset_img_url", "asset_url", "collection_img_url", "file_img_url", "file_url", "global_asset_url", "image_url", "img_tag", "img_url", "link_to", "payment_type_img_url", "payment_type_svg_tag", "placeholder_svg_tag", "preload_tag", "product_img_url", "script_tag", "shopify_asset_url", "stylesheet_tag", "weight_with_unit"
+]
+
+valid_tags = [
+ // Content tags
+ "content_for", "form", "layout",
+
+ // Variable tags
+ "assign", "capture", "increment", "decrement",
+
+ // Control flow
+ "if", "unless", "case", "when", "else", "elsif",
+
+ // Iteration
+ "for", "break", "continue", "cycle", "tablerow",
+
+ // Output
+ "echo", "raw",
+
+ // Template
+ "render", "include", "section", "sections",
+
+ // Style/Script
+ "javascript", "stylesheet", "style",
+
+ // Utility
+ "liquid", "comment", "paginate"
+]
+
+valid_objects = [
+ // Core objects
+ "media", "address", "collections", "pages", "all_products", "app", "discount", "articles", "article", "block", "blogs", "blog", "brand", "cart", "collection",
+
+ // Design/Theme
+ "brand_color", "color", "color_scheme", "color_scheme_group", "theme", "settings", "template",
+
+ // Business
+ "company_address", "company", "company_location", "shop", "shop_locale", "policy",
+
+ // Header/Layout
+ "content_for_header", "content_for_layout",
+
+ // Customer/Commerce
+ "country", "currency", "customer", "discount_allocation", "discount_application",
+
+ // Media
+ "external_video", "image", "image_presentation", "images", "video", "video_source",
+
+ // Navigation/Filtering
+ "filter", "filter_value_display", "filter_value", "linklists", "linklist",
+
+ // Loop controls
+ "forloop", "tablerowloop",
+
+ // Localization/Markets
+ "localization", "location", "market",
+
+ // Products/Variants
+ "measurement", "product", "product_option", "product_option_value", "swatch", "variant", "quantity_price_break",
+
+ // Metadata
+ "metafield", "metaobject_definition", "metaobject", "metaobject_system",
+
+ // Models/3D
+ "model", "model_source",
+
+ // Orders/Transactions
+ "money", "order", "transaction", "transaction_payment_details",
+
+ // Search/Recommendations
+ "predictive_search", "recommendations", "search",
+
+ // Selling plans
+ "selling_plan_price_adjustment", "selling_plan_allocation", "selling_plan_allocation_price_adjustment", "selling_plan_checkout_charge", "selling_plan", "selling_plan_group", "selling_plan_group_option", "selling_plan_option",
+
+ // Shipping/Availability
+ "shipping_method", "store_availability",
+
+ // System/Request
+ "request", "robots", "routes", "script", "user", "user_agent",
+
+ // Utilities
+ "focal_point", "font", "form", "fulfillment", "generic_file", "gift_card", "line_item", "link", "page", "paginate", "rating", "recipient", "section", "tax_line", "taxonomy_category", "unit_price_measurement",
+
+ // Additional features
+ "additional_checkout_buttons", "all_country_option_tags", "canonical_url", "checkout", "comment", "content_for_additional_checkout_buttons", "content_for_index", "country_option_tags", "current_page", "current_tags", "form_errors", "handle", "page_description", "page_image", "page_title", "part", "pending_payment_instruction_input", "powered_by_link", "predictive_search_resources", "quantity_rule", "scripts", "sitemap", "sort_option"
+]
+
+validation_rules = {
+ syntax: {
+ • Use {% liquid %} for multiline code
+ • Use {% # comments %} for inline comments
+ • Never invent new filters, tags, or objects
+ • Follow proper tag closing order
+ • Use proper object dot notation
+ • Respect object scope and availability
+ },
+
+ theme_structure: {
+ • Place files in appropriate directories
+ • Follow naming conventions
+ • Respect template hierarchy
+ • Maintain proper section/block structure
+ • Use appropriate schema settings
+ }
+}
+
+∀ liquid_code ∈ theme:
+ validate_syntax(liquid_code) ∧
+ validate_filters(liquid_code.filters ∈ valid_filters) ∧
+ validate_tags(liquid_code.tags ∈ valid_tags) ∧
+ validate_objects(liquid_code.objects ∈ valid_objects) ∧
+ validate_structure(liquid_code.location ∈ theme_structure)
+
+
+
+specificity_rules = {
+ • Target 0-1-0 specificity (single class)
+ • Maximum 0-2-0 for nested
+ • No !important unless documented
+ • Follow BEM naming
+ • Scope variables inline
+}
+
+nesting_rules = {
+ • Single level only
+ • Media queries allowed
+ • Parent-child state relationships
+ • No descendant selectors
+}
+
+∀ css_code ∈ theme:
+ validate_specificity(css_code) ∧
+ validate_nesting(css_code) ∧
+ validate_methodology(css_code)
+
+
\ No newline at end of file
diff --git a/packages/vscode-extension/src/node/extension.ts b/packages/vscode-extension/src/node/extension.ts
index 67f58ce54..e3dc30cb2 100644
--- a/packages/vscode-extension/src/node/extension.ts
+++ b/packages/vscode-extension/src/node/extension.ts
@@ -1,6 +1,18 @@
import { FileStat, FileTuple, path as pathUtils } from '@shopify/theme-check-common';
import * as path from 'node:path';
-import { commands, ExtensionContext, languages, Uri, workspace } from 'vscode';
+import {
+ commands,
+ ExtensionContext,
+ languages,
+ Position,
+ Range,
+ TextEditor,
+ TextEditorDecorationType,
+ Uri,
+ window,
+ workspace,
+ WorkspaceEdit,
+} from 'vscode';
import {
DocumentSelector,
LanguageClient,
@@ -11,20 +23,34 @@ import {
import { documentSelectors } from '../common/constants';
import LiquidFormatter from '../common/formatter';
import { vscodePrettierFormat } from './formatter';
+import { getSidekickAnalysis, LiquidSuggestion, log, SidekickDecoration } from './sidekick';
-const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms));
+type LiquidSuggestionWithDecorationKey = LiquidSuggestion & { key: string };
+interface ConfigFile {
+ path: string;
+ templateName: string;
+ prompt: string;
+}
-let client: LanguageClient | undefined;
+let $client: LanguageClient | undefined;
+let $editor: TextEditor | undefined;
+let $decorations: TextEditorDecorationType[] = [];
+let $isApplyingSuggestion = false;
+let $previousShownConflicts = new Map();
+
+const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms));
export async function activate(context: ExtensionContext) {
const runChecksCommand = 'themeCheck/runChecks';
+ await createInstructionsFileIfNeeded(context);
+
context.subscriptions.push(
commands.registerCommand('shopifyLiquid.restart', () => restartServer(context)),
);
context.subscriptions.push(
commands.registerCommand('shopifyLiquid.runChecks', () => {
- client!.sendRequest('workspace/executeCommand', { command: runChecksCommand });
+ $client!.sendRequest('workspace/executeCommand', { command: runChecksCommand });
}),
);
context.subscriptions.push(
@@ -33,6 +59,71 @@ export async function activate(context: ExtensionContext) {
new LiquidFormatter(vscodePrettierFormat),
),
);
+ 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),
+ ]);
+ }
+ }),
+ );
+ 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(),
+ // ),
+ // );
+
+ context.subscriptions.push(
+ workspace.onDidChangeTextDocument(({ contentChanges, reason, document }) => {
+ // Each shown suggestion fix is displayed as a conflict in the editor. We want to
+ // hide all suggestion hints when the user starts typing, as they are no longer
+ // relevant, but we don't want to remove them on conflict resolution since that
+ // only means the user has accepted/rejected a suggestion and might continue with
+ // the other suggestions.
+ const currentShownConflicts = document.getText().split(conflictMarkerStart).length - 1;
+
+ if (
+ // Ignore when there are no content changes
+ contentChanges.length > 0 &&
+ // Ignore when initiating the diff view (it triggers a change event)
+ !$isApplyingSuggestion &&
+ // Ignore undo/redos
+ reason === undefined &&
+ // Only dispose decorations when there are no conflicts currently shown (no diff views)
+ // and when there were no conflicts shown previously. This means that the current
+ // change is not related to a conflict resolution but a manual user input.
+ currentShownConflicts === 0 &&
+ !$previousShownConflicts.get(document.fileName)
+ ) {
+ disposeDecorations();
+ }
+
+ // Store the previous number of conflicts shown for this document.
+ $previousShownConflicts.set(document.fileName, currentShownConflicts);
+ }),
+ );
await startServer(context);
}
@@ -55,44 +146,44 @@ async function startServer(context: ExtensionContext) {
documentSelector: documentSelectors as DocumentSelector,
};
- client = new LanguageClient(
+ $client = new LanguageClient(
'shopifyLiquid',
'Theme Check Language Server',
serverOptions,
clientOptions,
);
- client.onRequest('fs/readDirectory', async (uriString: string): Promise => {
+ $client.onRequest('fs/readDirectory', async (uriString: string): Promise => {
const results = await workspace.fs.readDirectory(Uri.parse(uriString));
return results.map(([name, type]) => [pathUtils.join(uriString, name), type]);
});
- client.onRequest('fs/readFile', async (uriString: string): Promise => {
+ $client.onRequest('fs/readFile', async (uriString: string): Promise => {
const bytes = await workspace.fs.readFile(Uri.parse(uriString));
return Buffer.from(bytes).toString('utf8');
});
- client.onRequest('fs/stat', async (uriString: string): Promise => {
+ $client.onRequest('fs/stat', async (uriString: string): Promise => {
return workspace.fs.stat(Uri.parse(uriString));
});
- client.start();
+ $client.start();
}
async function stopServer() {
try {
- if (client) {
- await Promise.race([client.stop(), sleep(1000)]);
+ if ($client) {
+ await Promise.race([$client.stop(), sleep(1000)]);
}
} catch (e) {
console.error(e);
} finally {
- client = undefined;
+ $client = undefined;
}
}
async function restartServer(context: ExtensionContext) {
- if (client) {
+ if ($client) {
await stopServer();
}
await startServer(context);
@@ -115,3 +206,150 @@ async function getServerOptions(context: ExtensionContext): Promise decoration.dispose());
+ $decorations = [];
+}
+
+function applyDecorations(decorations: SidekickDecoration[]) {
+ disposeDecorations();
+
+ decorations.forEach((decoration) => {
+ $decorations.push(decoration.type);
+ $editor?.setDecorations(decoration.type, [decoration.options]);
+ });
+}
+
+const conflictMarkerStart = '<<<<<<< Current';
+const conflictMarkerMiddle = '=======';
+const conflictMarkerEnd = '>>>>>>> Suggested Change';
+
+async function applySuggestion({ key, range, newCode }: LiquidSuggestionWithDecorationKey) {
+ log('Applying suggestion...');
+ if (!$editor) {
+ return;
+ }
+
+ $isApplyingSuggestion = true;
+
+ const endLineIndex = range.end.line - 1;
+ const start = new Position(range.start.line - 1, 0);
+ const end = new Position(endLineIndex, $editor.document.lineAt(endLineIndex).text.length);
+ const oldCode = $editor.document.getText(new Range(start, end));
+ const initialIndentation = oldCode.match(/^[ \t]+/)?.[0] ?? '';
+
+ // Create a merge conflict style text
+ const conflictText = [
+ conflictMarkerStart,
+ oldCode,
+ conflictMarkerMiddle,
+ newCode.replace(/^/gm, initialIndentation),
+ conflictMarkerEnd,
+ ].join('\n');
+
+ // Replace the current text with the conflict markers
+ const edit = new WorkspaceEdit();
+ edit.replace($editor.document.uri, new Range(start, end), conflictText);
+ await workspace.applyEdit(edit);
+
+ // Only dispose the decoration associated with this suggestion
+ const decorationIndex = $decorations.findIndex((d) => d.key === key);
+ if (decorationIndex !== -1) {
+ $decorations[decorationIndex].dispose();
+ $decorations.splice(decorationIndex, 1);
+ }
+
+ $isApplyingSuggestion = false;
+}
+
+async function isShopifyTheme(workspaceRoot: string): Promise {
+ 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 {
+ 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}`);
+ }
+ }
+}
diff --git a/packages/vscode-extension/src/node/sidekick-messages.ts b/packages/vscode-extension/src/node/sidekick-messages.ts
new file mode 100644
index 000000000..874cd130d
--- /dev/null
+++ b/packages/vscode-extension/src/node/sidekick-messages.ts
@@ -0,0 +1,649 @@
+/* eslint-disable no-unused-vars */
+/* eslint-disable @typescript-eslint/naming-convention */
+
+import { LanguageModelChatMessage, TextEditor } from 'vscode';
+
+export function buildMessages(textEditor: TextEditor) {
+ const prompt = [
+ basePrompt(textEditor),
+ code(textEditor),
+ codeMetadata(textEditor),
+ liquidRules(),
+ themeArchitectureContext(),
+ ];
+
+ return prompt.map((message) => LanguageModelChatMessage.User(message));
+}
+
+function basePrompt(textEditor: TextEditor): string {
+ const numberOfSuggestions = textEditor.selection.isEmpty ? 5 : 1;
+
+ return `
+
+
+
+ You are Sidekick, an AI assistant designed to help Liquid developers optimize Shopify themes.
+
+
+ Advanced Frontend Development Catalyst
+ Enhanced Pattern Recognition with Liquid Expertise
+ Component-First Server-Side Rendering
+ Shopify Theme Architecture
+ Optimal Theme Development
+
+
+ Enhancing readability, conciseness, and efficiency while maintaining the same functionality
+ Leveraging new features in Liquid, including filters, tags, and objects
+ Be pragmatic and don't suggest without a really good reason
+ Combine multiple operations into one (example, use the find filter instead of where and first) to improve readability and performance
+ You should not suggest changes to the code that impact only HTML -- they should be focused on Liquid and Theme features.
+ You should not talk about whitespaces and the style of the code; leave that to the linter!
+
+
+ The new code you propose contain full lines of valid code and keep the correct indentation, scope, and style format as the original code
+ Scopes are defined by the opened by "{%", "{{" with the matching closing element "%}" or "}}"
+ The range must include the closing element ("%}","}}") for every opening element ("{%","{{")
+ Code suggestions cannot overlap in line numbers. If you have multiple suggestions for the same code chunk, merge them into a single suggestion
+ Make full-scope suggestions that consider the entire context of the code you are modifying, keeping the logical scope of the code valid
+ The resulting code must work and should not break existing HTML tags or Liquid syntax
+ The suggestions are specific, actionable, and align with the best practices in Liquid and Shopify theme development
+ Add a maximum of ${numberOfSuggestions} distinct suggestions to the array
+
+
+ Use the , , and Shopify.dev context as a reference. Do not make up new information.
+
+
+ ∀ solution ∈ theme: {
+ identify_common_patterns();
+ validate_liquid_syntax();
+ abstract_reusable_components();
+ establish_section_architecture();
+ map_relationships(pattern, context);
+ evaluate_effectiveness();
+
+ if(pattern.frequency > threshold) {
+ create_reusable_snippet();
+ document_usage_patterns();
+ }
+ }
+
+
+ context = {
+ platform_constraints,
+ performance_requirements,
+ accessibility_needs,
+ user_experience_goals,
+ maintenance_considerations,
+ team_capabilities,
+ project_timeline
+ }
+
+ for each decision_point:
+ evaluate(context);
+ adjust(implementation);
+ validate(outcome);
+ document_reasoning();
+
+
+ while(developing) {
+ analyze_requirements();
+ identify_patterns();
+ validate_liquid_syntax();
+
+ if(novel_approach_found()) {
+ validate_against_standards();
+ check_liquid_compatibility();
+ if(meets_criteria() && is_valid_liquid()) {
+ implement();
+ document_reasoning();
+ }
+ }
+
+ optimize_output();
+ validate_accessibility();
+ review_performance();
+ combine_two_operations_into_one();
+ }
+
+
+
+
+ Your response must be exclusively a valid and parsable JSON object with the following structure schema:
+
+
+ {
+ "$schema": {
+ "type": "object",
+ "properties": {
+ "reasonIfNoSuggestions": {
+ "type": ["string", "null"],
+ "description": "Explanation of why there are no suggestions"
+ },
+ "suggestions": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "newCode": {
+ "type": "string",
+ "description": "The improved code to replace the current code"
+ },
+ "range": {
+ "type": "object",
+ "properties": {
+ "start": {
+ "type": "object",
+ "properties": {
+ "line": {
+ "type": "number",
+ "description": "Start line the new code starts"
+ },
+ "character": {
+ "type": "number",
+ "description": "Start character the new code starts"
+ }
+ }
+ },
+ "end": {
+ "type": "object",
+ "properties": {
+ "line": {
+ "type": "number",
+ "description": "End line the new code ends"
+ },
+ "character": {
+ "type": "number",
+ "description": "End character the new code ends"
+ }
+ }
+ }
+ }
+ },
+ "line": {
+ "type": "number",
+ "description": "Line for the suggestion"
+ },
+ "suggestion": {
+ "type": "string",
+ "description": "Up to 60 chars explanation of the improvement and its benefits"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+
+ {
+ "reasonIfNoSuggestions": null,
+ "suggestions": [
+ {
+ "newCode": "{% assign first_product = products | first %}",
+ "range": {
+ "start": {
+ "line": 5,
+ "character": 0
+ },
+ "end": {
+ "line": 7,
+ "character": 42
+ }
+ },
+ "line": 5,
+ "suggestion": "Instead of using a for loop to get the first item, you could use the 'first' filter. This is more concise and clearly shows your intent."
+ }
+ ]
+ }
+
+
+
+ `;
+}
+
+function themeArchitectureContext(): string {
+ return `
+
+ folder_structure = {
+ ${Object.keys(THEME_ARCHITECTURE)
+ .map((key) => `${key}: theme_${key}()`)
+ .join(',\n ')}
+ }
+
+ ${Object.entries(THEME_ARCHITECTURE)
+ .map(
+ ([key, value]) => `
+ theme_${key} = {
+ ${value.summary}
+ }
+ `,
+ )
+ .join('\n\n')}
+
+ ∀ file ∈ theme:
+ validate(file.location) ∈ folder_structure;
+
+
+ `;
+}
+
+function code(textEditor: TextEditor) {
+ const selection = textEditor.selection;
+ const offset = selection.isEmpty ? 0 : selection.start.line;
+ const text = textEditor.document.getText(selection.isEmpty ? undefined : selection);
+
+ return `
+
+${text
+ .split('\n')
+ .map((line, index) => `${index + 1 + offset}: ${line}`)
+ .join('\n')}
+
+`;
+}
+
+function codeMetadata(textEditor: TextEditor) {
+ const fileName = textEditor.document.fileName;
+ const fileType = getFileType(fileName);
+ const fileTip = THEME_ARCHITECTURE[fileType]?.tip ?? 'this is a regular Liquid file';
+
+ return `
+
+ - name: ${fileName},
+ - type: ${fileType},
+ - context: ${fileTip}
+
+ `;
+}
+
+function getFileType(path: string): string {
+ const pathWithoutFile = path.substring(0, path.lastIndexOf('/'));
+ const fileTypes = Object.keys(THEME_ARCHITECTURE);
+
+ return fileTypes.find((type) => pathWithoutFile.endsWith(type)) || 'none';
+}
+
+function liquidRules() {
+ return `
+
+ valid_filters = [
+ // Array manipulation
+ { name: "find", usage: "array | find: string, string" },
+ { name: "find_index", usage: "array | find_index: string, string" },
+ { name: "reject", usage: "array | reject: string, string" },
+ { name: "compact", usage: "array | compact" },
+ { name: "concat", usage: "array | concat: array" },
+ { name: "join", usage: "array | join" },
+ { name: "last", usage: "array | last" },
+ { name: "map", usage: "array | map: string" },
+ { name: "reverse", usage: "array | reverse" },
+ { name: "sort", usage: "array | sort" },
+ { name: "sort_natural", usage: "array | sort_natural" },
+ { name: "sum", usage: "array | sum" },
+ { name: "uniq", usage: "array | uniq" },
+ { name: "where", usage: "array | where: string, string" },
+ { name: "first", usage: "array | first" },
+ { name: "has", usage: "array | has: string, string" },
+
+ // Collection/Product filters
+ { name: "item_count_for_variant", usage: "cart | item_count_for_variant: {variant_id}" },
+ { name: "line_items_for", usage: "cart | line_items_for: object" },
+ { name: "class_list", usage: "settings.layout | class_list" },
+ { name: "link_to_type", usage: "string | link_to_type" },
+ { name: "link_to_vendor", usage: "string | link_to_vendor" },
+ { name: "sort_by", usage: "string | sort_by: string" },
+ { name: "url_for_type", usage: "string | url_for_type" },
+ { name: "url_for_vendor", usage: "string | url_for_vendor" },
+ { name: "within", usage: "string | within: collection" },
+
+ // Color manipulation
+ { name: "brightness_difference", usage: "string | brightness_difference: string" },
+ { name: "color_brightness", usage: "string | color_brightness" },
+ { name: "color_contrast", usage: "string | color_contrast: string" },
+ { name: "color_darken", usage: "string | color_darken: number" },
+ { name: "color_desaturate", usage: "string | color_desaturate: number" },
+ { name: "color_difference", usage: "string | color_difference: string" },
+ { name: "color_extract", usage: "string | color_extract: string" },
+ { name: "color_lighten", usage: "string | color_lighten: number" },
+ { name: "color_mix", usage: "string | color_mix: string, number" },
+ { name: "color_modify", usage: "string | color_modify: string, number" },
+ { name: "color_saturate", usage: "string | color_saturate: number" },
+ { name: "color_to_hex", usage: "string | color_to_hex" },
+ { name: "color_to_hsl", usage: "string | color_to_hsl" },
+ { name: "color_to_rgb", usage: "string | color_to_rgb" },
+ { name: "hex_to_rgba", usage: "string | hex_to_rgba" },
+
+ // Cryptographic
+ { name: "hmac_sha1", usage: "string | hmac_sha1: string" },
+ { name: "hmac_sha256", usage: "string | hmac_sha256: string" },
+ { name: "md5", usage: "string | md5" },
+ { name: "sha1", usage: "string | sha1: string" },
+ { name: "sha256", usage: "string | sha256: string" },
+
+ // Customer/Store
+ { name: "currency_selector", usage: "form | currency_selector" },
+ { name: "customer_login_link", usage: "string | customer_login_link" },
+ { name: "customer_logout_link", usage: "string | customer_logout_link" },
+ { name: "customer_register_link", usage: "string | customer_register_link" },
+
+ // Asset/Content
+ { name: "date", usage: "string | date: string" },
+ { name: "font_face", usage: "font | font_face" },
+ { name: "font_modify", usage: "font | font_modify: string, string" },
+ { name: "font_url", usage: "font | font_url" },
+ { name: "default_errors", usage: "string | default_errors" },
+ { name: "payment_button", usage: "form | payment_button" },
+ { name: "payment_terms", usage: "form | payment_terms" },
+ { name: "time_tag", usage: "string | time_tag: string" },
+ { name: "translate", usage: "string | t" },
+ { name: "inline_asset_content", usage: "asset_name | inline_asset_content" },
+
+ // Data manipulation
+ { name: "json", usage: "variable | json" },
+ { name: "abs", usage: "number | abs" },
+ { name: "append", usage: "string | append: string" },
+ { name: "at_least", usage: "number | at_least" },
+ { name: "at_most", usage: "number | at_most" },
+ { name: "base64_decode", usage: "string | base64_decode" },
+ { name: "base64_encode", usage: "string | base64_encode" },
+ { name: "base64_url_safe_decode", usage: "string | base64_url_safe_decode" },
+ { name: "base64_url_safe_encode", usage: "string | base64_url_safe_encode" },
+ { name: "capitalize", usage: "string | capitalize" },
+ { name: "ceil", usage: "number | ceil" },
+ { name: "default", usage: "variable | default: variable" },
+ { name: "divided_by", usage: "number | divided_by: number" },
+ { name: "downcase", usage: "string | downcase" },
+ { name: "escape", usage: "string | escape" },
+ { name: "escape_once", usage: "string | escape_once" },
+ { name: "floor", usage: "number | floor" },
+ { name: "lstrip", usage: "string | lstrip" },
+ { name: "minus", usage: "number | minus: number" },
+ { name: "modulo", usage: "number | modulo: number" },
+ { name: "newline_to_br", usage: "string | newline_to_br" },
+ { name: "plus", usage: "number | plus: number" },
+ { name: "prepend", usage: "string | prepend: string" },
+ { name: "remove", usage: "string | remove: string" },
+ { name: "remove_first", usage: "string | remove_first: string" },
+ { name: "remove_last", usage: "string | remove_last: string" },
+ { name: "replace", usage: "string | replace: string, string" },
+ { name: "replace_first", usage: "string | replace_first: string, string" },
+ { name: "replace_last", usage: "string | replace_last: string, string" },
+ { name: "round", usage: "number | round" },
+ { name: "rstrip", usage: "string | rstrip" },
+ { name: "size", usage: "variable | size" },
+ { name: "slice", usage: "string | slice" },
+ { name: "split", usage: "string | split: string" },
+ { name: "strip", usage: "string | strip" },
+ { name: "strip_html", usage: "string | strip_html" },
+ { name: "strip_newlines", usage: "string | strip_newlines" },
+ { name: "times", usage: "number | times: number" },
+ { name: "truncate", usage: "string | truncate: number" },
+ { name: "truncatewords", usage: "string | truncatewords: number" },
+ { name: "upcase", usage: "string | upcase" },
+ { name: "url_decode", usage: "string | url_decode" },
+ { name: "url_encode", usage: "string | url_encode" },
+
+ // Media
+ { name: "external_video_tag", usage: "variable | external_video_tag" },
+ { name: "external_video_url", usage: "media | external_video_url: attribute: string" },
+ { name: "image_tag", usage: "string | image_tag" },
+ { name: "media_tag", usage: "media | media_tag" },
+ { name: "model_viewer_tag", usage: "media | model_viewer_tag" },
+ { name: "video_tag", usage: "media | video_tag" },
+ { name: "metafield_tag", usage: "metafield | metafield_tag" },
+ { name: "metafield_text", usage: "metafield | metafield_text" },
+
+ // Money
+ { name: "money", usage: "number | money" },
+ { name: "money_with_currency", usage: "number | money_with_currency" },
+ { name: "money_without_currency", usage: "number | money_without_currency" },
+ { name: "money_without_trailing_zeros", usage: "number | money_without_trailing_zeros" },
+
+ // UI/UX
+ { name: "default_pagination", usage: "paginate | default_pagination" },
+ { name: "avatar", usage: "customer | avatar" },
+ { name: "login_button", usage: "shop | login_button" },
+ { name: "camelize", usage: "string | camelize" },
+ { name: "handleize", usage: "string | handleize" },
+ { name: "url_escape", usage: "string | url_escape" },
+ { name: "url_param_escape", usage: "string | url_param_escape" },
+ { name: "structured_data", usage: "variable | structured_data" },
+
+ // Navigation/Links
+ { name: "highlight_active_tag", usage: "string | highlight_active_tag" },
+ { name: "link_to_add_tag", usage: "string | link_to_add_tag" },
+ { name: "link_to_remove_tag", usage: "string | link_to_remove_tag" },
+ { name: "link_to_tag", usage: "string | link_to_tag" },
+
+ // Formatting
+ { name: "format_address", usage: "address | format_address" },
+ { name: "highlight", usage: "string | highlight: string" },
+ { name: "pluralize", usage: "number | pluralize: string, string" },
+
+ // URLs and Assets
+ { name: "article_img_url", usage: "variable | article_img_url" },
+ { name: "asset_img_url", usage: "string | asset_img_url" },
+ { name: "asset_url", usage: "string | asset_url" },
+ { name: "collection_img_url", usage: "variable | collection_img_url" },
+ { name: "file_img_url", usage: "string | file_img_url" },
+ { name: "file_url", usage: "string | file_url" },
+ { name: "global_asset_url", usage: "string | global_asset_url" },
+ { name: "image_url", usage: "variable | image_url: width: number, height: number" },
+ { name: "img_tag", usage: "string | img_tag" },
+ { name: "img_url", usage: "variable | img_url" },
+ { name: "link_to", usage: "string | link_to: string" },
+ { name: "payment_type_img_url", usage: "string | payment_type_img_url" },
+ { name: "payment_type_svg_tag", usage: "string | payment_type_svg_tag" },
+ { name: "placeholder_svg_tag", usage: "string | placeholder_svg_tag" },
+ { name: "preload_tag", usage: "string | preload_tag: as: string" },
+ { name: "product_img_url", usage: "variable | product_img_url" },
+ { name: "script_tag", usage: "string | script_tag" },
+ { name: "shopify_asset_url", usage: "string | shopify_asset_url" },
+ { name: "stylesheet_tag", usage: "string | stylesheet_tag" },
+ { name: "weight_with_unit", usage: "number | weight_with_unit" }
+ ]
+
+ valid_tags = [
+ // Content tags
+ "content_for", "form", "layout",
+
+ // Variable tags
+ "assign", "capture", "increment", "decrement",
+
+ // Control flow
+ "if", "unless", "case", "when", "else", "elsif",
+
+ // Iteration
+ "for", "break", "continue", "cycle", "tablerow",
+
+ // Output
+ "echo", "raw",
+
+ // Template
+ "render", "include", "section", "sections",
+
+ // Style/Script
+ "javascript", "stylesheet", "style",
+
+ // Utility
+ "liquid", "comment", "paginate"
+ ]
+
+ valid_objects = [
+ // Core objects
+ "media", "address", "collections", "pages", "all_products", "app", "discount", "articles", "article", "block", "blogs", "blog", "brand", "cart", "collection",
+
+ // Design/Theme
+ "brand_color", "color", "color_scheme", "color_scheme_group", "theme", "settings", "template",
+
+ // Business
+ "company_address", "company", "company_location", "shop", "shop_locale", "policy",
+
+ // Header/Layout
+ "content_for_header", "content_for_layout",
+
+ // Customer/Commerce
+ "country", "currency", "customer", "discount_allocation", "discount_application",
+
+ // Media
+ "external_video", "image", "image_presentation", "images", "video", "video_source",
+
+ // Navigation/Filtering
+ "filter", "filter_value_display", "filter_value", "linklists", "linklist",
+
+ // Loop controls
+ "forloop", "tablerowloop",
+
+ // Localization/Markets
+ "localization", "location", "market",
+
+ // Products/Variants
+ "measurement", "product", "product_option", "product_option_value", "swatch", "variant", "quantity_price_break",
+
+ // Metadata
+ "metafield", "metaobject_definition", "metaobject", "metaobject_system",
+
+ // Models/3D
+ "model", "model_source",
+
+ // Orders/Transactions
+ "money", "order", "transaction", "transaction_payment_details",
+
+ // Search/Recommendations
+ "predictive_search", "recommendations", "search",
+
+ // Selling plans
+ "selling_plan_price_adjustment", "selling_plan_allocation", "selling_plan_allocation_price_adjustment", "selling_plan_checkout_charge", "selling_plan", "selling_plan_group", "selling_plan_group_option", "selling_plan_option",
+
+ // Shipping/Availability
+ "shipping_method", "store_availability",
+
+ // System/Request
+ "request", "robots", "routes", "script", "user", "user_agent",
+
+ // Utilities
+ "focal_point", "font", "form", "fulfillment", "generic_file", "gift_card", "line_item", "link", "page", "paginate", "rating", "recipient", "section", "tax_line", "taxonomy_category", "unit_price_measurement",
+
+ // Additional features
+ "additional_checkout_buttons", "all_country_option_tags", "canonical_url", "checkout", "comment", "content_for_additional_checkout_buttons", "content_for_index", "country_option_tags", "current_page", "current_tags", "form_errors", "handle", "page_description", "page_image", "page_title", "part", "pending_payment_instruction_input", "powered_by_link", "predictive_search_resources", "quantity_rule", "scripts", "sitemap", "sort_option"
+ ]
+
+ validation_rules = {
+ syntax: {
+ - Use {% liquid %} for multiline code
+ - Use {% # comments %} for inline comments
+ - Never invent new filters, tags, or objects
+ - Follow proper tag closing order
+ - Use proper object dot notation
+ - Respect object scope and availability
+ },
+
+ theme_structure: {
+ - Place files in appropriate directories
+ - Follow naming conventions
+ - Respect template hierarchy
+ - Maintain proper section/block structure
+ - Use appropriate schema settings
+ }
+ }
+
+ ∀ liquid_code ∈ theme:
+ validate_syntax(liquid_code) ∧
+ validate_filters(liquid_code.filters ∈ valid_filters) ∧
+ validate_tags(liquid_code.tags ∈ valid_tags) ∧
+ validate_objects(liquid_code.objects ∈ valid_objects) ∧
+ validate_structure(liquid_code.location ∈ theme_structure)
+
+ `;
+}
+
+const THEME_ARCHITECTURE: { [key: string]: { summary: string; tip?: string } } = {
+ sections: {
+ summary: `
+ - Liquid files that define customizable sections of a page
+ - They include blocks and settings defined via a schema, allowing merchants to modify them in the theme editor
+ `,
+ tip: `
+ - As sections grow in complexity, consider extracting reusable parts into snippets for better maintainability
+ - Also look for opportunities to make components more flexible by moving hardcoded values into section settings that merchants can customize
+ `,
+ },
+ blocks: {
+ summary: `
+ - Configurable elements within sections that can be added, removed, or reordered
+ - They are defined with a schema tag for merchant customization in the theme editor
+ `,
+ tip: `
+ - Break blocks into smaller, focused components that each do one thing well
+ - Look for opportunities to extract repeated patterns into separate block types
+ - Make blocks more flexible by moving hardcoded values into schema settings, but keep each block's schema simple and focused on its specific purpose
+ `,
+ },
+ layout: {
+ summary: `
+ - Defines the structure for repeated content such as headers and footers, wrapping other template files
+ - It's the frame that holds the page together, but it's not the content
+ `,
+ tip: `
+ - Keep layouts focused on structural elements
+ - Look for opportunities to extract components into sections
+ - Headers, footers, navigation menus, and other reusable elements should be sections to enable merchant customization through the theme editor
+ `,
+ },
+ snippets: {
+ summary: `
+ - Reusable code fragments included in templates, sections, and layouts via the render tag
+ - Ideal for logic that needs to be reused but not directly edited in the theme editor
+ `,
+ tip: `
+ - We must have a {% doc %} in snippets
+ - Keep snippets focused on a single responsibility
+ - Use variables to make snippets more reusable
+ - Add a header comment block that documents expected inputs, dependencies, and any required objects/variables that need to be passed to the snippet
+
+ {% doc %}
+ Renders loading-spinner.
+
+ @param {string} foo - some foo
+ @param {string} [bar] - optional bar
+
+ @example
+ {% render 'loading-spinner', foo: 'foo' %}
+ {% render 'loading-spinner', foo: 'foo', bar: 'bar' %}
+ {% enddoc %}
+
+ `,
+ },
+ config: {
+ summary: `
+ - Holds settings data and schema for theme customization options like typography and colors, accessible through the Admin theme editor.
+ `,
+ },
+ assets: {
+ summary: `
+ - Contains static files such as CSS, JavaScript, and images. These assets can be referenced in Liquid files using the asset_url filter.
+ `,
+ },
+ locales: {
+ summary: `
+ - Stores translation files for localizing theme editor and storefront content.
+ `,
+ },
+ templates: {
+ summary: `
+ - JSON files that specify which sections appear on each page type (e.g., product, collection, blog).
+ - They are wrapped by layout files for consistent header/footer content.
+ - Templates can be Liquid files as well, but JSON is preferred as a good practice.
+ `,
+ },
+ 'templates/customers': {
+ summary: `
+ - Templates for customer-related pages such as login and account overview.
+ `,
+ },
+ 'templates/metaobject': {
+ summary: `
+ - Templates for rendering custom content types defined as metaobjects.
+ `,
+ },
+};
diff --git a/packages/vscode-extension/src/node/sidekick.ts b/packages/vscode-extension/src/node/sidekick.ts
new file mode 100644
index 000000000..b19cf57b8
--- /dev/null
+++ b/packages/vscode-extension/src/node/sidekick.ts
@@ -0,0 +1,148 @@
+import {
+ CancellationTokenSource,
+ DecorationOptions,
+ LanguageModelChatResponse,
+ lm,
+ MarkdownString,
+ Position,
+ Range,
+ TextEditor,
+ TextEditorDecorationType,
+ window,
+} from 'vscode';
+import { buildMessages } from './sidekick-messages';
+
+/** A sidekick decoration that provides code improvement suggestions */
+export interface SidekickDecoration {
+ /** The type defining the visual styling */
+ type: TextEditorDecorationType;
+ /** The options specifying where and how to render the suggestion */
+ options: DecorationOptions;
+}
+
+/** Represents a suggestion for improving Liquid code */
+export interface LiquidSuggestion {
+ /** Line number where this suggestion starts */
+ line: number;
+ /** The range where this suggestion applies */
+ range: Range;
+ /** The improved code that should replace the existing code */
+ newCode: string;
+ /** Human-friendly explanation of the suggested improvement */
+ suggestion: string;
+}
+
+export async function getSidekickAnalysis(textEditor: TextEditor): Promise {
+ const [model] = await lm.selectChatModels({
+ vendor: 'copilot',
+ family: 'gpt-4o',
+ });
+
+ if (!model) {
+ log('No language model available');
+ return [];
+ }
+
+ try {
+ const messages = buildMessages(textEditor);
+ const chatResponse = await model.sendRequest(messages, {}, new CancellationTokenSource().token);
+ const jsonResponse = await parseChatResponse(chatResponse);
+
+ if (!Array.isArray(jsonResponse?.suggestions)) {
+ log('Invalid response from language model', jsonResponse);
+ return [];
+ }
+
+ log(
+ `Received response from language model with ${jsonResponse?.suggestions?.length} suggestions.`,
+ );
+
+ if (jsonResponse.suggestions.length === 0) {
+ log(jsonResponse.reasonIfNoSuggestions ?? 'No suggestions provided');
+ return [];
+ }
+
+ return jsonResponse.suggestions.flatMap(buildSidekickDecoration.bind(null, textEditor));
+ } catch (err) {
+ log('Error during language model request', err);
+ }
+
+ return [];
+}
+
+function buildSidekickDecoration(
+ editor: TextEditor,
+ liquidSuggestion: LiquidSuggestion,
+): SidekickDecoration[] {
+ const { suggestion, range } = liquidSuggestion;
+ const type = createTextEditorDecorationType(suggestion);
+ const line = Math.max(0, range.start.line - 1);
+ const options = {
+ range: new Range(
+ new Position(line, 0),
+ new Position(line, editor.document.lineAt(line).text.length),
+ ),
+ hoverMessage: createHoverMessage(type.key, liquidSuggestion),
+ };
+
+ return [{ type, options }];
+}
+
+async function parseChatResponse(chatResponse: LanguageModelChatResponse) {
+ let accResponse = '';
+
+ for await (const fragment of chatResponse.text) {
+ accResponse += fragment;
+
+ if (fragment.includes('}')) {
+ try {
+ const parsedResponse = JSON.parse(accResponse.replace('```json', ''));
+ console.error(' parsedResponse >>>>>>>');
+ console.error(JSON.stringify(parsedResponse, null, 2));
+ console.error(' parsedResponse <<<<<<<');
+ return parsedResponse;
+ } catch (_err) {
+ // ingore; next iteration
+ }
+ }
+ }
+
+ return [];
+}
+
+export function log(message?: any, ...optionalParams: any[]) {
+ console.error(`[Sidekick] ${message}`, ...optionalParams);
+}
+
+function createHoverMessage(key: string, liquidSuggestion: LiquidSuggestion) {
+ const hoverUrlArgs = encodeURIComponent(JSON.stringify({ key, ...liquidSuggestion }));
+ const hoverMessage = new MarkdownString(
+ `#### ✨ Sidekick suggestion\n ${liquidSuggestion.suggestion}
+ \n\n[Quick fix](command:shopifyLiquid.sidefix?${hoverUrlArgs})`,
+ );
+
+ hoverMessage.isTrusted = true;
+ hoverMessage.supportHtml = true;
+
+ return hoverMessage;
+}
+
+function createTextEditorDecorationType(text: string) {
+ return window.createTextEditorDecorationType({
+ after: {
+ contentText: ` ✨ ${truncate(text, 120)}`,
+ color: 'grey',
+ fontStyle: 'italic',
+ backgroundColor: 'rgba(255, 255, 255, 0.05)',
+ border: '0; border-radius: 3px',
+ },
+ });
+}
+
+function truncate(text: string, maxLength: number): string {
+ if (text.length <= maxLength) {
+ return text;
+ }
+
+ return text.substring(0, maxLength).trim() + '...';
+}