diff --git a/Packages/Sources/RxCodeCore/Hooks/HookController.swift b/Packages/Sources/RxCodeCore/Hooks/HookController.swift index 6931ef2..b817853 100644 --- a/Packages/Sources/RxCodeCore/Hooks/HookController.swift +++ b/Packages/Sources/RxCodeCore/Hooks/HookController.swift @@ -286,6 +286,17 @@ public protocol HookController: AnyObject { /// (plus the "all projects" items). Backs the `CustomMenuHook`. func customMenuItems(projectId: UUID?, surface: CustomMenuItemRecord.Surface) -> [CustomMenuItemRecord] + /// Whether a custom menu item passes its show condition in this context. + /// `always` items always pass; a `swiftScript` item is gated on its compiled + /// script (evaluated asynchronously and cached — a cache miss shows the item, + /// failing open). Synchronous so it fits the context-menu build path. + func shouldShowConditionalMenuItem( + _ record: CustomMenuItemRecord, + project: Project, + branch: String?, + sessionId: String? + ) -> Bool + /// Centralized presentation actions used by hook-supplied context menus. func requestSecretsSetup(project: Project) func requestSecretsDownload(project: Project) diff --git a/Packages/Sources/RxCodeCore/Models/CustomMenuItemRecord.swift b/Packages/Sources/RxCodeCore/Models/CustomMenuItemRecord.swift index 66c441e..e5566e6 100644 --- a/Packages/Sources/RxCodeCore/Models/CustomMenuItemRecord.swift +++ b/Packages/Sources/RxCodeCore/Models/CustomMenuItemRecord.swift @@ -30,6 +30,16 @@ public final class CustomMenuItemRecord { case continueThread } + /// How the item's visibility is decided. + public enum ConditionType: String, Codable, Sendable, CaseIterable { + /// No condition — the item is always shown (the default). + case always + /// A user-authored Swift script with a + /// `func checkShowMenu(context: Context) async throws -> Bool` that returns + /// `true` to show the item. Evaluated on the desktop at menu-build time. + case swiftScript + } + @Attribute(.unique) public var id: String /// User-entered display title (plain text — not localized). public var title: String @@ -55,6 +65,25 @@ public final class CustomMenuItemRecord { /// Target thread id for `continueThread`. public var targetSessionId: String? + // Show condition + /// Raw `ConditionType` value. Defaults to `always` so existing records — and + /// any new item without a condition — are simply always shown. + /// + /// The `= always` default is also load-bearing for SwiftData lightweight + /// migration: adding a non-optional attribute to an existing entity requires a + /// default, otherwise the store fails to open and the whole container (which + /// also holds `ChatThread`) falls back to an empty in-memory store — wiping + /// all chat history on the next launch. + public var conditionType: String = ConditionType.always.rawValue + /// The Swift source for a `swiftScript` condition (the user's + /// `checkShowMenu(context:)` function). `nil` for `always`. + public var conditionScript: String? + /// Whether `conditionScript` last compiled successfully. The editor refuses to + /// save a `swiftScript` item until this is `true`, so a record on disk with a + /// script is always known-compiled. Defaulted for lightweight migration (see + /// `conditionType`). + public var conditionCompiled: Bool = false + public var isEnabled: Bool public var sortOrder: Int public var createdAt: Date @@ -73,6 +102,9 @@ public final class CustomMenuItemRecord { bodyTemplate: String? = nil, messageTemplate: String? = nil, targetSessionId: String? = nil, + conditionType: ConditionType = .always, + conditionScript: String? = nil, + conditionCompiled: Bool = false, isEnabled: Bool = true, sortOrder: Int = 0, createdAt: Date = .now, @@ -90,6 +122,9 @@ public final class CustomMenuItemRecord { self.bodyTemplate = bodyTemplate self.messageTemplate = messageTemplate self.targetSessionId = targetSessionId + self.conditionType = conditionType.rawValue + self.conditionScript = conditionScript + self.conditionCompiled = conditionCompiled self.isEnabled = isEnabled self.sortOrder = sortOrder self.createdAt = createdAt @@ -108,6 +143,9 @@ public final class CustomMenuItemRecord { /// First surface — kept for callers that only need a single representative value. public var surfaceValue: Surface { surfaces.first ?? .project } public var actionKindValue: ActionKind { ActionKind(rawValue: actionKind) ?? .createThread } + /// Decoded condition type. Falls back to `always` for empty/legacy values so an + /// item without a stored condition is always shown. + public var conditionTypeValue: ConditionType { ConditionType(rawValue: conditionType) ?? .always } /// Encode a surface list to the stored comma-separated representation. Falls /// back to `project` when empty so a record always has a home. diff --git a/RxCode.xcodeproj/project.pbxproj b/RxCode.xcodeproj/project.pbxproj index 5f833b5..707143e 100644 --- a/RxCode.xcodeproj/project.pbxproj +++ b/RxCode.xcodeproj/project.pbxproj @@ -47,7 +47,6 @@ DF46683A2FCDB56B002D9562 /* JSONSchemaForm in Frameworks */ = {isa = PBXBuildFile; productRef = DF4668392FCDB56B002D9562 /* JSONSchemaForm */; }; DF46683C2FCDB56B002D9562 /* JSONSchemaValidator in Frameworks */ = {isa = PBXBuildFile; productRef = DF46683B2FCDB56B002D9562 /* JSONSchemaValidator */; }; DFA0CCD12FB4CC01005991E1 /* PlanDecisionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCC02FB4CC01005991E1 /* PlanDecisionTests.swift */; }; - FE0A11BB22CC33DD44EE5501 /* PlanModeHookSuppressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE0A11BB22CC33DD44EE5502 /* PlanModeHookSuppressionTests.swift */; }; DFA0CCD22FB4CC01005991E1 /* PlanCardViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCC12FB4CC01005991E1 /* PlanCardViewTests.swift */; }; DFA0CCD42FB4CC01005991E1 /* RxCodeChatKit in Frameworks */ = {isa = PBXBuildFile; productRef = DFA0CCC32FB4CC01005991E1 /* RxCodeChatKit */; }; DFA0CCE12FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCD52FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift */; }; @@ -65,6 +64,7 @@ FB0000040000000000000001 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = FB0000020000000000000001 /* FirebaseCrashlytics */; }; FB0000070000000000000001 /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = FB0000050000000000000001 /* FirebaseAnalytics */; }; FB0000080000000000000001 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = FB0000060000000000000001 /* FirebaseCrashlytics */; }; + FE0A11BB22CC33DD44EE5501 /* PlanModeHookSuppressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE0A11BB22CC33DD44EE5502 /* PlanModeHookSuppressionTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -155,11 +155,11 @@ DF5B0DDC2FC023C8000CE36F /* MobileUITestPlan-iPad.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = "MobileUITestPlan-iPad.xctestplan"; sourceTree = ""; }; DF5B0DDE2FCB300100CE36F /* MobileUnitTestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MobileUnitTestPlan.xctestplan; sourceTree = ""; }; DFA0CCC02FB4CC01005991E1 /* PlanDecisionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlanDecisionTests.swift; sourceTree = ""; }; - FE0A11BB22CC33DD44EE5502 /* PlanModeHookSuppressionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlanModeHookSuppressionTests.swift; sourceTree = ""; }; DFA0CCC12FB4CC01005991E1 /* PlanCardViewTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlanCardViewTests.swift; sourceTree = ""; }; DFA0CCD52FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HistoryListArchiveFilterTests.swift; sourceTree = ""; }; E62000002FCB000100000001 /* MemoryIntentTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MemoryIntentTests.swift; sourceTree = ""; }; E67335382F7356F600FD26C7 /* RxCode.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RxCode.app; sourceTree = BUILT_PRODUCTS_DIR; }; + FE0A11BB22CC33DD44EE5502 /* PlanModeHookSuppressionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlanModeHookSuppressionTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ diff --git a/RxCode/App/AppState+MenuConditions.swift b/RxCode/App/AppState+MenuConditions.swift new file mode 100644 index 0000000..ea0a31a --- /dev/null +++ b/RxCode/App/AppState+MenuConditions.swift @@ -0,0 +1,132 @@ +#if os(macOS) +import Foundation +import os +import RxCodeCore + +private let menuConditionLogger = Logger(subsystem: "com.rxlab.RxCode", category: "MenuCondition") + +/// Show-condition evaluation for custom context-menu items. Menu building is +/// synchronous (SwiftUI builds context menus the instant they open), but a Swift +/// script condition has to compile and run a subprocess. So the synchronous path +/// reads `menuConditionResults`; a cache miss shows the item and triggers an async +/// evaluation that fills the cache and bumps `customMenuItemsRevision` so the next +/// menu open reflects the real result. The mobile relay path is async, so it can +/// `warmMenuConditions(...)` up front and get accurate results on the first fetch. +extension AppState { + /// Synchronous gate used by `CustomMenuHook`. Returns whether `record` should + /// appear, given its condition. `always` items and items without a script show + /// unconditionally. For a `swiftScript` item: a cached result is authoritative; + /// a miss returns `true` (show) and schedules a background evaluation. + func shouldShowConditionalMenuItem( + _ record: CustomMenuItemRecord, + project: Project, + branch: String?, + sessionId: String? + ) -> Bool { + guard record.conditionTypeValue == .swiftScript, + let script = record.conditionScript, !script.isEmpty else { + menuConditionLogger.debug("[ContextMenuCondition] shouldShow[\(record.title, privacy: .public)]: conditionType=\(record.conditionType, privacy: .public) scriptEmpty=\((record.conditionScript ?? "").isEmpty) -> show=true (no condition)") + return true + } + + let key = menuConditionKey(record: record, project: project, branch: branch, sessionId: sessionId) + if let cached = menuConditionResults[key] { + menuConditionLogger.debug("[ContextMenuCondition] shouldShow[\(record.title, privacy: .public)]: cache HIT key=\(key, privacy: .public) -> show=\(cached, privacy: .public)") + return cached + } + + // Miss: show by default now, evaluate in the background, refresh on result. + let inFlight = menuConditionInFlight.contains(key) + menuConditionLogger.debug("[ContextMenuCondition] shouldShow[\(record.title, privacy: .public)]: cache MISS key=\(key, privacy: .public) inFlight=\(inFlight) -> show=true (default), scheduling eval") + if !inFlight { + menuConditionInFlight.insert(key) + let context = menuConditionContext(project: project, branch: branch, sessionId: sessionId) + Task { [weak self] in + guard let self else { return } + let result = await self.menuConditionEvaluator.evaluate(script: script, context: context) + self.storeMenuConditionResult(result, forKey: key) + } + } + return true + } + + /// Eagerly evaluate every conditional item for `project` on the surface implied + /// by `branch`/`sessionId`, populating `menuConditionResults` before a + /// (synchronous) menu build. Used by the mobile relay so the phone's first + /// fetch already reflects the conditions. + func warmMenuConditions(for project: Project, branch: String?, sessionId: String?) async { + let surface: CustomMenuItemRecord.Surface = sessionId != nil + ? .thread + : (branch != nil ? .briefing : .project) + + let conditional = threadStore.customMenuItems(projectId: project.id, surface: surface) + .filter { $0.conditionTypeValue == .swiftScript && !($0.conditionScript ?? "").isEmpty } + guard !conditional.isEmpty else { return } + + let context = menuConditionContext(project: project, branch: branch, sessionId: sessionId) + for record in conditional { + guard let script = record.conditionScript else { continue } + let key = menuConditionKey(record: record, project: project, branch: branch, sessionId: sessionId) + let result = await menuConditionEvaluator.evaluate(script: script, context: context) + menuConditionResults[key] = result + } + } + + // MARK: - Editor support + + /// Compile a condition script (used by the editor's Compile button). Returns + /// whether it built and, on failure, the compiler diagnostics. + func compileMenuConditionScript(_ script: String) async -> CustomMenuConditionEvaluator.CompileResult { + await menuConditionEvaluator.compile(script: script) + } + + /// Generate a condition script from a natural-language requirement using the + /// app's default model (falling back to the Claude default when the configured + /// default provider isn't Claude Code). + func generateMenuConditionScript(requirement: String, project: Project?) async -> String? { + let selection = defaultModelSelection(for: project) + let model = selection.provider == .claudeCode ? selection.model : "default" + return await claude.generateConditionScript(requirement: requirement, model: model) + } + + // MARK: - Helpers + + private func menuConditionContext( + project: Project, + branch: String?, + sessionId: String? + ) -> CustomMenuConditionEvaluator.Context { + CustomMenuConditionEvaluator.Context( + projectName: project.name, + projectPath: project.path, + gitHubRepo: project.gitHubRepo ?? "", + branch: branch ?? "", + sessionId: sessionId ?? "" + ) + } + + /// Cache key for one item in one context. `updatedAt` invalidates the entry + /// when the item (and thus its script) is edited. + private func menuConditionKey( + record: CustomMenuItemRecord, + project: Project, + branch: String?, + sessionId: String? + ) -> String { + "\(record.id)|\(record.updatedAt.timeIntervalSince1970)|\(project.id.uuidString)|\(branch ?? "")|\(sessionId ?? "")" + } + + private func storeMenuConditionResult(_ result: Bool, forKey key: String) { + menuConditionInFlight.remove(key) + let previous = menuConditionResults[key] + menuConditionResults[key] = result + // Re-render the menu when the result changes what's shown. A first-time + // `true` matches the show-by-default assumption, so it needs no refresh. + let willRefresh = previous != result && !(previous == nil && result == true) + menuConditionLogger.debug("[ContextMenuCondition] storeResult: key=\(key, privacy: .public) previous=\(previous.map(String.init(describing:)) ?? "nil", privacy: .public) result=\(result, privacy: .public) refresh=\(willRefresh) revision=\(self.customMenuItemsRevision)") + if willRefresh { + customMenuItemsRevision += 1 + } + } +} +#endif diff --git a/RxCode/App/AppState+MobileAutopilot.swift b/RxCode/App/AppState+MobileAutopilot.swift index a1967e3..c8a05ff 100644 --- a/RxCode/App/AppState+MobileAutopilot.swift +++ b/RxCode/App/AppState+MobileAutopilot.swift @@ -428,6 +428,9 @@ extension AppState { guard let project = projects.first(where: { $0.id == body.projectId }) else { throw MobileRemoteConfigError.invalidRequest("No project found for the requested id.") } + // Evaluate Swift-script show conditions up front so the phone's first + // fetch already reflects them (the menu build itself is synchronous). + await warmMenuConditions(for: project, branch: body.branch, sessionId: nil) return try encoder.encode(AutopilotMenuResult(items: projectContextMenuItems(for: project, branch: body.branch, locale: body.locale))) case .menuForThread: @@ -436,6 +439,9 @@ extension AppState { ?? threadStore.fetch(id: body.sessionId)?.toSummary() else { throw MobileRemoteConfigError.invalidRequest("No thread found for the requested id.") } + if let project = projects.first(where: { $0.id == summary.projectId }) { + await warmMenuConditions(for: project, branch: nil, sessionId: summary.id) + } return try encoder.encode(AutopilotMenuResult(items: threadContextMenuItems(for: summary, locale: body.locale))) case .menuExecuteCommand: diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index 415751c..75b3c60 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -254,6 +254,17 @@ final class AppState { /// an app restart. var customMenuItemsRevision: Int = 0 + /// Compiles + runs user-authored Swift "show condition" scripts for custom + /// menu items. Shared across windows; results land in `menuConditionResults`. + let menuConditionEvaluator = CustomMenuConditionEvaluator() + /// Cached condition results keyed by `menuConditionKey(...)`. The synchronous + /// menu builder reads this; a cache miss shows the item and kicks off an async + /// evaluation that fills the cache and bumps `customMenuItemsRevision`. + var menuConditionResults: [String: Bool] = [:] + /// Keys with an evaluation currently in flight, so repeated menu opens don't + /// spawn duplicate evaluators before the first result lands. + var menuConditionInFlight: Set = [] + /// Pending permission/question prompts keyed by hook id. This mirrors the /// per-window queues so mobile thread rows can show the same attention state. var mobilePendingRequests: [String: PermissionRequest] = [:] diff --git a/RxCode/Resources/Localizable.xcstrings b/RxCode/Resources/Localizable.xcstrings index a1bb85d..9529beb 100644 --- a/RxCode/Resources/Localizable.xcstrings +++ b/RxCode/Resources/Localizable.xcstrings @@ -767,6 +767,22 @@ } } }, + "`context` exposes projectName, projectPath, gitHubRepo, branch, sessionId, and async `shell(_:)` / `git(_:)` helpers that run in the project directory. Return true to show the item." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "`context`는 projectName, projectPath, gitHubRepo, branch, sessionId와 프로젝트 디렉터리에서 실행되는 async `shell(_:)` / `git(_:)` 헬퍼를 제공합니다. 항목을 표시하려면 true를 반환하세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "`context` 提供 projectName、projectPath、gitHubRepo、branch、sessionId,以及在项目目录中运行的异步 `shell(_:)` / `git(_:)` 辅助函数。返回 true 以显示该项。" + } + } + } + }, "^[%lld hook](inflect: true)" : { "localizations" : { "ko" : { @@ -1015,7 +1031,20 @@ } }, "Active" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "활성" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "活跃" + } + } + } }, "Add" : { "localizations" : { @@ -1770,6 +1799,22 @@ } } }, + "AI generate" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI 생성" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI 生成" + } + } + } + }, "All archived chats in the current project will be deleted. This action cannot be undone." : { "localizations" : { "ko" : { @@ -1986,6 +2031,22 @@ } } }, + "Always" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "항상" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "始终" + } + } + } + }, "Always allow this command" : { "localizations" : { "en" : { @@ -2026,7 +2087,20 @@ } }, "An SF Symbol shown beside the title, e.g. \"bolt\"." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "제목 옆에 표시되는 SF Symbol입니다. 예: \"bolt\"." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示在标题旁边的 SF Symbol,例如 “bolt”。" + } + } + } }, "API Key" : { "localizations" : { @@ -3858,6 +3932,38 @@ } } }, + "Compile" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "컴파일" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "编译" + } + } + } + }, + "Compiled" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "컴파일됨" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已编译" + } + } + } + }, "Condition" : { "localizations" : { "ko" : { @@ -4278,6 +4384,22 @@ } } }, + "Continues the thread this menu is opened from — its session id is filled in automatically." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 메뉴를 연 스레드를 계속합니다 — 세션 ID가 자동으로 채워집니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "继续打开此菜单的对话——其会话 ID 会自动填入。" + } + } + } + }, "Control what autopilot does automatically — issue labeling, PR validation and linking, project field population, and more." : { "localizations" : { "ko" : { @@ -4652,10 +4774,36 @@ } }, "Create Workspace" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "작업 공간 생성" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "创建工作区" + } + } + } }, "Create Workspace..." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "작업 공간 생성..." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "创建工作区..." + } + } + } }, "Create worktree" : { "localizations" : { @@ -5390,7 +5538,20 @@ } }, "Delete Workspace" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "작업 공간 삭제" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除工作区" + } + } + } }, "Deleting \"%@\"…" : { "localizations" : { @@ -5430,6 +5591,22 @@ } } }, + "Describe when this menu item should appear. The default model writes the Swift condition and it's compiled before use." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 메뉴 항목이 언제 표시되어야 하는지 설명하세요. 기본 모델이 Swift 조건을 작성하고 사용 전에 컴파일됩니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "描述此菜单项应在何时出现。默认模型会编写 Swift 条件,并在使用前进行编译。" + } + } + } + }, "Destination" : { "localizations" : { "ko" : { @@ -6956,6 +7133,22 @@ } } }, + "func checkShowMenu(context: Context) async throws -> Bool" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "func checkShowMenu(context: Context) async throws -> Bool" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "func checkShowMenu(context: Context) async throws -> Bool" + } + } + } + }, "General" : { "localizations" : { "en" : { @@ -7010,6 +7203,22 @@ } } }, + "Generate condition with AI" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI로 조건 생성" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用 AI 生成条件" + } + } + } + }, "Get it on Google Play" : { "localizations" : { "ko" : { @@ -7582,7 +7791,20 @@ } }, "Icon" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아이콘" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "图标" + } + } + } }, "Images" : { "localizations" : { @@ -8789,10 +9011,36 @@ } }, "Manage Workspaces" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "작업 공간 관리" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "管理工作区" + } + } + } }, "Manage Workspaces..." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "작업 공간 관리..." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "管理工作区..." + } + } + } }, "Manage your repositories, environments, and secrets." : { "localizations" : { @@ -9911,7 +10159,20 @@ } }, "No matches." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "일치 항목 없음." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无匹配项。" + } + } + } }, "No matching memories" : { "localizations" : { @@ -10456,6 +10717,22 @@ } } }, + "Not compiled" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "컴파일되지 않음" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未编译" + } + } + } + }, "not found" : { "localizations" : { "ko" : { @@ -11431,7 +11708,20 @@ } }, "Pick one or more menus this item appears on." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 항목이 표시될 메뉴를 하나 이상 선택하세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择此项出现的一个或多个菜单。" + } + } + } }, "Pick which model performs the review — defaults to the same model as the thread." : { "localizations" : { @@ -12599,7 +12889,20 @@ } }, "Rename Workspace" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "작업 공간 이름 변경" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重命名工作区" + } + } + } }, "Rename…" : { "localizations" : { @@ -13767,7 +14070,20 @@ } }, "Search symbols" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "심볼 검색" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "搜索符号" + } + } + } }, "Search Threads (⌘K)" : { "extractionState" : "stale", @@ -14593,6 +14909,22 @@ } } }, + "Show condition" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "표시 조건" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示条件" + } + } + } + }, "Show current project only" : { "localizations" : { "en" : { @@ -14749,6 +15081,22 @@ } } }, + "Show this item" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 항목 표시" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示此项" + } + } + } + }, "Show thread summary" : { "localizations" : { "ko" : { @@ -15419,7 +15767,20 @@ } }, "Switch Workspace" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "작업 공간 전환" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "切换工作区" + } + } + } }, "Tap to answer" : { "comment" : "Notification body when Claude invokes AskUserQuestion.", @@ -15455,6 +15816,7 @@ } }, "Target thread id" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -15599,6 +15961,7 @@ } }, "The session id of the thread to continue. Leave a {{sessionId}} placeholder to target the tapped thread." : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -15765,7 +16128,20 @@ } }, "This removes the workspace and its settings. Files on disk are not deleted." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "작업 공간과 해당 설정이 제거됩니다. 디스크의 파일은 삭제되지 않습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "这将移除该工作区及其设置。磁盘上的文件不会被删除。" + } + } + } }, "This session will be deleted. This action cannot be undone." : { "localizations" : { @@ -16752,6 +17128,22 @@ } } }, + "When a Swift script returns true" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Swift 스크립트가 true를 반환할 때" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当 Swift 脚本返回 true 时" + } + } + } + }, "When CI fails on a project's current branch, automatically start a thread so an agent can fix it. CI failures are always notified; this only controls the automatic fix." : { "localizations" : { "ko" : { @@ -16914,7 +17306,20 @@ } }, "Workspace name" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "작업 공간 이름" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "工作区名称" + } + } + } }, "Wrote %@ to the project." : { "localizations" : { @@ -16998,4 +17403,4 @@ } }, "version" : "1.1" -} \ No newline at end of file +} diff --git a/RxCode/Services/ClaudeService+Summaries.swift b/RxCode/Services/ClaudeService+Summaries.swift index f36f8f5..8a942ef 100644 --- a/RxCode/Services/ClaudeService+Summaries.swift +++ b/RxCode/Services/ClaudeService+Summaries.swift @@ -170,6 +170,75 @@ extension ClaudeCodeServer { return await generatePlainSummary(prompt: prompt, model: model, limit: 4000) } + /// Generate a Swift "show condition" for a custom menu item from a natural + /// language requirement. Returns *only* the `checkShowMenu(context:)` function + /// body (markdown fences stripped) — the caller compiles it before accepting. + /// Unlike `generatePlainSummary`, the output is preserved verbatim (no summary + /// sanitizer) so code formatting/newlines survive. + func generateConditionScript(requirement: String, model: String) async -> String? { + guard let binary = await findClaudeBinary() else { return nil } + let emptyMCPConfigPath = writeEmptyMCPConfig() + let prompt = """ + You are writing a Swift "show condition" for a custom context-menu item in a macOS app. + Output ONLY a single Swift function — no prose, no markdown, no extra declarations: + + func checkShowMenu(context: Context) async throws -> Bool { ... } + + Return true to SHOW the menu item, false to HIDE it. + + The `Context` type is already defined elsewhere — do NOT redeclare it. Its API: + struct Context { + let projectName: String + let projectPath: String + let gitHubRepo: String // "owner/repo", or empty + let branch: String // current branch, or empty + let sessionId: String // thread id, or empty + // runs in the project directory, returns trimmed combined output: + func shell(_ command: String) async throws -> String + func git(_ args: String...) async throws -> String + } + + Foundation is available. Keep the check read-only (no mutations). Use `try await` + for context.shell/context.git. Return only the function. + + Requirement: \(requirement) + """ + var args: [String] = ["-p", prompt, "--output-format", "text", "--model", model] + if let emptyMCPConfigPath { + args.append(contentsOf: ["--strict-mcp-config", "--mcp-config", emptyMCPConfigPath]) + } + do { + let output = try await runShellCommand(binary, arguments: args) + return Self.extractGeneratedSwift(from: output) + } catch { + logger.warning("Condition script generation failed: \(error.localizedDescription)") + return nil + } + } + + /// Pull the Swift source out of a model reply, stripping a single ```/```swift + /// fenced block if present. Returns nil for empty output. + static func extractGeneratedSwift(from raw: String) -> String? { + var text = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return nil } + + if let fenceStart = text.range(of: "```") { + var rest = String(text[fenceStart.upperBound...]) + // Drop an optional language tag (e.g. "swift") on the opening fence line. + if let newline = rest.firstIndex(of: "\n") { + let firstLine = rest[.. String? { guard let binary = await findClaudeBinary() else { return nil } let emptyMCPConfigPath = writeEmptyMCPConfig() diff --git a/RxCode/Services/CustomMenuConditionEvaluator.swift b/RxCode/Services/CustomMenuConditionEvaluator.swift new file mode 100644 index 0000000..caa5805 --- /dev/null +++ b/RxCode/Services/CustomMenuConditionEvaluator.swift @@ -0,0 +1,332 @@ +#if os(macOS) +import Foundation +import CryptoKit +import os +import RxCodeCore + +/// Compiles and runs user-authored Swift "show condition" scripts for custom +/// context-menu items (`CustomMenuItemRecord` with `conditionType == .swiftScript`). +/// +/// The user writes a single function: +/// ```swift +/// func checkShowMenu(context: Context) async throws -> Bool { ... } +/// ``` +/// We wrap it in a generated harness (`conditionHarness`) that defines the +/// `Context` struct — exposing the project name/path and `shell`/`git` helpers — +/// and an async `@main` runner that decodes the context from the environment, +/// `try await`s the user's function, and prints `"true"`/`"false"`. +/// +/// Compilation goes through `xcrun swiftc`; the resulting binary is cached by a +/// SHA-256 of the full harness+script source so a given script is compiled once. +/// Evaluation just runs that cached binary with the context in its environment. +/// +/// Every failure mode (no toolchain, compile error, runtime crash, timeout, +/// non-`true` output) resolves to **show** the item — a broken condition never +/// silently hides a menu entry the user configured. +actor CustomMenuConditionEvaluator { + private let logger = Logger(subsystem: "com.rxlab.RxCode", category: "MenuCondition") + + /// Hard cap on how long a single condition may run before we give up and show + /// the item. Keeps a runaway script from wedging menu building. + private let evaluationTimeout: TimeInterval = 3 + + struct CompileResult: Sendable { + let success: Bool + /// Compiler diagnostics (stderr), surfaced to the editor on failure. + let diagnostics: String + let binaryPath: String? + } + + struct Context: Sendable { + let projectName: String + let projectPath: String + let gitHubRepo: String + let branch: String + let sessionId: String + + var environment: [String: String] { + [ + "RXC_PROJECT_NAME": projectName, + "RXC_PROJECT_PATH": projectPath, + "RXC_GITHUB_REPO": gitHubRepo, + "RXC_BRANCH": branch, + "RXC_SESSION_ID": sessionId, + ] + } + } + + // MARK: - Public API + + /// Compile `script` (the user's `checkShowMenu` function). Returns whether it + /// built and, on failure, the compiler diagnostics. On success the binary is + /// left in the cache so a later `evaluate` runs instantly. + func compile(script: String) async -> CompileResult { + let source = Self.conditionHarness(userScript: script) + let binaryURL = cachedBinaryURL(forSource: source) + + // Already compiled this exact source — nothing to do. + if FileManager.default.isExecutableFile(atPath: binaryURL.path) { + return CompileResult(success: true, diagnostics: "", binaryPath: binaryURL.path) + } + + do { + return try await compile(source: source, to: binaryURL) + } catch { + return CompileResult(success: false, diagnostics: "Failed to run the Swift compiler: \(error.localizedDescription)", binaryPath: nil) + } + } + + /// Evaluate a script for `context`, returning whether the item should be shown. + /// Compiles on demand if the binary isn't cached. Any failure → `true` (show). + func evaluate(script: String, context: Context) async -> Bool { + let source = Self.conditionHarness(userScript: script) + let binaryURL = cachedBinaryURL(forSource: source) + + logger.debug("[ContextMenuCondition] evaluate: project=\(context.projectName, privacy: .public) branch=\(context.branch, privacy: .public) session=\(context.sessionId, privacy: .public) cachedBinary=\(binaryURL.lastPathComponent, privacy: .public)") + + if !FileManager.default.isExecutableFile(atPath: binaryURL.path) { + logger.debug("[ContextMenuCondition] evaluate: binary not cached — compiling") + let result = await compile(script: script) + guard result.success else { + logger.warning("[ContextMenuCondition] Menu condition failed to compile; showing item by default. diagnostics=\(result.diagnostics, privacy: .public)") + return true + } + } + + do { + // Inject a resolved login PATH so the rc-less harness shell still finds + // user-installed tools (git is in the default PATH, but gh/node/etc. may + // not be). + var environment = context.environment + environment["PATH"] = await loginPath() + let output = try await run(binaryURL, environment: environment, timeout: evaluationTimeout) + let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) + // Anything other than an explicit "false" shows the item. + let show = trimmed != "false" + logger.debug("[ContextMenuCondition] evaluate: rawOutput=\(output, privacy: .public) trimmed=\(trimmed, privacy: .public) -> show=\(show, privacy: .public)") + return show + } catch { + logger.warning("[ContextMenuCondition] Menu condition evaluation failed (\(error.localizedDescription, privacy: .public)); showing item by default.") + return true + } + } + + // MARK: - Compilation + + private func compile(source: String, to binaryURL: URL) async throws -> CompileResult { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("RxCodeMenuConditions", isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + // The file must NOT be named main.swift, or `@main` is rejected. + let sourceURL = tempDir.appendingPathComponent("condition.swift") + try source.write(to: sourceURL, atomically: true, encoding: .utf8) + + try FileManager.default.createDirectory( + at: binaryURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + + // Compile to a temp output first, then move into place — so a half-written + // binary never looks cached. + let stagedBinary = tempDir.appendingPathComponent("condition.bin") + // `-parse-as-library` is required: a single compiled .swift file is + // otherwise treated as top-level code, which conflicts with `@main`. + let result = try await run( + URL(fileURLWithPath: "/usr/bin/xcrun"), + arguments: ["swiftc", "-Onone", "-parse-as-library", sourceURL.path, "-o", stagedBinary.path], + captureStdErr: true, + timeout: 60 + ) + + guard FileManager.default.isExecutableFile(atPath: stagedBinary.path) else { + return CompileResult(success: false, diagnostics: result.isEmpty ? "Compilation failed." : result, binaryPath: nil) + } + + try? FileManager.default.removeItem(at: binaryURL) + try FileManager.default.moveItem(at: stagedBinary, to: binaryURL) + return CompileResult(success: true, diagnostics: "", binaryPath: binaryURL.path) + } + + /// Cache location for a compiled binary, keyed by a hash of its full source. + private func cachedBinaryURL(forSource source: String) -> URL { + let digest = SHA256.hash(data: Data(source.utf8)) + let hash = digest.map { String(format: "%02x", $0) }.joined() + let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("RxCode/menu-conditions", isDirectory: true) + return dir.appendingPathComponent(hash) + } + + // MARK: - PATH resolution + + private var cachedLoginPath: String? + + /// The user's login `PATH`, resolved once via a login shell and cached. Falls + /// back to a sensible default if resolution fails. stderr (profile noise) is + /// discarded; we take the last stdout line in case the profile echoes. + private func loginPath() async -> String { + if let cachedLoginPath { return cachedLoginPath } + let fallback = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" + do { + let out = try await run( + URL(fileURLWithPath: "/bin/zsh"), + arguments: ["-lc", "echo $PATH"], + timeout: 10 + ) + let line = out.split(separator: "\n").map(String.init).last? + .trimmingCharacters(in: .whitespaces) ?? "" + let resolved = line.isEmpty ? fallback : line + cachedLoginPath = resolved + return resolved + } catch { + cachedLoginPath = fallback + return fallback + } + } + + // MARK: - Process runner + + /// Run an executable to completion (or `timeout` seconds, whichever comes + /// first) and return its captured output. Throws `Timeout` if it overruns. + private func run( + _ executable: URL, + arguments: [String] = [], + environment: [String: String]? = nil, + captureStdErr: Bool = false, + timeout: TimeInterval + ) async throws -> String { + let proc = Process() + proc.executableURL = executable + proc.arguments = arguments + let pipe = Pipe() + proc.standardOutput = pipe + proc.standardError = captureStdErr ? pipe : FileHandle.nullDevice + + var env = ProcessInfo.processInfo.environment + if let environment { + for (key, value) in environment { env[key] = value } + } + proc.environment = env + + try proc.run() + + return try await withThrowingTaskGroup(of: String?.self) { group in + group.addTask { + await withCheckedContinuation { (continuation: CheckedContinuation) in + proc.terminationHandler = { _ in continuation.resume() } + } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + return String(data: data, encoding: .utf8) ?? "" + } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + return nil // sentinel: timed out + } + + defer { group.cancelAll() } + let first = try await group.next() ?? nil + if let output = first { + return output + } + // Timed out before the process finished. + if proc.isRunning { proc.terminate() } + throw Timeout() + } + } + + private struct Timeout: Error {} + + // MARK: - Harness + + /// Wrap the user's `checkShowMenu` function in a self-contained Swift program. + /// The file is compiled as-is; the `Context` API documented here is the public + /// contract the editor and the AI-generate prompt describe to the user. + static func conditionHarness(userScript: String) -> String { + """ + import Foundation + + /// The context handed to a show-condition script. Exposes the current + /// project and helpers to run CLI commands inside it. + struct Context { + let projectName: String + let projectPath: String + let gitHubRepo: String + let branch: String + let sessionId: String + + /// Run a shell command in the project directory and return its trimmed + /// stdout. The shell is started with `-d -f` so NO startup files run: + /// many setups override `cd` or change the working directory in their rc + /// files, which would otherwise break this. The working directory is set + /// directly on the process instead, and `PATH` is injected by the host so + /// user tools still resolve. stderr is discarded so it can't pollute the + /// result. + @discardableResult + func shell(_ command: String) async throws -> String { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/bin/zsh") + proc.arguments = ["-d", "-f", "-c", command] + proc.currentDirectoryURL = URL(fileURLWithPath: projectPath) + let outPipe = Pipe() + proc.standardOutput = outPipe + proc.standardError = FileHandle.nullDevice + try proc.run() + proc.waitUntilExit() + let data = outPipe.fileHandleForReading.readDataToEndOfFile() + return (String(data: data, encoding: .utf8) ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Run `git` with the given arguments in the project directory. + @discardableResult + func git(_ args: String...) async throws -> String { + let quoted = args + .map { "'" + $0.replacingOccurrences(of: "'", with: "'\\\\''") + "'" } + .joined(separator: " ") + return try await shell("git " + quoted) + } + } + + // ---- User script ---- + \(userScript) + // ---- End user script ---- + + @main + struct __RxCodeConditionRunner { + static func main() async { + let env = ProcessInfo.processInfo.environment + let context = Context( + projectName: env["RXC_PROJECT_NAME"] ?? "", + projectPath: env["RXC_PROJECT_PATH"] ?? "", + gitHubRepo: env["RXC_GITHUB_REPO"] ?? "", + branch: env["RXC_BRANCH"] ?? "", + sessionId: env["RXC_SESSION_ID"] ?? "" + ) + do { + let show = try await checkShowMenu(context: context) + print(show ? "true" : "false") + } catch { + FileHandle.standardError.write(Data("condition error: \\(error)".utf8)) + print("true") + } + } + } + """ + } + + /// The starter template offered in the editor for a new Swift-script condition. + static let starterScript = """ + func checkShowMenu(context: Context) async throws -> Bool { + // Return true to show the menu item, false to hide it. + // `context` exposes: projectName, projectPath, gitHubRepo, branch, sessionId, + // and async helpers `shell(_:)` / `git(_:)` that run in the project directory. + // + // Example — only show when there are uncommitted git changes: + let status = try await context.git("status", "--porcelain") + return !status.isEmpty + } + """ +} +#endif diff --git a/RxCode/Services/Hooks/AppStateHookController.swift b/RxCode/Services/Hooks/AppStateHookController.swift index 1be08d2..7c3b66b 100644 --- a/RxCode/Services/Hooks/AppStateHookController.swift +++ b/RxCode/Services/Hooks/AppStateHookController.swift @@ -741,6 +741,15 @@ final class AppStateHookController: HookController { app?.threadStore.customMenuItems(projectId: projectId, surface: surface) ?? [] } + func shouldShowConditionalMenuItem( + _ record: CustomMenuItemRecord, + project: Project, + branch: String?, + sessionId: String? + ) -> Bool { + app?.shouldShowConditionalMenuItem(record, project: project, branch: branch, sessionId: sessionId) ?? true + } + func requestSecretsSetup(project: Project) { app?.secretsSetupRequest = SecretsSetupRequest( repoFullName: project.gitHubRepo, diff --git a/RxCode/Services/Hooks/hooks/CustomMenuHook.swift b/RxCode/Services/Hooks/hooks/CustomMenuHook.swift index e8320ae..c9781c7 100644 --- a/RxCode/Services/Hooks/hooks/CustomMenuHook.swift +++ b/RxCode/Services/Hooks/hooks/CustomMenuHook.swift @@ -25,6 +25,7 @@ final class CustomMenuHook: Hook { let surface: CustomMenuItemRecord.Surface = payload.branch != nil ? .briefing : .project let context = PlaceholderContext(project: payload.project, branch: payload.branch, sessionId: nil) return controller.customMenuItems(projectId: payload.project.id, surface: surface) + .filter { controller.shouldShowConditionalMenuItem($0, project: payload.project, branch: payload.branch, sessionId: nil) } .map { item($0, surface: surface, context: context, projectId: payload.project.id) } } @@ -33,6 +34,7 @@ final class CustomMenuHook: Hook { func onThreadContextMenu(_ payload: ThreadContextMenuPayload, controller: any HookController) -> [MenuItem] { let context = PlaceholderContext(project: payload.project, branch: nil, sessionId: payload.session.id) return controller.customMenuItems(projectId: payload.project.id, surface: .thread) + .filter { controller.shouldShowConditionalMenuItem($0, project: payload.project, branch: nil, sessionId: payload.session.id) } .map { item($0, surface: .thread, context: context, projectId: payload.project.id, sessionId: payload.session.id) } } diff --git a/RxCode/Services/ThreadStore+CustomMenus.swift b/RxCode/Services/ThreadStore+CustomMenus.swift index 95286aa..c09fc44 100644 --- a/RxCode/Services/ThreadStore+CustomMenus.swift +++ b/RxCode/Services/ThreadStore+CustomMenus.swift @@ -56,6 +56,9 @@ extension ThreadStore { existing.bodyTemplate = record.bodyTemplate existing.messageTemplate = record.messageTemplate existing.targetSessionId = record.targetSessionId + existing.conditionType = record.conditionType + existing.conditionScript = record.conditionScript + existing.conditionCompiled = record.conditionCompiled existing.isEnabled = record.isEnabled existing.sortOrder = record.sortOrder existing.updatedAt = .now diff --git a/RxCode/Views/Settings/CustomMenuEditorSheet.swift b/RxCode/Views/Settings/CustomMenuEditorSheet.swift index 8f0ef79..c9ba02a 100644 --- a/RxCode/Views/Settings/CustomMenuEditorSheet.swift +++ b/RxCode/Views/Settings/CustomMenuEditorSheet.swift @@ -23,6 +23,14 @@ struct CustomMenuDraft: Identifiable { var messageTemplate: String var targetSessionId: String + // show condition + var conditionType: CustomMenuItemRecord.ConditionType + var conditionScript: String + /// The exact script source that last compiled successfully. The item counts as + /// "compiled" only while this still equals `conditionScript`, so any edit + /// (manual or AI) transparently invalidates it without an `onChange` race. + var conditionCompiledScript: String? + var isEnabled: Bool var sortOrder: Int @@ -47,6 +55,9 @@ struct CustomMenuDraft: Identifiable { bodyTemplate = "" messageTemplate = "" targetSessionId = "" + conditionType = .always + conditionScript = "" + conditionCompiledScript = nil isEnabled = true sortOrder = 0 } @@ -66,24 +77,35 @@ struct CustomMenuDraft: Identifiable { bodyTemplate = record.bodyTemplate ?? "" messageTemplate = record.messageTemplate ?? "" targetSessionId = record.targetSessionId ?? "" + conditionType = record.conditionTypeValue + conditionScript = record.conditionScript ?? "" + // A saved record with a script is known-compiled, so seed the marker to the + // stored source — it stays "compiled" until the user edits it. + conditionCompiledScript = record.conditionCompiled ? record.conditionScript : nil isEnabled = record.isEnabled sortOrder = record.sortOrder } var trimmedTitle: String { title.trimmingCharacters(in: .whitespacesAndNewlines) } + /// Whether the show condition is satisfied for saving. `always` is always fine; + /// a `swiftScript` needs a non-empty script that currently matches the last + /// successful compile. + var isConditionCompiled: Bool { + guard conditionType == .swiftScript else { return true } + let trimmed = conditionScript.trimmingCharacters(in: .whitespacesAndNewlines) + return !trimmed.isEmpty && conditionCompiledScript == conditionScript + } + var isValid: Bool { - guard !trimmedTitle.isEmpty, !surfaces.isEmpty else { return false } + guard !trimmedTitle.isEmpty, !surfaces.isEmpty, isConditionCompiled else { return false } switch actionKind { case .callAPI: return !urlString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty case .createThread: return !messageTemplate.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty case .continueThread: - guard !messageTemplate.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false } - // A thread-only menu item continues the tapped thread, so no explicit - // target is needed; if it also appears on a project/briefing surface it - // must name the thread to continue. - if surfaces == [.thread] { return true } - return !targetSessionId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + // Always continues the tapped thread (its session id is auto-filled at + // dispatch time), so only the message is required — never a target id. + return !messageTemplate.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } } @@ -105,7 +127,12 @@ struct CustomMenuDraft: Identifiable { headersJSON: actionKind == .callAPI ? CustomMenuItemRecord.encodeHeaders(headerMap) : nil, bodyTemplate: actionKind == .callAPI ? bodyTemplate : nil, messageTemplate: actionKind == .callAPI ? nil : messageTemplate, - targetSessionId: actionKind == .continueThread ? targetSessionId.trimmingCharacters(in: .whitespacesAndNewlines) : nil, + // continueThread always targets the tapped thread, resolved at dispatch + // time, so no explicit target is stored. + targetSessionId: nil, + conditionType: conditionType, + conditionScript: conditionType == .swiftScript && !conditionScript.isEmpty ? conditionScript : nil, + conditionCompiled: conditionType == .swiftScript && isConditionCompiled, isEnabled: isEnabled, sortOrder: sortOrder ) @@ -116,8 +143,13 @@ struct CustomMenuDraft: Identifiable { struct CustomMenuEditorSheet: View { @Environment(\.dismiss) private var dismiss + @Environment(AppState.self) private var appState + @State var draft: CustomMenuDraft @State private var bodyJSONFormatError: String? + @State private var isCompilingCondition = false + @State private var conditionDiagnostics: String? + @State private var showingGeneratePopover = false let projects: [Project] var onSave: (CustomMenuDraft) -> Void @@ -134,6 +166,7 @@ struct CustomMenuEditorSheet: View { Form { generalSection + conditionSection actionSection switch draft.actionKind { case .callAPI: apiSection @@ -184,6 +217,117 @@ struct CustomMenuEditorSheet: View { } } + private var conditionSection: some View { + Section("Show condition") { + Picker("Show this item", selection: $draft.conditionType) { + Text("Always").tag(CustomMenuItemRecord.ConditionType.always) + Text("When a Swift script returns true").tag(CustomMenuItemRecord.ConditionType.swiftScript) + } + .pickerStyle(.menu) + .onChange(of: draft.conditionType) { _, newValue in + conditionDiagnostics = nil + // Seed the starter template the first time a user picks Swift script. + if newValue == .swiftScript, draft.conditionScript.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + draft.conditionScript = CustomMenuConditionEvaluator.starterScript + } + } + + if draft.conditionType == .swiftScript { + conditionEditor + } + } + } + + private var conditionEditor: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline) { + Text("func checkShowMenu(context: Context) async throws -> Bool") + .font(.system(size: ClaudeTheme.size(10), design: .monospaced)) + .foregroundStyle(.secondary) + Spacer() + Button { + showingGeneratePopover = true + } label: { + Label("AI generate", systemImage: "sparkles") + } + .buttonStyle(.bordered) + .controlSize(.small) + .popover(isPresented: $showingGeneratePopover, arrowEdge: .bottom) { + SwiftConditionGeneratePopover( + projectId: draft.projectId, + isPresented: $showingGeneratePopover + ) { script, compiled in + draft.conditionScript = script + draft.conditionCompiledScript = compiled ? script : nil + conditionDiagnostics = compiled ? nil : conditionDiagnostics + } + .environment(appState) + } + } + + SwiftConditionEditor(text: $draft.conditionScript, fontSize: ClaudeTheme.size(12), minHeight: 200) + .frame(minHeight: 200) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(Color(NSColor.separatorColor))) + + Text("`context` exposes projectName, projectPath, gitHubRepo, branch, sessionId, and async `shell(_:)` / `git(_:)` helpers that run in the project directory. Return true to show the item.") + .font(.system(size: ClaudeTheme.size(10))) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + HStack(spacing: 10) { + Button { + Task { await compileCondition() } + } label: { + if isCompilingCondition { + ProgressView().controlSize(.small) + } else { + Label("Compile", systemImage: "hammer") + } + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(isCompilingCondition || draft.conditionScript.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + + if draft.isConditionCompiled { + Label("Compiled", systemImage: "checkmark.circle.fill") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(ClaudeTheme.statusSuccess) + } else { + Label("Not compiled", systemImage: "exclamationmark.triangle.fill") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + } + Spacer() + } + + if let conditionDiagnostics { + ScrollView { + Text(verbatim: conditionDiagnostics) + .font(.system(size: ClaudeTheme.size(10), design: .monospaced)) + .foregroundStyle(ClaudeTheme.statusError) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + .frame(maxHeight: 120) + } + } + } + + private func compileCondition() async { + isCompilingCondition = true + conditionDiagnostics = nil + defer { isCompilingCondition = false } + let script = draft.conditionScript + let result = await appState.compileMenuConditionScript(script) + if result.success { + draft.conditionCompiledScript = script + } else { + draft.conditionCompiledScript = nil + conditionDiagnostics = result.diagnostics.isEmpty ? "Compilation failed." : result.diagnostics + } + } + private var actionSection: some View { Section("Action") { Picker("When tapped", selection: $draft.actionKind) { @@ -273,9 +417,10 @@ struct CustomMenuEditorSheet: View { private var threadSection: some View { Section(draft.actionKind == .continueThread ? "Continue thread" : "New thread") { if draft.actionKind == .continueThread { - TextField("Target thread id", text: $draft.targetSessionId) - .font(.system(size: ClaudeTheme.size(12), design: .monospaced)) - .help("The session id of the thread to continue. Leave a {{sessionId}} placeholder to target the tapped thread.") + Text("Continues the thread this menu is opened from — its session id is filled in automatically.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } VStack(alignment: .leading, spacing: 4) { Text("Message") diff --git a/RxCode/Views/Settings/CustomMenusSettingsTab.swift b/RxCode/Views/Settings/CustomMenusSettingsTab.swift index ec2db19..832ed46 100644 --- a/RxCode/Views/Settings/CustomMenusSettingsTab.swift +++ b/RxCode/Views/Settings/CustomMenusSettingsTab.swift @@ -149,6 +149,9 @@ private struct CustomMenuRow: View { HStack(spacing: 6) { ForEach(record.surfaces, id: \.self) { badge(surfaceLabel($0)) } badge(actionLabel) + if record.conditionTypeValue == .swiftScript { + badge("Conditional") + } Text(verbatim: projectName) .font(.system(size: ClaudeTheme.size(10))) .foregroundStyle(.tertiary) diff --git a/RxCode/Views/Settings/SwiftConditionEditor.swift b/RxCode/Views/Settings/SwiftConditionEditor.swift new file mode 100644 index 0000000..ccf3346 --- /dev/null +++ b/RxCode/Views/Settings/SwiftConditionEditor.swift @@ -0,0 +1,31 @@ +#if os(macOS) +import SwiftUI +import RxCodeEditor + +/// A Swift code editor with live syntax highlighting, used by the custom menu +/// editor for a `swiftScript` show condition. Mirrors `JSONCodeEditor` but for +/// Swift and with the condition `Context` fields as autocomplete placeholders. +struct SwiftConditionEditor: View { + @Binding var text: String + var fontSize: CGFloat = 12 + var minHeight: CGFloat = 200 + + private let placeholderProvider = PredefinedAutocompleteProvider.placeholders([ + "projectName", + "projectPath", + "gitHubRepo", + "branch", + "sessionId", + ]) + + var body: some View { + CodeEditorView( + text: $text, + language: "swift", + fontSize: fontSize, + autocompleteProvider: placeholderProvider + ) + .frame(minHeight: minHeight) + } +} +#endif diff --git a/RxCode/Views/Settings/SwiftConditionGeneratePopover.swift b/RxCode/Views/Settings/SwiftConditionGeneratePopover.swift new file mode 100644 index 0000000..8393e2c --- /dev/null +++ b/RxCode/Views/Settings/SwiftConditionGeneratePopover.swift @@ -0,0 +1,94 @@ +#if os(macOS) +import SwiftUI +import RxCodeCore + +/// Popover that turns a natural-language requirement into a Swift `checkShowMenu` +/// condition using the default model, then compiles the result before handing it +/// back. On a clean compile it closes and reports the script as compiled; if the +/// generated script doesn't build, it stays open, shows the diagnostics, and still +/// drops the script into the editor (marked not-compiled) so the user can fix it. +struct SwiftConditionGeneratePopover: View { + @Environment(AppState.self) private var appState + + let projectId: UUID? + @Binding var isPresented: Bool + /// `(script, compiled)` — compiled is true only when the generated script built. + var onResult: (String, Bool) -> Void + + @State private var requirement = "" + @State private var isGenerating = false + @State private var diagnostics: String? + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Generate condition with AI") + .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) + Text("Describe when this menu item should appear. The default model writes the Swift condition and it's compiled before use.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + TextEditor(text: $requirement) + .font(.system(size: ClaudeTheme.size(12))) + .frame(height: 70) + .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(Color(NSColor.separatorColor))) + .disabled(isGenerating) + + if let diagnostics { + ScrollView { + Text(verbatim: diagnostics) + .font(.system(size: ClaudeTheme.size(10), design: .monospaced)) + .foregroundStyle(ClaudeTheme.statusError) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + .frame(maxHeight: 90) + } + + HStack { + Spacer() + Button("Cancel") { isPresented = false } + .disabled(isGenerating) + Button { + Task { await generate() } + } label: { + if isGenerating { + ProgressView().controlSize(.small) + } else { + Text("Generate") + } + } + .buttonStyle(.borderedProminent) + .disabled(isGenerating || requirement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .padding(14) + .frame(width: 360) + } + + private func generate() async { + isGenerating = true + diagnostics = nil + defer { isGenerating = false } + + let project = appState.projects.first { $0.id == projectId } + guard let script = await appState.generateMenuConditionScript(requirement: requirement, project: project), + !script.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + diagnostics = "The model didn't return a script. Try rephrasing your requirement." + return + } + + let result = await appState.compileMenuConditionScript(script) + if result.success { + onResult(script, true) + isPresented = false + } else { + // Surface the script (uncompiled) so the user can fix it, and show why. + onResult(script, false) + diagnostics = result.diagnostics.isEmpty + ? "The generated script didn't compile. Edit it and compile again." + : result.diagnostics + } + } +} +#endif