diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d8e664..c35be20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,62 @@ All notable changes to the "vscode-handyllm" extension will be documented in thi Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file. -## [Unreleased] -- Initial release \ No newline at end of file +## [0.1.3] - 2024-05-22 + +### Added + +- Frontmatter background highlight +- Message boundaries highlight + +### Changed + +- Rename `keyword.role` to `markup.heading` for themes in the wild to bold this scope + +### Removed + +- Remove `#` line comment + + +## [0.1.2] - 2024-05-20 + +### Added + +- Highlighting extra properties of messages +- Parse special typed blocks as YAML +- MIT License + +### Removed + +- Remove comment highlight to properly highlight completions prompt + + +## [0.1.1] - 2024-05-05 + +### Changed + +- Update command category and title + + +## [0.1.0] - 2024-05-05 + +### Added + +- Run hprompt command as menu action, context menu command or command palette command, with keybinding +- Configuration for handyllm command name +- Create hprompt command +- New files use a starter template +- Default turn on word wrap + + +## [0.0.1] - 2024-04-29 + +### Added + +Basic syntax highlighting for hprompt files: +- Parse each content block as a markdown +- Support embedded languages in markdown +- Use `#` as single line comment +- Injection to highlight %...% variables +- No bracket color + diff --git a/demo/create.gif b/demo/create.gif index d20fafb..766cb89 100644 Binary files a/demo/create.gif and b/demo/create.gif differ diff --git a/demo/example.png b/demo/example.png index 52c2583..c264a02 100644 Binary files a/demo/example.png and b/demo/example.png differ diff --git a/demo/run.gif b/demo/run.gif index 521f0e2..f2c1f64 100644 Binary files a/demo/run.gif and b/demo/run.gif differ diff --git a/language-configuration.json b/language-configuration.json index 0a22a08..a563d9c 100644 --- a/language-configuration.json +++ b/language-configuration.json @@ -1,7 +1,7 @@ { "comments": { // symbol used for single line comment. Remove this entry if your language does not support line comments - "lineComment": "#" + // "lineComment": "#" }, // symbols used as brackets "brackets": [ diff --git a/package-lock.json b/package-lock.json index 902246f..8e4d6bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,18 @@ { "name": "handyllm", - "version": "0.1.2", + "version": "0.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "handyllm", - "version": "0.1.2", + "version": "0.1.3", + "license": "MIT", + "dependencies": { + "lodash.debounce": "^4.0.8" + }, "devDependencies": { + "@types/lodash.debounce": "^4.0.9", "@types/mocha": "^10.0.6", "@types/node": "18.x", "@types/vscode": "^1.66.0", @@ -534,6 +539,21 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "node_modules/@types/lodash": { + "version": "4.17.4", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.4.tgz", + "integrity": "sha512-wYCP26ZLxaT3R39kiN2+HcJ4kTd3U1waI/cY7ivWYqFP6pW3ZNpvi6Wd6PHZx7T/t8z0vlkXMg3QYLa7DZ/IJQ==", + "dev": true + }, + "node_modules/@types/lodash.debounce": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/lodash.debounce/-/lodash.debounce-4.0.9.tgz", + "integrity": "sha512-Ma5JcgTREwpLRwMM+XwBR7DaWe96nC38uCBDFKZWbNKD+osjVzdpnUSwBcqCptrp16sSOLBAUb50Car5I0TCsQ==", + "dev": true, + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/mocha": { "version": "10.0.6", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.6.tgz", @@ -2951,6 +2971,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", diff --git a/package.json b/package.json index db16211..938c178 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "url": "https://github.com/atomiechen/vscode-handyllm" }, "private": true, - "version": "0.1.2", + "version": "0.1.3", "engines": { "vscode": "^1.66.0" }, @@ -89,7 +89,66 @@ "handyllm.commandName": { "type": "string", "default": "handyllm", - "description": "The command name of HandyLLM" + "description": "The command name of HandyLLM", + "order": 0 + }, + "handyllm.frontmatter.background.enabled": { + "type": "boolean", + "default": true, + "description": "Enable frontmatter background", + "order": 100 + }, + "handyllm.frontmatter.background.light": { + "type": "string", + "default": "#acacac36", + "description": "The background color of frontmatter in light theme", + "order": 101 + }, + "handyllm.frontmatter.background.dark": { + "type": "string", + "default": "#54545436", + "description": "The background color of frontmatter in dark theme", + "order": 102 + }, + "handyllm.message.boundary.enabled": { + "type": "boolean", + "default": true, + "description": "Enable message boundary", + "order": 200 + }, + "handyllm.message.boundary.style": { + "type": "string", + "enum": [ + "dotted", + "dashed", + "solid", + "double", + "groove", + "ridge", + "inset", + "outset" + ], + "default": "dotted", + "description": "The style of message boundary", + "order": 201 + }, + "handyllm.message.boundary.width": { + "type": "number", + "default": 1, + "description": "The width of message boundary", + "order": 202 + }, + "handyllm.message.boundary.light": { + "type": "string", + "default": "#acacac", + "description": "The color of message boundary in light theme", + "order": 203 + }, + "handyllm.message.boundary.dark": { + "type": "string", + "default": "#545454", + "description": "The color of message boundary in dark theme", + "order": 204 } } } @@ -126,6 +185,7 @@ "path": "./syntaxes/hprompt.tmLanguage.json", "embeddedLanguages": { "meta.frontmatter.block": "yaml", + "meta.block.yaml": "yaml", "source.js": "javascript", "source.css": "css", "meta.embedded.block.html": "html", @@ -187,15 +247,19 @@ ] }, "devDependencies": { - "@vscode/vsce": "^2.26.0", - "@types/vscode": "^1.66.0", + "@types/lodash.debounce": "^4.0.9", "@types/mocha": "^10.0.6", "@types/node": "18.x", + "@types/vscode": "^1.66.0", "@typescript-eslint/eslint-plugin": "^7.7.1", "@typescript-eslint/parser": "^7.7.1", - "eslint": "^8.57.0", - "typescript": "^5.4.5", "@vscode/test-cli": "^0.0.8", - "@vscode/test-electron": "^2.3.9" + "@vscode/test-electron": "^2.3.9", + "@vscode/vsce": "^2.26.0", + "eslint": "^8.57.0", + "typescript": "^5.4.5" + }, + "dependencies": { + "lodash.debounce": "^4.0.8" } } diff --git a/src/cmd_create.ts b/src/cmd_create.ts new file mode 100644 index 0000000..147e01f --- /dev/null +++ b/src/cmd_create.ts @@ -0,0 +1,15 @@ +import * as vscode from 'vscode'; + + +export function registerCommandCreate(context: vscode.ExtensionContext) { + let disposableCreateHprompt = vscode.commands.registerCommand('handyllm.createHprompt', function () { + vscode.workspace.openTextDocument({ + content: '---\n# add YAML frontmatter data here\n\n---\n\n$system$\nYou are a helpful assistant.\n\n$user$\nPlace you instructions here.\n\n', + language: 'hprompt' // set the language mode to hprompt + }).then(document => { + vscode.window.showTextDocument(document); + }); + }); + + context.subscriptions.push(disposableCreateHprompt); +} diff --git a/src/cmd_run.ts b/src/cmd_run.ts new file mode 100644 index 0000000..14e0778 --- /dev/null +++ b/src/cmd_run.ts @@ -0,0 +1,47 @@ +import * as vscode from 'vscode'; + + +function getOrCreateTerminal(name: string): vscode.Terminal { + // check if the terminal already exists + const existingTerminal = vscode.window.terminals.find(terminal => terminal.name === name); + if (existingTerminal) { + return existingTerminal; + } else { + return vscode.window.createTerminal(name); + } +} + +export function registerCommandRun(context: vscode.ExtensionContext) { + // The command has been defined in the package.json file + // Now provide the implementation of the command with registerCommand + // The commandId parameter must match the command field in package.json + let disposableRunHprompt = vscode.commands.registerCommand('handyllm.runHprompt', (uri: vscode.Uri) => { + let filePath = undefined; + if (uri) { + filePath = uri.fsPath; + } else { + const activeEditor = vscode.window.activeTextEditor; + if (activeEditor) { + // get the file path of the currently active file + filePath = activeEditor.document.uri.fsPath; + } + } + if (filePath) { + // get handyllm command name from the settings + let handyllmCommand = vscode.workspace.getConfiguration().get('handyllm.commandName', 'handyllm').trim(); + if (handyllmCommand === '') { + handyllmCommand = 'handyllm'; + } + // get or create a terminal with the name "hprompt" + const terminal = getOrCreateTerminal("hprompt"); + terminal.show(true); + // run the hprompt command in the terminal + terminal.sendText(`${handyllmCommand} hprompt ${filePath}`); + } else { + // Display a message box to the user + vscode.window.showErrorMessage('No active editor found!'); + } + }); + + context.subscriptions.push(disposableRunHprompt); +} diff --git a/src/decor_frontmatter.ts b/src/decor_frontmatter.ts new file mode 100644 index 0000000..50cee5e --- /dev/null +++ b/src/decor_frontmatter.ts @@ -0,0 +1,139 @@ +import * as vscode from 'vscode'; +import debounce from 'lodash.debounce'; + +import { isHpromptDoc } from './utils'; + + +class FrontmatterConfig { + private _enabled: boolean = true; + private _backgroundDecoration: vscode.TextEditorDecorationType | undefined; + private _delayMs: number = 200; + + constructor() {} + + get enabled() { + return this._enabled; + } + + get backgroundDecoration() { + return this._backgroundDecoration; + } + + get delayMs() { + return this._delayMs; + } + + public update() { + const config = vscode.workspace.getConfiguration("handyllm"); + this._enabled = config.get("frontmatter.background.enabled", true); + + if (this._backgroundDecoration) { + this._backgroundDecoration.dispose(); + } + this._backgroundDecoration = vscode.window.createTextEditorDecorationType({ + isWholeLine: true, + light: { + backgroundColor: config.get("frontmatter.background.light", "#acacac36"), + }, + dark: { + backgroundColor: config.get("frontmatter.background.dark", "#54545436"), + }, + }); + } +} + +const frontmatterConfig = new FrontmatterConfig(); + +function triggerUpdateEditor(editor: vscode.TextEditor, throttle = false) { + if (throttle) { + debounce( + () => updateEditor(editor), + frontmatterConfig.delayMs, + { leading: true } + )(); + } else { + updateEditor(editor); + } +} + +function triggerUpdateAllVisibleEditors() { + for (const editor of vscode.window.visibleTextEditors) { + triggerUpdateEditor(editor); + } +} + +function updateEditor(editor: vscode.TextEditor) { + if (!editor || !isHpromptDoc(editor.document)) { + return; + } + + // ranges to highlight + const frontmatterRanges: vscode.Range[] = []; + + if (frontmatterConfig.enabled) { + // find frontmatter wrapped in --- + const firstLine = editor.document.lineAt(0); + // check if first line matches ---\s* + if (/^---\s*$/.test(firstLine.text)) { + // find the next ---\s* line + for (let i=1; i { + frontmatterConfig.update(); + triggerUpdateAllVisibleEditors(); + }, null, context.subscriptions); + + // when editing a document, update the decorations + vscode.workspace.onDidChangeTextDocument(event => { + let activeEditor = vscode.window.activeTextEditor; + if (activeEditor && event.document === activeEditor.document) { + triggerUpdateEditor(activeEditor, true); + } + }, null, context.subscriptions); + + // when switching tabs, update the decorations + vscode.window.onDidChangeActiveTextEditor(editor => { + if (editor) { + triggerUpdateEditor(editor); + } + }, null, context.subscriptions); + + // when language ID changes, update the decorations + vscode.workspace.onDidOpenTextDocument(doc => { + if (vscode.window.activeTextEditor?.document === doc) { + if (isHpromptDoc(doc)) { + triggerUpdateEditor(vscode.window.activeTextEditor); + } else { + // clear decorations + vscode.window.activeTextEditor?.setDecorations(frontmatterConfig.backgroundDecoration!, []); + } + } + }, null, context.subscriptions); + + // when visible editors change, update the decorations + vscode.window.onDidChangeVisibleTextEditors(editors => { + for (const editor of editors) { + triggerUpdateEditor(editor); + } + }, null, context.subscriptions); +} + diff --git a/src/decor_message.ts b/src/decor_message.ts new file mode 100644 index 0000000..6f85e3f --- /dev/null +++ b/src/decor_message.ts @@ -0,0 +1,138 @@ +import debounce from 'lodash.debounce'; +import * as vscode from 'vscode'; + +import { isHpromptDoc } from './utils'; + + +class MessageConfig { + private _enabled: boolean = true; + private _boundaryDecoration: vscode.TextEditorDecorationType | undefined; + private _delayMs: number = 200; + + constructor() {} + + get enabled() { + return this._enabled; + } + + get boundaryDecoration() { + return this._boundaryDecoration; + } + + get delayMs() { + return this._delayMs; + } + + public update() { + const config = vscode.workspace.getConfiguration("handyllm"); + this._enabled = config.get("message.boundary.enabled", true); + + if (this._boundaryDecoration) { + this._boundaryDecoration.dispose(); + } + + const boundaryWidth = config.get("message.boundary.width", 1); + this._boundaryDecoration = vscode.window.createTextEditorDecorationType({ + isWholeLine: true, + borderWidth: `0 0 ${boundaryWidth}px 0`, // set to bottom border + borderStyle: config.get("message.boundary.style", "dotted"), + light: { + borderColor: config.get("message.boundary.light", "#acacac"), + }, + dark: { + borderColor: config.get("message.boundary.dark", "#545454"), + }, + }); + } +} + +const messageConfig = new MessageConfig(); + +function triggerUpdateEditor(editor: vscode.TextEditor, throttle = false) { + if (throttle) { + debounce( + () => updateEditor(editor), + messageConfig.delayMs, + { leading: true } + )(); + } else { + updateEditor(editor); + } +} + +function triggerUpdateAllVisibleEditors() { + for (const editor of vscode.window.visibleTextEditors) { + triggerUpdateEditor(editor); + } +} + +function updateEditor(editor: vscode.TextEditor) { + if (!editor || !isHpromptDoc(editor.document)) { + return; + } + + // ranges to highlight + const frontmatterRanges: vscode.Range[] = []; + + if (messageConfig.enabled) { + // find lines that matches \$\w+\$[^\S\r\n]*({[^{}]*?})?[^\S\r\n]*$ + for (let i = 0; i < editor.document.lineCount; i++) { + const text = editor.document.lineAt(i).text; + const match = text.match(/^\$\w+\$[^\S\r\n]*({[^{}]*?})?[^\S\r\n]*$/); + if (match) { + const start = new vscode.Position(i, 0); + const end = new vscode.Position(i, text.length); + frontmatterRanges.push(new vscode.Range(start, end)); + } + } + + // set the decorations + editor.setDecorations(messageConfig.boundaryDecoration!, frontmatterRanges); + } +} + +export function activateMessageDecor(context: vscode.ExtensionContext) { + messageConfig.update(); + triggerUpdateAllVisibleEditors(); + + // update decorations when configuration changes + vscode.workspace.onDidChangeConfiguration(() => { + messageConfig.update(); + triggerUpdateAllVisibleEditors(); + }, null, context.subscriptions); + + // update decorations when editing a text document + vscode.workspace.onDidChangeTextDocument(event => { + let activeEditor = vscode.window.activeTextEditor; + if (activeEditor && event.document === activeEditor.document) { + triggerUpdateEditor(activeEditor, true); + } + }, null, context.subscriptions); + + // update decorations when switching between editors + vscode.window.onDidChangeActiveTextEditor(editor => { + if (editor) { + triggerUpdateEditor(editor); + } + }, null, context.subscriptions); + + // update decorations when language ID changes + vscode.workspace.onDidOpenTextDocument(doc => { + if (vscode.window.activeTextEditor?.document === doc) { + if (isHpromptDoc(doc)) { + triggerUpdateEditor(vscode.window.activeTextEditor); + } else { + // clear decorations + vscode.window.activeTextEditor?.setDecorations(messageConfig.boundaryDecoration!, []); + } + } + }, null, context.subscriptions); + + // update decorations when visible editors change + vscode.window.onDidChangeVisibleTextEditors(editors => { + for (const editor of editors) { + triggerUpdateEditor(editor); + } + }, null, context.subscriptions); +} + diff --git a/src/extension.ts b/src/extension.ts index e6700f6..05bc7f7 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,16 +1,11 @@ // The module 'vscode' contains the VS Code extensibility API // Import the module and reference it with the alias vscode in your code below import * as vscode from 'vscode'; +import { registerCommandCreate } from './cmd_create'; +import { registerCommandRun } from './cmd_run'; +import { activateFrontmatterDecor } from './decor_frontmatter'; +import { activateMessageDecor } from './decor_message'; -function getOrCreateTerminal(name: string): vscode.Terminal { - // check if the terminal already exists - const existingTerminal = vscode.window.terminals.find(terminal => terminal.name === name); - if (existingTerminal) { - return existingTerminal; - } else { - return vscode.window.createTerminal(name); - } -} // This method is called when your extension is activated // Your extension is activated the very first time the command is executed @@ -20,47 +15,10 @@ export function activate(context: vscode.ExtensionContext) { // This line of code will only be executed once when your extension is activated console.log('"handyllm" extension is now active!'); - // The command has been defined in the package.json file - // Now provide the implementation of the command with registerCommand - // The commandId parameter must match the command field in package.json - let disposableRunHprompt = vscode.commands.registerCommand('handyllm.runHprompt', (uri: vscode.Uri) => { - let filePath = undefined; - if (uri) { - filePath = uri.fsPath; - } else { - const activeEditor = vscode.window.activeTextEditor; - if (activeEditor) { - // get the file path of the currently active file - filePath = activeEditor.document.uri.fsPath; - } - } - if (filePath) { - // get handyllm command name from the settings - let handyllmCommand = vscode.workspace.getConfiguration().get('handyllm.commandName', 'handyllm').trim(); - if (handyllmCommand === '') { - handyllmCommand = 'handyllm'; - } - // get or create a terminal with the name "hprompt" - const terminal = getOrCreateTerminal("hprompt"); - terminal.show(true); - // run the hprompt command in the terminal - terminal.sendText(`${handyllmCommand} hprompt ${filePath}`); - } else { - // Display a message box to the user - vscode.window.showErrorMessage('No active editor found!'); - } - }); - - let disposableCreateHprompt = vscode.commands.registerCommand('handyllm.createHprompt', function () { - vscode.workspace.openTextDocument({ - content: '---\n# add YAML frontmatter data here\n\n---\n\n$system$\nYou are a helpful assistant.\n\n$user$\nPlace you instructions here.\n\n', - language: 'hprompt' // set the language mode to hprompt - }).then(document => { - vscode.window.showTextDocument(document); - }); - }); - - context.subscriptions.push(disposableRunHprompt, disposableCreateHprompt); + registerCommandCreate(context); + registerCommandRun(context); + activateFrontmatterDecor(context); + activateMessageDecor(context); } // This method is called when your extension is deactivated diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..f2469dc --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,13 @@ +import * as vscode from "vscode"; + + +export const hpromptLanguageId = "hprompt"; + +export function isLanguageDoc(languageId: string, doc?: vscode.TextDocument) { + return !!doc && doc.languageId === languageId; +} + +export function isHpromptDoc(doc?: vscode.TextDocument) { + return isLanguageDoc(hpromptLanguageId, doc); +} + diff --git a/syntaxes/hprompt.tmLanguage.json b/syntaxes/hprompt.tmLanguage.json index 49891ca..2c25c48 100644 --- a/syntaxes/hprompt.tmLanguage.json +++ b/syntaxes/hprompt.tmLanguage.json @@ -44,7 +44,7 @@ "name": "meta.block.start" }, "1": { - "name": "keyword.role" + "name": "markup.heading" }, "2": { "name": "meta.block.properties", @@ -75,7 +75,7 @@ "name": "meta.block.start" }, "1": { - "name": "keyword.role" + "name": "markup.heading" }, "2": { "name": "meta.block.properties",