diff --git a/packages/types/src/providers/mistral.ts b/packages/types/src/providers/mistral.ts index f3cc47787a83..fa70f2606a44 100644 --- a/packages/types/src/providers/mistral.ts +++ b/packages/types/src/providers/mistral.ts @@ -7,9 +7,9 @@ export const mistralDefaultModelId: MistralModelId = "codestral-latest" export const mistralModels = { "magistral-medium-latest": { - maxTokens: 41_000, - contextWindow: 41_000, - supportsImages: false, + maxTokens: 8192, + contextWindow: 128_000, + supportsImages: true, supportsPromptCache: false, inputPrice: 2.0, outputPrice: 5.0, diff --git a/releases/3.29.0-release.png b/releases/3.29.0-release.png new file mode 100644 index 000000000000..8f9381fbae7b Binary files /dev/null and b/releases/3.29.0-release.png differ diff --git a/src/api/providers/fetchers/roo.ts b/src/api/providers/fetchers/roo.ts index 5836c28b2d81..d7b212c3e875 100644 --- a/src/api/providers/fetchers/roo.ts +++ b/src/api/providers/fetchers/roo.ts @@ -1,6 +1,7 @@ import { RooModelsResponseSchema } from "@roo-code/types" import type { ModelRecord } from "../../../shared/api" +import { parseApiPrice } from "../../../shared/cost" import { DEFAULT_HEADERS } from "../constants" @@ -71,10 +72,10 @@ export async function getRooModels(baseUrl: string, apiKey?: string): Promise { private authStateListener?: (state: { state: AuthState }) => void private fetcherBaseURL: string @@ -124,10 +130,14 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { } if (chunk.usage) { + const usage = chunk.usage as RooUsage yield { type: "usage", - inputTokens: chunk.usage.prompt_tokens || 0, - outputTokens: chunk.usage.completion_tokens || 0, + inputTokens: usage.prompt_tokens || 0, + outputTokens: usage.completion_tokens || 0, + cacheWriteTokens: usage.cache_creation_input_tokens, + cacheReadTokens: usage.prompt_tokens_details?.cached_tokens, + totalCost: usage.cost ?? 0, } } } diff --git a/src/core/checkpoints/__tests__/checkpoint.test.ts b/src/core/checkpoints/__tests__/checkpoint.test.ts index 9b55fbff9f41..3df554c92719 100644 --- a/src/core/checkpoints/__tests__/checkpoint.test.ts +++ b/src/core/checkpoints/__tests__/checkpoint.test.ts @@ -329,7 +329,7 @@ describe("Checkpoint functionality", () => { ] }) - it("should show diff for full mode", async () => { + it("should show diff for to-current mode", async () => { const mockChanges = [ { paths: { absolute: "/test/file.ts", relative: "file.ts" }, @@ -341,7 +341,7 @@ describe("Checkpoint functionality", () => { await checkpointDiff(mockTask, { ts: 4, commitHash: "commit2", - mode: "full", + mode: "to-current", }) expect(mockCheckpointService.getDiff).toHaveBeenCalledWith({ @@ -350,7 +350,7 @@ describe("Checkpoint functionality", () => { }) expect(vscode.commands.executeCommand).toHaveBeenCalledWith( "vscode.changes", - "Changes since task started", + "common:errors.checkpoint_diff_to_current", expect.any(Array), ) }) @@ -375,7 +375,7 @@ describe("Checkpoint functionality", () => { }) expect(vscode.commands.executeCommand).toHaveBeenCalledWith( "vscode.changes", - "Changes compare with next checkpoint", + "common:errors.checkpoint_diff_with_next", expect.any(Array), ) }) @@ -407,10 +407,10 @@ describe("Checkpoint functionality", () => { await checkpointDiff(mockTask, { ts: 4, commitHash: "commit2", - mode: "full", + mode: "to-current", }) - expect(vscode.window.showInformationMessage).toHaveBeenCalledWith("No changes found.") + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith("common:errors.checkpoint_no_changes") expect(vscode.commands.executeCommand).not.toHaveBeenCalled() }) @@ -420,7 +420,7 @@ describe("Checkpoint functionality", () => { await checkpointDiff(mockTask, { ts: 4, commitHash: "commit2", - mode: "full", + mode: "to-current", }) expect(mockTask.enableCheckpoints).toBe(false) diff --git a/src/core/checkpoints/index.ts b/src/core/checkpoints/index.ts index ad77d41d685e..315dd6b9bbf5 100644 --- a/src/core/checkpoints/index.ts +++ b/src/core/checkpoints/index.ts @@ -338,10 +338,16 @@ export async function checkpointRestore( } export type CheckpointDiffOptions = { - ts: number + ts?: number previousCommitHash?: string commitHash: string - mode: "full" | "checkpoint" + /** + * from-init: Compare from the first checkpoint to the selected checkpoint. + * checkpoint: Compare the selected checkpoint to the next checkpoint. + * to-current: Compare the selected checkpoint to the current workspace. + * full: Compare from the first checkpoint to the current workspace. + */ + mode: "from-init" | "checkpoint" | "to-current" | "full" } export async function checkpointDiff(task: Task, { ts, previousCommitHash, commitHash, mode }: CheckpointDiffOptions) { @@ -353,30 +359,57 @@ export async function checkpointDiff(task: Task, { ts, previousCommitHash, commi TelemetryService.instance.captureCheckpointDiffed(task.taskId) - let prevHash = commitHash - let nextHash: string | undefined = undefined + let fromHash: string | undefined + let toHash: string | undefined + let title: string - if (mode !== "full") { - const checkpoints = task.clineMessages.filter(({ say }) => say === "checkpoint_saved").map(({ text }) => text!) - const idx = checkpoints.indexOf(commitHash) - if (idx !== -1 && idx < checkpoints.length - 1) { - nextHash = checkpoints[idx + 1] - } else { - nextHash = undefined - } + const checkpoints = task.clineMessages.filter(({ say }) => say === "checkpoint_saved").map(({ text }) => text!) + + if (["from-init", "full"].includes(mode) && checkpoints.length < 1) { + vscode.window.showInformationMessage(t("common:errors.checkpoint_no_first")) + return + } + + const idx = checkpoints.indexOf(commitHash) + switch (mode) { + case "checkpoint": + fromHash = commitHash + toHash = idx !== -1 && idx < checkpoints.length - 1 ? checkpoints[idx + 1] : undefined + title = t("common:errors.checkpoint_diff_with_next") + break + case "from-init": + fromHash = checkpoints[0] + toHash = commitHash + title = t("common:errors.checkpoint_diff_since_first") + break + case "to-current": + fromHash = commitHash + toHash = undefined + title = t("common:errors.checkpoint_diff_to_current") + break + case "full": + fromHash = checkpoints[0] + toHash = undefined + title = t("common:errors.checkpoint_diff_since_first") + break + } + + if (!fromHash) { + vscode.window.showInformationMessage(t("common:errors.checkpoint_no_previous")) + return } try { - const changes = await service.getDiff({ from: prevHash, to: nextHash }) + const changes = await service.getDiff({ from: fromHash, to: toHash }) if (!changes?.length) { - vscode.window.showInformationMessage("No changes found.") + vscode.window.showInformationMessage(t("common:errors.checkpoint_no_changes")) return } await vscode.commands.executeCommand( "vscode.changes", - mode === "full" ? "Changes since task started" : "Changes compare with next checkpoint", + title, changes.map((change) => [ vscode.Uri.file(change.paths.absolute), vscode.Uri.parse(`${DIFF_VIEW_URI_SCHEME}:${change.paths.relative}`).with({ diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index 5f3a175534d8..01cdc0cdf6cc 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -16,6 +16,7 @@ import { import { TelemetryService } from "@roo-code/telemetry" import { Mode, modes } from "../../shared/modes" +import { buildApiHandler } from "../../api" // Type-safe model migrations mapping type ModelMigrations = { @@ -549,6 +550,31 @@ export class ProviderSettingsManager { for (const name in configs) { // Avoid leaking properties from other providers. configs[name] = discriminatedProviderSettingsWithIdSchema.parse(configs[name]) + + // If it has no apiProvider, skip filtering + if (!configs[name].apiProvider) { + continue + } + + // Try to build an API handler to get model information + try { + const apiHandler = buildApiHandler(configs[name]) + const modelInfo = apiHandler.getModel().info + + // Check if the model supports reasoning budgets + const supportsReasoningBudget = + modelInfo.supportsReasoningBudget || modelInfo.requiredReasoningBudget + + // If the model doesn't support reasoning budgets, remove the token fields + if (!supportsReasoningBudget) { + delete configs[name].modelMaxTokens + delete configs[name].modelMaxThinkingTokens + } + } catch (error) { + // If we can't build the API handler or get model info, skip filtering + // to avoid accidental data loss from incomplete configurations + console.warn(`Skipping token field filtering for config '${name}': ${error}`) + } } return profiles }) diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts index 63ea17dcd7bd..dad41604064b 100644 --- a/src/core/config/__tests__/importExport.spec.ts +++ b/src/core/config/__tests__/importExport.spec.ts @@ -17,6 +17,17 @@ import { safeWriteJson } from "../../../utils/safeWriteJson" import type { Mock } from "vitest" vi.mock("vscode", () => ({ + workspace: { + getConfiguration: vi.fn().mockReturnValue({ + get: vi.fn(), + }), + createFileSystemWatcher: vi.fn(() => ({ + onDidCreate: vi.fn(() => ({ dispose: vi.fn() })), + onDidChange: vi.fn(() => ({ dispose: vi.fn() })), + onDidDelete: vi.fn(() => ({ dispose: vi.fn() })), + dispose: vi.fn(), + })), + }, window: { showOpenDialog: vi.fn(), showSaveDialog: vi.fn(), @@ -46,14 +57,6 @@ vi.mock("vscode", () => ({ base: any pattern: any }, - workspace: { - createFileSystemWatcher: vi.fn(() => ({ - onDidCreate: vi.fn(() => ({ dispose: vi.fn() })), - onDidChange: vi.fn(() => ({ dispose: vi.fn() })), - onDidDelete: vi.fn(() => ({ dispose: vi.fn() })), - dispose: vi.fn(), - })), - }, })) vi.mock("fs/promises", () => ({ @@ -87,6 +90,45 @@ vi.mock("os", () => ({ vi.mock("../../../utils/safeWriteJson") +// Mock buildApiHandler to avoid issues with provider instantiation in tests +vi.mock("../../../api", () => ({ + buildApiHandler: vi.fn().mockImplementation((config) => { + // Return different model info based on the provider and model + const getModelInfo = () => { + if (config.apiProvider === "claude-code") { + return { + id: config.apiModelId || "claude-sonnet-4-5", + info: { + supportsReasoningBudget: false, + requiredReasoningBudget: false, + }, + } + } + if (config.apiProvider === "anthropic" && config.apiModelId === "claude-3-5-sonnet-20241022") { + return { + id: "claude-3-5-sonnet-20241022", + info: { + supportsReasoningBudget: true, + requiredReasoningBudget: true, + }, + } + } + // Default fallback + return { + id: config.apiModelId || "claude-sonnet-4-5", + info: { + supportsReasoningBudget: false, + requiredReasoningBudget: false, + }, + } + } + + return { + getModel: vi.fn().mockReturnValue(getModelInfo()), + } + }), +})) + describe("importExport", () => { let mockProviderSettingsManager: ReturnType> let mockContextProxy: ReturnType> @@ -465,6 +507,71 @@ describe("importExport", () => { showErrorMessageSpy.mockRestore() }) + + it("should handle import when reasoning budget fields are missing from config", async () => { + // This test verifies that import works correctly when reasoning budget fields are not present + // Using claude-code provider which doesn't support reasoning budgets + + ;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }]) + + const mockFileContent = JSON.stringify({ + providerProfiles: { + currentApiConfigName: "claude-code-provider", + apiConfigs: { + "claude-code-provider": { + apiProvider: "claude-code" as ProviderName, + apiModelId: "claude-3-5-sonnet-20241022", + id: "claude-code-id", + apiKey: "test-key", + // No modelMaxTokens or modelMaxThinkingTokens fields + }, + }, + }, + globalSettings: { mode: "code", autoApprovalEnabled: true }, + }) + + ;(fs.readFile as Mock).mockResolvedValue(mockFileContent) + + const previousProviderProfiles = { + currentApiConfigName: "default", + apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } }, + } + + mockProviderSettingsManager.export.mockResolvedValue(previousProviderProfiles) + mockProviderSettingsManager.listConfig.mockResolvedValue([ + { name: "claude-code-provider", id: "claude-code-id", apiProvider: "claude-code" as ProviderName }, + { name: "default", id: "default-id", apiProvider: "anthropic" as ProviderName }, + ]) + + mockContextProxy.export.mockResolvedValue({ mode: "code" }) + + const result = await importSettings({ + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + }) + + expect(result.success).toBe(true) + expect(fs.readFile).toHaveBeenCalledWith("/mock/path/settings.json", "utf-8") + expect(mockProviderSettingsManager.export).toHaveBeenCalled() + + expect(mockProviderSettingsManager.import).toHaveBeenCalledWith({ + currentApiConfigName: "claude-code-provider", + apiConfigs: { + default: { apiProvider: "anthropic" as ProviderName, id: "default-id" }, + "claude-code-provider": { + apiProvider: "claude-code" as ProviderName, + apiModelId: "claude-3-5-sonnet-20241022", + apiKey: "test-key", + id: "claude-code-id", + }, + }, + modeApiConfigs: {}, + }) + + expect(mockContextProxy.setValues).toHaveBeenCalledWith({ mode: "code", autoApprovalEnabled: true }) + expect(mockContextProxy.setValue).toHaveBeenCalledWith("currentApiConfigName", "claude-code-provider") + }) }) describe("exportSettings", () => { @@ -1637,5 +1744,78 @@ describe("importExport", () => { "https://custom-api.example.com/v1", ) }) + + it.each([ + { + testCase: "supportsReasoningBudget is false", + providerName: "claude-code-provider", + modelId: "claude-sonnet-4-5", + providerId: "claude-code-id", + }, + { + testCase: "requiredReasoningBudget is false", + providerName: "claude-code-provider-2", + modelId: "claude-sonnet-4-5", + providerId: "claude-code-id-2", + }, + { + testCase: "both supportsReasoningBudget and requiredReasoningBudget are false", + providerName: "claude-code-provider-3", + modelId: "claude-3-5-haiku-20241022", + providerId: "claude-code-id-3", + }, + ])( + "should exclude modelMaxTokens and modelMaxThinkingTokens when $testCase", + async ({ providerName, modelId, providerId }) => { + // This test verifies that token fields are excluded when model doesn't support reasoning budget + // Using claude-code provider which has supportsReasoningBudget: false and requiredReasoningBudget: false + + ;(vscode.window.showSaveDialog as Mock).mockResolvedValue({ + fsPath: "/mock/path/roo-code-settings.json", + }) + + // Use a real ProviderSettingsManager instance to test the actual filtering logic + const realProviderSettingsManager = new ProviderSettingsManager(mockExtensionContext) + + // Wait for initialization to complete + await realProviderSettingsManager.initialize() + + // Save a claude-code provider config with token fields + await realProviderSettingsManager.saveConfig(providerName, { + apiProvider: "claude-code" as ProviderName, + apiModelId: modelId, + id: providerId, + apiKey: "test-key", + modelMaxTokens: 4096, // This should be removed during export + modelMaxThinkingTokens: 2048, // This should be removed during export + }) + + // Set this as the current provider + await realProviderSettingsManager.activateProfile({ name: providerName }) + + const mockGlobalSettings = { + mode: "code", + autoApprovalEnabled: true, + } + + mockContextProxy.export.mockResolvedValue(mockGlobalSettings) + ;(fs.mkdir as Mock).mockResolvedValue(undefined) + + await exportSettings({ + providerSettingsManager: realProviderSettingsManager, + contextProxy: mockContextProxy, + }) + + // Get the exported data + const exportedData = (safeWriteJson as Mock).mock.calls[0][1] + + // Verify that token fields were excluded because reasoning budget is not supported/required + const provider = exportedData.providerProfiles.apiConfigs[providerName] + expect(provider).toBeDefined() + expect(provider.apiModelId).toBe(modelId) + expect("modelMaxTokens" in provider).toBe(false) // Should be excluded + expect("modelMaxThinkingTokens" in provider).toBe(false) // Should be excluded + }, + ) }) }) diff --git a/src/core/diff/strategies/__tests__/multi-search-replace-trailing-newline.spec.ts b/src/core/diff/strategies/__tests__/multi-search-replace-trailing-newline.spec.ts new file mode 100644 index 000000000000..95512193941a --- /dev/null +++ b/src/core/diff/strategies/__tests__/multi-search-replace-trailing-newline.spec.ts @@ -0,0 +1,163 @@ +import { MultiSearchReplaceDiffStrategy } from "../multi-search-replace" + +describe("MultiSearchReplaceDiffStrategy - trailing newline preservation", () => { + let strategy: MultiSearchReplaceDiffStrategy + + beforeEach(() => { + strategy = new MultiSearchReplaceDiffStrategy() + }) + + it("should preserve trailing newlines in SEARCH content with line numbers", async () => { + // This test verifies the fix for issue #8020 + // The regex should not consume trailing newlines, allowing stripLineNumbers to work correctly + const originalContent = `class Example { + constructor() { + this.value = 0; + } +}` + const diffContent = `<<<<<<< SEARCH +1 | class Example { +2 | constructor() { +3 | this.value = 0; +4 | } +5 | } +======= +class Example { + constructor() { + this.value = 1; + } +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`class Example { + constructor() { + this.value = 1; + } +}`) + } + }) + + it("should handle Windows line endings with trailing newlines and line numbers", async () => { + const originalContent = "function test() {\r\n return true;\r\n}\r\n" + const diffContent = `<<<<<<< SEARCH +1 | function test() { +2 | return true; +3 | } +======= +function test() { + return false; +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + // Should preserve Windows line endings + expect(result.content).toBe("function test() {\r\n return false;\r\n}\r\n") + } + }) + + it("should handle multiple search/replace blocks with trailing newlines", async () => { + const originalContent = `function one() { + return 1; +} + +function two() { + return 2; +}` + const diffContent = `<<<<<<< SEARCH +1 | function one() { +2 | return 1; +3 | } +======= +function one() { + return 10; +} +>>>>>>> REPLACE + +<<<<<<< SEARCH +5 | function two() { +6 | return 2; +7 | } +======= +function two() { + return 20; +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function one() { + return 10; +} + +function two() { + return 20; +}`) + } + }) + + it("should handle content with line numbers at the last line", async () => { + // This specifically tests the scenario from the bug report + const originalContent = ` List addressInfoList = new ArrayList<>(CollectionUtils.size(repairInfoList) > 10 ? 10 + : CollectionUtils.size(repairInfoList) + CollectionUtils.size(homeAddressInfoList) + + CollectionUtils.size(idNoAddressInfoList) + CollectionUtils.size(workAddressInfoList) + + CollectionUtils.size(personIdentityInfoList));` + + const diffContent = `<<<<<<< SEARCH +1476 | List addressInfoList = new ArrayList<>(CollectionUtils.size(repairInfoList) > 10 ? 10 +1477 | : CollectionUtils.size(repairInfoList) + CollectionUtils.size(homeAddressInfoList) +1478 | + CollectionUtils.size(idNoAddressInfoList) + CollectionUtils.size(workAddressInfoList) +1479 | + CollectionUtils.size(personIdentityInfoList)); +======= + + // Filter addresses if optimization is enabled + if (isAddressDisplayOptimizeEnabled()) { + homeAddressInfoList = filterAddressesByThreeYearRule(homeAddressInfoList); + personIdentityInfoList = filterAddressesByThreeYearRule(personIdentityInfoList); + idNoAddressInfoList = filterAddressesByThreeYearRule(idNoAddressInfoList); + workAddressInfoList = filterAddressesByThreeYearRule(workAddressInfoList); + } + + List addressInfoList = new ArrayList<>(CollectionUtils.size(repairInfoList) > 10 ? 10 + : CollectionUtils.size(repairInfoList) + CollectionUtils.size(homeAddressInfoList) + + CollectionUtils.size(idNoAddressInfoList) + CollectionUtils.size(workAddressInfoList) + + CollectionUtils.size(personIdentityInfoList)); +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toContain("// Filter addresses if optimization is enabled") + expect(result.content).toContain("if (isAddressDisplayOptimizeEnabled())") + // Verify the last line doesn't have line numbers + expect(result.content).not.toContain("1488 |") + expect(result.content).not.toContain("1479 |") + } + }) + + it("should correctly strip line numbers even when last line has no trailing newline", async () => { + const originalContent = "line 1\nline 2\nline 3" // No trailing newline + const diffContent = `<<<<<<< SEARCH +1 | line 1 +2 | line 2 +3 | line 3 +======= +line 1 +modified line 2 +line 3 +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe("line 1\nmodified line 2\nline 3") + // Verify no line numbers remain + expect(result.content).not.toContain(" | ") + } + }) +}) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index de30de56c200..ee39bbd5c9fd 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -155,7 +155,7 @@ export class ClineProvider public isViewLaunched = false public settingsImportedAt?: number - public readonly latestAnnouncementId = "sep-2025-code-supernova-1m" // Code Supernova 1M context window announcement + public readonly latestAnnouncementId = "oct-2025-v3.29.0-cloud-agents" // v3.29.0 Cloud Agents announcement public readonly providerSettingsManager: ProviderSettingsManager public readonly customModesManager: CustomModesManager diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 02453b75ceba..4c609f888997 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -32,6 +32,12 @@ "checkpoint_timeout": "Timed out when attempting to restore checkpoint.", "checkpoint_failed": "Failed to restore checkpoint.", "git_not_installed": "Git is required for the checkpoints feature. Please install Git to enable checkpoints.", + "checkpoint_no_first": "No first checkpoint to compare.", + "checkpoint_no_previous": "No previous checkpoint to compare.", + "checkpoint_no_changes": "No changes found.", + "checkpoint_diff_with_next": "Changes compared with next checkpoint", + "checkpoint_diff_since_first": "Changes since first checkpoint", + "checkpoint_diff_to_current": "Changes to current workspace", "nested_git_repos_warning": "Checkpoints are disabled because a nested git repository was detected at: {{path}}. To use checkpoints, please remove or relocate this nested git repository.", "no_workspace": "Please open a project folder first", "update_support_prompt": "Failed to update support prompt", diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 35db3a3db017..10caf7754693 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -36,7 +36,13 @@ "could_not_open_file_generic": "无法打开文件!", "checkpoint_timeout": "尝试恢复检查点时超时。", "checkpoint_failed": "恢复检查点失败。", - "git_not_installed": "存档点功能需要 Git。请安装 Git 以启用存档点。", + "git_not_installed": "检查点功能需要 Git。请安装 Git 以启用检查点。", + "checkpoint_no_first": "没有第一个存档点可供比较。", + "checkpoint_no_previous": "没有上一个存档点可供比较。", + "checkpoint_no_changes": "未发现任何更改。", + "checkpoint_diff_with_next": "与下一个存档点比较的更改", + "checkpoint_diff_since_first": "自第一个存档点以来的更改", + "checkpoint_diff_to_current": "对当前工作区的更改", "nested_git_repos_warning": "存档点已禁用,因为在 {{path}} 检测到嵌套的 git 仓库。要使用存档点,请移除或重新定位此嵌套的 git 仓库。", "no_workspace": "请先打开项目文件夹", "update_support_prompt": "更新支持消息失败", diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index 49aed9957495..29e9ba9252cd 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -32,6 +32,12 @@ "checkpoint_timeout": "嘗試恢復檢查點時超時。", "checkpoint_failed": "恢復檢查點失敗。", "git_not_installed": "存檔點功能需要 Git。請安裝 Git 以啟用存檔點。", + "checkpoint_no_first": "沒有第一個存檔點可供比較。", + "checkpoint_no_previous": "沒有上一個存檔點可供比較。", + "checkpoint_no_changes": "未發現任何變更。", + "checkpoint_diff_with_next": "與下一個存檔點比較的變更", + "checkpoint_diff_since_first": "自第一個存檔點以來的變更", + "checkpoint_diff_to_current": "對目前工作區的變更", "nested_git_repos_warning": "存檔點已停用,因為在 {{path}} 偵測到巢狀的 git 儲存庫。要使用存檔點,請移除或重新配置此巢狀的 git 儲存庫。", "no_workspace": "請先開啟專案資料夾", "update_support_prompt": "更新支援訊息失敗", diff --git a/src/integrations/misc/extract-text.ts b/src/integrations/misc/extract-text.ts index fa23cd1062f8..45adbe4831a9 100644 --- a/src/integrations/misc/extract-text.ts +++ b/src/integrations/misc/extract-text.ts @@ -191,7 +191,16 @@ export function stripLineNumbers(content: string, aggressive: boolean = false): // Join back with original line endings (carriage return (\r) + line feed (\n) or just line feed (\n)) const lineEnding = content.includes("\r\n") ? "\r\n" : "\n" - return processedLines.join(lineEnding) + let result = processedLines.join(lineEnding) + + // Preserve trailing newline if present in original content + if (content.endsWith(lineEnding)) { + if (!result.endsWith(lineEnding)) { + result += lineEnding + } + } + + return result } /** diff --git a/src/services/ripgrep/index.ts b/src/services/ripgrep/index.ts index 8dbf6437a5fb..3f5a18b76cff 100644 --- a/src/services/ripgrep/index.ts +++ b/src/services/ripgrep/index.ts @@ -150,7 +150,15 @@ export async function regexSearchFiles( throw new Error("Could not find ripgrep binary") } - const args = ["--json", "-e", regex, "--glob", filePattern || "*", "--context", "1", "--no-messages", directoryPath] + const args = ["--json", "-e", regex] + + // Only add --glob if a specific file pattern is provided + // Using --glob "*" overrides .gitignore behavior, so we omit it when no pattern is specified + if (filePattern) { + args.push("--glob", filePattern) + } + + args.push("--context", "1", "--no-messages", directoryPath) let output: string try { diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 298ff268a553..7e62044510e0 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -334,10 +334,10 @@ export interface WebviewMessage { } export const checkoutDiffPayloadSchema = z.object({ - ts: z.number(), + ts: z.number().optional(), previousCommitHash: z.string().optional(), commitHash: z.string(), - mode: z.enum(["full", "checkpoint"]), + mode: z.enum(["full", "checkpoint", "from-init", "to-current"]), }) export type CheckpointDiffPayload = z.infer diff --git a/webview-ui/src/components/chat/Announcement.tsx b/webview-ui/src/components/chat/Announcement.tsx index 370c7701a63f..20953df765f1 100644 --- a/webview-ui/src/components/chat/Announcement.tsx +++ b/webview-ui/src/components/chat/Announcement.tsx @@ -4,7 +4,6 @@ import { VSCodeLink } from "@vscode/webview-ui-toolkit/react" import { Package } from "@roo/package" import { useAppTranslation } from "@src/i18n/TranslationContext" -import { useExtensionState } from "@src/context/ExtensionStateContext" import { vscode } from "@src/utils/vscode" import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@src/components/ui" import { Button } from "@src/components/ui" @@ -25,7 +24,6 @@ interface AnnouncementProps { const Announcement = ({ hideAnnouncement }: AnnouncementProps) => { const { t } = useAppTranslation() const [open, setOpen] = useState(true) - const { cloudIsAuthenticated } = useExtensionState() return ( { {t("chat:announcement.title", { version: Package.version })}
-
- , - }} - /> + {/* Regular Release Highlights */} +
+

{t("chat:announcement.release.heading")}

+
    +
  • {t("chat:announcement.release.fileReading")}
  • +
  • {t("chat:announcement.release.browserUse")}
  • +
  • {t("chat:announcement.release.bugFixes")}
  • +
-

- {t("chat:announcement.stealthModel.note")} -

+ {/* Horizontal Rule */} +
+ + {/* Cloud Agents Section */} +
+

{t("chat:announcement.cloudAgents.heading")}

+ +
+ , + }} + /> +
+ +

+ {t("chat:announcement.cloudAgents.description")} +

-
- {!cloudIsAuthenticated ? ( +
- ) : ( - <> -

- , - }} - /> -

- - - )} +
diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index ac4dc7b07dd8..5aea5646c087 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -88,6 +88,7 @@ interface ChatRowProps { shouldHighlight?: boolean searchResults?: SearchResult[] searchQuery?: string + hasCheckpoint?: boolean } interface ChatRowContentProps extends Omit { diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 518db9a6957c..513b83083f6e 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -1569,6 +1569,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction ) } + const hasCheckpoint = modifiedMessages.some((message) => message.say === "checkpoint_saved") // regular message return ( @@ -1609,6 +1610,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction ) }, diff --git a/webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx b/webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx index c6948e5ce325..22802cd45535 100644 --- a/webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx +++ b/webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx @@ -14,25 +14,43 @@ type CheckpointMenuBaseProps = { checkpoint: Checkpoint } type CheckpointMenuControlledProps = { - open: boolean onOpenChange: (open: boolean) => void } type CheckpointMenuUncontrolledProps = { - open?: undefined onOpenChange?: undefined } type CheckpointMenuProps = CheckpointMenuBaseProps & (CheckpointMenuControlledProps | CheckpointMenuUncontrolledProps) -export const CheckpointMenu = ({ ts, commitHash, checkpoint, open, onOpenChange }: CheckpointMenuProps) => { +export const CheckpointMenu = ({ ts, commitHash, checkpoint, onOpenChange }: CheckpointMenuProps) => { const { t } = useTranslation() - const [internalOpen, setInternalOpen] = useState(false) - const [isConfirming, setIsConfirming] = useState(false) + const [internalRestoreOpen, setInternalRestoreOpen] = useState(false) + const [restoreConfirming, setRestoreConfirming] = useState(false) + const [internalMoreOpen, setInternalMoreOpen] = useState(false) const portalContainer = useRooPortal("roo-portal") const previousCommitHash = checkpoint?.from - const isOpen = open ?? internalOpen - const setOpen = onOpenChange ?? setInternalOpen + const restoreOpen = internalRestoreOpen + const moreOpen = internalMoreOpen + const setRestoreOpen = useCallback( + (open: boolean) => { + setInternalRestoreOpen(open) + if (onOpenChange) { + onOpenChange(open) + } + }, + [onOpenChange], + ) + + const setMoreOpen = useCallback( + (open: boolean) => { + setInternalMoreOpen(open) + if (onOpenChange) { + onOpenChange(open) + } + }, + [onOpenChange], + ) const onCheckpointDiff = useCallback(() => { vscode.postMessage({ @@ -41,24 +59,38 @@ export const CheckpointMenu = ({ ts, commitHash, checkpoint, open, onOpenChange }) }, [ts, previousCommitHash, commitHash]) + const onDiffFromInit = useCallback(() => { + vscode.postMessage({ + type: "checkpointDiff", + payload: { ts, commitHash, mode: "from-init" }, + }) + }, [ts, commitHash]) + + const onDiffWithCurrent = useCallback(() => { + vscode.postMessage({ + type: "checkpointDiff", + payload: { ts, commitHash, mode: "to-current" }, + }) + }, [ts, commitHash]) + const onPreview = useCallback(() => { vscode.postMessage({ type: "checkpointRestore", payload: { ts, commitHash, mode: "preview" } }) - setOpen(false) - }, [ts, commitHash, setOpen]) + setRestoreOpen(false) + }, [ts, commitHash, setRestoreOpen]) const onRestore = useCallback(() => { vscode.postMessage({ type: "checkpointRestore", payload: { ts, commitHash, mode: "restore" } }) - setOpen(false) - }, [ts, commitHash, setOpen]) + setRestoreOpen(false) + }, [ts, commitHash, setRestoreOpen]) const handleOpenChange = useCallback( (open: boolean) => { - setOpen(open) + setRestoreOpen(open) if (!open) { - setIsConfirming(false) + setRestoreConfirming(false) } }, - [setOpen], + [setRestoreOpen], ) return ( @@ -68,7 +100,13 @@ export const CheckpointMenu = ({ ts, commitHash, checkpoint, open, onOpenChange - + { + handleOpenChange(open) + setRestoreConfirming(false) + }} + data-testid="restore-popover">
- {!isConfirming ? ( + {!restoreConfirming ? ( @@ -106,7 +144,7 @@ export const CheckpointMenu = ({ ts, commitHash, checkpoint, open, onOpenChange
{t("chat:checkpoint.menu.confirm")}
- )} - {isConfirming ? ( + {restoreConfirming ? (
{t("chat:checkpoint.menu.cannotUndo")}
@@ -127,6 +165,37 @@ export const CheckpointMenu = ({ ts, commitHash, checkpoint, open, onOpenChange
+ setMoreOpen(open)} data-testid="more-popover"> + + + + + + +
+ + +
+
+
) } diff --git a/webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx b/webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx index 710c2b3287c7..1bc510266622 100644 --- a/webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx +++ b/webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx @@ -85,9 +85,9 @@ export const CheckpointSaved = ({ checkpoint, currentHash, ...props }: Checkpoin data-testid="checkpoint-menu-container" className={cn("h-4 -mt-2", menuVisible ? "block" : "hidden group-hover:block")}> diff --git a/webview-ui/src/components/chat/checkpoints/__tests__/CheckpointSaved.spec.tsx b/webview-ui/src/components/chat/checkpoints/__tests__/CheckpointSaved.spec.tsx index 3e1cd2a46524..043e7eb7299b 100644 --- a/webview-ui/src/components/chat/checkpoints/__tests__/CheckpointSaved.spec.tsx +++ b/webview-ui/src/components/chat/checkpoints/__tests__/CheckpointSaved.spec.tsx @@ -1,14 +1,20 @@ // npx vitest run src/components/chat/checkpoints/__tests__/CheckpointSaved.spec.tsx +// Capture onOpenChange from Popover to control open/close in tests +let lastOnOpenChange: ((open: boolean) => void) | undefined + vi.mock("@/components/ui", () => { // Minimal UI primitives to ensure deterministic behavior in tests return { Button: ({ children, ...rest }: any) => , StandardTooltip: ({ children }: any) => <>{children}, - Popover: ({ children, onOpenChange, open }: any) => { - lastOnOpenChange = onOpenChange + Popover: (props: any) => { + const { children, onOpenChange, open, ...rest } = props + if (rest["data-testid"] === "restore-popover") { + lastOnOpenChange = onOpenChange + } return ( -
+
{children}
) @@ -23,9 +29,6 @@ import React from "react" import userEvent from "@testing-library/user-event" import { CheckpointSaved } from "../CheckpointSaved" -// Capture onOpenChange from Popover to control open/close in tests -let lastOnOpenChange: ((open: boolean) => void) | undefined - const waitForOpenHandler = async () => { await waitFor(() => { // ensure Popover mock captured the onOpenChange handler before using it @@ -101,7 +104,7 @@ describe("CheckpointSaved popover visibility", () => { it("closes popover after preview and after confirm restore", async () => { const { getByTestId } = render() - const popoverRoot = () => getByTestId("popover-root") + const popoverRoot = () => getByTestId("restore-popover") const menuContainer = () => getByTestId("checkpoint-menu-container") // Open diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 2da35d4c1d4b..afacf116f644 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -155,6 +155,9 @@ "initializingWarning": "Still initializing checkpoint... If this takes too long, you can disable checkpoints in settings and restart your task.", "menu": { "viewDiff": "View Diff", + "more": "More options", + "viewDiffFromInit": "View All Changes", + "viewDiffWithCurrent": "View Changes Since This Checkpoint", "restore": "Restore Checkpoint", "restoreFiles": "Restore Files", "restoreFilesDescription": "Restores your project's files back to a snapshot taken at this point.", @@ -305,6 +308,18 @@ "selectModel": "Select roo/code-supernova from the Roo Code Cloud provider in Settings to get started.", "goToSettingsButton": "Go to Settings" }, + "release": { + "heading": "New in the Extension:", + "fileReading": "Intelligent file reading that avoids context overruns", + "browserUse": "Browser-use tool enabled for all image-capable models", + "bugFixes": "Many bug fixes and model updates" + }, + "cloudAgents": { + "heading": "New in the Cloud:", + "feature": "Introducing Roo Code's first Cloud Agent, the PR Reviewer.", + "description": "It runs Roo in the cloud, giving extremely high quality code reviews instantly. We've been using it heavily to build Roo and now it's also available to the community.", + "createAgentButton": "Try out PR Reviewer" + }, "socialLinks": "Join us on X, Discord, or r/RooCode 🚀" }, "reasoning": { diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index 6958253c82d2..011ec70fbfff 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -149,6 +149,9 @@ "initializingWarning": "正在初始化检查点...如果耗时过长,你可以在设置中禁用检查点并重新启动任务。", "menu": { "viewDiff": "查看差异", + "more": "更多选项", + "viewDiffFromInit": "查看所有更改", + "viewDiffWithCurrent": "查看自此检查点以来的更改", "restore": "恢复检查点", "restoreFiles": "恢复文件", "restoreFilesDescription": "将项目文件恢复到此检查点状态", @@ -298,6 +301,18 @@ "selectModel": "在设置中从 Roo Code Cloud 提供商选择 roo/code-supernova 开始使用。", "goToSettingsButton": "前往设置" }, + "release": { + "heading": "扩展新功能:", + "fileReading": "智能文件读取,避免上下文溢出", + "browserUse": "浏览器工具已为所有支持图像的模型启用", + "bugFixes": "大量错误修复和模型更新" + }, + "cloudAgents": { + "heading": "云端新功能:", + "feature": "推出 Roo Code 首个云端 Agent,PR 审查员。", + "description": "在云端运行 Roo,即时提供极高质量的代码审查。我们在构建 Roo 时大量使用它,现在也向社区开放。", + "createAgentButton": "试用 PR 审查员" + }, "socialLinks": "在 XDiscordr/RooCode 上关注我们 🚀" }, "browser": { diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 55a20ffc8a31..33c63a11f399 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -155,6 +155,9 @@ "initializingWarning": "正在初始化檢查點... 如果耗時過長,您可以在設定中停用檢查點並重新啟動工作。", "menu": { "viewDiff": "檢視差異", + "more": "更多選項", + "viewDiffFromInit": "檢視所有變更", + "viewDiffWithCurrent": "檢視自此檢查點以來的變更", "restore": "還原檢查點", "restoreFiles": "還原檔案", "restoreFilesDescription": "將您的專案檔案還原到此時的快照。", @@ -307,6 +310,18 @@ "selectModel": "在設定中從 Roo Code Cloud 提供商選擇 roo/code-supernova 開始使用。", "goToSettingsButton": "前往設定" }, + "release": { + "heading": "擴充功能的新功能:", + "fileReading": "智慧檔案讀取,避免上下文溢位", + "browserUse": "為所有支援圖像的模型啟用瀏覽器使用工具", + "bugFixes": "許多錯誤修復和模型更新" + }, + "cloudAgents": { + "heading": "雲端的新功能:", + "feature": "介紹 Roo Code 的第一個雲端代理,PR Reviewer。", + "description": "它在雲端執行 Roo,立即提供極高品質的程式碼審查。我們在建立 Roo 時大量使用它,現在它也可供社群使用。", + "createAgentButton": "試用 PR Reviewer" + }, "socialLinks": "在 XDiscordr/RooCode 上關注我們 🚀" }, "reasoning": {