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
2 changes: 2 additions & 0 deletions Packages/Sources/RxCodeCore/Hooks/Hook.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public protocol Hook: AnyObject {
var isEnabled: Bool { get }

func onProjectNewChatStart(_ payload: NewChatStartPayload, controller: any HookController) async -> HookOutcome
func onProjectDelete(_ payload: ProjectDeletePayload, controller: any HookController) async -> HookOutcome
func onSessionStart(_ payload: SessionStartPayload, controller: any HookController) async -> HookOutcome
func beforeSessionEnd(_ payload: SessionEndPayload, controller: any HookController) async -> HookOutcome
func afterSessionEnd(_ payload: SessionEndPayload, controller: any HookController) async -> HookOutcome
Expand All @@ -37,6 +38,7 @@ public extension Hook {
var isEnabled: Bool { true }

func onProjectNewChatStart(_ payload: NewChatStartPayload, controller: any HookController) async -> HookOutcome { .ignored }
func onProjectDelete(_ payload: ProjectDeletePayload, controller: any HookController) async -> HookOutcome { .ignored }
func onSessionStart(_ payload: SessionStartPayload, controller: any HookController) async -> HookOutcome { .ignored }
func beforeSessionEnd(_ payload: SessionEndPayload, controller: any HookController) async -> HookOutcome { .ignored }
func afterSessionEnd(_ payload: SessionEndPayload, controller: any HookController) async -> HookOutcome { .ignored }
Expand Down
84 changes: 81 additions & 3 deletions Packages/Sources/RxCodeCore/Hooks/HookController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,93 @@ public protocol HookController: AnyObject {
/// carries dynamic, non-localizable text (e.g. a list of filenames).
func requestConfirmation(title: LocalizedStringKey, detail: String?) async -> Bool

// MARK: Banners

/// Show (or replace) a hook-supplied banner in a screen surface. The hook
/// owns the entire view — including any button and its action — so banners
/// are not limited to a fixed shape. `id` is a stable key: showing again
/// with the same `(surface, id)` replaces the existing banner. Prefer the
/// `@ViewBuilder` convenience below over calling this directly.
///
/// `projectId` scopes the banner to one project: the host only renders it
/// while that project is open, so switching projects hides it automatically.
/// Pass `nil` for a banner that should show regardless of the open project.
func showBanner(_ content: AnyView, id: String, projectId: UUID?, in surface: HookBannerSurface, position: HookBannerPosition)
/// Remove a previously-shown banner. No-op if it isn't currently shown.
func dismissBanner(id: String, in surface: HookBannerSurface)

/// Whether the user previously dismissed the banner with this `id`. The flag
/// is persisted across launches, so a hook should check this before showing a
/// banner the user has already closed.
func isBannerDismissed(id: String) -> Bool
/// Record that the user dismissed this banner (persisted across launches) and
/// remove it from the UI now. Wired to the banner's close button.
func markBannerDismissed(id: String, in surface: HookBannerSurface)
/// Forget a persisted dismissal so the banner can show again — e.g. when the
/// owning project is deleted, so re-adding it surfaces the banner anew.
func clearBannerDismissal(id: String)

// MARK: Secrets

/// Environments configured for a repo in autopilot. Returns `[]` when there
/// are none, the user is signed out, or the request fails.
func secretEnvironments(repoFullName: String) async -> [HookChoice]
/// Environments configured for a repo in autopilot. Returns `nil` when the
/// check can't be completed (signed out, offline, request failed, or the
/// task was cancelled mid-flight) so callers can tell that apart from a
/// genuine empty `[]` and avoid surfacing a misleading banner.
func secretEnvironments(repoFullName: String) async -> [HookChoice]?

/// Whether a secret file named `filename` already exists in *any* of the
/// repo's autopilot environments. Returns `true` on error / signed-out so
/// callers don't nag the user when the check itself can't run.
func secretFileExists(repoFullName: String, filename: String) async -> Bool
/// Download + decrypt an environment's files (may prompt for the passkey).
/// Does not write anything to disk.
func fetchSecrets(repoFullName: String, env: String) async throws -> [HookSecretFile]
/// Write decrypted files into a project folder, skipping existing files
/// unless `overwrite`. Returns the filenames actually written.
func writeSecrets(_ files: [HookSecretFile], toPath path: String, overwrite: Bool) throws -> [String]
}

// MARK: - Banner surfaces

/// A screen region a hook can attach a banner to.
public enum HookBannerSurface: String, Sendable, Hashable {
/// The new-project / empty-state composer screen.
case newProject
}

/// Where within a surface the banner renders.
public enum HookBannerPosition: String, Sendable, Hashable {
/// Directly above the chat input box (below the title on the empty state).
case aboveInputBox
}

/// A live banner: its stable `id`, where it sits, and the hook-built view.
public struct HookBannerItem: Identifiable {
public let id: String
public let position: HookBannerPosition
/// Project this banner belongs to; the host only renders it while that
/// project is open. `nil` shows regardless of the selected project.
public let projectId: UUID?
public let content: AnyView

public init(id: String, position: HookBannerPosition, projectId: UUID? = nil, content: AnyView) {
self.id = id
self.position = position
self.projectId = projectId
self.content = content
}
}

public extension HookController {
/// Ergonomic `@ViewBuilder` form mirroring the requested call site:
/// `controller.showBanner(in: .newProject, position: .aboveInputBox, id: …) { MyBanner() }`.
func showBanner<Content: View>(
in surface: HookBannerSurface,
position: HookBannerPosition,
id: String,
projectId: UUID? = nil,
@ViewBuilder content: () -> Content
) {
showBanner(AnyView(content()), id: id, projectId: projectId, in: surface, position: position)
}
}
11 changes: 11 additions & 0 deletions Packages/Sources/RxCodeCore/Hooks/HookPayloads.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import Foundation
/// event to a future out-of-process plugin.
public enum HookEventKind: String, Codable, Sendable, CaseIterable {
case onProjectNewChatStart
case onProjectDelete
case onSessionStart
case beforeSessionEnd
case afterSessionEnd
Expand Down Expand Up @@ -100,6 +101,16 @@ public struct RepositoryPayload: Codable, Sendable {
}
}

/// Fired when a project is removed from the IDE. Carries the full project so a
/// hook can clean up any persisted, project-scoped state (e.g. a dismissed
/// banner keyed by the project's repo) — so re-adding it starts fresh.
public struct ProjectDeletePayload: Codable, Sendable {
public let project: Project
public init(project: Project) {
self.project = project
}
}

public struct QuestionAskPayload: Codable, Sendable {
public let toolUseId: String
public let sessionId: String?
Expand Down
27 changes: 27 additions & 0 deletions Packages/Sources/RxCodeCore/Secrets/SecretsModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,33 @@ public struct SecretsFileList: Codable, Sendable {
public let items: [SecretsFileMeta]
}

// MARK: - Cross-environment filename search

/// One environment that contains a file matching the searched filename.
public struct SecretsFileLocation: Codable, Sendable, Identifiable, Hashable {
public let environmentId: String
public let environmentName: String
public let fileId: String
public let size: Int?
public let updatedAt: Double?

public var id: String { environmentId }
}

/// Result of searching a repo's environments for a given filename, from
/// `GET /repositories/{id}/files?filename=…`.
public struct SecretsFileSearch: Codable, Sendable, Identifiable {
public let filename: String
public let environments: [SecretsFileLocation]
public let exists: Bool

public var id: String { filename }
}

public struct SecretsFileSearchList: Codable, Sendable {
public let items: [SecretsFileSearch]
}

public struct UpsertFileBody: Codable, Sendable {
public let filename: String
public let ciphertext: String
Expand Down
15 changes: 15 additions & 0 deletions RxCode/App/AppState+Hooks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,21 @@ extension AppState {
}
}

// MARK: - New-chat banner hooks

/// Run the "new chat started" hooks for a project's empty-state / composer
/// screen — e.g. the Autopilot `.env` backup banner. This used to fire only
/// inside the stream preflight (on first message send), so the banner never
/// appeared just from opening the screen. Views call this from `.task(id:)`
/// when a project's chat screen appears. The hook itself decides whether to
/// show or dismiss its banner, so this is safe to call repeatedly.
func runProjectNewChatHooks(projectId: UUID, sessionKey: String) async {
logger.debug("[Hook] runProjectNewChatHooks: projectId=\(projectId.uuidString, privacy: .public) sessionKey=\(sessionKey, privacy: .public)")
await hookManager.dispatchProjectNewChatStart(
NewChatStartPayload(projectId: projectId, sessionKey: sessionKey)
)
}

// MARK: - Hook execution

/// Tool-call name carried by a hook's chat card. The `Hook: ` prefix lets
Expand Down
22 changes: 19 additions & 3 deletions RxCode/App/AppState+Stream.swift
Original file line number Diff line number Diff line change
Expand Up @@ -725,29 +725,43 @@
let summary: String
let decision: PermissionDecision
let nextMode: PermissionMode?
// Whether this decision moves the session out of plan mode. Only an
// *accept* does — the user has approved a plan and is ready to implement.
// A reject (with or without feedback) keeps the user in the planning
// phase: the model will revise and re-emit `ExitPlanMode`, and that next
// plan must launch with `--permission-mode plan` so the CLI actually
// pauses for the plan card instead of auto-resolving it with the
// "Exit plan mode?" placeholder (which leaves the user having to
// manually re-chat to make any decision stick).
let exitsPlanMode: Bool

switch action {
case .acceptAsk:
summary = "Accepted with Ask"
decision = .allowAndSetMode(newMode: .default)
nextMode = .default
exitsPlanMode = true
case .acceptWithEdits:
summary = "Accepted with Edits"
decision = .allowAndSetMode(newMode: .acceptEdits)
nextMode = .acceptEdits
exitsPlanMode = true
case .acceptAutoApprove:
summary = "Accepted with Auto-approve"
decision = .allowAndSetMode(newMode: .auto)
nextMode = .auto
exitsPlanMode = true
case .rejectWithFeedback(let reason):
let trimmed = reason.trimmingCharacters(in: .whitespacesAndNewlines)
summary = trimmed.isEmpty ? "Rejected" : "Rejected: \(trimmed)"
decision = .denyWithReason(reason: trimmed.isEmpty ? "User rejected the plan." : trimmed)
nextMode = nil
exitsPlanMode = false
case .reject:
summary = "Rejected"
decision = .denyWithReason(reason: "User rejected the plan.")
nextMode = nil
exitsPlanMode = false
}

// Record the outcome on the tool block so `PlanCardView` flips from buttons to
Expand All @@ -768,9 +782,11 @@
}
threadStore.setPlanDecision(sessionId: key, toolCallId: toolUseId, summary: summary)

// Plan-mode is one-shot — clear the pill so the next user turn isn't in plan mode.
// This also triggers a permission re-register (no-op if there's no live CLI sid).
if window.sessionPlanMode {
// Clear the plan-mode pill only when the user accepted — that's the
// transition out of planning and into implementation. On a reject we stay
// in plan mode so the revised plan re-enters the real ExitPlanMode pause
// (see `exitsPlanMode` above). The mode re-register below runs regardless.
if exitsPlanMode, window.sessionPlanMode {
window.sessionPlanMode = false
updateState(key) { $0.planMode = false }
}
Expand Down Expand Up @@ -902,4 +918,4 @@
}
}

}

