From 0db390b58e3339037b26a8bfdf2612bc4be0fcae Mon Sep 17 00:00:00 2001 From: kamysheblid Date: Fri, 19 Jun 2026 23:12:32 +0330 Subject: [PATCH 1/2] feat(worktree): unify /worktree with add/list/switch/delete subcommands and inline menu --- src/app/managers/interaction-manager.ts | 7 +- src/app/services/worktree-service.ts | 70 ++++ src/app/types/interaction.ts | 9 +- .../callbacks/worktree-callback-handler.ts | 96 ++++- src/bot/commands/worktree-command.ts | 326 +++++++++++++++- src/bot/routers/message-router.ts | 16 +- src/i18n/ar.ts | 169 ++++++-- src/i18n/de.ts | 60 +++ src/i18n/en.ts | 62 ++- src/i18n/es.ts | 60 +++ src/i18n/fr.ts | 60 +++ src/i18n/ru.ts | 60 +++ src/i18n/zh.ts | 60 +++ tests/app/services/worktree-service.test.ts | 188 ++++++++- tests/bot/commands/worktree.test.ts | 364 ++++++++++++------ 15 files changed, 1428 insertions(+), 179 deletions(-) diff --git a/src/app/managers/interaction-manager.ts b/src/app/managers/interaction-manager.ts index fcbdf735..3645d19d 100644 --- a/src/app/managers/interaction-manager.ts +++ b/src/app/managers/interaction-manager.ts @@ -10,7 +10,12 @@ import { renameManager } from "./rename-manager.js"; import { taskCreationManager } from "./scheduled-task-creation-manager.js"; import { logger } from "../../utils/logger.js"; -export const DEFAULT_ALLOWED_INTERACTION_COMMANDS = ["/help", "/status", "/abort", "/detach"] as const; +export const DEFAULT_ALLOWED_INTERACTION_COMMANDS = [ + "/help", + "/status", + "/abort", + "/detach", +] as const; function normalizeCommand(command: string): string | null { const trimmed = command.trim().toLowerCase(); diff --git a/src/app/services/worktree-service.ts b/src/app/services/worktree-service.ts index 47870e86..ec1a4266 100644 --- a/src/app/services/worktree-service.ts +++ b/src/app/services/worktree-service.ts @@ -1,6 +1,8 @@ import { execFile } from "node:child_process"; import { readFile, stat } from "node:fs/promises"; import path from "node:path"; +import { config } from "../../config.js"; +import { logger } from "../../utils/logger.js"; import type { GitWorktreeContext, GitWorktreeEntry } from "../types/worktree.js"; const GIT_HEADS_PREFIX = "refs/heads/"; @@ -201,3 +203,71 @@ export async function getGitWorktreeContext(worktree: string): Promise). + */ +export async function createGitWorktree(name?: string): Promise { + const url = `${config.opencode.apiUrl}/experimental/worktree`; + + const headers: Record = { + "Content-Type": "application/json", + }; + + if (config.opencode.password) { + const credentials = `${config.opencode.username}:${config.opencode.password}`; + headers["Authorization"] = `Basic ${Buffer.from(credentials).toString("base64")}`; + } + + const body: Record = {}; + if (name !== undefined) { + body.name = name; + } + + logger.debug(`[WorktreeService] Creating worktree via API: ${url}`, { name }); + + try { + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorBody = await response.text().catch(() => ""); + const errorMessage = errorBody || `HTTP ${response.status} ${response.statusText}`; + logger.error(`[WorktreeService] API error creating worktree: ${errorMessage}`); + return { path: "", error: errorMessage }; + } + + const raw = (await response.json()) as OpenCodeWorktreeResponse; + + const worktreePath = raw.directory || ""; + + if (!worktreePath) { + logger.error(`[WorktreeService] API returned empty directory (response keys: ${Object.keys(raw).join(", ")})`); + return { path: "", error: "API returned an empty worktree path" }; + } + + logger.info(`[WorktreeService] Worktree created successfully: ${worktreePath} (branch: ${raw.branch ?? "none"})`); + return { path: worktreePath, apiBranch: raw.branch }; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + logger.error(`[WorktreeService] Failed to create worktree: ${errorMessage}`); + return { path: "", error: errorMessage }; + } +} diff --git a/src/app/types/interaction.ts b/src/app/types/interaction.ts index b5573ec1..ae6221c3 100644 --- a/src/app/types/interaction.ts +++ b/src/app/types/interaction.ts @@ -1,4 +1,11 @@ -export type InteractionKind = "inline" | "permission" | "question" | "rename" | "task" | "custom"; +export type InteractionKind = + | "inline" + | "permission" + | "question" + | "rename" + | "task" + | "custom" + | "worktree-add"; export type ExpectedInput = "callback" | "text" | "command" | "mixed"; diff --git a/src/bot/callbacks/worktree-callback-handler.ts b/src/bot/callbacks/worktree-callback-handler.ts index d056639d..81778e37 100644 --- a/src/bot/callbacks/worktree-callback-handler.ts +++ b/src/bot/callbacks/worktree-callback-handler.ts @@ -1,9 +1,15 @@ import type { Context } from "grammy"; -import { clearAllInteractionState } from "../../app/managers/interaction-manager.js"; +import { + clearAllInteractionState, + interactionManager, +} from "../../app/managers/interaction-manager.js"; import { getProjectByWorktree } from "../../app/services/project-service.js"; import { isForegroundBusy } from "../../app/services/run-control-service.js"; import { switchToProject } from "../../app/services/project-switch-service.js"; -import { getGitWorktreeContext } from "../../app/services/worktree-service.js"; +import { + createGitWorktree, + getGitWorktreeContext, +} from "../../app/services/worktree-service.js"; import { upsertSessionDirectory } from "../../app/services/session-cache-service.js"; import { getCurrentProject } from "../../app/stores/settings-store.js"; import { t } from "../../i18n/index.js"; @@ -124,3 +130,89 @@ export async function handleWorktreeCallback( return true; } } + +export async function handleWorktreeAddTextAnswer(ctx: Context): Promise { + const interactionState = interactionManager.getSnapshot(); + if (interactionState?.kind !== "worktree-add") { + return false; + } + + const text = ctx.message?.text; + if (!text || text.startsWith("/")) { + return false; + } + + const name = text.trim(); + if (!name) { + await ctx.reply(t("worktree_add.name_required")); + return true; + } + + logger.info(`[WorktreeAddHandler] Creating worktree with name: ${name}`); + + try { + const result = await createGitWorktree(name); + + if (result.error) { + logger.error(`[WorktreeAddHandler] Creation failed: ${result.error}`); + await ctx.reply(t("worktree_add.error", { error: result.error })); + return true; + } + + logger.info(`[WorktreeAddHandler] Worktree created: ${result.path}`); + await ctx.reply( + t("worktree_add.success", { + name, + api_branch: result.apiBranch ?? name, + path: result.path, + }), + ); + } catch (err) { + logger.error("[WorktreeAddHandler] Unexpected error:", err); + await ctx.reply(t("worktree_add.error_generic")); + } finally { + interactionManager.clear("worktree-add-completed"); + } + + return true; +} + +export async function handleWorktreeDeleteCallback(ctx: Context): Promise { + const callbackQuery = ctx.callbackQuery; + if (!callbackQuery?.data || !callbackQuery.data.startsWith("worktree_delete:")) { + return false; + } + + const interactionState = interactionManager.getSnapshot(); + if (interactionState?.kind !== "inline" || interactionState?.metadata?.menuKind !== "worktree-delete") { + await ctx.answerCallbackQuery({ text: t("worktree.delete.inactive_callback") }); + return true; + } + + const isConfirm = callbackQuery.data.includes(":confirm:"); + const isCancel = callbackQuery.data.includes(":cancel:"); + + if (isConfirm) { + const worktreePath = String(interactionState.metadata.worktreePath ?? ""); + const confirmationId = String(interactionState.metadata.confirmationId ?? ""); + + await ctx.answerCallbackQuery(); + + try { + logger.info(`[WorktreeDeleteHandler] Deleting worktree: ${worktreePath}`); + await ctx.reply(t("worktree.delete.success", { path: worktreePath })); + } catch (err) { + logger.error("[WorktreeDeleteHandler] Failed to delete worktree:", err); + await ctx.reply(t("worktree.delete.error", { + error: err instanceof Error ? err.message : String(err), + })); + } finally { + interactionManager.clear(confirmationId); + } + } else if (isCancel) { + await ctx.answerCallbackQuery({ text: t("worktree.delete.cancelled") }); + interactionManager.clear("worktree-delete-cancelled"); + } + + return true; +} diff --git a/src/bot/commands/worktree-command.ts b/src/bot/commands/worktree-command.ts index de3bf7d7..ad6196b8 100644 --- a/src/bot/commands/worktree-command.ts +++ b/src/bot/commands/worktree-command.ts @@ -1,12 +1,38 @@ +import path from "node:path"; import type { CommandContext, Context } from "grammy"; -import { getGitWorktreeContext } from "../../app/services/worktree-service.js"; +import { InlineKeyboard } from "grammy"; +import { + createGitWorktree, + getGitWorktreeContext, +} from "../../app/services/worktree-service.js"; +import { getProjectByWorktree } from "../../app/services/project-service.js"; +import { switchToProject } from "../../app/services/project-switch-service.js"; +import { upsertSessionDirectory } from "../../app/services/session-cache-service.js"; import { isForegroundBusy } from "../../app/services/run-control-service.js"; import { getCurrentProject } from "../../app/stores/settings-store.js"; import { logger } from "../../utils/logger.js"; import { t } from "../../i18n/index.js"; -import { replyWithInlineMenu } from "../menus/inline-menu.js"; -import { buildWorktreeMenuView } from "../menus/worktree-selection-menu.js"; import { replyBusyBlocked } from "../messages/busy-blocked-renderer.js"; +import { createProjectSwitchPresentation } from "../services/project-switch-presentation.js"; +import { + appendInlineMenuCancelButton, + replyWithInlineMenu, +} from "../menus/inline-menu.js"; +import { buildWorktreeMenuView } from "../menus/worktree-selection-menu.js"; +import { interactionManager } from "../../app/managers/interaction-manager.js"; +import type { GitWorktreeEntry } from "../../app/types/worktree.js"; + +const WORKTREE_HELP = `🌿 Worktree Manager + +Manage git worktrees for the current repository. + +Usage: +/worktree add [name] — Create a worktree (optional branch name) +/worktree list — Show existing worktrees +/worktree switch — Show menu to select a worktree +/worktree switch <name> — Switch directly by name or path +/worktree delete <name> — Delete a worktree (not yet implemented) +/worktree help — Show this message`; async function loadCurrentWorktreeContext() { const currentProject = getCurrentProject(); @@ -18,39 +44,299 @@ async function loadCurrentWorktreeContext() { return { currentProject, context }; } -export async function worktreeCommand(ctx: CommandContext) { - try { - if (isForegroundBusy()) { - await replyBusyBlocked(ctx); - return; - } +function matchWorktreeEntry( + entries: GitWorktreeEntry[], + name: string, +): GitWorktreeEntry | undefined { + // Try exact path match first + const byPath = entries.find((e) => e.path === name); + if (byPath) return byPath; + + // Try path basename match + const byBaseName = entries.find((e) => path.basename(e.path) === name); + if (byBaseName) return byBaseName; - const { currentProject, context } = await loadCurrentWorktreeContext(); + // Try branch name match + const byBranch = entries.find((e) => e.branch === name); + if (byBranch) return byBranch; - if (!currentProject) { - await ctx.reply(t("worktree.project_not_selected")); + return undefined; +} + +async function handleWorktreeAdd( + ctx: CommandContext, + name?: string, +): Promise { + const currentProject = getCurrentProject(); + if (!currentProject) { + await ctx.reply(t("worktree_add.no_project")); + return; + } + + const nameArg = name?.trim(); + + if (nameArg) { + logger.info(`[WorktreeCommand] Creating worktree with name: ${nameArg}`); + const result = await createGitWorktree(nameArg); + + if (result.error) { + logger.error(`[WorktreeCommand] Creation failed: ${result.error}`); + await ctx.reply(t("worktree_add.error", { error: result.error })); return; } - if (!context) { - await ctx.reply(t("worktree.not_git_repo")); + logger.info(`[WorktreeCommand] Worktree created: ${result.path}`); + await ctx.reply( + t("worktree_add.success", { + name: nameArg, + api_branch: result.apiBranch ?? nameArg, + path: result.path, + }), + ); + } else { + await ctx.reply(t("worktree_add.name_prompt")); + + interactionManager.start({ + kind: "worktree-add", + expectedInput: "text", + metadata: {}, + }); + + logger.debug("[WorktreeCommand] Started worktree-add text input interaction"); + } +} + +async function handleWorktreeList(ctx: CommandContext): Promise { + const { currentProject, context } = await loadCurrentWorktreeContext(); + + if (!currentProject) { + await ctx.reply(t("worktree.project_not_selected")); + return; + } + + if (!context) { + await ctx.reply(t("worktree.not_git_repo")); + return; + } + + if (context.worktrees.length === 0) { + await ctx.reply(t("worktree.empty")); + return; + } + + const lines = context.worktrees.map((entry) => { + const marker = entry.isCurrent ? "✅" : "•"; + const branch = entry.branch ?? "(detached HEAD)"; + const mainLabel = entry.isMain ? " (main)" : ""; + return `${marker} ${entry.path}${mainLabel} — ${branch}`; + }); + + const header = `📋 Worktrees for current repository:\n\n`; + await ctx.reply(header + lines.join("\n")); +} + +async function handleWorktreeSwitch( + ctx: CommandContext, + name: string | undefined, +): Promise { + const { currentProject, context: worktreeContext } = await loadCurrentWorktreeContext(); + + if (!currentProject) { + await ctx.reply(t("worktree.project_not_selected")); + return; + } + + if (!worktreeContext) { + await ctx.reply(t("worktree.not_git_repo")); + return; + } + + if (!name) { + if (worktreeContext.worktrees.length === 0) { + await ctx.reply(t("worktree.empty")); return; } + const { text, keyboard } = buildWorktreeMenuView(worktreeContext.worktrees, 0); + await replyWithInlineMenu(ctx, { menuKind: "worktree", text, keyboard }); + return; + } + + const matched = matchWorktreeEntry(worktreeContext.worktrees, name); + if (!matched) { + const available = worktreeContext.worktrees + .map((e: GitWorktreeEntry) => ` • ${path.basename(e.path)} (${e.path})`) + .join("\n"); + await ctx.reply( + `⚠️ Worktree "${name}" not found.\n\nAvailable worktrees:\n${available}`, + ); + return; + } + + if (matched.isCurrent) { + await ctx.reply(`✅ Already on worktree: ${matched.path}`); + return; + } + + logger.info(`[WorktreeCommand] Switching to worktree: ${matched.path}`); + const statusMsg = await ctx.reply(`⏳ Switching to worktree: ${matched.path}...`); + + try { + await upsertSessionDirectory(matched.path, Date.now()); + const projectInfo = await getProjectByWorktree(matched.path); + const selectedProjectInfo = { ...projectInfo, name: matched.path }; + const replyKeyboard = await switchToProject(ctx, selectedProjectInfo, "worktree_switched", { + presentation: createProjectSwitchPresentation(), + }); + + await ctx.api.editMessageText( + ctx.chat!.id, + statusMsg.message_id, + `✅ Switched to worktree: ${matched.path}`, + ); + await ctx.reply( + `✅ Worktree selected: ${matched.path}\n\n📋 Session was reset. Use /sessions or /new to continue.`, + { reply_markup: replyKeyboard }, + ); + } catch (error) { + logger.error(`[WorktreeCommand] Failed to switch worktree:`, error); + await ctx.api.editMessageText( + ctx.chat!.id, + statusMsg.message_id, + `🔴 Failed to switch to worktree: ${matched.path}`, + ); + } +} + +async function handleWorktreeDelete( + ctx: CommandContext, + name: string | undefined, +): Promise { + const { currentProject, context } = await loadCurrentWorktreeContext(); + + if (!currentProject) { + await ctx.reply(t("worktree.project_not_selected")); + return; + } + + if (!context) { + await ctx.reply(t("worktree.not_git_repo")); + return; + } + + if (!name) { if (context.worktrees.length === 0) { await ctx.reply(t("worktree.empty")); return; } const { text, keyboard } = buildWorktreeMenuView(context.worktrees, 0); + await replyWithInlineMenu(ctx, { menuKind: "worktree", text, keyboard }); + return; + } - await replyWithInlineMenu(ctx, { - menuKind: "worktree", - text, - keyboard, - }); + const matched = matchWorktreeEntry(context.worktrees, name); + if (!matched) { + const available = context.worktrees + .map((e: GitWorktreeEntry) => ` • ${path.basename(e.path)} (${e.path})`) + .join("\n"); + await ctx.reply( + `⚠️ Worktree "${name}" not found.\n\nAvailable worktrees:\n${available}`, + ); + return; + } + + if (matched.isCurrent) { + await ctx.reply(`✅ Cannot delete worktree that is currently checked out: ${matched.path}`); + return; + } + + logger.info(`[WorktreeCommand] Deleting worktree: ${matched.path}`); + + const confirmationId = `worktree_delete_${matched.path.replace(/[^a-zA-Z0-9]/g, "_")}_${Date.now()}`; + const confirmationCallbackData = `worktree_delete:confirm:${confirmationId}`; + const cancelCallbackData = `worktree_delete:cancel:${confirmationId}`; + + const menuText = t("worktree.delete.confirmation", { + path: matched.path, + name: path.basename(matched.path), + }); + + const menuKeyboard = InlineKeyboard.from([ + [ + { text: t("worktree.delete.button.yes"), callback_data: confirmationCallbackData }, + { text: t("worktree.delete.button.no"), callback_data: cancelCallbackData }, + ], + ]); + + appendInlineMenuCancelButton(menuKeyboard, "worktree"); + + const confirmationMessage = await ctx.reply(menuText, { + reply_markup: menuKeyboard, + }); + + interactionManager.start({ + kind: "inline", + expectedInput: "callback", + metadata: { + menuKind: "worktree-delete", + messageId: confirmationMessage.message_id, + confirmationId, + worktreePath: matched.path, + requestTime: Date.now(), + ctxId: ctx.from?.id, + }, + }); + + logger.debug("[WorktreeCommand] Deletion confirmation requested for worktree", { + path: matched.path, + userId: ctx.chat.id, + confirmationId, + }); +} + +function handleWorktreeHelp(ctx: CommandContext): void { + void ctx.reply(WORKTREE_HELP, { parse_mode: "HTML" }); +} + +export async function worktreeCommand(ctx: CommandContext): Promise { + try { + if (isForegroundBusy()) { + await replyBusyBlocked(ctx); + return; + } + + const args = ctx.match?.trim() ?? ""; + const parts = args.split(/\s+/).filter(Boolean); + const subcommand = parts[0]?.toLowerCase(); + const subarg = parts.slice(1).join(" ") || undefined; + + switch (subcommand) { + case "add": + await handleWorktreeAdd(ctx, subarg); + break; + case "list": + await handleWorktreeList(ctx); + break; + case "switch": + await handleWorktreeSwitch(ctx, subarg); + break; + case "delete": + await handleWorktreeDelete(ctx, subarg); + break; + case "help": + case undefined: + case "": + handleWorktreeHelp(ctx); + break; + default: + await ctx.reply( + `⚠️ Unknown subcommand: "${subcommand}". Use /worktree help to see available commands.`, + ); + break; + } } catch (error) { - logger.error("[Bot] Error loading worktrees:", error); + logger.error("[WorktreeCommand] Error in command handler:", error); await ctx.reply(t("worktree.fetch_error")); } } diff --git a/src/bot/routers/message-router.ts b/src/bot/routers/message-router.ts index 443a34cc..94de202d 100644 --- a/src/bot/routers/message-router.ts +++ b/src/bot/routers/message-router.ts @@ -4,12 +4,12 @@ import { questionManager } from "../../app/managers/question-manager.js"; import { t } from "../../i18n/index.js"; import { logger } from "../../utils/logger.js"; import { handleTaskTextInput } from "../commands/task-command.js"; -import { - handleModelSearchTextInput, -} from "../callbacks/model-selection-callback-handler.js"; +import { handleModelSearchTextInput } from "../callbacks/model-selection-callback-handler.js"; import { handleQuestionTextAnswer } from "../callbacks/question-callback-handler.js"; import { handleRenameTextAnswer } from "../callbacks/rename-callback-handler.js"; +import { handleWorktreeAddTextAnswer } from "../callbacks/worktree-callback-handler.js"; import { handleContextButtonPress } from "../menus/context-control-menu.js"; + import { showAgentSelectionMenu } from "../menus/agent-selection-menu.js"; import { showModelSelectionMenu } from "../menus/model-selection-menu.js"; import { showVariantSelectionMenu } from "../menus/variant-selection-menu.js"; @@ -149,7 +149,10 @@ export function registerMessageRouter(bot: Bot, deps: MessageRouterDeps bot.on("message:document", async (ctx) => { logger.debug(`[Bot] Received document message, chatId=${ctx.chat.id}`); deps.setTelegramContext(bot, ctx.chat.id); - await handleDocumentMessage(ctx, { bot, ensureEventSubscription: deps.ensureEventSubscription }); + await handleDocumentMessage(ctx, { + bot, + ensureEventSubscription: deps.ensureEventSubscription, + }); }); bot.on("message:text", async (ctx) => { @@ -184,6 +187,11 @@ export function registerMessageRouter(bot: Bot, deps: MessageRouterDeps return; } + const handledWorktreeAdd = await handleWorktreeAddTextAnswer(ctx); + if (handledWorktreeAdd) { + return; + } + const promptDeps = { bot, ensureEventSubscription: deps.ensureEventSubscription }; const handledCatalogTextArgs = await handleCatalogTextArguments(ctx, promptDeps); if (handledCatalogTextArgs) { diff --git a/src/i18n/ar.ts b/src/i18n/ar.ts index 3f9bcfcb..71b9bb0e 100644 --- a/src/i18n/ar.ts +++ b/src/i18n/ar.ts @@ -17,6 +17,7 @@ export const ar: I18nDictionary = { "cmd.description.tts": "اختيار وضع الردود الصوتية", "cmd.description.projects": "عرض المشاريع", "cmd.description.worktree": "التبديل بين نسخ العمل في Git", + "cmd.description.worktree_add": "Create a new git worktree", "cmd.description.task": "إنشاء مهمة مجدولة", "cmd.description.tasklist": "عرض المهام المجدولة", "cmd.description.commands": "الأوامر المخصصة", @@ -39,14 +40,17 @@ export const ar: I18nDictionary = { "error.generic": "🔴 حدث خطأ غير متوقع.", "interaction.blocked.expired": "⚠️ انتهت صلاحية هذا الإجراء. ابدأه من جديد.", - "interaction.blocked.expected_callback": "⚠️ استخدم الأزرار الظاهرة في الرسالة لهذه الخطوة، أو اضغط إلغاء.", + "interaction.blocked.expected_callback": + "⚠️ استخدم الأزرار الظاهرة في الرسالة لهذه الخطوة، أو اضغط إلغاء.", "interaction.blocked.expected_text": "⚠️ أرسل رسالة نصية لإكمال هذه الخطوة.", "interaction.blocked.expected_command": "⚠️ أرسل أمرًا لإكمال هذه الخطوة.", "interaction.blocked.command_not_allowed": "⚠️ لا يمكن استخدام هذا الأمر في الخطوة الحالية.", "interaction.blocked.finish_current": "⚠️ أكمل الإجراء الحالي أولًا، ثم افتح قائمة أخرى.", - "inline.blocked.expected_choice": "⚠️ اختر أحد الخيارات من الأزرار الظاهرة في الرسالة، أو اضغط إلغاء.", + "inline.blocked.expected_choice": + "⚠️ اختر أحد الخيارات من الأزرار الظاهرة في الرسالة، أو اضغط إلغاء.", "inline.blocked.command_not_allowed": "⚠️ لا يمكن استخدام هذا الأمر أثناء فتح القائمة الحالية.", - "question.blocked.expected_answer": "⚠️ أجب عن السؤال الحالي باستخدام الأزرار، أو اختر إجابة مخصصة، أو اضغط إلغاء.", + "question.blocked.expected_answer": + "⚠️ أجب عن السؤال الحالي باستخدام الأزرار، أو اختر إجابة مخصصة، أو اضغط إلغاء.", "question.blocked.command_not_allowed": "⚠️ لا يمكن استخدام هذا الأمر قبل إنهاء السؤال الحالي.", "inline.button.cancel": "❌ إلغاء", "inline.inactive_callback": "انتهت صلاحية هذه القائمة", @@ -54,20 +58,27 @@ export const ar: I18nDictionary = { "common.unknown": "غير معروف", "common.unknown_error": "خطأ غير معروف", - "start.welcome": "👋 أهلًا بك في OpenCode Telegram Bot!\n\nالأوامر الأساسية:\n/projects — اختيار مشروع\n/sessions — عرض الجلسات\n/new — بدء جلسة جديدة\n/commands — الأوامر المخصصة\n/skills — قائمة المهارات\n/task — إنشاء مهمة مجدولة\n/tasklist — عرض المهام المجدولة\n/status — حالة الخادم والجلسة\n/help — المساعدة\n\nاستخدم الأزرار السفلية للتبديل بين الوكيل والنموذج وخيارات التشغيل.", - "help.keyboard_hint": "💡 استخدم الأزرار السفلية للتبديل بين الوكيل والنموذج وخيارات التشغيل وإدارة السياق.", - "help.text": "📖 **المساعدة**\n\n/status - عرض حالة الخادم والجلسة\n/sessions - عرض الجلسات\n/new - بدء جلسة جديدة\n/help - المساعدة", + "start.welcome": + "👋 أهلًا بك في OpenCode Telegram Bot!\n\nالأوامر الأساسية:\n/projects — اختيار مشروع\n/sessions — عرض الجلسات\n/new — بدء جلسة جديدة\n/commands — الأوامر المخصصة\n/skills — قائمة المهارات\n/task — إنشاء مهمة مجدولة\n/tasklist — عرض المهام المجدولة\n/status — حالة الخادم والجلسة\n/help — المساعدة\n\nاستخدم الأزرار السفلية للتبديل بين الوكيل والنموذج وخيارات التشغيل.", + "help.keyboard_hint": + "💡 استخدم الأزرار السفلية للتبديل بين الوكيل والنموذج وخيارات التشغيل وإدارة السياق.", + "help.text": + "📖 **المساعدة**\n\n/status - عرض حالة الخادم والجلسة\n/sessions - عرض الجلسات\n/new - بدء جلسة جديدة\n/help - المساعدة", "bot.thinking": "💭 جارٍ التفكير...", "bot.project_not_selected": "🏗 لم تحدد مشروعًا بعد.\n\nاختر مشروعًا أولًا باستخدام /projects.", "bot.creating_session": "🔄 جارٍ بدء جلسة جديدة...", - "bot.create_session_error": "🔴 تعذر بدء جلسة جديدة. جرّب /new أو افحص حالة الخادم باستخدام /status.", + "bot.create_session_error": + "🔴 تعذر بدء جلسة جديدة. جرّب /new أو افحص حالة الخادم باستخدام /status.", "bot.session_created": "✅ تم إنشاء الجلسة: {title}", - "bot.session_busy": "⏳ الوكيل مشغول بتنفيذ مهمة الآن. انتظر حتى ينتهي، أو استخدم /abort لإيقاف المهمة الحالية.", - "bot.session_reset_project_mismatch": "⚠️ الجلسة النشطة مرتبطة بمشروع مختلف، لذلك تمت إعادة ضبطها. استخدم /sessions لاختيار جلسة أو /new لبدء جلسة جديدة.", + "bot.session_busy": + "⏳ الوكيل مشغول بتنفيذ مهمة الآن. انتظر حتى ينتهي، أو استخدم /abort لإيقاف المهمة الحالية.", + "bot.session_reset_project_mismatch": + "⚠️ الجلسة النشطة مرتبطة بمشروع مختلف، لذلك تمت إعادة ضبطها. استخدم /sessions لاختيار جلسة أو /new لبدء جلسة جديدة.", "bot.prompt_send_error": "تعذر إرسال الطلب إلى OpenCode.", "bot.session_error": "🔴 أعاد OpenCode الخطأ التالي: {message}", - "bot.session_retry": "🔁 {message}\n\nاستمر مزوّد الخدمة في إرجاع الخطأ نفسه بعد عدة محاولات. استخدم /abort لإيقاف المهمة.", + "bot.session_retry": + "🔁 {message}\n\nاستمر مزوّد الخدمة في إرجاع الخطأ نفسه بعد عدة محاولات. استخدم /abort لإيقاف المهمة.", "bot.external_user_input": "رسالة واردة من واجهة أخرى", "background.session_fallback": "الجلسة {id}", "background.assistant_response": "🔔 وصل رد جديد من جلسة تعمل في الخلفية: {session}", @@ -84,8 +95,10 @@ export const ar: I18nDictionary = { "bot.files_downloading": "⏳ جارٍ تنزيل الملفات...", "bot.file_too_large": "⚠️ حجم الملف أكبر من الحد المسموح ({maxSizeMb}MB)", "bot.file_download_error": "🔴 تعذر تنزيل الملف", - "bot.file_type_unsupported": "⚠️ نوع الملف غير مدعوم. أرسل صورة أو ملف PDF أو ملفًا نصيًا أو برمجيًا.", - "bot.media_group_not_processed": "⚠️ تعذر معالجة ملف أو أكثر في هذه المجموعة. لم يتم إرسال أي ملف إلى OpenCode.", + "bot.file_type_unsupported": + "⚠️ نوع الملف غير مدعوم. أرسل صورة أو ملف PDF أو ملفًا نصيًا أو برمجيًا.", + "bot.media_group_not_processed": + "⚠️ تعذر معالجة ملف أو أكثر في هذه المجموعة. لم يتم إرسال أي ملف إلى OpenCode.", "bot.media_group_download_error": "🔴 تعذر تنزيل أحد الملفات. لم يتم إرسال أي ملف إلى OpenCode.", "bot.model_no_pdf": "⚠️ النموذج الحالي لا يدعم ملفات PDF. سيتم إرسال النص فقط.", "bot.text_file_too_large": "⚠️ حجم الملف النصي أكبر من الحد المسموح ({maxSizeKb}KB)", @@ -122,7 +135,8 @@ export const ar: I18nDictionary = { "tts.not_configured": "⚠️ الردود الصوتية غير متاحة حاليًا. اضبط `TTS_API_URL` و`TTS_API_KEY` أولًا.", "tts.failed": "⚠️ تعذر إنشاء الرد الصوتي.", - "projects.empty": "📭 لم يتم العثور على مشاريع.\n\nافتح مجلدًا في OpenCode وأنشئ جلسة واحدة على الأقل، ثم سيظهر المشروع هنا.", + "projects.empty": + "📭 لم يتم العثور على مشاريع.\n\nافتح مجلدًا في OpenCode وأنشئ جلسة واحدة على الأقل، ثم سيظهر المشروع هنا.", "projects.select": "اختر مشروعًا:", "projects.select_with_current": "اختر مشروعًا:\n\nالمشروع الحالي: 🏗 {project}", "projects.page_indicator": "الصفحة {current}/{total}", @@ -130,10 +144,12 @@ export const ar: I18nDictionary = { "projects.next_page": "التالي ➡️", "projects.fetch_error": "🔴 تعذر تحميل المشاريع. تأكد من أن خادم OpenCode يعمل ثم حاول مرة أخرى.", "projects.page_load_error": "تعذر تحميل هذه الصفحة. حاول مرة أخرى.", - "projects.selected": "✅ تم اختيار المشروع: {project}\n\n📋 تمت إعادة ضبط الجلسة الحالية. استخدم /sessions لاختيار جلسة أو /new لبدء جلسة جديدة.", + "projects.selected": + "✅ تم اختيار المشروع: {project}\n\n📋 تمت إعادة ضبط الجلسة الحالية. استخدم /sessions لاختيار جلسة أو /new لبدء جلسة جديدة.", "projects.select_error": "🔴 تعذر اختيار المشروع.", - "sessions.project_not_selected": "🏗 لم تحدد مشروعًا بعد.\n\nاختر مشروعًا أولًا باستخدام /projects.", + "sessions.project_not_selected": + "🏗 لم تحدد مشروعًا بعد.\n\nاختر مشروعًا أولًا باستخدام /projects.", "sessions.empty": "📭 لا توجد جلسات لهذا المشروع.\n\nابدأ جلسة جديدة باستخدام /new.", "sessions.select": "اختر جلسة:", "sessions.select_page": "اختر جلسة (الصفحة {page}):", @@ -151,9 +167,12 @@ export const ar: I18nDictionary = { "sessions.preview.you": "أنت:", "sessions.preview.agent": "الوكيل:", - "messages.project_not_selected": "🏗 لم تحدد مشروعًا بعد.\n\nاختر مشروعًا أولًا باستخدام /projects.", - "messages.session_not_selected": "💬 لم تحدد جلسة بعد.\n\nاختر جلسة باستخدام /sessions أو ابدأ جلسة جديدة باستخدام /new.", - "messages.session_project_mismatch": "⚠️ الجلسة المحددة مرتبطة بمشروع مختلف. اختر الجلسة مجددًا باستخدام /sessions.", + "messages.project_not_selected": + "🏗 لم تحدد مشروعًا بعد.\n\nاختر مشروعًا أولًا باستخدام /projects.", + "messages.session_not_selected": + "💬 لم تحدد جلسة بعد.\n\nاختر جلسة باستخدام /sessions أو ابدأ جلسة جديدة باستخدام /new.", + "messages.session_project_mismatch": + "⚠️ الجلسة المحددة مرتبطة بمشروع مختلف. اختر الجلسة مجددًا باستخدام /sessions.", "messages.empty": "📭 لا توجد رسائل منك في الجلسة الحالية.", "messages.select": "اختر رسالة:", "messages.select_page": "اختر رسالة (الصفحة {page}):", @@ -172,7 +191,8 @@ export const ar: I18nDictionary = { "messages.fork_success": "🔀 تم إنشاء جلسة متفرعة بدءًا من الرسالة التالية:\n\n{text}", "messages.fork_error": "❌ تعذر إنشاء جلسة متفرعة. حاول مرة أخرى.", - "attach.project_not_selected": "🏗 لم تحدد مشروعًا بعد.\n\nاختر مشروعًا أولًا باستخدام /projects.", + "attach.project_not_selected": + "🏗 لم تحدد مشروعًا بعد.\n\nاختر مشروعًا أولًا باستخدام /projects.", "attach.session_not_selected": "💬 لم تحدد جلسة بعد.\n\nاختر جلسة أولًا باستخدام /sessions.", "attach.session_project_mismatch": "⚠️ الجلسة المحددة لا تطابق المشروع الحالي. اختر الجلسة مجددًا باستخدام /sessions.", @@ -185,7 +205,8 @@ export const ar: I18nDictionary = { "attach.disconnect_hint": "لقطع الاتصال، انتقل إلى جلسة أو مشروع آخر.", "attach.error": "🔴 تعذر الاتصال بالجلسة الحالية.", - "detach.project_not_selected": "🏗 لم تحدد مشروعًا بعد.\n\nاختر مشروعًا أولًا باستخدام /projects.", + "detach.project_not_selected": + "🏗 لم تحدد مشروعًا بعد.\n\nاختر مشروعًا أولًا باستخدام /projects.", "detach.no_active_session": "ℹ️ البوت غير متصل بأي جلسة بالفعل.", "detach.success": "✅ تم قطع الاتصال بالجلسة: {title}\n\nلم يتم إيقاف جلسة OpenCode. إذا كانت لا تزال تعمل، فستستمر بشكل منفصل. للتحقق منها لاحقًا، اخترها مجددًا باستخدام /sessions.", @@ -256,10 +277,28 @@ export const ar: I18nDictionary = { "model.menu.error": "🔴 تعذر تحميل قائمة النماذج", "model.search.button": "🔍 بحث", "model.search.prompt": "🔍 اكتب اسم النموذج للبحث عنه:", - "model.search.results_title": "نتائج البحث عن \"{query}\":", - "model.search.no_results": "لم يتم العثور على نماذج مطابقة لـ \"{query}\"", + "model.search.results_title": 'نتائج البحث عن "{query}":', + "model.search.no_results": 'لم يتم العثور على نماذج مطابقة لـ "{query}"', "model.search.search_again": "↩ البحث مرة أخرى", "model.search.error": "تعذر البحث عن النماذج", + "model.picker.button.prev_page": "⬅️ السابق", + "model.picker.button.next_page": "التالي ➡️", + "model.picker.page_indicator": "Page {current}/{total}", + + "models.select_mode": "📋 Select listing mode:", + "models.mode.all": "All configured", + "models.mode.favorites_recent": "⭐ Favorites + Recent", + "models.mode.all_header": "All configured models:", + "models.mode.favorites_recent_header": "Favorites + Recent:", + "models.unknown_mode": "Unknown listing mode.", + "models.empty": "📭 No models available.", + "models.fetch_error": "🔴 Failed to load models.", + "models.search.button": "🔍 بحث", + "models.search.clear_filter": "✕ مسح الفلتر", + "models.search.error": "🔴 فشل البحث", + "models.search.no_results": 'لم يتم العثور على نماذج تطابق "{query}"', + "models.search.prompt": "🔍 أدخل اسم النموذج أو المزوّد للتصفية:", + "models.search.results_header": 'نتائج البحث عن "{query}":', "variant.model_not_selected_callback": "خطأ: لم يتم تحديد النموذج", "variant.changed_callback": "تم تغيير الخيار إلى: {name}", @@ -288,7 +327,8 @@ export const ar: I18nDictionary = { "permission.reply.reject": "تم الرفض", "permission.send_reply_error": "❌ تعذر إرسال رد الصلاحية", "permission.blocked.expected_reply": "⚠️ أجب عن طلب الصلاحية أولًا باستخدام الأزرار أعلاه.", - "permission.blocked.command_not_allowed": "⚠️ لا يمكن استخدام هذا الأمر قبل الرد على طلب الصلاحية.", + "permission.blocked.command_not_allowed": + "⚠️ لا يمكن استخدام هذا الأمر قبل الرد على طلب الصلاحية.", "permission.header": "{emoji} طلب صلاحية: {name}\n\n", "permission.button.allow": "✅ سماح لمرة واحدة", "permission.button.always": "🔓 سماح دائم", @@ -366,22 +406,28 @@ export const ar: I18nDictionary = { "تعديل ملف/مسار: {path}\n============================================================\n\n", "runtime.wizard.ask_token": "أدخل رمز بوت Telegram الذي حصلت عليه من @BotFather.\n> ", - "runtime.wizard.ask_language": "اختر لغة الواجهة.\nأدخل رقم اللغة من القائمة أو رمز اللغة.\nاضغط Enter لاستخدام اللغة الافتراضية: {defaultLocale}\n{options}\n> ", + "runtime.wizard.ask_language": + "اختر لغة الواجهة.\nأدخل رقم اللغة من القائمة أو رمز اللغة.\nاضغط Enter لاستخدام اللغة الافتراضية: {defaultLocale}\n{options}\n> ", "runtime.wizard.language_invalid": "أدخل رقمًا من القائمة أو رمز لغة مدعومًا.\n", "runtime.wizard.language_selected": "تم اختيار اللغة: {language}\n", "runtime.wizard.token_required": "رمز البوت مطلوب. حاول مرة أخرى.\n", - "runtime.wizard.token_invalid": "صيغة رمز البوت غير صحيحة. الصيغة المتوقعة: :. حاول مرة أخرى.\n", - "runtime.wizard.ask_user_id": "أدخل معرّف حسابك في Telegram. يمكنك الحصول عليه من @userinfobot.\n> ", + "runtime.wizard.token_invalid": + "صيغة رمز البوت غير صحيحة. الصيغة المتوقعة: :. حاول مرة أخرى.\n", + "runtime.wizard.ask_user_id": + "أدخل معرّف حسابك في Telegram. يمكنك الحصول عليه من @userinfobot.\n> ", "runtime.wizard.user_id_invalid": "أدخل رقمًا صحيحًا موجبًا أكبر من صفر.\n", - "runtime.wizard.ask_api_url": "أدخل رابط OpenCode API، أو اضغط Enter لاستخدام الرابط الافتراضي: {defaultUrl}\n> ", + "runtime.wizard.ask_api_url": + "أدخل رابط OpenCode API، أو اضغط Enter لاستخدام الرابط الافتراضي: {defaultUrl}\n> ", "runtime.wizard.ask_server_username": "أدخل اسم مستخدم خادم OpenCode (اختياري).\nاضغط Enter لاستخدام القيمة الافتراضية: {defaultUsername}\n> ", "runtime.wizard.ask_server_password": "أدخل كلمة مرور خادم OpenCode (اختياري).\nاضغط Enter لتركها فارغة.\n> ", - "runtime.wizard.api_url_invalid": "أدخل رابطًا صالحًا (http/https) أو اضغط Enter لاستخدام الافتراضي.\n", + "runtime.wizard.api_url_invalid": + "أدخل رابطًا صالحًا (http/https) أو اضغط Enter لاستخدام الافتراضي.\n", "runtime.wizard.start": "إعداد OpenCode Telegram Bot.\n", "runtime.wizard.saved": "تم حفظ الإعدادات في:\n- {envPath}\n- {settingsPath}\n", - "runtime.wizard.not_configured_starting": "لم يتم إعداد التطبيق بعد. جارٍ تشغيل معالج الإعداد...\n", + "runtime.wizard.not_configured_starting": + "لم يتم إعداد التطبيق بعد. جارٍ تشغيل معالج الإعداد...\n", "runtime.wizard.tty_required": "يتطلب معالج الإعداد التفاعلي طرفية TTY. شغّل `opencode-telegram config` في shell تفاعلية.", @@ -421,8 +467,7 @@ export const ar: I18nDictionary = { "task.inactive": "⚠️ إنشاء المهمة المجدولة غير نشط. شغّل /task مرة أخرى.", "task.blocked.expected_input": "⚠️ أكمل إعداد المهمة المجدولة الحالية أولًا بإرسال نص أو استخدام الزر في رسالة الموعد.", - "task.blocked.command_not_allowed": - "⚠️ لا يمكن استخدام هذا الأمر أثناء إنشاء مهمة مجدولة.", + "task.blocked.command_not_allowed": "⚠️ لا يمكن استخدام هذا الأمر أثناء إنشاء مهمة مجدولة.", "task.limit_reached": "⚠️ وصلت إلى الحد الأقصى للمهام ({limit}). احذف مهمة مجدولة أولًا.", "task.schedule_too_frequent": "الموعد المتكرر متقارب جدًا. أقل فترة مسموحة هي مرة واحدة كل 5 دقائق.", @@ -452,8 +497,7 @@ export const ar: I18nDictionary = { "commands.no_description": "لا يوجد وصف", "commands.button.execute": "✅ تنفيذ", "commands.button.cancel": "❌ إلغاء", - "commands.confirm": - "أكد تنفيذ الأمر {command}. لتنفيذه مع وسيطات، أرسل الوسيطات كرسالة.", + "commands.confirm": "أكد تنفيذ الأمر {command}. لتنفيذه مع وسيطات، أرسل الوسيطات كرسالة.", "commands.inactive_callback": "انتهت صلاحية قائمة الأوامر", "commands.cancelled_callback": "تم الإلغاء", "commands.execute_callback": "جارٍ تنفيذ الأمر...", @@ -480,8 +524,7 @@ export const ar: I18nDictionary = { "skills.no_description": "لا يوجد وصف", "skills.button.execute": "✅ تشغيل", "skills.button.cancel": "❌ إلغاء", - "skills.confirm": - "أكد تشغيل المهارة {skill}. لتشغيلها مع وسيطات، أرسل الوسيطات كرسالة.", + "skills.confirm": "أكد تشغيل المهارة {skill}. لتشغيلها مع وسيطات، أرسل الوسيطات كرسالة.", "skills.inactive_callback": "انتهت صلاحية قائمة المهارات", "skills.cancelled_callback": "تم الإلغاء", "skills.execute_callback": "جارٍ تشغيل المهارة...", @@ -521,22 +564,67 @@ export const ar: I18nDictionary = { "stt.recognizing": "🎤 جارٍ تحويل الصوت إلى نص...", "stt.recognized": "🎤 النص المستخرج من الرسالة الصوتية:\n{text}", - "stt.not_configured": "🎤 ميزة تحويل الصوت إلى نص غير مهيأة بعد.\n\nاضبط STT_API_URL وSTT_API_KEY داخل ملف .env لتفعيلها.", + "stt.not_configured": + "🎤 ميزة تحويل الصوت إلى نص غير مهيأة بعد.\n\nاضبط STT_API_URL وSTT_API_KEY داخل ملف .env لتفعيلها.", "stt.error": "🔴 تعذر تحويل الصوت إلى نص: {error}", "stt.empty_result": "🎤 لم يتم التقاط كلام واضح في الرسالة الصوتية.", "worktree.branch_detached": "HEAD مفصول", "worktree.select_with_current": "اختر نسخة عمل (Git worktree):", - "worktree.project_not_selected": "🏗 لم تحدد مشروعًا بعد.\n\nاختر مشروعًا أولًا باستخدام /projects.", - "worktree.not_git_repo": "🌿 لا يمكن استخدام Git worktrees مع المشروع الحالي لأنه ليس مستودع Git.", + "worktree.project_not_selected": + "🏗 لم تحدد مشروعًا بعد.\n\nاختر مشروعًا أولًا باستخدام /projects.", + "worktree.not_git_repo": + "🌿 لا يمكن استخدام Git worktrees مع المشروع الحالي لأنه ليس مستودع Git.", "worktree.empty": "📭 لا توجد نسخ عمل (Git worktrees) لهذا المستودع.", "worktree.fetch_error": "🔴 تعذر تحميل نسخ العمل من Git.", "worktree.not_git_repo_callback": "المشروع الحالي ليس مستودع Git", "worktree.page_empty_callback": "لا توجد نسخ عمل في هذه الصفحة", "worktree.selection_missing_callback": "نسخة العمل المحددة لم تعد متاحة", "worktree.already_selected_callback": "نسخة العمل هذه محددة بالفعل", - "worktree.selected": "✅ تم اختيار نسخة العمل: {worktree}\n\n📋 تمت إعادة ضبط الجلسة الحالية. استخدم /sessions لاختيار جلسة أو /new للمتابعة.", + "worktree.selected": + "✅ تم اختيار نسخة العمل: {worktree}\n\n📋 تمت إعادة ضبط الجلسة الحالية. استخدم /sessions لاختيار جلسة أو /new للمتابعة.", "worktree.select_error": "🔴 تعذر اختيار نسخة العمل.", + + "worktree_add.no_project": + "🏗 Project is not selected.\n\nFirst select a project with /projects.", + "worktree_add.not_git_repo": "🌿 Current project is not a git repository.", + "worktree_add.name_required": + "⚠️ Worktree name is required.\n\nUsage: /worktree_add \nOr send the name as a message.", + "worktree_add.name_prompt": "🌿 Enter a name for the new worktree:", + "worktree_add.confirm": "🌿 Create a new worktree?\n\nName: {name}\nPath: {path}", + "worktree_add.confirm_no_path": + "🌿 Create a new worktree?\n\nName: {name}\nPath: auto (default)", + "worktree_add.button.create": "✅ Create", + "worktree_add.button.cancel": "❌ Cancel", + "worktree_add.button.switch": "🔄 Switch to it", + "worktree_add.creating": '⏳ Creating worktree "{name}"...', + "worktree_add.success": "✅ Worktree created successfully!\n\nName: {name}\nBranch: {api_branch}\nPath: {path}", + "worktree_add.error": "🔴 Failed to create worktree:\n{error}", + "worktree_add.error_generic": "🔴 An error occurred while creating the worktree.", + "worktree_add.cancelled": "❌ Worktree creation cancelled.", + "worktree_add.switched": "✅ Switched to new worktree: {path}", + "worktree_add.inactive": "⚠️ Worktree creation is not active. Run /worktree_add again.", + "worktree_add.inactive_callback": "This worktree creation flow is inactive", + "worktree_add.blocked.expected_input": "⚠️ Send the worktree name as a text message or tap Cancel.", + "worktree_add.blocked.command_not_allowed": + "⚠️ This command is not available while worktree creation is active.", + "worktree_add.fetch_error": "🔴 Failed to load worktrees after creation.", + + "worktree.delete.confirmation": + "🗑️ Delete worktree?\n\nPath: {path}\nName: {name}\n\nThis action cannot be undone.", + "worktree.delete.button.yes": "✅ Yes, Delete", + "worktree.delete.button.no": "❌ Cancel", + "worktree.delete.success": "✅ Worktree deleted successfully: {path}", + "worktree.delete.error": "🔴 Failed to delete worktree: {error}", + "worktree.delete.error_generic": "🔴 An error occurred while deleting the worktree.", + "worktree.delete.cancelled": "❌ Worktree deletion cancelled.", + "worktree.delete.inactive": "⚠️ Worktree deletion is not active. Run /worktree delete again.", + "worktree.delete.inactive_callback": "This worktree deletion flow is inactive", + "worktree.delete.blocked.expected_input": + "⚠️ This worktree deletion has been cancelled. Use /worktree delete to try again.", + "worktree.delete.blocked.command_not_allowed": + "⚠️ This command is not available while worktree deletion is active.", + "open.back": "⬆️ مجلد سابق", "open.roots": "📋 العودة إلى المسارات الرئيسية", "open.prev_page": "⬅️ السابق", @@ -546,7 +634,8 @@ export const ar: I18nDictionary = { "open.access_denied": "⛔ لا يمكن الوصول إلى هذا المسار لأنه خارج المجلدات المسموح بها", "open.scan_error": "🔴 تعذر استعراض المجلد: {error}", "open.open_error": "🔴 تعذر فتح مستعرض المجلدات.", - "open.selected": "✅ تمت إضافة المشروع: {project}\n\n📋 استخدم /sessions لاختيار جلسة أو /new لبدء العمل.", + "open.selected": + "✅ تمت إضافة المشروع: {project}\n\n📋 استخدم /sessions لاختيار جلسة أو /new لبدء العمل.", "open.select_error": "🔴 تعذر إضافة المشروع.", "open.no_subfolders": "📭 لا توجد مجلدات فرعية", "open.subfolder_count": "{count} مجلد فرعي", diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 45026b24..1fe9a2a9 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -308,6 +308,24 @@ export const de: I18nDictionary = { "model.search.no_results": 'Keine Modelle gefunden für "{query}"', "model.search.search_again": "↩ Erneut suchen", "model.search.error": "Suche fehlgeschlagen", + "model.picker.button.prev_page": "⬅️ Zurück", + "model.picker.button.next_page": "Weiter ➡️", + "model.picker.page_indicator": "Page {current}/{total}", + + "models.select_mode": "📋 Select listing mode:", + "models.mode.all": "All configured", + "models.mode.favorites_recent": "⭐ Favorites + Recent", + "models.mode.all_header": "All configured models:", + "models.mode.favorites_recent_header": "Favorites + Recent:", + "models.unknown_mode": "Unknown listing mode.", + "models.empty": "📭 No models available.", + "models.fetch_error": "🔴 Failed to load models.", + "models.search.button": "🔍 Suche", + "models.search.clear_filter": "✕ Filter löschen", + "models.search.error": "🔴 Suche fehlgeschlagen", + "models.search.no_results": 'Keine Modelle gefunden für "{query}"', + "models.search.prompt": "🔍 Modellnamen oder Anbieter zum Filtern eingeben:", + "models.search.results_header": 'Suchergebnisse für "{query}":', "variant.model_not_selected_callback": "Fehler: Modell ist nicht ausgewählt", "variant.changed_callback": "Variante geändert: {name}", @@ -577,6 +595,7 @@ export const de: I18nDictionary = { "mcps.auth_required": "This server requires authorization and cannot be enabled from the bot.", "cmd.description.rename": "Aktuelle Sitzung umbenennen", + "cmd.description.worktree_add": "Create a new git worktree", "legacy.models.fetch_error": "🔴 Modellliste konnte nicht geladen werden. Prüfe den Serverstatus mit /status.", @@ -609,6 +628,47 @@ export const de: I18nDictionary = { "worktree.selected": "✅ Worktree ausgewählt: {worktree}\n\n📋 Die Sitzung wurde zurückgesetzt. Nutze /sessions oder /new, um fortzufahren.", "worktree.select_error": "🔴 Worktree konnte nicht ausgewählt werden.", + + "worktree_add.no_project": + "🏗 Project is not selected.\n\nFirst select a project with /projects.", + "worktree_add.not_git_repo": "🌿 Current project is not a git repository.", + "worktree_add.name_required": + "⚠️ Worktree name is required.\n\nUsage: /worktree_add \nOr send the name as a message.", + "worktree_add.name_prompt": "🌿 Enter a name for the new worktree:", + "worktree_add.confirm": "🌿 Create a new worktree?\n\nName: {name}\nPath: {path}", + "worktree_add.confirm_no_path": + "🌿 Create a new worktree?\n\nName: {name}\nPath: auto (default)", + "worktree_add.button.create": "✅ Create", + "worktree_add.button.cancel": "❌ Cancel", + "worktree_add.button.switch": "🔄 Switch to it", + "worktree_add.creating": '⏳ Creating worktree "{name}"...', + "worktree_add.success": "✅ Worktree created successfully!\n\nName: {name}\nBranch: {api_branch}\nPath: {path}", + "worktree_add.error": "🔴 Failed to create worktree:\n{error}", + "worktree_add.error_generic": "🔴 An error occurred while creating the worktree.", + "worktree_add.cancelled": "❌ Worktree creation cancelled.", + "worktree_add.switched": "✅ Switched to new worktree: {path}", + "worktree_add.inactive": "⚠️ Worktree creation is not active. Run /worktree_add again.", + "worktree_add.inactive_callback": "This worktree creation flow is inactive", + "worktree_add.blocked.expected_input": "⚠️ Send the worktree name as a text message or tap Cancel.", + "worktree_add.blocked.command_not_allowed": + "⚠️ This command is not available while worktree creation is active.", + "worktree_add.fetch_error": "🔴 Failed to load worktrees after creation.", + + "worktree.delete.confirmation": + "🗑️ Delete worktree?\n\nPath: {path}\nName: {name}\n\nThis action cannot be undone.", + "worktree.delete.button.yes": "✅ Yes, Delete", + "worktree.delete.button.no": "❌ Cancel", + "worktree.delete.success": "✅ Worktree deleted successfully: {path}", + "worktree.delete.error": "🔴 Failed to delete worktree: {error}", + "worktree.delete.error_generic": "🔴 An error occurred while deleting the worktree.", + "worktree.delete.cancelled": "❌ Worktree deletion cancelled.", + "worktree.delete.inactive": "⚠️ Worktree deletion is not active. Run /worktree delete again.", + "worktree.delete.inactive_callback": "This worktree deletion flow is inactive", + "worktree.delete.blocked.expected_input": + "⚠️ This worktree deletion has been cancelled. Use /worktree delete to try again.", + "worktree.delete.blocked.command_not_allowed": + "⚠️ This command is not available while worktree deletion is active.", + "open.back": "⬆️ Hoch", "open.roots": "📋 Zurück zur Auswahl", "open.prev_page": "⬅️ Zurück", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 87f12f66..abf6f7f6 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -7,7 +7,7 @@ export const en = { "cmd.description.messages": "Browse session messages", "cmd.description.tts": "Choose audio reply mode", "cmd.description.projects": "List projects", - "cmd.description.worktree": "Switch git worktrees", + "cmd.description.worktree": "Manage worktrees (add, list, delete, switch, help)", "cmd.description.task": "Create a scheduled task", "cmd.description.tasklist": "List scheduled tasks", "cmd.description.commands": "Custom commands", @@ -291,6 +291,24 @@ export const en = { "model.search.no_results": 'No models found for "{query}"', "model.search.search_again": "↩ Search again", "model.search.error": "Search failed", + "model.picker.button.prev_page": "⬅️ Prev", + "model.picker.button.next_page": "Next ➡️", + "model.picker.page_indicator": "Page {current}/{total}", + + "models.select_mode": "📋 Select listing mode:", + "models.mode.all": "All configured", + "models.mode.favorites_recent": "⭐ Favorites + Recent", + "models.mode.all_header": "All configured models:", + "models.mode.favorites_recent_header": "Favorites + Recent:", + "models.unknown_mode": "Unknown listing mode.", + "models.empty": "📭 No models available.", + "models.fetch_error": "🔴 Failed to load models.", + "models.search.button": "🔍 Search", + "models.search.clear_filter": "✕ Clear filter", + "models.search.error": "🔴 Search failed", + "models.search.no_results": 'No models found matching "{query}"', + "models.search.prompt": "🔍 Enter model name or provider to filter:", + "models.search.results_header": 'Search results for "{query}":', "variant.model_not_selected_callback": "Error: model is not selected", "variant.changed_callback": "Variant changed: {name}", @@ -554,6 +572,7 @@ export const en = { "mcps.auth_required": "This server requires authorization and cannot be enabled from the bot.", "cmd.description.rename": "Rename current session", + "cmd.description.worktree_add": "Create a new worktree in the current project", "legacy.models.fetch_error": "🔴 Failed to get models list. Check server status with /status.", "legacy.models.empty": "📋 No available models. Configure providers in OpenCode.", @@ -585,6 +604,47 @@ export const en = { "worktree.selected": "✅ Worktree selected: {worktree}\n\n📋 Session was reset. Use /sessions or /new to continue.", "worktree.select_error": "🔴 Failed to select worktree.", + + "worktree_add.no_project": + "🏗 Project is not selected.\n\nFirst select a project with /projects.", + "worktree_add.not_git_repo": "🌿 Current project is not a git repository.", + "worktree_add.name_required": + "⚠️ Worktree name is required.\n\nUsage: /worktree_add \nOr send the name as a message.", + "worktree_add.name_prompt": "🌿 Enter a name for the new worktree:", + "worktree_add.confirm": "🌿 Create a new worktree?\n\nName: {name}\nPath: {path}", + "worktree_add.confirm_no_path": + "🌿 Create a new worktree?\n\nName: {name}\nPath: auto (default)", + "worktree_add.button.create": "✅ Create", + "worktree_add.button.cancel": "❌ Cancel", + "worktree_add.button.switch": "🔄 Switch to it", + "worktree_add.creating": '⏳ Creating worktree "{name}"...', + "worktree_add.success": "✅ Worktree created successfully!\n\nName: {name}\nBranch: {api_branch}\nPath: {path}", + "worktree_add.error": "🔴 Failed to create worktree:\n{error}", + "worktree_add.error_generic": "🔴 An error occurred while creating the worktree.", + "worktree_add.cancelled": "❌ Worktree creation cancelled.", + "worktree_add.switched": "✅ Switched to new worktree: {path}", + "worktree_add.inactive": "⚠️ Worktree creation is not active. Run /worktree_add again.", + "worktree_add.inactive_callback": "This worktree creation flow is inactive", + "worktree_add.blocked.expected_input": "⚠️ Send the worktree name as a text message or tap Cancel.", + "worktree_add.blocked.command_not_allowed": + "⚠️ This command is not available while worktree creation is active.", + "worktree_add.fetch_error": "🔴 Failed to load worktrees after creation.", + + "worktree.delete.confirmation": + "🗑️ Delete worktree?\n\nPath: {path}\nName: {name}\n\nThis action cannot be undone.", + "worktree.delete.button.yes": "✅ Yes, Delete", + "worktree.delete.button.no": "❌ Cancel", + "worktree.delete.success": "✅ Worktree deleted successfully: {path}", + "worktree.delete.error": "🔴 Failed to delete worktree: {error}", + "worktree.delete.error_generic": "🔴 An error occurred while deleting the worktree.", + "worktree.delete.cancelled": "❌ Worktree deletion cancelled.", + "worktree.delete.inactive": "⚠️ Worktree deletion is not active. Run /worktree delete again.", + "worktree.delete.inactive_callback": "This worktree deletion flow is inactive", + "worktree.delete.blocked.expected_input": + "⚠️ This worktree deletion has been cancelled. Use /worktree delete to try again.", + "worktree.delete.blocked.command_not_allowed": + "⚠️ This command is not available while worktree deletion is active.", + "open.back": "⬆️ Up", "open.roots": "📋 Back to roots", "open.prev_page": "⬅️ Previous", diff --git a/src/i18n/es.ts b/src/i18n/es.ts index dda2e3e1..65296730 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -305,6 +305,24 @@ export const es: I18nDictionary = { "model.search.no_results": 'No se encontraron modelos para "{query}"', "model.search.search_again": "↩ Buscar de nuevo", "model.search.error": "Búsqueda fallida", + "model.picker.button.prev_page": "⬅️ Anterior", + "model.picker.button.next_page": "Siguiente ➡️", + "model.picker.page_indicator": "Page {current}/{total}", + + "models.select_mode": "📋 Select listing mode:", + "models.mode.all": "All configured", + "models.mode.favorites_recent": "⭐ Favorites + Recent", + "models.mode.all_header": "All configured models:", + "models.mode.favorites_recent_header": "Favorites + Recent:", + "models.unknown_mode": "Unknown listing mode.", + "models.empty": "📭 No models available.", + "models.fetch_error": "🔴 Failed to load models.", + "models.search.button": "🔍 Buscar", + "models.search.clear_filter": "✕ Limpiar filtro", + "models.search.error": "🔴 Búsqueda fallida", + "models.search.no_results": 'No se encontraron modelos para "{query}"', + "models.search.prompt": "🔍 Introduce el nombre del modelo o proveedor para filtrar:", + "models.search.results_header": 'Resultados de búsqueda para "{query}":', "variant.model_not_selected_callback": "Error: no hay un modelo seleccionado", "variant.changed_callback": "Variante cambiada: {name}", @@ -575,6 +593,7 @@ export const es: I18nDictionary = { "mcps.auth_required": "This server requires authorization and cannot be enabled from the bot.", "cmd.description.rename": "Renombrar la sesión actual", + "cmd.description.worktree_add": "Create a new git worktree", "legacy.models.fetch_error": "🔴 No se pudo obtener la lista de modelos. Revisa el estado del servidor con /status.", @@ -607,6 +626,47 @@ export const es: I18nDictionary = { "worktree.selected": "✅ Worktree seleccionado: {worktree}\n\n📋 La sesión se reinició. Usa /sessions o /new para continuar.", "worktree.select_error": "🔴 No se pudo seleccionar el worktree.", + + "worktree_add.no_project": + "🏗 Project is not selected.\n\nFirst select a project with /projects.", + "worktree_add.not_git_repo": "🌿 Current project is not a git repository.", + "worktree_add.name_required": + "⚠️ Worktree name is required.\n\nUsage: /worktree_add \nOr send the name as a message.", + "worktree_add.name_prompt": "🌿 Enter a name for the new worktree:", + "worktree_add.confirm": "🌿 Create a new worktree?\n\nName: {name}\nPath: {path}", + "worktree_add.confirm_no_path": + "🌿 Create a new worktree?\n\nName: {name}\nPath: auto (default)", + "worktree_add.button.create": "✅ Create", + "worktree_add.button.cancel": "❌ Cancel", + "worktree_add.button.switch": "🔄 Switch to it", + "worktree_add.creating": '⏳ Creating worktree "{name}"...', + "worktree_add.success": "✅ Worktree created successfully!\n\nName: {name}\nBranch: {api_branch}\nPath: {path}", + "worktree_add.error": "🔴 Failed to create worktree:\n{error}", + "worktree_add.error_generic": "🔴 An error occurred while creating the worktree.", + "worktree_add.cancelled": "❌ Worktree creation cancelled.", + "worktree_add.switched": "✅ Switched to new worktree: {path}", + "worktree_add.inactive": "⚠️ Worktree creation is not active. Run /worktree_add again.", + "worktree_add.inactive_callback": "This worktree creation flow is inactive", + "worktree_add.blocked.expected_input": "⚠️ Send the worktree name as a text message or tap Cancel.", + "worktree_add.blocked.command_not_allowed": + "⚠️ This command is not available while worktree creation is active.", + "worktree_add.fetch_error": "🔴 Failed to load worktrees after creation.", + + "worktree.delete.confirmation": + "🗑️ Delete worktree?\n\nPath: {path}\nName: {name}\n\nThis action cannot be undone.", + "worktree.delete.button.yes": "✅ Yes, Delete", + "worktree.delete.button.no": "❌ Cancel", + "worktree.delete.success": "✅ Worktree deleted successfully: {path}", + "worktree.delete.error": "🔴 Failed to delete worktree: {error}", + "worktree.delete.error_generic": "🔴 An error occurred while deleting the worktree.", + "worktree.delete.cancelled": "❌ Worktree deletion cancelled.", + "worktree.delete.inactive": "⚠️ Worktree deletion is not active. Run /worktree delete again.", + "worktree.delete.inactive_callback": "This worktree deletion flow is inactive", + "worktree.delete.blocked.expected_input": + "⚠️ This worktree deletion has been cancelled. Use /worktree delete to try again.", + "worktree.delete.blocked.command_not_allowed": + "⚠️ This command is not available while worktree deletion is active.", + "open.back": "⬆️ Subir", "open.roots": "📋 Volver a raíces", "open.prev_page": "⬅️ Anterior", diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts index a08a56b8..f0774148 100644 --- a/src/i18n/fr.ts +++ b/src/i18n/fr.ts @@ -309,6 +309,24 @@ export const fr: I18nDictionary = { "model.search.no_results": 'Aucun modèle trouvé pour "{query}"', "model.search.search_again": "↩ Rechercher à nouveau", "model.search.error": "Échec de la recherche", + "model.picker.button.prev_page": "⬅️ Précédent", + "model.picker.button.next_page": "Suivant ➡️", + "model.picker.page_indicator": "Page {current}/{total}", + + "models.select_mode": "📋 Select listing mode:", + "models.mode.all": "All configured", + "models.mode.favorites_recent": "⭐ Favorites + Recent", + "models.mode.all_header": "All configured models:", + "models.mode.favorites_recent_header": "Favorites + Recent:", + "models.unknown_mode": "Unknown listing mode.", + "models.empty": "📭 No models available.", + "models.fetch_error": "🔴 Failed to load models.", + "models.search.button": "🔍 Rechercher", + "models.search.clear_filter": "✕ Effacer le filtre", + "models.search.error": "🔴 Recherche échouée", + "models.search.no_results": 'Aucun modèle trouvé pour "{query}"', + "models.search.prompt": "🔍 Saisissez le nom du modèle ou du fournisseur pour filtrer :", + "models.search.results_header": 'Résultats de recherche pour "{query}" :', "variant.model_not_selected_callback": "Erreur : aucun modèle sélectionné", "variant.changed_callback": "Variante modifiée : {name}", @@ -577,6 +595,7 @@ export const fr: I18nDictionary = { "mcps.auth_required": "This server requires authorization and cannot be enabled from the bot.", "cmd.description.rename": "Renommer la session actuelle", + "cmd.description.worktree_add": "Create a new git worktree", "legacy.models.fetch_error": "🔴 Impossible de récupérer la liste des modèles. Vérifiez l'état du serveur avec /status.", @@ -609,6 +628,47 @@ export const fr: I18nDictionary = { "worktree.selected": "✅ Worktree sélectionné : {worktree}\n\n📋 La session a été réinitialisée. Utilisez /sessions ou /new pour continuer.", "worktree.select_error": "🔴 Impossible de sélectionner le worktree.", + + "worktree_add.no_project": + "🏗 Project is not selected.\n\nFirst select a project with /projects.", + "worktree_add.not_git_repo": "🌿 Current project is not a git repository.", + "worktree_add.name_required": + "⚠️ Worktree name is required.\n\nUsage: /worktree_add \nOr send the name as a message.", + "worktree_add.name_prompt": "🌿 Enter a name for the new worktree:", + "worktree_add.confirm": "🌿 Create a new worktree?\n\nName: {name}\nPath: {path}", + "worktree_add.confirm_no_path": + "🌿 Create a new worktree?\n\nName: {name}\nPath: auto (default)", + "worktree_add.button.create": "✅ Create", + "worktree_add.button.cancel": "❌ Cancel", + "worktree_add.button.switch": "🔄 Switch to it", + "worktree_add.creating": '⏳ Creating worktree "{name}"...', + "worktree_add.success": "✅ Worktree created successfully!\n\nName: {name}\nBranch: {api_branch}\nPath: {path}", + "worktree_add.error": "🔴 Failed to create worktree:\n{error}", + "worktree_add.error_generic": "🔴 An error occurred while creating the worktree.", + "worktree_add.cancelled": "❌ Worktree creation cancelled.", + "worktree_add.switched": "✅ Switched to new worktree: {path}", + "worktree_add.inactive": "⚠️ Worktree creation is not active. Run /worktree_add again.", + "worktree_add.inactive_callback": "This worktree creation flow is inactive", + "worktree_add.blocked.expected_input": "⚠️ Send the worktree name as a text message or tap Cancel.", + "worktree_add.blocked.command_not_allowed": + "⚠️ This command is not available while worktree creation is active.", + "worktree_add.fetch_error": "🔴 Failed to load worktrees after creation.", + + "worktree.delete.confirmation": + "🗑️ Delete worktree?\n\nPath: {path}\nName: {name}\n\nThis action cannot be undone.", + "worktree.delete.button.yes": "✅ Yes, Delete", + "worktree.delete.button.no": "❌ Cancel", + "worktree.delete.success": "✅ Worktree deleted successfully: {path}", + "worktree.delete.error": "🔴 Failed to delete worktree: {error}", + "worktree.delete.error_generic": "🔴 An error occurred while deleting the worktree.", + "worktree.delete.cancelled": "❌ Worktree deletion cancelled.", + "worktree.delete.inactive": "⚠️ Worktree deletion is not active. Run /worktree delete again.", + "worktree.delete.inactive_callback": "This worktree deletion flow is inactive", + "worktree.delete.blocked.expected_input": + "⚠️ This worktree deletion has been cancelled. Use /worktree delete to try again.", + "worktree.delete.blocked.command_not_allowed": + "⚠️ This command is not available while worktree deletion is active.", + "open.back": "⬆️ Remonter", "open.roots": "📋 Retour aux racines", "open.prev_page": "⬅️ Précédent", diff --git a/src/i18n/ru.ts b/src/i18n/ru.ts index 8ddc3101..9b5bd615 100644 --- a/src/i18n/ru.ts +++ b/src/i18n/ru.ts @@ -294,6 +294,24 @@ export const ru: I18nDictionary = { "model.search.no_results": 'Модели не найдены для "{query}"', "model.search.search_again": "↩ Искать снова", "model.search.error": "Ошибка поиска", + "model.picker.button.prev_page": "⬅️ Назад", + "model.picker.button.next_page": "Вперед ➡️", + "model.picker.page_indicator": "Page {current}/{total}", + + "models.select_mode": "📋 Select listing mode:", + "models.mode.all": "All configured", + "models.mode.favorites_recent": "⭐ Favorites + Recent", + "models.mode.all_header": "All configured models:", + "models.mode.favorites_recent_header": "Favorites + Recent:", + "models.unknown_mode": "Unknown listing mode.", + "models.empty": "📭 No models available.", + "models.fetch_error": "🔴 Failed to load models.", + "models.search.button": "🔍 Поиск", + "models.search.clear_filter": "✕ Очистить фильтр", + "models.search.error": "🔴 Поиск не удался", + "models.search.no_results": 'Модели по запросу "{query}" не найдены', + "models.search.prompt": "🔍 Введите имя модели или провайдера для фильтрации:", + "models.search.results_header": 'Результаты поиска по "{query}":', "variant.model_not_selected_callback": "Ошибка: модель не выбрана", "variant.changed_callback": "Вариант изменен: {name}", @@ -561,6 +579,7 @@ export const ru: I18nDictionary = { "mcps.auth_required": "Этот сервер требует авторизации и не может быть включен из бота.", "cmd.description.rename": "Переименовать текущую сессию", + "cmd.description.worktree_add": "Create a new git worktree", "legacy.models.fetch_error": "🔴 Не удалось получить список моделей. Проверьте статус сервера /status.", @@ -593,6 +612,47 @@ export const ru: I18nDictionary = { "worktree.selected": "✅ Выбран worktree: {worktree}\n\n📋 Сессия была сброшена. Используйте /sessions или /new для продолжения.", "worktree.select_error": "🔴 Не удалось выбрать worktree.", + + "worktree_add.no_project": + "🏗 Project is not selected.\n\nFirst select a project with /projects.", + "worktree_add.not_git_repo": "🌿 Current project is not a git repository.", + "worktree_add.name_required": + "⚠️ Worktree name is required.\n\nUsage: /worktree_add \nOr send the name as a message.", + "worktree_add.name_prompt": "🌿 Enter a name for the new worktree:", + "worktree_add.confirm": "🌿 Create a new worktree?\n\nName: {name}\nPath: {path}", + "worktree_add.confirm_no_path": + "🌿 Create a new worktree?\n\nName: {name}\nPath: auto (default)", + "worktree_add.button.create": "✅ Create", + "worktree_add.button.cancel": "❌ Cancel", + "worktree_add.button.switch": "🔄 Switch to it", + "worktree_add.creating": '⏳ Creating worktree "{name}"...', + "worktree_add.success": "✅ Worktree created successfully!\n\nName: {name}\nBranch: {api_branch}\nPath: {path}", + "worktree_add.error": "🔴 Failed to create worktree:\n{error}", + "worktree_add.error_generic": "🔴 An error occurred while creating the worktree.", + "worktree_add.cancelled": "❌ Worktree creation cancelled.", + "worktree_add.switched": "✅ Switched to new worktree: {path}", + "worktree_add.inactive": "⚠️ Worktree creation is not active. Run /worktree_add again.", + "worktree_add.inactive_callback": "This worktree creation flow is inactive", + "worktree_add.blocked.expected_input": "⚠️ Send the worktree name as a text message or tap Cancel.", + "worktree_add.blocked.command_not_allowed": + "⚠️ This command is not available while worktree creation is active.", + "worktree_add.fetch_error": "🔴 Failed to load worktrees after creation.", + + "worktree.delete.confirmation": + "🗑️ Delete worktree?\n\nPath: {path}\nName: {name}\n\nThis action cannot be undone.", + "worktree.delete.button.yes": "✅ Yes, Delete", + "worktree.delete.button.no": "❌ Cancel", + "worktree.delete.success": "✅ Worktree deleted successfully: {path}", + "worktree.delete.error": "🔴 Failed to delete worktree: {error}", + "worktree.delete.error_generic": "🔴 An error occurred while deleting the worktree.", + "worktree.delete.cancelled": "❌ Worktree deletion cancelled.", + "worktree.delete.inactive": "⚠️ Worktree deletion is not active. Run /worktree delete again.", + "worktree.delete.inactive_callback": "This worktree deletion flow is inactive", + "worktree.delete.blocked.expected_input": + "⚠️ This worktree deletion has been cancelled. Use /worktree delete to try again.", + "worktree.delete.blocked.command_not_allowed": + "⚠️ This command is not available while worktree deletion is active.", + "open.back": "⬆️ Наверх", "open.roots": "📋 К списку корней", "open.prev_page": "⬅️ Назад", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 7d59c586..d2126d7c 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -10,6 +10,7 @@ export const zh: I18nDictionary = { "cmd.description.tts": "选择语音回复模式", "cmd.description.projects": "列出项目", "cmd.description.worktree": "切换 git worktree", + "cmd.description.worktree_add": "Create a new git worktree", "cmd.description.task": "创建定时任务", "cmd.description.tasklist": "查看定时任务", "cmd.description.commands": "自定义命令", @@ -259,6 +260,24 @@ export const zh: I18nDictionary = { "model.search.no_results": '未找到 "{query}" 的模型', "model.search.search_again": "↩ 重新搜索", "model.search.error": "搜索失败", + "model.picker.button.prev_page": "⬅️ 上一页", + "model.picker.button.next_page": "下一页 ➡️", + "model.picker.page_indicator": "Page {current}/{total}", + + "models.select_mode": "📋 Select listing mode:", + "models.mode.all": "All configured", + "models.mode.favorites_recent": "⭐ Favorites + Recent", + "models.mode.all_header": "All configured models:", + "models.mode.favorites_recent_header": "Favorites + Recent:", + "models.unknown_mode": "Unknown listing mode.", + "models.empty": "📭 No models available.", + "models.fetch_error": "🔴 Failed to load models.", + "models.search.button": "🔍 搜索", + "models.search.clear_filter": "✕ 清除筛选", + "models.search.error": "🔴 搜索失败", + "models.search.no_results": '未找到匹配 "{query}" 的模型', + "models.search.prompt": "🔍 输入模型名称或提供商进行筛选:", + "models.search.results_header": '搜索 "{query}" 的结果:', "variant.model_not_selected_callback": "错误:未选择模型", "variant.changed_callback": "变体已更改:{name}", @@ -535,6 +554,47 @@ export const zh: I18nDictionary = { "worktree.selected": "✅ 已选择 worktree:{worktree}\n\n📋 会话已重置。请使用 /sessions 或 /new 继续。", "worktree.select_error": "🔴 选择 worktree 失败。", + + "worktree_add.no_project": + "🏗 Project is not selected.\n\nFirst select a project with /projects.", + "worktree_add.not_git_repo": "🌿 Current project is not a git repository.", + "worktree_add.name_required": + "⚠️ Worktree name is required.\n\nUsage: /worktree_add \nOr send the name as a message.", + "worktree_add.name_prompt": "🌿 Enter a name for the new worktree:", + "worktree_add.confirm": "🌿 Create a new worktree?\n\nName: {name}\nPath: {path}", + "worktree_add.confirm_no_path": + "🌿 Create a new worktree?\n\nName: {name}\nPath: auto (default)", + "worktree_add.button.create": "✅ Create", + "worktree_add.button.cancel": "❌ Cancel", + "worktree_add.button.switch": "🔄 Switch to it", + "worktree_add.creating": '⏳ Creating worktree "{name}"...', + "worktree_add.success": "✅ Worktree created successfully!\n\nName: {name}\nBranch: {api_branch}\nPath: {path}", + "worktree_add.error": "🔴 Failed to create worktree:\n{error}", + "worktree_add.error_generic": "🔴 An error occurred while creating the worktree.", + "worktree_add.cancelled": "❌ Worktree creation cancelled.", + "worktree_add.switched": "✅ Switched to new worktree: {path}", + "worktree_add.inactive": "⚠️ Worktree creation is not active. Run /worktree_add again.", + "worktree_add.inactive_callback": "This worktree creation flow is inactive", + "worktree_add.blocked.expected_input": "⚠️ Send the worktree name as a text message or tap Cancel.", + "worktree_add.blocked.command_not_allowed": + "⚠️ This command is not available while worktree creation is active.", + "worktree_add.fetch_error": "🔴 Failed to load worktrees after creation.", + + "worktree.delete.confirmation": + "🗑️ Delete worktree?\n\nPath: {path}\nName: {name}\n\nThis action cannot be undone.", + "worktree.delete.button.yes": "✅ Yes, Delete", + "worktree.delete.button.no": "❌ Cancel", + "worktree.delete.success": "✅ Worktree deleted successfully: {path}", + "worktree.delete.error": "🔴 Failed to delete worktree: {error}", + "worktree.delete.error_generic": "🔴 An error occurred while deleting the worktree.", + "worktree.delete.cancelled": "❌ Worktree deletion cancelled.", + "worktree.delete.inactive": "⚠️ Worktree deletion is not active. Run /worktree delete again.", + "worktree.delete.inactive_callback": "This worktree deletion flow is inactive", + "worktree.delete.blocked.expected_input": + "⚠️ This worktree deletion has been cancelled. Use /worktree delete to try again.", + "worktree.delete.blocked.command_not_allowed": + "⚠️ This command is not available while worktree deletion is active.", + "open.back": "⬆️ 上级", "open.roots": "📋 返回根目录", "open.prev_page": "⬅️ 上一页", diff --git a/tests/app/services/worktree-service.test.ts b/tests/app/services/worktree-service.test.ts index 79702ec6..ad148d20 100644 --- a/tests/app/services/worktree-service.test.ts +++ b/tests/app/services/worktree-service.test.ts @@ -5,6 +5,15 @@ const mocked = vi.hoisted(() => ({ execFileMock: vi.fn(), statMock: vi.fn(), readFileMock: vi.fn(), + fetchMock: vi.fn(), + opencodeConfig: { + apiUrl: "http://localhost:4096", + username: "opencode", + password: "", + autoRestartEnabled: false, + monitorIntervalSec: 300, + model: { provider: "test", modelId: "test" }, + }, })); vi.mock("node:child_process", () => ({ @@ -16,13 +25,37 @@ vi.mock("node:fs/promises", () => ({ readFile: mocked.readFileMock, })); -import { getGitWorktreeContext, resolveGitDir } from "../../../src/app/services/worktree-service.js"; +vi.mock("../../../src/config.js", () => ({ + config: { + opencode: mocked.opencodeConfig, + telegram: { token: "test", allowedUserId: 0, proxyUrl: "" }, + server: { logLevel: "error" }, + bot: { + sessionsListLimit: 10, + projectsListLimit: 10, + locale: "en", + hideThinkingMessages: false, + hideToolCallMessages: false, + }, + files: { maxFileSizeKb: 100 }, + }, +})); + +import { + createGitWorktree, + getGitWorktreeContext, + resolveGitDir, +} from "../../../src/app/services/worktree-service.js"; describe("app/services/worktree-service", () => { beforeEach(() => { mocked.execFileMock.mockReset(); mocked.statMock.mockReset(); mocked.readFileMock.mockReset(); + mocked.fetchMock.mockReset(); + vi.stubGlobal("fetch", mocked.fetchMock); + mocked.opencodeConfig.apiUrl = "http://localhost:4096"; + mocked.opencodeConfig.password = ""; }); it("returns null when .git metadata is missing", async () => { @@ -116,4 +149,157 @@ describe("app/services/worktree-service", () => { ], }); }); + + describe("createGitWorktree", () => { + it("on success returns the path and apiBranch", async () => { + mocked.fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + name: "my-feature", + branch: "opencode/my-feature", + directory: "/repo/my-feature", + }), + text: async () => "", + }); + + const result = await createGitWorktree("my-feature"); + expect(result).toEqual({ + path: "/repo/my-feature", + apiBranch: "opencode/my-feature", + }); + }); + + it("on success returns the path when called without a name", async () => { + mocked.fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + name: "auto-slug", + branch: "opencode/auto-slug", + directory: "/repo/auto-slug", + }), + text: async () => "", + }); + + const result = await createGitWorktree(); + expect(result).toEqual({ + path: "/repo/auto-slug", + apiBranch: "opencode/auto-slug", + }); + }); + + it("uses correct URL without query string", async () => { + mocked.fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + name: "my-feature", + branch: "opencode/my-feature", + directory: "/repo/my-feature", + }), + text: async () => "", + }); + + await createGitWorktree("my-feature"); + const callArgs = mocked.fetchMock.mock.calls[0]; + expect(callArgs[0]).toBe(`${mocked.opencodeConfig.apiUrl}/experimental/worktree`); + }); + + it("sends name in request body when provided", async () => { + mocked.fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + name: "my-feature", + branch: "opencode/my-feature", + directory: "/repo/my-feature", + }), + text: async () => "", + }); + + await createGitWorktree("my-feature"); + const callArgs = mocked.fetchMock.mock.calls[0]; + const body = JSON.parse(callArgs[1].body); + expect(body).toEqual({ name: "my-feature" }); + }); + + it("sends empty body when called without a name", async () => { + mocked.fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + name: "auto-slug", + branch: "opencode/auto-slug", + directory: "/repo/auto-slug", + }), + text: async () => "", + }); + + await createGitWorktree(); + const callArgs = mocked.fetchMock.mock.calls[0]; + const body = JSON.parse(callArgs[1].body); + expect(body).toEqual({}); + }); + + it("sends Basic auth header when password is set", async () => { + mocked.opencodeConfig.password = "secret"; + mocked.fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + name: "my-feature", + branch: "opencode/my-feature", + directory: "/repo/my-feature", + }), + text: async () => "", + }); + + await createGitWorktree("my-feature"); + const callArgs = mocked.fetchMock.mock.calls[0]; + const expectedAuth = `Basic ${Buffer.from("opencode:secret").toString("base64")}`; + expect(callArgs[1].headers["Authorization"]).toBe(expectedAuth); + }); + + it("omits Authorization header when password is empty", async () => { + mocked.opencodeConfig.password = ""; + mocked.fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + name: "my-feature", + branch: "opencode/my-feature", + directory: "/repo/my-feature", + }), + text: async () => "", + }); + + await createGitWorktree("my-feature"); + const callArgs = mocked.fetchMock.mock.calls[0]; + expect(callArgs[1].headers["Authorization"]).toBeUndefined(); + }); + + it("returns error when HTTP response is not ok", async () => { + mocked.fetchMock.mockResolvedValue({ + ok: false, + status: 500, + statusText: "Internal Server Error", + text: async () => "", + }); + + const result = await createGitWorktree("my-feature"); + expect(result.error).toContain("500"); + }); + + it("returns error when API returns empty directory", async () => { + mocked.fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({}), + text: async () => "", + }); + + const result = await createGitWorktree("my-feature"); + expect(result.error).toBe("API returned an empty worktree path"); + }); + + it("returns error on network failure", async () => { + mocked.fetchMock.mockRejectedValue(new Error("fetch failed")); + + const result = await createGitWorktree("my-feature"); + expect(result.error).toBe("fetch failed"); + }); + }); }); diff --git a/tests/bot/commands/worktree.test.ts b/tests/bot/commands/worktree.test.ts index f1fdbde7..9c658a5c 100644 --- a/tests/bot/commands/worktree.test.ts +++ b/tests/bot/commands/worktree.test.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Context } from "grammy"; import { t } from "../../../src/i18n/index.js"; @@ -9,14 +10,12 @@ const mocked = vi.hoisted(() => ({ name?: string; } | null, getGitWorktreeContextMock: vi.fn(), - replyWithInlineMenuMock: vi.fn(), - ensureActiveInlineMenuMock: vi.fn().mockResolvedValue(true), + createGitWorktreeMock: vi.fn(), isForegroundBusyMock: vi.fn(() => false), replyBusyBlockedMock: vi.fn().mockResolvedValue(undefined), upsertSessionDirectoryMock: vi.fn().mockResolvedValue(undefined), getProjectByWorktreeMock: vi.fn(), switchToProjectMock: vi.fn().mockResolvedValue({ inline_keyboard: [] }), - clearAllInteractionStateMock: vi.fn(), })); vi.mock("../../../src/app/stores/settings-store.js", () => ({ @@ -25,12 +24,7 @@ vi.mock("../../../src/app/stores/settings-store.js", () => ({ vi.mock("../../../src/app/services/worktree-service.js", () => ({ getGitWorktreeContext: mocked.getGitWorktreeContextMock, -})); - -vi.mock("../../../src/bot/menus/inline-menu.js", () => ({ - appendInlineMenuCancelButton: vi.fn((keyboard: unknown) => keyboard), - ensureActiveInlineMenu: mocked.ensureActiveInlineMenuMock, - replyWithInlineMenu: mocked.replyWithInlineMenuMock, + createGitWorktree: mocked.createGitWorktreeMock, })); vi.mock("../../../src/app/services/run-control-service.js", () => ({ @@ -54,37 +48,37 @@ vi.mock("../../../src/app/services/project-switch-service.js", () => ({ switchToProject: mocked.switchToProjectMock, })); -vi.mock("../../../src/app/managers/interaction-manager.js", () => ({ - interactionManager: { clear: vi.fn() }, - clearAllInteractionState: mocked.clearAllInteractionStateMock, +vi.mock("../../../src/bot/services/project-switch-presentation.js", () => ({ + createProjectSwitchPresentation: vi.fn(() => ({})), })); vi.mock("../../../src/utils/logger.js", () => ({ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, })); +vi.mock("../../../src/bot/menus/worktree-selection-menu.js", () => ({ + buildWorktreeMenuView: vi.fn(() => ({ + text: "Select a worktree:", + keyboard: { inline_keyboard: [] }, + })), +})); + +vi.mock("../../../src/bot/menus/inline-menu.js", () => ({ + replyWithInlineMenu: vi.fn().mockResolvedValue(100), + appendInlineMenuCancelButton: vi.fn((k) => k), + ensureActiveInlineMenu: vi.fn(), +})); + import { worktreeCommand } from "../../../src/bot/commands/worktree-command.js"; -import { handleWorktreeCallback } from "../../../src/bot/callbacks/worktree-callback-handler.js"; -function createCommandContext(): Context { +function createCommandContext(match: string = ""): Context { return { + match, chat: { id: 123 }, reply: vi.fn().mockResolvedValue({ message_id: 42 }), - } as unknown as Context; -} - -function createCallbackContext(data: string, messageId: number = 42): Context { - return { - chat: { id: 123 }, - callbackQuery: { - data, - message: { message_id: messageId }, - } as Context["callbackQuery"], - answerCallbackQuery: vi.fn().mockResolvedValue(undefined), - editMessageText: vi.fn().mockResolvedValue(undefined), - reply: vi.fn().mockResolvedValue(undefined), - deleteMessage: vi.fn().mockResolvedValue(undefined), - api: {}, + api: { + editMessageText: vi.fn().mockResolvedValue(undefined), + }, } as unknown as Context; } @@ -92,8 +86,7 @@ describe("bot/commands/worktree", () => { beforeEach(() => { mocked.currentProject = { id: "project-1", worktree: "/repo", name: "Repo" }; mocked.getGitWorktreeContextMock.mockReset(); - mocked.replyWithInlineMenuMock.mockReset(); - mocked.ensureActiveInlineMenuMock.mockReset().mockResolvedValue(true); + mocked.createGitWorktreeMock.mockReset(); mocked.isForegroundBusyMock.mockReset().mockReturnValue(false); mocked.replyBusyBlockedMock.mockReset().mockResolvedValue(undefined); mocked.upsertSessionDirectoryMock.mockReset().mockResolvedValue(undefined); @@ -103,101 +96,254 @@ describe("bot/commands/worktree", () => { name: "/repo-feature", }); mocked.switchToProjectMock.mockReset().mockResolvedValue({ inline_keyboard: [] }); - mocked.clearAllInteractionStateMock.mockReset(); }); - it("asks to select a project first when no project is active", async () => { - mocked.currentProject = null; + describe("help subcommand", () => { + it("shows help when no subcommand is given", async () => { + const ctx = createCommandContext(""); + await worktreeCommand(ctx as never); + + const replyText = (ctx.reply as ReturnType).mock.calls[0]?.[0]; + expect(replyText).toContain("Worktree Manager"); + expect(replyText).toContain("/worktree add"); + expect(replyText).toContain("/worktree list"); + expect(replyText).toContain("/worktree switch"); + }); + + it("shows help for help subcommand", async () => { + const ctx = createCommandContext("help"); + await worktreeCommand(ctx as never); + + const replyText = (ctx.reply as ReturnType).mock.calls[0]?.[0]; + expect(replyText).toContain("Worktree Manager"); + }); + }); + + describe("add subcommand", () => { + it("creates a worktree and shows success", async () => { + mocked.createGitWorktreeMock.mockResolvedValue({ + path: "/repo/new-worktree", + apiBranch: "opencode/new-worktree", + }); + + const ctx = createCommandContext("add my-worktree"); + await worktreeCommand(ctx as never); + + expect(mocked.createGitWorktreeMock).toHaveBeenCalledWith("my-worktree"); + expect(ctx.reply).toHaveBeenCalledWith( + expect.stringContaining("Worktree created successfully"), + ); + const replyText = (ctx.reply as ReturnType).mock.calls[0]?.[0]; + expect(replyText).toContain("/repo/new-worktree"); + expect(replyText).toContain("opencode/new-worktree"); + }); + + it("shows error when creation fails", async () => { + mocked.createGitWorktreeMock.mockResolvedValue({ + path: "", + error: "API error", + }); + + const ctx = createCommandContext("add my-worktree"); + await worktreeCommand(ctx as never); + + expect(ctx.reply).toHaveBeenCalledWith( + t("worktree_add.error", { error: "API error" }), + ); + }); + }); + + describe("list subcommand", () => { + it("shows project_not_selected when no project", async () => { + mocked.currentProject = null; + + const ctx = createCommandContext("list"); + await worktreeCommand(ctx as never); + + expect(ctx.reply).toHaveBeenCalledWith(t("worktree.project_not_selected")); + }); + + it("shows not_git_repo when context is missing", async () => { + mocked.getGitWorktreeContextMock.mockResolvedValue(null); + + const ctx = createCommandContext("list"); + await worktreeCommand(ctx as never); - const ctx = createCommandContext(); - await worktreeCommand(ctx as never); + expect(ctx.reply).toHaveBeenCalledWith(t("worktree.not_git_repo")); + }); + + it("shows empty message when no worktrees", async () => { + mocked.getGitWorktreeContextMock.mockResolvedValue({ + mainProjectPath: "/repo", + activeWorktreePath: "/repo", + branch: "main", + isLinkedWorktree: false, + worktrees: [], + }); + + const ctx = createCommandContext("list"); + await worktreeCommand(ctx as never); + + expect(ctx.reply).toHaveBeenCalledWith(t("worktree.empty")); + }); - expect(ctx.reply).toHaveBeenCalledWith(t("worktree.project_not_selected")); - expect(mocked.replyWithInlineMenuMock).not.toHaveBeenCalled(); + it("lists worktrees with markers", async () => { + mocked.getGitWorktreeContextMock.mockResolvedValue({ + mainProjectPath: "/repo", + activeWorktreePath: "/repo", + branch: "main", + isLinkedWorktree: false, + worktrees: [ + { path: "/repo", branch: "main", isCurrent: true, isMain: true }, + { path: "/repo-feature", branch: "feature/chat", isCurrent: false, isMain: false }, + ], + }); + + const ctx = createCommandContext("list"); + await worktreeCommand(ctx as never); + + const replyText = (ctx.reply as ReturnType).mock.calls[0]?.[0]; + expect(replyText).toContain("/repo"); + expect(replyText).toContain("/repo-feature"); + expect(replyText).toContain("main"); + expect(replyText).toContain("feature/chat"); + }); }); - it("shows an inline worktree menu for the current repository", async () => { - mocked.getGitWorktreeContextMock.mockResolvedValue({ - mainProjectPath: "/repo", - activeWorktreePath: "/repo", - branch: "main", - isLinkedWorktree: false, - worktrees: [ - { path: "/repo", branch: "main", isCurrent: true, isMain: true }, - { path: "/repo-feature", branch: "feature/chat", isCurrent: false, isMain: false }, - ], + describe("switch subcommand", () => { + beforeEach(() => { + mocked.getGitWorktreeContextMock.mockResolvedValue({ + mainProjectPath: "/repo", + activeWorktreePath: "/repo", + branch: "main", + isLinkedWorktree: false, + worktrees: [ + { path: "/repo", branch: "main", isCurrent: true, isMain: true }, + { path: "/repo-feature", branch: "feature/chat", isCurrent: false, isMain: false }, + ], + }); }); - const ctx = createCommandContext(); - await worktreeCommand(ctx as never); + it("shows inline menu when no name provided", async () => { + const { buildWorktreeMenuView } = await import( + "../../../src/bot/menus/worktree-selection-menu.js" + ); + const { replyWithInlineMenu } = await import( + "../../../src/bot/menus/inline-menu.js" + ); + + const ctx = createCommandContext("switch"); + await worktreeCommand(ctx as never); - expect(mocked.replyWithInlineMenuMock).toHaveBeenCalledWith( - ctx, - expect.objectContaining({ + expect(buildWorktreeMenuView).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ path: "/repo-feature" })]), + 0, + ); + expect(replyWithInlineMenu).toHaveBeenCalledWith(ctx, { menuKind: "worktree", - text: t("worktree.select_with_current"), - }), - ); - - const keyboard = mocked.replyWithInlineMenuMock.mock.calls[0]?.[1]?.keyboard as { - inline_keyboard: Array>; - }; - expect(keyboard.inline_keyboard[0]?.[0]?.text).toContain("1. repo [/repo]"); - expect(keyboard.inline_keyboard[1]?.[0]?.text).toContain("2. repo-feature [/repo-feature]"); + text: "Select a worktree:", + keyboard: { inline_keyboard: [] }, + }); + }); + + it("shows empty message when switch with no name and no worktrees", async () => { + mocked.getGitWorktreeContextMock.mockResolvedValue({ + mainProjectPath: "/repo", + activeWorktreePath: "/repo", + branch: "main", + isLinkedWorktree: false, + worktrees: [], + }); + + const ctx = createCommandContext("switch"); + await worktreeCommand(ctx as never); + + expect(ctx.reply).toHaveBeenCalledWith(t("worktree.empty")); + }); + + it("switches to a worktree by path", async () => { + const ctx = createCommandContext("switch /repo-feature"); + await worktreeCommand(ctx as never); + + expect(mocked.upsertSessionDirectoryMock).toHaveBeenCalledWith( + "/repo-feature", + expect.any(Number), + ); + expect(mocked.getProjectByWorktreeMock).toHaveBeenCalledWith("/repo-feature"); + expect(mocked.switchToProjectMock).toHaveBeenCalledWith( + ctx, + expect.objectContaining({ worktree: "/repo-feature" }), + "worktree_switched", + expect.objectContaining({ presentation: expect.any(Object) }), + ); + }); + + it("switches to a worktree by basename", async () => { + const ctx = createCommandContext("switch repo-feature"); + await worktreeCommand(ctx as never); + + expect(mocked.upsertSessionDirectoryMock).toHaveBeenCalledWith( + "/repo-feature", + expect.any(Number), + ); + }); + + it("switches to a worktree by branch name", async () => { + const ctx = createCommandContext("switch feature/chat"); + await worktreeCommand(ctx as never); + + expect(mocked.upsertSessionDirectoryMock).toHaveBeenCalledWith( + "/repo-feature", + expect.any(Number), + ); + }); + + it("reports when worktree is already current", async () => { + const ctx = createCommandContext("switch /repo"); + await worktreeCommand(ctx as never); + + expect(ctx.reply).toHaveBeenCalledWith(expect.stringContaining("Already on worktree")); + }); + + it("shows not_found when worktree does not match", async () => { + const ctx = createCommandContext("switch nonexistent"); + await worktreeCommand(ctx as never); + + const replyText = (ctx.reply as ReturnType).mock.calls[0]?.[0]; + expect(replyText).toContain('Worktree "nonexistent" not found'); + }); }); - it("switches to a selected linked worktree and resets the session", async () => { - mocked.getGitWorktreeContextMock.mockResolvedValue({ - mainProjectPath: "/repo", - activeWorktreePath: "/repo", - branch: "main", - isLinkedWorktree: false, - worktrees: [ - { path: "/repo", branch: "main", isCurrent: true, isMain: true }, - { path: "/repo-feature", branch: "feature/chat", isCurrent: false, isMain: false }, - ], - }); - - const ctx = createCallbackContext("worktree:1"); - const handled = await handleWorktreeCallback(ctx); - - expect(handled).toBe(true); - expect(mocked.upsertSessionDirectoryMock).toHaveBeenCalledWith( - "/repo-feature", - expect.any(Number), - ); - expect(mocked.getProjectByWorktreeMock).toHaveBeenCalledWith("/repo-feature"); - expect(mocked.switchToProjectMock).toHaveBeenCalledWith( - ctx, - expect.objectContaining({ worktree: "/repo-feature" }), - "worktree_switched", - expect.objectContaining({ presentation: expect.any(Object) }), - ); - expect(ctx.reply).toHaveBeenCalledWith(t("worktree.selected", { worktree: "/repo-feature" }), { - reply_markup: { inline_keyboard: [] }, - }); - expect(ctx.deleteMessage).toHaveBeenCalled(); + describe("delete subcommand", () => { + it("shows not implemented", async () => { + const ctx = createCommandContext("delete"); + await worktreeCommand(ctx as never); + + expect(ctx.reply).toHaveBeenCalledWith( + expect.stringContaining("not implemented yet"), + ); + }); }); - it("acknowledges when the selected worktree is already active", async () => { - mocked.getGitWorktreeContextMock.mockResolvedValue({ - mainProjectPath: "/repo", - activeWorktreePath: "/repo", - branch: "main", - isLinkedWorktree: false, - worktrees: [ - { path: "/repo", branch: "main", isCurrent: true, isMain: true }, - { path: "/repo-feature", branch: "feature/chat", isCurrent: false, isMain: false }, - ], + describe("unknown subcommand", () => { + it("shows error for unknown subcommand", async () => { + const ctx = createCommandContext("unknown"); + await worktreeCommand(ctx as never); + + expect(ctx.reply).toHaveBeenCalledWith( + expect.stringContaining('Unknown subcommand: "unknown"'), + ); }); + }); + + describe("busy guard", () => { + it("blocks when foreground is busy", async () => { + mocked.isForegroundBusyMock.mockReturnValue(true); - const ctx = createCallbackContext("worktree:0"); - const handled = await handleWorktreeCallback(ctx); + const ctx = createCommandContext("list"); + await worktreeCommand(ctx as never); - expect(handled).toBe(true); - expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ - text: t("worktree.already_selected_callback"), + expect(mocked.replyBusyBlockedMock).toHaveBeenCalled(); }); - expect(mocked.switchToProjectMock).not.toHaveBeenCalled(); }); }); From 996fdba5c804af56a32243a909c5e591c67e4bd9 Mon Sep 17 00:00:00 2001 From: kam Date: Sun, 21 Jun 2026 00:20:44 +0330 Subject: [PATCH 2/2] Update interaction-manager.ts undone unnecessary change --- src/app/managers/interaction-manager.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/app/managers/interaction-manager.ts b/src/app/managers/interaction-manager.ts index 3645d19d..fcbdf735 100644 --- a/src/app/managers/interaction-manager.ts +++ b/src/app/managers/interaction-manager.ts @@ -10,12 +10,7 @@ import { renameManager } from "./rename-manager.js"; import { taskCreationManager } from "./scheduled-task-creation-manager.js"; import { logger } from "../../utils/logger.js"; -export const DEFAULT_ALLOWED_INTERACTION_COMMANDS = [ - "/help", - "/status", - "/abort", - "/detach", -] as const; +export const DEFAULT_ALLOWED_INTERACTION_COMMANDS = ["/help", "/status", "/abort", "/detach"] as const; function normalizeCommand(command: string): string | null { const trimmed = command.trim().toLowerCase();