diff --git a/.changeset/add-run-interactive-flag.md b/.changeset/add-run-interactive-flag.md new file mode 100644 index 000000000..457e89a5e --- /dev/null +++ b/.changeset/add-run-interactive-flag.md @@ -0,0 +1,4 @@ +--- +"lingo.dev": minor +--- +Add `--interactive` review mode with colorized diff and per‑key edits. diff --git a/packages/cli/src/cli/cmd/i18n.ts b/packages/cli/src/cli/cmd/i18n.ts index fde855711..6a814669e 100644 --- a/packages/cli/src/cli/cmd/i18n.ts +++ b/packages/cli/src/cli/cmd/i18n.ts @@ -28,14 +28,13 @@ import createBucketLoader from "../loaders"; import { createAuthenticator } from "../utils/auth"; import { getBuckets } from "../utils/buckets"; import chalk from "chalk"; -import { createTwoFilesPatch } from "diff"; import inquirer from "inquirer"; -import externalEditor from "external-editor"; import updateGitignore from "../utils/update-gitignore"; import createProcessor from "../processor"; import { withExponentialBackoff } from "../utils/exp-backoff"; import trackEvent from "../utils/observability"; import { createDeltaProcessor } from "../utils/delta"; +import { reviewChanges } from "../utils/review"; export default new Command() .command("i18n") @@ -691,148 +690,4 @@ function validateParams( } } -async function reviewChanges(args: { - pathPattern: string; - targetLocale: string; - currentData: Record; - proposedData: Record; - sourceData: Record; - force: boolean; -}): Promise> { - const currentStr = JSON.stringify(args.currentData, null, 2); - const proposedStr = JSON.stringify(args.proposedData, null, 2); - - // Early return if no changes - if (currentStr === proposedStr && !args.force) { - console.log( - `\n${chalk.blue(args.pathPattern)} (${chalk.yellow( - args.targetLocale, - )}): ${chalk.gray("No changes to review")}`, - ); - return args.proposedData; - } - - const patch = createTwoFilesPatch( - `${args.pathPattern} (current)`, - `${args.pathPattern} (proposed)`, - currentStr, - proposedStr, - undefined, - undefined, - { context: 3 }, - ); - - // Color the diff output - const coloredDiff = patch - .split("\n") - .map((line) => { - if (line.startsWith("+")) return chalk.green(line); - if (line.startsWith("-")) return chalk.red(line); - if (line.startsWith("@")) return chalk.cyan(line); - return line; - }) - .join("\n"); - - console.log( - `\nReviewing changes for ${chalk.blue(args.pathPattern)} (${chalk.yellow( - args.targetLocale, - )}):`, - ); - console.log(coloredDiff); - - const { action } = await inquirer.prompt([ - { - type: "list", - name: "action", - message: "Choose action:", - choices: [ - { name: "Approve changes", value: "approve" }, - { name: "Skip changes", value: "skip" }, - { name: "Edit individually", value: "edit" }, - ], - default: "approve", - }, - ]); - - if (action === "approve") { - return args.proposedData; - } - - if (action === "skip") { - return args.currentData; - } - - // If edit was chosen, prompt for each changed value - const customData = { ...args.currentData }; - const changes = _.reduce( - args.proposedData, - (result: string[], value: string, key: string) => { - if (args.currentData[key] !== value) { - result.push(key); - } - return result; - }, - [], - ); - - for (const key of changes) { - console.log(`\nEditing value for: ${chalk.cyan(key)}`); - console.log(chalk.gray("Source text:"), chalk.blue(args.sourceData[key])); - console.log( - chalk.gray("Current value:"), - chalk.red(args.currentData[key] || "(empty)"), - ); - console.log( - chalk.gray("Suggested value:"), - chalk.green(args.proposedData[key]), - ); - console.log( - chalk.gray( - "\nYour editor will open. Edit the text and save to continue.", - ), - ); - console.log(chalk.gray("------------")); - - try { - // Prepare the editor content with a header comment and the suggested value - const editorContent = [ - "# Edit the translation below.", - "# Lines starting with # will be ignored.", - "# Save and exit the editor to continue.", - "#", - `# Source text (${chalk.blue("English")}):`, - `# ${args.sourceData[key]}`, - "#", - `# Current value (${chalk.red(args.targetLocale)}):`, - `# ${args.currentData[key] || "(empty)"}`, - "#", - args.proposedData[key], - ].join("\n"); - - const result = externalEditor.edit(editorContent); - - // Clean up the result by removing comments and trimming - const customValue = result - .split("\n") - .filter((line) => !line.startsWith("#")) - .join("\n") - .trim(); - - if (customValue) { - customData[key] = customValue; - } else { - console.log( - chalk.yellow("Empty value provided, keeping the current value."), - ); - customData[key] = args.currentData[key] || args.proposedData[key]; - } - } catch (error) { - console.log( - chalk.red("Error while editing, keeping the suggested value."), - ); - customData[key] = args.proposedData[key]; - } - } - - return customData; -} +// reviewChanges now provided by ../utils/review diff --git a/packages/cli/src/cli/cmd/run/execute.ts b/packages/cli/src/cli/cmd/run/execute.ts index 16c63de22..528938d59 100644 --- a/packages/cli/src/cli/cmd/run/execute.ts +++ b/packages/cli/src/cli/cmd/run/execute.ts @@ -1,5 +1,5 @@ import chalk from "chalk"; -import { Listr, ListrTask } from "listr2"; +import { Listr, ListrTask, ListrTaskWrapper } from "listr2"; import pLimit, { LimitFunction } from "p-limit"; import _ from "lodash"; import { minimatch } from "minimatch"; @@ -9,88 +9,94 @@ import { CmdRunContext, CmdRunTask, CmdRunTaskResult } from "./_types"; import { commonTaskRendererOptions } from "./_const"; import createBucketLoader from "../../loaders"; import { createDeltaProcessor, Delta } from "../../utils/delta"; +import { reviewChanges } from "../../utils/review"; const MAX_WORKER_COUNT = 10; export default async function execute(input: CmdRunContext) { - const effectiveConcurrency = Math.min( - input.flags.concurrency, - input.tasks.length, - MAX_WORKER_COUNT, - ); + const effectiveConcurrency = input.flags.interactive + ? 1 + : Math.min(input.flags.concurrency, input.tasks.length, MAX_WORKER_COUNT); console.log(chalk.hex(colors.orange)(`[Localization]`)); - return new Listr( - [ - { - title: "Initializing localization engine", - task: async (ctx, task) => { - task.title = `Localization engine ${chalk.hex(colors.green)( - "ready", - )} (${ctx.localizer!.id})`; - }, + const tasks: ListrTask[] = [ + { + title: "Initializing localization engine", + task: async (ctx, task) => { + task.title = `Localization engine ${chalk.hex(colors.green)( + "ready", + )} (${ctx.localizer!.id})`; }, - { - title: `Processing localization tasks ${chalk.dim( - `(tasks: ${input.tasks.length}, concurrency: ${effectiveConcurrency})`, - )}`, - task: async (ctx, task) => { - if (input.tasks.length < 1) { - task.title = `Skipping, nothing to localize.`; - task.skip(); - return; - } + }, + { + title: `Processing localization tasks ${chalk.dim( + `(tasks: ${input.tasks.length}, concurrency: ${effectiveConcurrency})`, + )}`, + task: async (ctx, task) => { + if (input.tasks.length < 1) { + task.title = `Skipping, nothing to localize.`; + task.skip(); + return; + } - // Preload checksums for all unique bucket path patterns before starting any workers - const initialChecksumsMap = new Map>(); - const uniqueBucketPatterns = _.uniq( - ctx.tasks.map((t) => t.bucketPathPattern), - ); - for (const bucketPathPattern of uniqueBucketPatterns) { - const deltaProcessor = createDeltaProcessor(bucketPathPattern); - const checksums = await deltaProcessor.loadChecksums(); - initialChecksumsMap.set(bucketPathPattern, checksums); - } + // Preload checksums for all unique bucket path patterns before starting any workers + const initialChecksumsMap = new Map>(); + const uniqueBucketPatterns = _.uniq( + ctx.tasks.map((t) => t.bucketPathPattern), + ); + for (const bucketPathPattern of uniqueBucketPatterns) { + const deltaProcessor = createDeltaProcessor(bucketPathPattern); + const checksums = await deltaProcessor.loadChecksums(); + initialChecksumsMap.set(bucketPathPattern, checksums); + } - const i18nLimiter = pLimit(effectiveConcurrency); - const ioLimiter = pLimit(1); - const workersCount = effectiveConcurrency; + const i18nLimiter = pLimit(effectiveConcurrency); + const ioLimiter = pLimit(1); + const workersCount = effectiveConcurrency; - const workerTasks: ListrTask[] = []; - for (let i = 0; i < workersCount; i++) { - const assignedTasks = ctx.tasks.filter( - (_, idx) => idx % workersCount === i, - ); - workerTasks.push( - createWorkerTask({ - ctx, - assignedTasks, - ioLimiter, - i18nLimiter, - initialChecksumsMap, - onDone() { - task.title = createExecutionProgressMessage(ctx); - }, - }), - ); - } + const workerTasks: ListrTask[] = []; + for (let i = 0; i < workersCount; i++) { + const assignedTasks = ctx.tasks.filter( + (_, idx) => idx % workersCount === i, + ); + workerTasks.push( + createWorkerTask({ + ctx, + assignedTasks, + ioLimiter, + i18nLimiter, + initialChecksumsMap, + onDone() { + task.title = createExecutionProgressMessage(ctx); + }, + }), + ); + } - return task.newListr(workerTasks, { - concurrent: true, - exitOnError: false, - rendererOptions: { - ...commonTaskRendererOptions, - collapseSubtasks: true, - }, - }); - }, + return task.newListr(workerTasks, { + concurrent: true, + exitOnError: false, + rendererOptions: { + ...commonTaskRendererOptions, + collapseSubtasks: true, + }, + }); }, - ], - { + }, + ]; + + if (input.flags.interactive) { + return new Listr(tasks, { exitOnError: false, + renderer: "verbose", rendererOptions: commonTaskRendererOptions, - }, - ).run(input); + }).run(input); + } + + return new Listr(tasks, { + exitOnError: false, + rendererOptions: commonTaskRendererOptions, + }).run(input); } function createWorkerStatusMessage(args: { @@ -156,7 +162,7 @@ function createWorkerTask(args: { }): ListrTask { return { title: "Initializing...", - task: async (_subCtx: any, subTask: any) => { + task: async (_subCtx, subTask) => { for (const assignedTask of args.assignedTasks) { subTask.title = createWorkerStatusMessage({ assignedTask, @@ -228,32 +234,34 @@ function createWorkerTask(args: { hints: relevantHints, }, async (progress, _sourceChunk, processedChunk) => { - // write translated chunks as they are received from LLM - await args.ioLimiter(async () => { - // pull the latest source data before pushing for buckets that store all locales in a single file - await bucketLoader.pull(assignedTask.sourceLocale); - // pull the latest target data to include all already processed chunks - const latestTargetData = await bucketLoader.pull( - assignedTask.targetLocale, - ); - - // add the new chunk to target data - const _partialData = _.merge( - {}, - latestTargetData, - processedChunk, - ); - // process renamed keys - const finalChunkTargetData = processRenamedKeys( - delta, - _partialData, - ); - // push final chunk to the target locale - await bucketLoader.push( - assignedTask.targetLocale, - finalChunkTargetData, - ); - }); + if (!args.ctx.flags.interactive) { + // write translated chunks as they are received from LLM + await args.ioLimiter(async () => { + // pull the latest source data before pushing for buckets that store all locales in a single file + await bucketLoader.pull(assignedTask.sourceLocale); + // pull the latest target data to include all already processed chunks + const latestTargetData = await bucketLoader.pull( + assignedTask.targetLocale, + ); + + // add the new chunk to target data + const _partialData = _.merge( + {}, + latestTargetData, + processedChunk, + ); + // process renamed keys + const finalChunkTargetData = processRenamedKeys( + delta, + _partialData, + ); + // push final chunk to the target locale + await bucketLoader.push( + assignedTask.targetLocale, + finalChunkTargetData, + ); + }); + } subTask.title = createWorkerStatusMessage({ assignedTask, @@ -274,13 +282,21 @@ function createWorkerTask(args: { ); await args.ioLimiter(async () => { - // not all localizers have progress callback (eg. explicit localizer), - // the final target data might not be pushed yet - push now to ensure it's up to date + // In interactive mode, review and edit before pushing + const dataToPush = args.ctx.flags.interactive + ? await reviewChanges({ + pathPattern: assignedTask.bucketPathPattern, + targetLocale: assignedTask.targetLocale, + currentData: targetData, + proposedData: finalRenamedTargetData, + sourceData, + force: !!args.ctx.flags.force, + }) + : finalRenamedTargetData; + + // Ensure source pulled for buckets that persist all locales together await bucketLoader.pull(assignedTask.sourceLocale); - await bucketLoader.push( - assignedTask.targetLocale, - finalRenamedTargetData, - ); + await bucketLoader.push(assignedTask.targetLocale, dataToPush); const checksums = await deltaProcessor.createChecksums(sourceData); diff --git a/packages/cli/src/cli/cmd/run/index.ts b/packages/cli/src/cli/cmd/run/index.ts index 8d8d80c17..d45d9e97e 100644 --- a/packages/cli/src/cli/cmd/run/index.ts +++ b/packages/cli/src/cli/cmd/run/index.ts @@ -112,6 +112,10 @@ export default new Command() "--sound", "Play audio feedback when translations complete (success or failure sounds)", ) + .option( + "--interactive", + "Review and edit AI-generated translations interactively before applying changes to files", + ) .action(async (args) => { let authId: string | null = null; try { diff --git a/packages/cli/src/cli/utils/review.ts b/packages/cli/src/cli/utils/review.ts new file mode 100644 index 000000000..e5cb8e1fa --- /dev/null +++ b/packages/cli/src/cli/utils/review.ts @@ -0,0 +1,146 @@ +import chalk from "chalk"; +import _ from "lodash"; +import { createTwoFilesPatch } from "diff"; +import inquirer from "inquirer"; +import externalEditor from "external-editor"; + +export async function reviewChanges(args: { + pathPattern: string; + targetLocale: string; + currentData: Record; + proposedData: Record; + sourceData: Record; + force: boolean; +}): Promise> { + const currentStr = JSON.stringify(args.currentData, null, 2); + const proposedStr = JSON.stringify(args.proposedData, null, 2); + + if (currentStr === proposedStr && !args.force) { + console.log( + `\n${chalk.blue(args.pathPattern)} (${chalk.yellow( + args.targetLocale, + )}): ${chalk.gray("No changes to review")}`, + ); + return args.proposedData; + } + + const patch = createTwoFilesPatch( + `${args.pathPattern} (current)`, + `${args.pathPattern} (proposed)`, + currentStr, + proposedStr, + undefined, + undefined, + { context: 3 }, + ); + + const coloredDiff = patch + .split("\n") + .map((line) => { + if (line.startsWith("+")) return chalk.green(line); + if (line.startsWith("-")) return chalk.red(line); + if (line.startsWith("@")) return chalk.cyan(line); + return line; + }) + .join("\n"); + + console.log( + `\nReviewing changes for ${chalk.blue(args.pathPattern)} (${chalk.yellow( + args.targetLocale, + )}):`, + ); + console.log(coloredDiff); + + const { action } = await inquirer.prompt([ + { + type: "list", + name: "action", + message: "Choose action:", + choices: [ + { name: "Approve changes", value: "approve" }, + { name: "Skip changes", value: "skip" }, + { name: "Edit individually", value: "edit" }, + ], + default: "approve", + }, + ]); + + if (action === "approve") { + return args.proposedData; + } + + if (action === "skip") { + return args.currentData; + } + + const customData = { ...args.currentData }; + const changes = _.reduce( + args.proposedData, + (result: string[], value: string, key: string) => { + if (args.currentData[key] !== value) { + result.push(key); + } + return result; + }, + [], + ); + + for (const key of changes) { + console.log(`\nEditing value for: ${chalk.cyan(key)}`); + console.log(chalk.gray("Source text:"), chalk.blue(args.sourceData[key])); + console.log( + chalk.gray("Current value:"), + chalk.red(args.currentData[key] || "(empty)"), + ); + console.log( + chalk.gray("Suggested value:"), + chalk.green(args.proposedData[key]), + ); + console.log( + chalk.gray( + "\nYour editor will open. Edit the text and save to continue.", + ), + ); + console.log(chalk.gray("------------")); + + try { + const editorContent = [ + "# Edit the translation below.", + "# Lines starting with # will be ignored.", + "# Save and exit the editor to continue.", + "#", + `# Source text (${chalk.blue("English")}):`, + `# ${args.sourceData[key]}`, + "#", + `# Current value (${chalk.red(args.targetLocale)}):`, + `# ${args.currentData[key] || "(empty)"}`, + "#", + args.proposedData[key], + ].join("\n"); + + const result = externalEditor.edit(editorContent); + + const customValue = result + .split("\n") + .filter((line) => !line.startsWith("#")) + .join("\n") + .trim(); + + if (customValue) { + customData[key] = customValue; + } else { + console.log( + chalk.yellow("Empty value provided, keeping the current value."), + ); + customData[key] = args.currentData[key] || args.proposedData[key]; + } + } catch (_error) { + console.log( + chalk.red("Error while editing, keeping the suggested value."), + ); + customData[key] = args.proposedData[key]; + } + } + + return customData; +}