Check warning on line 921 in RxCode/App/AppState+Stream.swift

View workflow job for this annotation

GitHub Actions / swiftlint

File should contain 600 lines or less excluding comments and whitespaces: currently contains 661 (file_length)
4 changes: 4 additions & 0 deletions RxCode/App/AppState+Worktree.swift
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,10 @@ extension AppState {
} catch {
logger.error("Failed to save projects after deletion: \(error.localizedDescription)")
}

// Let hooks clean up persisted, project-scoped state (e.g. the autopilot
// banner's stored dismissal) so re-adding the project starts fresh.
await hookManager.dispatchProjectDelete(ProjectDeletePayload(project: project))
}

func deleteSession(_ session: ChatSession, in window: WindowState) async {
Expand Down
8 changes: 7 additions & 1 deletion RxCode/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -912,6 +912,12 @@
var hookChoiceRequest: HookChoiceRequest?
/// Non-nil while a hook is awaiting a confirm/cancel decision.
var hookConfirmRequest: HookConfirmRequest?
/// Hook-supplied banners, keyed by surface. Each surface renders its items
/// at their requested position (see `HookBannerHost`).
var hookBanners: [HookBannerSurface: [HookBannerItem]] = [:]
/// Non-nil while the secret-setup form should be presented (e.g. opened from
/// the autopilot `.env` banner's deep link).
var secretsSetupRequest: SecretsSetupRequest?

// MARK: - Services

Expand Down Expand Up @@ -1127,7 +1133,7 @@
hookManager.register(CINotificationHook())
hookManager.register(RemoteConfigNotificationHook())
#if os(macOS)
hookManager.register(SecretsAutoDownloadHook())
hookManager.register(AutopilotHook())
#endif
}

