From aeef5eeae9882f34811ba1ea8ae42ec941792856 Mon Sep 17 00:00:00 2001 From: Aidan Bleser Date: Wed, 12 Feb 2025 11:00:37 -0600 Subject: [PATCH 1/8] add additional instructions prompt to update --- packages/cli/src/commands/update.ts | 69 ++++++++++++++++++++++------- packages/cli/src/utils/ai.ts | 38 +++++++++------- 2 files changed, 74 insertions(+), 33 deletions(-) diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index 69a0ac40..bf49895b 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -1,5 +1,5 @@ import fs from 'node:fs'; -import { cancel, confirm, isCancel, log, multiselect, outro, select } from '@clack/prompts'; +import { cancel, confirm, isCancel, log, multiselect, outro, select, text } from '@clack/prompts'; import color from 'chalk'; import { Command, program } from 'commander'; import { diffLines } from 'diff'; @@ -292,6 +292,8 @@ const _update = async (blockNames: string[], options: Options) => { const to = path.relative(options.cwd, file.destPath); + let hasUpdatedWithAI = false; + while (true) { const changes = diffLines(localContent, remoteContent); @@ -321,23 +323,39 @@ const _update = async (blockNames: string[], options: Options) => { acceptedChanges = options.yes; if (!options.yes && !options.no) { - // prompt the user - const confirmResult = await select({ - message: 'Accept changes?', - options: [ - { - label: 'Accept', - value: 'accept', - }, + const confirmOptions = [ + { + label: 'Accept', + value: 'accept', + }, + { + label: 'Reject', + value: 'reject', + }, + ]; + + if (hasUpdatedWithAI) { + confirmOptions.push( { - label: 'Reject', - value: 'reject', + label: `✨ ${color.yellow('Update with AI')} ✨ ${color.gray('(Iterate)')}`, + value: 'update-iterate', }, { - label: `✨ ${color.yellow('Update with AI')} ✨`, + label: `✨ ${color.yellow('Update with AI')} ✨ ${color.gray('(Retry)')}`, value: 'update', - }, - ], + } + ); + } else { + confirmOptions.push({ + label: `✨ ${color.yellow('Update with AI')} ✨`, + value: 'update', + }); + } + + // prompt the user + const confirmResult = await select({ + message: 'Accept changes?', + options: confirmOptions, }); if (isCancel(confirmResult)) { @@ -345,7 +363,7 @@ const _update = async (blockNames: string[], options: Options) => { process.exit(0); } - if (confirmResult === 'update') { + if (confirmResult === 'update' || confirmResult === 'update-iterate') { // prompt for model const modelResult = await select({ message: 'Select a model', @@ -353,6 +371,7 @@ const _update = async (blockNames: string[], options: Options) => { label: key, value: key, })), + initialValue: model, }); if (isCancel(modelResult)) { @@ -362,16 +381,32 @@ const _update = async (blockNames: string[], options: Options) => { model = modelResult as ModelName; + const additionalInstructions = await text({ + message: 'Any additional instructions?', + }); + + if (isCancel(additionalInstructions)) { + cancel('Canceled!'); + process.exit(0); + } + try { remoteContent = await models[model].updateFile({ originalFile: { - content: localContent, + content: + confirmResult === 'update-iterate' + ? remoteContent + : localContent, path: to, }, newFile: { content: originalRemoteContent, path: from, }, + additionalInstructions: + additionalInstructions.trim().length > 0 + ? additionalInstructions + : undefined, loading, verbose: options.verbose ? verbose : undefined, }); @@ -394,6 +429,8 @@ const _update = async (blockNames: string[], options: Options) => { process.stdout.write(`${ascii.VERTICAL_LINE}\n`); + hasUpdatedWithAI = true; + continue; } diff --git a/packages/cli/src/utils/ai.ts b/packages/cli/src/utils/ai.ts index 06011574..c2a0669d 100644 --- a/packages/cli/src/utils/ai.ts +++ b/packages/cli/src/utils/ai.ts @@ -15,6 +15,7 @@ export interface Model { originalFile: File; newFile: File; loading: ReturnType; + additionalInstructions?: string; verbose?: (msg: string) => void; }) => Promise; } @@ -28,12 +29,12 @@ type Prompt = { const models: Record = { 'Claude 3.5 Sonnet': { - updateFile: async ({ originalFile, newFile, loading, verbose }) => { + updateFile: async ({ originalFile, newFile, loading, verbose, additionalInstructions }) => { const apiKey = await getApiKey('Anthropic'); if (!verbose) loading.start(`Asking ${'Claude 3.5 Sonnet'}`); - const prompt = createUpdatePrompt({ originalFile, newFile }); + const prompt = createUpdatePrompt({ originalFile, newFile, additionalInstructions }); verbose?.( `Prompting ${'Claude 3.5 Sonnet'} with:\n${JSON.stringify(prompt, null, '\t')}` @@ -54,12 +55,12 @@ const models: Record = { }, }, 'ChatGPT 4o': { - updateFile: async ({ originalFile, newFile, loading, verbose }) => { + updateFile: async ({ originalFile, newFile, loading, verbose, additionalInstructions }) => { const apiKey = await getApiKey('OpenAI'); if (!verbose) loading.start(`Asking ${'ChatGPT 4o'}`); - const prompt = createUpdatePrompt({ originalFile, newFile }); + const prompt = createUpdatePrompt({ originalFile, newFile, additionalInstructions }); verbose?.(`Prompting ${'ChatGPT 4o'} with:\n${JSON.stringify(prompt, null, '\t')}`); @@ -78,12 +79,12 @@ const models: Record = { }, }, 'ChatGPT 4o-mini': { - updateFile: async ({ originalFile, newFile, loading, verbose }) => { + updateFile: async ({ originalFile, newFile, loading, verbose, additionalInstructions }) => { const apiKey = await getApiKey('OpenAI'); if (!verbose) loading.start(`Asking ${'ChatGPT 4o-mini'}`); - const prompt = createUpdatePrompt({ originalFile, newFile }); + const prompt = createUpdatePrompt({ originalFile, newFile, additionalInstructions }); verbose?.(`Prompting ${'ChatGPT 4o'} with:\n${JSON.stringify(prompt, null, '\t')}`); @@ -102,10 +103,10 @@ const models: Record = { }, }, Phi4: { - updateFile: async ({ originalFile, newFile, loading, verbose }) => { + updateFile: async ({ originalFile, newFile, loading, verbose, additionalInstructions }) => { if (!verbose) loading.start(`Asking ${'Phi4'}`); - const prompt = createUpdatePrompt({ originalFile, newFile }); + const prompt = createUpdatePrompt({ originalFile, newFile, additionalInstructions }); verbose?.(`Prompting ${'Phi4'} with:\n${JSON.stringify(prompt, null, '\t')}`); @@ -221,21 +222,24 @@ const getNextCompletionOllama = async ({ const createUpdatePrompt = ({ originalFile, newFile, -}: { originalFile: File; newFile: File }): Prompt => { + additionalInstructions, +}: { + originalFile: File; + newFile: File; + additionalInstructions?: string; +}): Prompt => { return { - system: 'You will respond only with the resulting code. DO NOT format the code with markdown, DO NOT put the code inside of triple quotes, only return the code as a raw string.', - message: `Help me merge these two files. DO NOT make unnecessary changes. -I expect the original code to maintain the same behavior as it currently has while including any added functionality from the new file. -This means stuff like defaults or configuration should normally stay intact unless the new behaviors in the new file depend on those defaults or configuration. + system: 'You will merge two files provided by the user. You will respond only with the resulting code. DO NOT format the code with markdown, DO NOT put the code inside of triple quotes, only return the code as a raw string. DO NOT make unnecessary changes.', + message: ` This is my current file ${originalFile.path}: -\`\`\` + ${originalFile.content} -\`\`\` + This is the file that has changes I want to update with ${newFile.path}: -\`\`\` + ${newFile.content} -\`\`\` +${additionalInstructions ? `${additionalInstructions}` : ''} `, }; }; From be7831e0e7e5981ad3c7c22ac5e32ca840393541 Mon Sep 17 00:00:00 2001 From: Aidan Bleser Date: Wed, 12 Feb 2025 14:32:47 -0600 Subject: [PATCH 2/8] fix default value --- packages/cli/src/commands/update.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index bf49895b..6726f7cc 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -383,6 +383,7 @@ const _update = async (blockNames: string[], options: Options) => { const additionalInstructions = await text({ message: 'Any additional instructions?', + defaultValue: 'None', }); if (isCancel(additionalInstructions)) { @@ -404,7 +405,7 @@ const _update = async (blockNames: string[], options: Options) => { path: from, }, additionalInstructions: - additionalInstructions.trim().length > 0 + additionalInstructions !== 'None' ? additionalInstructions : undefined, loading, From e4709f7144dbb3efbb65308d92af6b9022dd5c1f Mon Sep 17 00:00:00 2001 From: Aidan Bleser Date: Wed, 12 Feb 2025 15:22:58 -0600 Subject: [PATCH 3/8] attempt to consolidate update logic Running into weird windows/node/idk issues --- packages/cli/src/commands/update.ts | 201 ++++---------------------- packages/cli/src/utils/files.ts | 6 +- packages/cli/src/utils/prompts.ts | 211 +++++++++++++++++++++++++++- 3 files changed, 241 insertions(+), 177 deletions(-) diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index 6726f7cc..701bd24a 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -19,7 +19,7 @@ import { formatFile, transformRemoteContent } from '../utils/files'; import { loadFormatterConfig } from '../utils/format'; import { getWatermark } from '../utils/get-watermark'; import { returnShouldInstall } from '../utils/package'; -import { intro, nextSteps, spinner } from '../utils/prompts'; +import { intro, nextSteps, promptUpdateFile, spinner } from '../utils/prompts'; import * as registry from '../utils/registry-providers/internal'; const schema = v.object({ @@ -205,8 +205,6 @@ const _update = async (blockNames: string[], options: Options) => { const resolvedPaths = resolvedPathsResult.unwrap(); - let model: ModelName = 'Claude 3.5 Sonnet'; - for (const { block } of updatingBlocks) { const fullSpecifier = url.join(block.sourceRepo.url, block.category, block.name); @@ -274,181 +272,38 @@ const _update = async (blockNames: string[], options: Options) => { program.error(color.red(remoteContentResult.unwrapErr())); } - const originalRemoteContent = remoteContentResult.unwrap(); - - let remoteContent = remoteContentResult.unwrap(); - - let acceptedChanges = options.yes; - - if (!options.yes) { - process.stdout.write(`${ascii.VERTICAL_LINE}\n`); - - let localContent = ''; - if (fs.existsSync(file.destPath)) { - localContent = fs.readFileSync(file.destPath).toString(); - } - - const from = url.join(providerState.url, file.fileName); - - const to = path.relative(options.cwd, file.destPath); - - let hasUpdatedWithAI = false; - - while (true) { - const changes = diffLines(localContent, remoteContent); - - // print diff - const formattedDiff = formatDiff({ - from, - to, - changes, - expand: options.expand, - maxUnchanged: options.maxUnchanged, - prefix: () => `${ascii.VERTICAL_LINE} `, - onUnchanged: ({ from, to, prefix }) => - `${prefix?.() ?? ''}${color.cyan(from)} → ${color.gray(to)} ${color.gray('(unchanged)')}\n`, - intro: ({ from, to, changes, prefix }) => { - const totalChanges = changes.filter((a) => a.added || a.removed).length; - - return `${prefix?.() ?? ''}${color.cyan(from)} → ${color.gray(to)} (${totalChanges} change${ - totalChanges === 1 ? '' : 's' - })\n${prefix?.() ?? ''}\n`; - }, - }); - - process.stdout.write(formattedDiff); - - // if there are no changes then don't ask - if (changes.length > 1 || localContent === '') { - acceptedChanges = options.yes; - - if (!options.yes && !options.no) { - const confirmOptions = [ - { - label: 'Accept', - value: 'accept', - }, - { - label: 'Reject', - value: 'reject', - }, - ]; - - if (hasUpdatedWithAI) { - confirmOptions.push( - { - label: `✨ ${color.yellow('Update with AI')} ✨ ${color.gray('(Iterate)')}`, - value: 'update-iterate', - }, - { - label: `✨ ${color.yellow('Update with AI')} ✨ ${color.gray('(Retry)')}`, - value: 'update', - } - ); - } else { - confirmOptions.push({ - label: `✨ ${color.yellow('Update with AI')} ✨`, - value: 'update', - }); - } - - // prompt the user - const confirmResult = await select({ - message: 'Accept changes?', - options: confirmOptions, - }); - - if (isCancel(confirmResult)) { - cancel('Canceled!'); - process.exit(0); - } - - if (confirmResult === 'update' || confirmResult === 'update-iterate') { - // prompt for model - const modelResult = await select({ - message: 'Select a model', - options: Object.keys(models).map((key) => ({ - label: key, - value: key, - })), - initialValue: model, - }); - - if (isCancel(modelResult)) { - cancel('Canceled!'); - process.exit(0); - } - - model = modelResult as ModelName; - - const additionalInstructions = await text({ - message: 'Any additional instructions?', - defaultValue: 'None', - }); - - if (isCancel(additionalInstructions)) { - cancel('Canceled!'); - process.exit(0); - } - - try { - remoteContent = await models[model].updateFile({ - originalFile: { - content: - confirmResult === 'update-iterate' - ? remoteContent - : localContent, - path: to, - }, - newFile: { - content: originalRemoteContent, - path: from, - }, - additionalInstructions: - additionalInstructions !== 'None' - ? additionalInstructions - : undefined, - loading, - verbose: options.verbose ? verbose : undefined, - }); - } catch (err) { - loading.stop(); - log.error(color.red(`Error getting completions: ${err}`)); - process.stdout.write(`${ascii.VERTICAL_LINE}\n`); - continue; - } - - remoteContent = await formatFile({ - file: { - content: remoteContent, - destPath: file.destPath, - }, - biomeOptions, - prettierOptions, - config, - }); - - process.stdout.write(`${ascii.VERTICAL_LINE}\n`); - - hasUpdatedWithAI = true; - - continue; - } - - acceptedChanges = confirmResult === 'accept'; - - break; - } - } + const remoteContent = remoteContentResult.unwrap(); - break; // there were no changes or changes were automatically accepted - } + let localContent = ''; + if (fs.existsSync(file.destPath)) { + localContent = fs.readFileSync(file.destPath).toString(); } - if (acceptedChanges) { + const from = url.join(providerState.url, file.fileName); + + const to = path.relative(options.cwd, file.destPath); + + const updateResult = await promptUpdateFile({ + config: { biomeOptions, prettierOptions, formatter: config.formatter }, + current: { + content: localContent, + path: to, + }, + incoming: { + content: remoteContent, + path: from, + }, + options: { + ...options, + loading, + verbose: options.verbose ? verbose : undefined, + }, + }); + + if (updateResult.applyChanges) { loading.start(`Writing changes to ${color.cyan(file.destPath)}`); - fs.writeFileSync(file.destPath, remoteContent); + fs.writeFileSync(file.destPath, updateResult.updatedContent); loading.stop(`Wrote changes to ${color.cyan(file.destPath)}.`); } diff --git a/packages/cli/src/utils/files.ts b/packages/cli/src/utils/files.ts index 81859024..8dd20d5b 100644 --- a/packages/cli/src/utils/files.ts +++ b/packages/cli/src/utils/files.ts @@ -92,7 +92,7 @@ type FormatOptions = { /** The dest path of the file used to determine the language */ destPath: string; }; - config: ProjectConfig; + formatter: ProjectConfig['formatter']; prettierOptions: prettier.Options | null; biomeOptions: PartialConfiguration | null; }; @@ -104,7 +104,7 @@ type FormatOptions = { */ export const formatFile = async ({ file, - config, + formatter, prettierOptions, biomeOptions, }: FormatOptions): Promise => { @@ -116,7 +116,7 @@ export const formatFile = async ({ try { newContent = await lang.format(file.content, { filePath: file.destPath, - formatter: config.formatter, + formatter, prettierOptions, biomeOptions, }); diff --git a/packages/cli/src/utils/prompts.ts b/packages/cli/src/utils/prompts.ts index a9a592d5..9c20b1e0 100644 --- a/packages/cli/src/utils/prompts.ts +++ b/packages/cli/src/utils/prompts.ts @@ -1,12 +1,19 @@ -import { intro, spinner } from '@clack/prompts'; +import type { PartialConfiguration } from '@biomejs/wasm-nodejs'; +import { cancel, intro, isCancel, log, select, spinner, text } from '@clack/prompts'; import boxen from 'boxen'; import color from 'chalk'; import { detectSync, resolveCommand } from 'package-manager-detector'; +import type * as prettier from 'prettier'; import semver from 'semver'; import * as ascii from './ascii'; import { stripAsni } from './blocks/ts/strip-ansi'; import { packageJson } from './context'; import { getLatestVersion } from './get-latest-version'; +import { diffLines } from 'diff'; +import { formatDiff } from './diff'; +import { type ModelName, models } from './ai'; +import { formatFile } from './files'; +import type { ProjectConfig } from './config'; export type Task = { loadingMessage: string; @@ -162,6 +169,208 @@ const _intro = async () => { ); }; +type UpdateBlockOptions = { + incoming: { + content: string; + path: string; + }; + current: { + content: string; + path: string; + }; + config: { + formatter: ProjectConfig['formatter']; + prettierOptions: prettier.Options | null; + biomeOptions: PartialConfiguration | null; + }; + options: { + yes: boolean; + no: boolean; + expand: boolean; + maxUnchanged: number; + verbose?: (msg: string) => void; + loading: ReturnType; + }; +}; + +type UpdateBlockResult = + | { + applyChanges: true; + updatedContent: string; + } + | { + applyChanges: false; + }; + +export const promptUpdateFile = async ({ + incoming, + current, + config, + options, +}: UpdateBlockOptions): Promise => { + process.stdout.write(`${ascii.VERTICAL_LINE}\n`); + + let acceptedChanges = false; + + let hasUpdatedWithAI = false; + let updatedContent = incoming.content; + + let model: ModelName = 'Claude 3.5 Sonnet'; + + while (true) { + const changes = diffLines(current.content, updatedContent); + + // print diff + const formattedDiff = formatDiff({ + from: incoming.path, + to: current.path, + changes, + expand: options.expand, + maxUnchanged: options.maxUnchanged, + prefix: () => `${ascii.VERTICAL_LINE} `, + onUnchanged: ({ from, to, prefix }) => + `${prefix?.() ?? ''}${color.cyan(from)} → ${color.gray(to)} ${color.gray('(unchanged)')}\n`, + intro: ({ from, to, changes, prefix }) => { + const totalChanges = changes.filter((a) => a.added || a.removed).length; + + return `${prefix?.() ?? ''}${color.cyan(from)} → ${color.gray(to)} (${totalChanges} change${ + totalChanges === 1 ? '' : 's' + })\n${prefix?.() ?? ''}\n`; + }, + }); + + process.stdout.write(formattedDiff); + + // if there are no changes then don't ask + if (changes.length > 1 || current.content === '') { + acceptedChanges = options.yes; + + if (!options.yes && !options.no) { + const confirmOptions = [ + { + label: 'Accept', + value: 'accept', + }, + { + label: 'Reject', + value: 'reject', + }, + ]; + + if (hasUpdatedWithAI) { + confirmOptions.push( + { + label: `✨ ${color.yellow('Update with AI')} ✨ ${color.gray('(Iterate)')}`, + value: 'update-iterate', + }, + { + label: `✨ ${color.yellow('Update with AI')} ✨ ${color.gray('(Retry)')}`, + value: 'update', + } + ); + } else { + confirmOptions.push({ + label: `✨ ${color.yellow('Update with AI')} ✨`, + value: 'update', + }); + } + + // prompt the user + const confirmResult = await select({ + message: 'Accept changes?', + options: confirmOptions, + }); + + if (isCancel(confirmResult)) { + cancel('Canceled!'); + process.exit(0); + } + + if (confirmResult === 'update' || confirmResult === 'update-iterate') { + // prompt for model + const modelResult = await select({ + message: 'Select a model', + options: Object.keys(models).map((key) => ({ + label: key, + value: key, + })), + initialValue: model, + }); + + if (isCancel(modelResult)) { + cancel('Canceled!'); + process.exit(0); + } + + model = modelResult as ModelName; + + const additionalInstructions = await text({ + message: 'Any additional instructions?', + defaultValue: 'None', + }); + + if (isCancel(additionalInstructions)) { + cancel('Canceled!'); + process.exit(0); + } + + try { + updatedContent = await models[model].updateFile({ + originalFile: current, + newFile: { + content: + confirmResult === 'update-iterate' + ? updatedContent + : incoming.content, + path: incoming.path, + }, + additionalInstructions: + additionalInstructions !== 'None' + ? additionalInstructions + : undefined, + loading: options.loading, + verbose: options.verbose, + }); + } catch (err) { + options.loading.stop(); + log.error(color.red(`Error getting completions: ${err}`)); + process.stdout.write(`${ascii.VERTICAL_LINE}\n`); + continue; + } + + updatedContent = await formatFile({ + file: { + content: updatedContent, + destPath: current.path, + }, + biomeOptions: config.biomeOptions, + prettierOptions: config.prettierOptions, + formatter: config.formatter, + }); + + process.stdout.write(`${ascii.VERTICAL_LINE}\n`); + + hasUpdatedWithAI = true; + + continue; + } + + acceptedChanges = confirmResult === 'accept'; + + break; + } + } + + break; // there were no changes or changes were automatically accepted + } + + if (acceptedChanges) { + return { applyChanges: true, updatedContent }; + } + + return { applyChanges: false }; +}; + export { runTasks, nextSteps, From fc4756bc1375a0158ac76dcf4ef35742d86f9a35 Mon Sep 17 00:00:00 2001 From: Aidan Bleser Date: Wed, 12 Feb 2025 17:37:50 -0600 Subject: [PATCH 4/8] integrate with init command --- packages/cli/src/commands/init.ts | 183 ++++-------------- packages/cli/src/utils/ai.ts | 149 +++++++++++--- packages/cli/src/utils/prompts.ts | 39 +++- .../ui/copy-button/copy-button.svelte | 2 +- 4 files changed, 183 insertions(+), 190 deletions(-) diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 14eda045..11565aeb 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -13,13 +13,12 @@ import { } from '@clack/prompts'; import color from 'chalk'; import { Command, Option, program } from 'commander'; -import { diffLines } from 'diff'; import { createPathsMatcher } from 'get-tsconfig'; import { detect, resolveCommand } from 'package-manager-detector'; import path from 'pathe'; import * as v from 'valibot'; -import { type ModelName, models } from '../utils/ai'; import * as ascii from '../utils/ascii'; +import * as url from '../utils/blocks/ts/url'; import { type Formatter, PROJECT_CONFIG_NAME, @@ -32,13 +31,12 @@ import { } from '../utils/config'; import { packageJson } from '../utils/context'; import { installDependencies } from '../utils/dependencies'; -import { formatDiff } from '../utils/diff'; import { formatFile, matchJSDescendant, tryGetTsconfig } from '../utils/files'; import { loadFormatterConfig } from '../utils/format'; import { json } from '../utils/language-support'; import { returnShouldInstall } from '../utils/package'; import * as persisted from '../utils/persisted'; -import { type Task, intro, nextSteps, runTasks } from '../utils/prompts'; +import { type Task, intro, nextSteps, promptUpdateFile, runTasks } from '../utils/prompts'; import * as registry from '../utils/registry-providers/internal'; const schema = v.object({ @@ -595,10 +593,10 @@ const promptForProviderConfig = async ({ let fullFilePath = path.join(options.cwd, configFiles[file.name]); - let originalFileContents: string | undefined; + let fileContents: string | undefined; if (fs.existsSync(fullFilePath)) { - originalFileContents = fs.readFileSync(fullFilePath).toString(); + fileContents = fs.readFileSync(fullFilePath).toString(); } else { const dir = path.dirname(fullFilePath); @@ -606,7 +604,7 @@ const promptForProviderConfig = async ({ const matchedPath = matchJSDescendant(fullFilePath); if (matchedPath) { - originalFileContents = fs.readFileSync(matchedPath).toString(); + fileContents = fs.readFileSync(matchedPath).toString(); const newPath = path.relative(options.cwd, matchedPath); @@ -637,152 +635,37 @@ const promptForProviderConfig = async ({ }, biomeOptions, prettierOptions, - config: { - $schema: '', - includeTests: false, - paths: { - '*': '', - }, - repos: [], - watermark: false, - configFiles: {}, - formatter, - }, + formatter, }); - let remoteContent = originalRemoteContent; - loading.stop(`Fetched the ${color.cyan(file.name)} from ${color.cyan(repo)}`); - let acceptedChanges = options.yes || originalFileContents === undefined; + let acceptedChanges = options.yes || fileContents === undefined; - if (originalFileContents) { + if (fileContents) { if (!options.yes) { - process.stdout.write(`${ascii.VERTICAL_LINE}\n`); - - while (true) { - const changes = diffLines(originalFileContents, remoteContent); - - // print diff - const formattedDiff = formatDiff({ - from: file.name, - to: fullFilePath, - changes, - expand: options.expand, - maxUnchanged: options.maxUnchanged, - prefix: () => `${ascii.VERTICAL_LINE} `, - onUnchanged: ({ from, to, prefix }) => - `${prefix?.() ?? ''}${color.cyan(from)} → ${color.gray(to)} ${color.gray('(unchanged)')}\n`, - intro: ({ from, to, changes, prefix }) => { - const totalChanges = changes.filter( - (a) => a.added || a.removed - ).length; - - return `${prefix?.() ?? ''}${color.cyan(from)} → ${color.gray(to)} (${totalChanges} change${ - totalChanges === 1 ? '' : 's' - })\n${prefix?.() ?? ''}\n`; - }, - }); - - process.stdout.write(formattedDiff); - - // if there are no changes then don't ask - if (changes.length > 1 || originalFileContents === '') { - acceptedChanges = options.yes; - - if (!options.yes) { - // prompt the user - const confirmResult = await select({ - message: 'Accept changes?', - options: [ - { - label: 'Accept', - value: 'accept', - }, - { - label: 'Reject', - value: 'reject', - }, - { - label: `✨ ${color.yellow('Update with AI')} ✨`, - value: 'update', - }, - ], - }); - - if (isCancel(confirmResult)) { - cancel('Canceled!'); - process.exit(0); - } - - if (confirmResult === 'update') { - // prompt for model - const modelResult = await select({ - message: 'Select a model', - options: Object.keys(models).map((key) => ({ - label: key, - value: key, - })), - }); - - if (isCancel(modelResult)) { - cancel('Canceled!'); - process.exit(0); - } - - const model = modelResult as ModelName; - - try { - remoteContent = await models[model].updateFile({ - originalFile: { - content: originalFileContents, - path: fullFilePath, - }, - newFile: { - content: originalRemoteContent, - path: file.name, - }, - loading, - }); - } catch (err) { - loading.stop(); - log.error(color.red(`Error getting completions: ${err}`)); - process.stdout.write(`${ascii.VERTICAL_LINE}\n`); - continue; - } - - remoteContent = await formatFile({ - file: { - content: remoteContent, - destPath: fullFilePath, - }, - biomeOptions, - prettierOptions, - config: { - $schema: '', - includeTests: false, - paths: { - '*': '', - }, - repos: [], - watermark: false, - configFiles: {}, - formatter, - }, - }); - - process.stdout.write(`${ascii.VERTICAL_LINE}\n`); - - continue; - } - - acceptedChanges = confirmResult === 'accept'; - - break; - } - } - - break; // there were no changes or changes were automatically accepted + const from = url.join(providerState.unwrap().url, file.name); + + const updateResult = await promptUpdateFile({ + config: { biomeOptions, prettierOptions, formatter }, + current: { + content: fileContents, + path: fullFilePath, + }, + incoming: { + content: originalRemoteContent, + path: from, + }, + options: { + ...options, + loading, + no: false, + }, + }); + + if (updateResult.applyChanges) { + acceptedChanges = true; + fileContents = updateResult.updatedContent; } } } else { @@ -791,12 +674,14 @@ const promptForProviderConfig = async ({ if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } + + fileContents = originalRemoteContent; } - if (acceptedChanges) { + if (acceptedChanges && fileContents) { loading.start(`Writing ${color.cyan(file.name)} to ${color.cyan(fullFilePath)}`); - fs.writeFileSync(fullFilePath, remoteContentResult.unwrap()); + fs.writeFileSync(fullFilePath, fileContents); loading.stop(`Wrote ${color.cyan(file.name)} to ${color.cyan(fullFilePath)}`); } diff --git a/packages/cli/src/utils/ai.ts b/packages/cli/src/utils/ai.ts index c2a0669d..b33f70cd 100644 --- a/packages/cli/src/utils/ai.ts +++ b/packages/cli/src/utils/ai.ts @@ -10,14 +10,26 @@ type File = { content: string; }; +export type Message = { + role: 'assistant' | 'user'; + content: string; +}; + +export type UpdateFileResult = { + content: string; + /** Prompt constructed by the user (for context) */ + prompt: string; +}; + export interface Model { updateFile: (opts: { originalFile: File; newFile: File; loading: ReturnType; additionalInstructions?: string; + messages?: Message[]; verbose?: (msg: string) => void; - }) => Promise; + }) => Promise; } export type ModelName = 'Claude 3.5 Sonnet' | 'ChatGPT 4o-mini' | 'ChatGPT 4o' | 'Phi4'; @@ -29,12 +41,24 @@ type Prompt = { const models: Record = { 'Claude 3.5 Sonnet': { - updateFile: async ({ originalFile, newFile, loading, verbose, additionalInstructions }) => { + updateFile: async ({ + originalFile, + newFile, + loading, + verbose, + additionalInstructions, + messages, + }) => { const apiKey = await getApiKey('Anthropic'); if (!verbose) loading.start(`Asking ${'Claude 3.5 Sonnet'}`); - const prompt = createUpdatePrompt({ originalFile, newFile, additionalInstructions }); + const prompt = createUpdatePrompt({ + originalFile, + newFile, + additionalInstructions, + rePrompt: messages !== undefined && messages.length > 0, + }); verbose?.( `Prompting ${'Claude 3.5 Sonnet'} with:\n${JSON.stringify(prompt, null, '\t')}` @@ -44,23 +68,36 @@ const models: Record = { model: 'claude-3-5-sonnet-latest', prompt, apiKey, + messages, maxTokens: (originalFile.content.length + newFile.content.length) * 2, }); if (!verbose) loading.stop(`${'Claude 3.5 Sonnet'} updated the file`); - if (!text) return newFile.content; + if (!text) return { content: newFile.content, prompt: prompt.message }; - return unwrapCodeFromQuotes(text); + return { content: unwrapCodeFromQuotes(text), prompt: prompt.message }; }, }, 'ChatGPT 4o': { - updateFile: async ({ originalFile, newFile, loading, verbose, additionalInstructions }) => { + updateFile: async ({ + originalFile, + newFile, + loading, + verbose, + additionalInstructions, + messages, + }) => { const apiKey = await getApiKey('OpenAI'); if (!verbose) loading.start(`Asking ${'ChatGPT 4o'}`); - const prompt = createUpdatePrompt({ originalFile, newFile, additionalInstructions }); + const prompt = createUpdatePrompt({ + originalFile, + newFile, + additionalInstructions, + rePrompt: messages !== undefined && messages.length > 0, + }); verbose?.(`Prompting ${'ChatGPT 4o'} with:\n${JSON.stringify(prompt, null, '\t')}`); @@ -68,23 +105,36 @@ const models: Record = { model: 'gpt-4o', prompt, apiKey, + messages, maxTokens: (originalFile.content.length + newFile.content.length) * 2, }); if (!verbose) loading.stop(`${'ChatGPT 4o'} updated the file`); - if (!text) return newFile.content; + if (!text) return { content: newFile.content, prompt: prompt.message }; - return unwrapCodeFromQuotes(text); + return { content: unwrapCodeFromQuotes(text), prompt: prompt.message }; }, }, 'ChatGPT 4o-mini': { - updateFile: async ({ originalFile, newFile, loading, verbose, additionalInstructions }) => { + updateFile: async ({ + originalFile, + newFile, + loading, + verbose, + additionalInstructions, + messages, + }) => { const apiKey = await getApiKey('OpenAI'); if (!verbose) loading.start(`Asking ${'ChatGPT 4o-mini'}`); - const prompt = createUpdatePrompt({ originalFile, newFile, additionalInstructions }); + const prompt = createUpdatePrompt({ + originalFile, + newFile, + additionalInstructions, + rePrompt: messages !== undefined && messages.length > 0, + }); verbose?.(`Prompting ${'ChatGPT 4o'} with:\n${JSON.stringify(prompt, null, '\t')}`); @@ -92,31 +142,44 @@ const models: Record = { model: 'gpt-4o-mini', prompt, apiKey, + messages, maxTokens: (originalFile.content.length + newFile.content.length) * 2, }); if (!verbose) loading.stop(`${'ChatGPT 4o-mini'} updated the file`); - if (!text) return newFile.content; + if (!text) return { content: newFile.content, prompt: prompt.message }; - return unwrapCodeFromQuotes(text); + return { content: unwrapCodeFromQuotes(text), prompt: prompt.message }; }, }, Phi4: { - updateFile: async ({ originalFile, newFile, loading, verbose, additionalInstructions }) => { + updateFile: async ({ + originalFile, + newFile, + loading, + verbose, + additionalInstructions, + messages, + }) => { if (!verbose) loading.start(`Asking ${'Phi4'}`); - const prompt = createUpdatePrompt({ originalFile, newFile, additionalInstructions }); + const prompt = createUpdatePrompt({ + originalFile, + newFile, + additionalInstructions, + rePrompt: messages !== undefined && messages.length > 0, + }); verbose?.(`Prompting ${'Phi4'} with:\n${JSON.stringify(prompt, null, '\t')}`); - const text = await getNextCompletionOllama({ model: 'phi4', prompt }); + const text = await getNextCompletionOllama({ model: 'phi4', prompt, messages }); if (!verbose) loading.stop(`${'Phi4'} updated the file`); - if (!text) return newFile.content; + if (!text) return { content: newFile.content, prompt: prompt.message }; - return unwrapCodeFromQuotes(text); + return { content: unwrapCodeFromQuotes(text), prompt: prompt.message }; }, }, }; @@ -126,8 +189,10 @@ const getNextCompletionOpenAI = async ({ maxTokens, model, apiKey, + messages, }: { prompt: Prompt; + messages?: Message[]; maxTokens: number; model: OpenAI.Chat.ChatModel; apiKey: string; @@ -142,6 +207,7 @@ const getNextCompletionOpenAI = async ({ role: 'system', content: prompt.system, }, + ...(messages ?? []), { role: 'user', content: prompt.message, @@ -158,33 +224,49 @@ const getNextCompletionOpenAI = async ({ const getNextCompletionAnthropic = async ({ prompt, + messages, maxTokens, model, apiKey, }: { prompt: Prompt; + messages?: Message[]; maxTokens: number; model: Anthropic.Messages.Model; apiKey: string; }): Promise => { const anthropic = new Anthropic({ apiKey }); + // didn't want to do it this way but I couldn't get `.map` to work + const history: Anthropic.Messages.MessageParam[] = []; + + // add history + if (messages) { + for (const message of messages) { + history.push({ + role: message.role, + content: [{ type: 'text', text: message.content }], + }); + } + } + + // add new message + history.push({ + role: 'user', + content: [ + { + type: 'text', + text: prompt.message, + }, + ], + }); + const msg = await anthropic.messages.create({ model, max_tokens: Math.min(maxTokens, 8192), temperature: 0.5, system: prompt.system, - messages: [ - { - role: 'user', - content: [ - { - type: 'text', - text: prompt.message, - }, - ], - }, - ], + messages: history, }); const first = msg.content[0]; @@ -197,9 +279,11 @@ const getNextCompletionAnthropic = async ({ const getNextCompletionOllama = async ({ prompt, + messages, model, }: { prompt: Prompt; + messages?: Message[]; model: string; }): Promise => { const resp = await ollama.chat({ @@ -209,6 +293,7 @@ const getNextCompletionOllama = async ({ role: 'system', content: prompt.system, }, + ...(messages ?? []), { role: 'user', content: prompt.message, @@ -223,14 +308,18 @@ const createUpdatePrompt = ({ originalFile, newFile, additionalInstructions, + rePrompt, }: { originalFile: File; newFile: File; additionalInstructions?: string; + rePrompt: boolean; }): Prompt => { return { system: 'You will merge two files provided by the user. You will respond only with the resulting code. DO NOT format the code with markdown, DO NOT put the code inside of triple quotes, only return the code as a raw string. DO NOT make unnecessary changes.', - message: ` + message: rePrompt + ? (additionalInstructions ?? '') + : ` This is my current file ${originalFile.path}: ${originalFile.content} diff --git a/packages/cli/src/utils/prompts.ts b/packages/cli/src/utils/prompts.ts index 9c20b1e0..e09b1fff 100644 --- a/packages/cli/src/utils/prompts.ts +++ b/packages/cli/src/utils/prompts.ts @@ -2,18 +2,18 @@ import type { PartialConfiguration } from '@biomejs/wasm-nodejs'; import { cancel, intro, isCancel, log, select, spinner, text } from '@clack/prompts'; import boxen from 'boxen'; import color from 'chalk'; +import { diffLines } from 'diff'; import { detectSync, resolveCommand } from 'package-manager-detector'; import type * as prettier from 'prettier'; import semver from 'semver'; +import { type Message, type ModelName, models } from './ai'; import * as ascii from './ascii'; import { stripAsni } from './blocks/ts/strip-ansi'; +import type { ProjectConfig } from './config'; import { packageJson } from './context'; -import { getLatestVersion } from './get-latest-version'; -import { diffLines } from 'diff'; import { formatDiff } from './diff'; -import { type ModelName, models } from './ai'; import { formatFile } from './files'; -import type { ProjectConfig } from './config'; +import { getLatestVersion } from './get-latest-version'; export type Task = { loadingMessage: string; @@ -212,11 +212,12 @@ export const promptUpdateFile = async ({ let acceptedChanges = false; - let hasUpdatedWithAI = false; let updatedContent = incoming.content; let model: ModelName = 'Claude 3.5 Sonnet'; + let messageHistory: Message[] = []; + while (true) { const changes = diffLines(current.content, updatedContent); @@ -257,14 +258,14 @@ export const promptUpdateFile = async ({ }, ]; - if (hasUpdatedWithAI) { + if (messageHistory.length > 0) { confirmOptions.push( { label: `✨ ${color.yellow('Update with AI')} ✨ ${color.gray('(Iterate)')}`, value: 'update-iterate', }, { - label: `✨ ${color.yellow('Update with AI')} ✨ ${color.gray('(Retry)')}`, + label: `✨ ${color.yellow('Update with AI')} ✨ ${color.gray('(Start over)')}`, value: 'update', } ); @@ -287,6 +288,11 @@ export const promptUpdateFile = async ({ } if (confirmResult === 'update' || confirmResult === 'update-iterate') { + // clear chat context + if (confirmResult === 'update') { + messageHistory = []; + } + // prompt for model const modelResult = await select({ message: 'Select a model', @@ -307,6 +313,14 @@ export const promptUpdateFile = async ({ const additionalInstructions = await text({ message: 'Any additional instructions?', defaultValue: 'None', + validate: (val) => { + // don't care if no messages have been sent + if (messageHistory.length === 0) return undefined; + + if (val.trim() === '') { + return 'Please provide additional context so that I know how I can improve.'; + } + }, }); if (isCancel(additionalInstructions)) { @@ -315,7 +329,7 @@ export const promptUpdateFile = async ({ } try { - updatedContent = await models[model].updateFile({ + const { content, prompt } = await models[model].updateFile({ originalFile: current, newFile: { content: @@ -330,7 +344,14 @@ export const promptUpdateFile = async ({ : undefined, loading: options.loading, verbose: options.verbose, + messages: messageHistory, }); + + updatedContent = content; + + // add messages to history + messageHistory.push({ role: 'user', content: prompt }); + messageHistory.push({ role: 'assistant', content: content }); } catch (err) { options.loading.stop(); log.error(color.red(`Error getting completions: ${err}`)); @@ -350,8 +371,6 @@ export const promptUpdateFile = async ({ process.stdout.write(`${ascii.VERTICAL_LINE}\n`); - hasUpdatedWithAI = true; - continue; } diff --git a/sites/docs/src/lib/components/ui/copy-button/copy-button.svelte b/sites/docs/src/lib/components/ui/copy-button/copy-button.svelte index 58ec4077..1be4736b 100644 --- a/sites/docs/src/lib/components/ui/copy-button/copy-button.svelte +++ b/sites/docs/src/lib/components/ui/copy-button/copy-button.svelte @@ -6,7 +6,7 @@