Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 90 additions & 3 deletions Packages/Sources/RxCodeCore/Backend/IDEToolRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -110,20 +110,60 @@ public enum IDEToolRegistry {
"required": .array([.string("job_id")]),
])
),
IDETool(
name: "ide__get_projects",
description: "List every project registered in RxCode. Use to discover sibling projects you may want to read threads from or chat with.",
visibility: .alwaysIDEOnly,
inputSchema: .object([
"type": .string("object"),
"properties": .object([:]),
])
),
IDETool(
name: "ide__get_threads",
description: "List chat threads in the current project (or all projects if none specified).",
description: "List or natural-language search chat threads. With `query`, results are ranked by the same on-device embedding search the global search overlay uses and include matched snippets + scores. Without `query`, returns recent threads sorted by updatedAt. Each result includes the AI-generated summary when available.",
visibility: .alwaysIDEOnly,
inputSchema: .object([
"type": .string("object"),
"properties": .object([
"project_id": .object(["type": .string("string")]),
"project_id": .object([
"type": .string("string"),
"description": .string("Optional UUID to restrict results to a single project. Applied after ranking when `query` is set."),
]),
"query": .object([
"type": .string("string"),
"description": .string("Optional natural-language query. When set, results are ranked by semantic similarity using on-device embeddings."),
]),
"limit": .object([
"type": .string("integer"),
"description": .string("Maximum number of threads to return. Default 50, capped at 200."),
]),
]),
])
),
IDETool(
name: "ide__get_thread_messages",
description: "Fetch the message history of a specific thread by id. Hydrates the full ChatSession (CLI-backed history or RxCode JSON) and returns role+text per message.",
visibility: .alwaysIDEOnly,
inputSchema: .object([
"type": .string("object"),
"properties": .object([
"thread_id": .object(["type": .string("string")]),
"limit": .object([
"type": .string("integer"),
"description": .string("Most recent N messages to return. Default 200, capped at 1000."),
]),
"include_tool_calls": .object([
"type": .string("boolean"),
"description": .string("When true, include tool-call blocks in the messages array. Default false — only user/assistant text is returned."),
]),
]),
"required": .array([.string("thread_id")]),
])
),
IDETool(
name: "ide__get_thread_detail",
description: "Fetch the message history of a specific thread by id.",
description: "Deprecated alias for ide__get_thread_messages. Prefer ide__get_thread_messages.",
visibility: .alwaysIDEOnly,
inputSchema: .object([
"type": .string("object"),
Expand All @@ -133,6 +173,53 @@ public enum IDEToolRegistry {
"required": .array([.string("thread_id")]),
])
),
IDETool(
name: "ide__send_to_thread",
description: "Send a chat prompt to a thread in any project — continue an existing thread by `thread_id`, or start a brand-new thread by passing `project_id`. Triggers a real agent run that may consume tokens. Returns the assistant's reply text (waits up to `timeout_seconds`).",
visibility: .alwaysIDEOnly,
inputSchema: .object([
"type": .string("object"),
"properties": .object([
"prompt": .object([
"type": .string("string"),
"description": .string("The user message to send to the target agent."),
]),
"thread_id": .object([
"type": .string("string"),
"description": .string("Continue this existing thread. Mutually exclusive with `project_id`."),
]),
"project_id": .object([
"type": .string("string"),
"description": .string("Start a brand-new thread in this project. Mutually exclusive with `thread_id`."),
]),
"model": .object([
"type": .string("string"),
"description": .string("Optional model override for a new thread."),
]),
"agent_provider": .object([
"type": .string("string"),
"description": .string("Optional agent provider override for a new thread (e.g. 'claude_code', 'codex', 'acp')."),
]),
"effort": .object([
"type": .string("string"),
"description": .string("Optional effort override for a new thread."),
]),
"permission_mode": .object([
"type": .string("string"),
"description": .string("Optional permission mode override for a new thread."),
]),
"wait_for_response": .object([
"type": .string("boolean"),
"description": .string("If true (default), block until the assistant finishes or `timeout_seconds` elapses. If false, return immediately with the new thread id."),
]),
"timeout_seconds": .object([
"type": .string("number"),
"description": .string("How long to wait for the assistant's reply. Default 120, capped at 600. On timeout the call returns the partial text with `done: false` — the caller can poll via ide__get_thread_messages."),
]),
]),
"required": .array([.string("prompt")]),
])
),
IDETool(
name: "ide__get_usage",
description: "Get current rate-limit / token usage stats reported by the active provider.",
Expand Down
133 changes: 133 additions & 0 deletions Packages/Sources/RxCodeCore/Models/XcodeDestination.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import Foundation

/// A single `xcodebuild` destination — populated either from
/// `xcodebuild -showdestinations` or `xcrun simctl list devices --json`.
///
/// The destination is the structured form of what Xcode shows in its
/// run-destination menu (My Mac, iPhone 16, etc). It serializes to the
/// `-destination` argument via `xcodebuildArgument` and is the source of
/// truth for which launcher path `RunTaskExecutor` uses (macOS `open` vs.
/// `xcrun simctl install/launch`).
public struct XcodeDestination: Codable, Sendable, Hashable, Identifiable {
public enum Kind: String, Codable, Sendable, Hashable {
case macOS
case macCatalyst
case iosSimulator
case iosDevice
case tvSimulator
case tvDevice
case watchSimulator
case watchDevice
case visionSimulator
case visionDevice
case other
}

public var kind: Kind
/// Raw platform string as reported by `xcodebuild` (e.g. "macOS",
/// "iOS Simulator", "iOS"). Used verbatim when building the
/// `-destination` argument.
public var platform: String
public var name: String
/// Simulator UDID, or device identifier when known. Preferred over
/// `name` for the `-destination` argument because it's unambiguous.
public var udid: String?
/// OS version string for simulators (e.g. "18.0"). Optional.
public var os: String?
/// CPU architecture for macOS destinations (e.g. "arm64", "x86_64").
public var arch: String?
/// Optional macOS variant (e.g. "Mac Catalyst", "Designed for iPad").
public var variant: String?

public init(
kind: Kind,
platform: String,
name: String,
udid: String? = nil,
os: String? = nil,
arch: String? = nil,
variant: String? = nil
) {
self.kind = kind
self.platform = platform
self.name = name
self.udid = udid
self.os = os
self.arch = arch
self.variant = variant
}

public var id: String {
// UDID is unique per device/simulator; fall back to a composite key
// when missing so generic destinations (e.g. "Any iOS Device") still
// get a stable id for SwiftUI list diffing.
if let udid, !udid.isEmpty { return "\(platform):\(udid)" }
var parts = [platform, name]
if let os { parts.append(os) }
if let arch { parts.append(arch) }
if let variant { parts.append(variant) }
return parts.joined(separator: "|")
}

/// Build the value passed to `xcodebuild -destination "<value>"`.
/// Prefers `id=<udid>` when available — it's the only form that
/// unambiguously targets a single simulator runtime.
public var xcodebuildArgument: String {
var parts: [String] = ["platform=\(platform)"]
if let udid, !udid.isEmpty {
parts.append("id=\(udid)")
} else {
parts.append("name=\(name)")
if let os, !os.isEmpty { parts.append("OS=\(os)") }
}
if let arch, !arch.isEmpty { parts.append("arch=\(arch)") }
if let variant, !variant.isEmpty { parts.append("variant=\(variant)") }
return parts.joined(separator: ",")
}

/// Human-readable label for menus. Mirrors what Xcode shows.
public var displayName: String {
switch kind {
case .macOS:
if let variant, !variant.isEmpty { return "\(name) (\(variant))" }
if let arch, !arch.isEmpty { return "\(name) (\(arch))" }
return name
case .iosSimulator, .tvSimulator, .watchSimulator, .visionSimulator:
if let os, !os.isEmpty { return "\(name) — \(os)" }
return name
default:
return name
}
}

/// Coarse grouping for menu sections.
public var groupLabel: String {
switch kind {
case .macOS: return "macOS"
case .macCatalyst: return "Mac Catalyst"
case .iosSimulator: return "iOS Simulators"
case .iosDevice: return "iOS Devices"
case .tvSimulator: return "tvOS Simulators"
case .tvDevice: return "tvOS Devices"
case .watchSimulator: return "watchOS Simulators"
case .watchDevice: return "watchOS Devices"
case .visionSimulator: return "visionOS Simulators"
case .visionDevice: return "visionOS Devices"
case .other: return "Other"
}
}

/// Whether `RunTaskExecutor` can actually launch the produced bundle for
/// this destination. Currently true for macOS and any simulator; false
/// for physical iOS/tvOS/watchOS/visionOS devices (which require Xcode's
/// `devicectl` plumbing we don't have yet).
public var isLaunchable: Bool {
switch kind {
case .macOS, .macCatalyst,
.iosSimulator, .tvSimulator, .watchSimulator, .visionSimulator:
return true
case .iosDevice, .tvDevice, .watchDevice, .visionDevice, .other:
return false
}
}
}
36 changes: 35 additions & 1 deletion Packages/Sources/RxCodeCore/Models/XcodeRunConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@ public struct XcodeRunConfig: Codable, Sendable, Hashable {
public var scheme: String
public var configuration: String
public var action: XcodeAction
/// Optional `-destination` argument. Empty string omits the flag.
/// Structured destination chosen via the toolbar picker. When set, the
/// run script uses `selectedDestination.xcodebuildArgument` and branches
/// the launcher path on `selectedDestination.kind`.
public var selectedDestination: XcodeDestination?
/// Legacy free-text `-destination` override. Older profiles on disk
/// stored their destination here. Still honored when
/// `selectedDestination` is nil so we don't break those.
public var destination: String

public init(
Expand All @@ -24,13 +30,41 @@ public struct XcodeRunConfig: Codable, Sendable, Hashable {
scheme: String = "",
configuration: String = "Debug",
action: XcodeAction = .run,
selectedDestination: XcodeDestination? = nil,
destination: String = ""
) {
self.container = container
self.isWorkspace = isWorkspace
self.scheme = scheme
self.configuration = configuration
self.action = action
self.selectedDestination = selectedDestination
self.destination = destination
}

/// Resolves the value (if any) that should be passed to
/// `xcodebuild -destination`. Prefers the structured picker selection.
public var resolvedDestinationArgument: String? {
if let selectedDestination {
return selectedDestination.xcodebuildArgument
}
let trimmed = destination.trimmingCharacters(in: .whitespaces)
return trimmed.isEmpty ? nil : trimmed
}

private enum CodingKeys: String, CodingKey {
case container, isWorkspace, scheme, configuration, action
case selectedDestination, destination
}

public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
container = try c.decodeIfPresent(String.self, forKey: .container) ?? ""
isWorkspace = try c.decodeIfPresent(Bool.self, forKey: .isWorkspace) ?? false
scheme = try c.decodeIfPresent(String.self, forKey: .scheme) ?? ""
configuration = try c.decodeIfPresent(String.self, forKey: .configuration) ?? "Debug"
action = try c.decodeIfPresent(XcodeAction.self, forKey: .action) ?? .run
selectedDestination = try c.decodeIfPresent(XcodeDestination.self, forKey: .selectedDestination)
destination = try c.decodeIfPresent(String.self, forKey: .destination) ?? ""
}
}
Loading