Expand Down Expand Up @@ -1177,4 +1183,4 @@
return message
}
}
}

Check warning on line 1186 in RxCode/App/AppState.swift

View workflow job for this annotation

GitHub Actions / swiftlint

File should contain 600 lines or less excluding comments and whitespaces: currently contains 740 (file_length)
17 changes: 15 additions & 2 deletions RxCode/App/RxCodeApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -604,14 +604,23 @@
.environment(windowState)
.environment(chatBridge)
.environment(\.openURL, OpenURLAction { url in
openMarkdownLink(url, in: windowState)
if let request = SecretsDeepLink.parse(url) {
appState.secretsSetupRequest = request
return .handled
}
return openMarkdownLink(url, in: windowState)
})
.transition(.opacity)
} else {
LoadingView()
.transition(.opacity)
}
}
.onOpenURL { url in
if let request = SecretsDeepLink.parse(url) {
appState.secretsSetupRequest = request
}
}
.animation(.easeInOut(duration: 0.3), value: appState.isInitialized)
.task {
await appState.initialize()
Expand Down Expand Up @@ -674,7 +683,11 @@
.environment(windowState)
.environment(chatBridge)
.environment(\.openURL, OpenURLAction { url in
openMarkdownLink(url, in: windowState)
if let request = SecretsDeepLink.parse(url) {
appState.secretsSetupRequest = request
return .handled
Comment on lines +686 to +688
Comment on lines +686 to +688
}
return openMarkdownLink(url, in: windowState)
})
.transition(.opacity)
} else {
Expand Down Expand Up @@ -763,4 +776,4 @@
.frame(minWidth: 600, idealWidth: 900, minHeight: 400, idealHeight: 600)
.background(ClaudeTheme.surfaceElevated)
}
}

Check warning on line 779 in RxCode/App/RxCodeApp.swift

View workflow job for this annotation

GitHub Actions / swiftlint

File should contain 600 lines or less excluding comments and whitespaces: currently contains 662 (file_length)
12 changes: 12 additions & 0 deletions RxCode/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,9 @@
}
}
}
},
"%@ isn't backed up to Autopilot" : {

},
"%@ not found" : {
"extractionState" : "stale",
Expand Down Expand Up @@ -1654,6 +1657,9 @@
}
}
}
},
"Back up this project's secrets to Autopilot" : {

},
"Bash" : {
"localizations" : {
Expand Down Expand Up @@ -3179,6 +3185,9 @@
}
}
}
},
"Dismiss" : {

},
"Display name" : {
"localizations" : {
Expand Down Expand Up @@ -8373,6 +8382,9 @@
}
}
}
},
"Set up" : {

},
"Set up encryption" : {
"localizations" : {
Expand Down
Loading
Loading