From 9216d23fadb00dd3a588be516dbe0c3e021102c6 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Fri, 29 May 2026 21:09:19 +0800 Subject: [PATCH 1/3] feat: add hooks support --- .github/workflows/build.yaml | 2 - .../RxCodeChatKit/MessageListView.swift | 9 + .../RxCodeChatKit/ToolResultView.swift | 41 +- .../RxCodeCore/Models/ChatMessage.swift | 4 + .../RxCodeCore/Models/HookProfile.swift | 64 +++ .../RxCodeCore/Models/HookStatusRecord.swift | 40 ++ .../RxCodeCore/Models/PackageRunConfig.swift | 50 +++ .../RxCodeCore/Models/RunProfile.swift | 9 + .../RunProfile/DetectedRunnable.swift | 8 +- .../RunProfile/RunTaskExecutor.swift | 17 + .../RunTaskExecutorTests.swift | 55 +++ RxCode/App/AppState+CrossProject.swift | 50 ++- RxCode/App/AppState+Helpers.swift | 4 +- RxCode/App/AppState+Hooks.swift | 234 +++++++++++ RxCode/App/AppState+Messaging.swift | 8 + RxCode/App/AppState+Project.swift | 2 +- RxCode/App/AppState+Stream.swift | 12 + RxCode/App/AppState.swift | 12 + RxCode/Resources/Localizable.xcstrings | 387 ++++++++++++++++++ RxCode/Resources/user_manual_hooks.md | 50 +++ RxCode/Resources/user_manual_hooks_zh_CN.md | 42 ++ RxCode/Services/Hooks/HookService.swift | 76 ++++ RxCode/Services/PersistenceService.swift | 21 + .../RunProfile/RunProfileDetector.swift | 84 +++- RxCode/Services/ThreadStore.swift | 66 ++- .../Views/Hooks/BashEnvironmentEditor.swift | 191 +++++++++ .../Views/Hooks/HookConfigurationsView.swift | 199 +++++++++ .../Views/Hooks/HookProfileDetailForm.swift | 224 ++++++++++ .../RunProfile/RunConfigurationsView.swift | 51 ++- .../RunProfile/RunProfileDetailForm.swift | 132 ++++++ .../Views/Settings/HooksSettingsSection.swift | 77 ++++ RxCode/Views/SettingsView.swift | 2 + .../Views/Sidebar/BriefingMarkdownView.swift | 158 +++++++ RxCode/Views/Sidebar/BriefingView.swift | 210 +++------- RxCode/Views/UserManualView.swift | 3 + .../Views/MobileRunProfileEditorView.swift | 45 ++ RxCodeMobile/Views/SessionsList.swift | 28 ++ 37 files changed, 2474 insertions(+), 193 deletions(-) create mode 100644 Packages/Sources/RxCodeCore/Models/HookProfile.swift create mode 100644 Packages/Sources/RxCodeCore/Models/HookStatusRecord.swift create mode 100644 Packages/Sources/RxCodeCore/Models/PackageRunConfig.swift create mode 100644 RxCode/App/AppState+Hooks.swift create mode 100644 RxCode/Resources/user_manual_hooks.md create mode 100644 RxCode/Resources/user_manual_hooks_zh_CN.md create mode 100644 RxCode/Services/Hooks/HookService.swift create mode 100644 RxCode/Views/Hooks/BashEnvironmentEditor.swift create mode 100644 RxCode/Views/Hooks/HookConfigurationsView.swift create mode 100644 RxCode/Views/Hooks/HookProfileDetailForm.swift create mode 100644 RxCode/Views/Settings/HooksSettingsSection.swift create mode 100644 RxCode/Views/Sidebar/BriefingMarkdownView.swift diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 6f24a80..e6d5432 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -6,8 +6,6 @@ concurrency: on: push: - branches: - - main release: types: - created diff --git a/Packages/Sources/RxCodeChatKit/MessageListView.swift b/Packages/Sources/RxCodeChatKit/MessageListView.swift index 99e9b52..b50f4e4 100644 --- a/Packages/Sources/RxCodeChatKit/MessageListView.swift +++ b/Packages/Sources/RxCodeChatKit/MessageListView.swift @@ -155,6 +155,15 @@ struct MessageListView: View { .onChange(of: chatBridge.messages.last?.id) { _, _ in handleLastMessageChange() } + // Synthetic hook/auto-continue cards mutate the last message in + // place (result: nil → result) without changing its id, content, or + // the session-level isStreaming flag, so none of the other rebuild + // triggers fire. They flip isResponseComplete when the result lands; + // observe it so the running card refreshes to its result live + // instead of only after a reload. + .onChange(of: chatBridge.messages.last?.isResponseComplete) { _, _ in + handleLastMessageChange() + } .onChange(of: chatBridge.messages.last?.content) { _, _ in guard isSessionReady else { return } requestScrollToBottom() diff --git a/Packages/Sources/RxCodeChatKit/ToolResultView.swift b/Packages/Sources/RxCodeChatKit/ToolResultView.swift index 8ccd00a..1cfdbf4 100644 --- a/Packages/Sources/RxCodeChatKit/ToolResultView.swift +++ b/Packages/Sources/RxCodeChatKit/ToolResultView.swift @@ -104,7 +104,10 @@ struct ToolResultView: View { Spacer() Group { - if displayIsError { + if isAutoContinueTool { + // System action, not a pass/fail result — no status badge. + EmptyView() + } else if displayIsError { Image(systemName: "exclamationmark.circle.fill") .foregroundStyle(ClaudeTheme.statusError) .font(.caption) @@ -114,7 +117,7 @@ struct ToolResultView: View { .foregroundStyle(ClaudeTheme.statusSuccess) .font(.caption) .accessibilityLabel("Completed") - } else if isMessageStreaming { + } else if isMessageStreaming || isRunningHookCard { ProgressView() .controlSize(.mini) .accessibilityLabel("Running") @@ -235,7 +238,7 @@ struct ToolResultView: View { inputSummaryView(maximumNumberOfLines: 1) - if toolCall.result == nil && isMessageStreaming { + if toolCall.result == nil && (isMessageStreaming || isRunningHookCard) { ProgressView() .controlSize(.mini) .accessibilityLabel("Running") @@ -289,7 +292,28 @@ struct ToolResultView: View { } private var isCardTool: Bool { - isEditTool + isEditTool || isHookTool || isAutoContinueTool + } + + /// Lifecycle hooks are emitted as tool calls named "Hook: " so they + /// render through the same status/result card as real tools. + private var isHookTool: Bool { + toolNameLower.hasPrefix("hook:") + } + + /// The synthetic card inserted when a failing before-session-stop hook + /// re-prompts the agent. Rendered as its own card (not a user bubble) so it + /// reads as a system action rather than something the user typed. + private var isAutoContinueTool: Bool { + toolNameLower == ToolCall.autoContinueToolName.lowercased() + } + + /// A hook card whose command is still running. Hook cards are synthetic and + /// never carry the message-level `isStreaming` flag (a stop hook runs after + /// the turn is finalized), so the "running" spinner keys off the absent + /// result instead of `isMessageStreaming`. + private var isRunningHookCard: Bool { + isHookTool && toolCall.result == nil } // MARK: - Edit Diff @@ -481,6 +505,8 @@ struct ToolResultView: View { // MARK: - Helpers private var sfSymbol: String { + if isAutoContinueTool { return "arrow.triangle.2.circlepath" } + if isHookTool { return "bolt.horizontal.circle" } switch toolNameLower { case "agent": return "cpu" case "read": return "doc.text" @@ -497,6 +523,8 @@ struct ToolResultView: View { } private var iconColor: Color { + if isAutoContinueTool { return ClaudeTheme.accent } + if isHookTool { return ClaudeTheme.accent } switch toolNameLower { case "agent", "bash", InteractiveTerminalState.toolName: return ClaudeTheme.accent @@ -583,6 +611,11 @@ struct ToolResultView: View { } private var inputSummary: String { + if isAutoContinueTool { + return toolCall.input["summary"]?.stringValue + ?? "Stop hook failed — sent back to the agent to continue." + } + if toolNameLower == "agent" { if let desc = toolCall.input["description"]?.stringValue { return desc diff --git a/Packages/Sources/RxCodeCore/Models/ChatMessage.swift b/Packages/Sources/RxCodeCore/Models/ChatMessage.swift index 8f3affd..e0edbde 100644 --- a/Packages/Sources/RxCodeCore/Models/ChatMessage.swift +++ b/Packages/Sources/RxCodeCore/Models/ChatMessage.swift @@ -235,6 +235,10 @@ public struct ToolCall: Identifiable, Codable, Sendable, Equatable { result.map { !$0.isEmpty } ?? false } + /// Tool name for the synthetic card shown when a failing before-session-stop + /// hook auto-continues the agent. Rendered specially by `ToolResultView`. + public static let autoContinueToolName = "Auto-continue" + /// Tool names that must stay in the message block even without a result — /// either because the result would be empty by design, or because the UI /// needs to render them before the user/CLI produces a result. diff --git a/Packages/Sources/RxCodeCore/Models/HookProfile.swift b/Packages/Sources/RxCodeCore/Models/HookProfile.swift new file mode 100644 index 0000000..2d48540 --- /dev/null +++ b/Packages/Sources/RxCodeCore/Models/HookProfile.swift @@ -0,0 +1,64 @@ +import Foundation + +/// Lifecycle point at which a hook fires. +public enum HookTrigger: String, Codable, Sendable, CaseIterable, Hashable { + /// Fires once, when a brand-new thread begins its first turn. The hook's + /// stdout is injected into the agent's context for that turn. + case beforeSessionStart + /// Fires when streaming stops, before the thread is finalized. The hook's + /// output is shown and saved as a context block in the thread. + case beforeSessionStop + /// Fires after the thread is finalized/saved when streaming stops. The + /// hook's output is shown only — nothing is passed back to the session. + case afterSessionStop + + public var displayName: String { + switch self { + case .beforeSessionStart: return "Before Session Start" + case .beforeSessionStop: return "Before Session Stop" + case .afterSessionStop: return "After Session Stop" + } + } +} + +/// What a hook runs. Bash-only today; typed so other kinds can be added later. +public enum HookType: String, Codable, Sendable, CaseIterable, Hashable { + case bash +} + +/// A single project-scoped automation that runs a command at a session +/// lifecycle point. Modeled on `RunProfile`; reuses `BashRunConfig` for the +/// command, working directory, and environment presets. +public struct HookProfile: Identifiable, Codable, Sendable, Hashable { + public var id: UUID + public var projectId: UUID + public var name: String + public var enabled: Bool + public var trigger: HookTrigger + public var type: HookType + public var bash: BashRunConfig + public var createdAt: Date + public var updatedAt: Date + + public init( + id: UUID = UUID(), + projectId: UUID, + name: String, + enabled: Bool = true, + trigger: HookTrigger = .beforeSessionStart, + type: HookType = .bash, + bash: BashRunConfig = BashRunConfig(), + createdAt: Date = Date(), + updatedAt: Date = Date() + ) { + self.id = id + self.projectId = projectId + self.name = name + self.enabled = enabled + self.trigger = trigger + self.type = type + self.bash = bash + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} diff --git a/Packages/Sources/RxCodeCore/Models/HookStatusRecord.swift b/Packages/Sources/RxCodeCore/Models/HookStatusRecord.swift new file mode 100644 index 0000000..d1c4dd0 --- /dev/null +++ b/Packages/Sources/RxCodeCore/Models/HookStatusRecord.swift @@ -0,0 +1,40 @@ +import Foundation +import SwiftData + +/// Persisted status of the most recent hook card for a session. One row per +/// `sessionId` — we only keep the last hook (start/stop) so its card survives a +/// reload, mirroring how `PlanDecisionRecord` keeps plan decisions alive across +/// CLI-backed session reloads. Hook cards are synthetic `ChatMessage`s injected +/// by `runHooks`; they never reach the CLI's jsonl transcript, so without this +/// sidecar they vanish when messages are reloaded from disk. +@Model +public final class HookStatusRecord { + @Attribute(.unique) public var sessionId: String + /// The original tool-call id of the hook card, so the rebuilt card dedupes + /// against a live in-memory one. + public var toolId: String + public var name: String + /// `HookTrigger.displayName` at the time the hook ran. + public var trigger: String + public var output: String + public var isError: Bool + public var updatedAt: Date + + public init( + sessionId: String, + toolId: String, + name: String, + trigger: String, + output: String, + isError: Bool, + updatedAt: Date = .now + ) { + self.sessionId = sessionId + self.toolId = toolId + self.name = name + self.trigger = trigger + self.output = output + self.isError = isError + self.updatedAt = updatedAt + } +} diff --git a/Packages/Sources/RxCodeCore/Models/PackageRunConfig.swift b/Packages/Sources/RxCodeCore/Models/PackageRunConfig.swift new file mode 100644 index 0000000..ac7c07d --- /dev/null +++ b/Packages/Sources/RxCodeCore/Models/PackageRunConfig.swift @@ -0,0 +1,50 @@ +import Foundation + +/// A JavaScript/TypeScript package manager (or runtime task runner) capable of +/// running a named script from `package.json` (or `deno.json` tasks). +public enum PackageManager: String, Codable, Sendable, CaseIterable, Hashable { + case npm + case yarn + case pnpm + case bun + case deno + + /// Command prefix that precedes the script name. npm and pnpm require an + /// explicit `run`; yarn/bun take the script directly; deno uses `deno task`. + public var runPrefix: String { + switch self { + case .npm: return "npm run" + case .yarn: return "yarn" + case .pnpm: return "pnpm run" + case .bun: return "bun run" + case .deno: return "deno task" + } + } + + /// Executable name used for install detection (`command -v `). + public var executable: String { rawValue } + + public var displayName: String { rawValue } +} + +/// Configuration for a `.packageScript` run profile: run a named script through +/// a selected package manager, with optional extra arguments appended verbatim +/// after the script (e.g. `bun run dev -- --port 3000`). +public struct PackageRunConfig: Codable, Sendable, Hashable { + public var packageManager: PackageManager + /// The script/task name, e.g. `dev`. + public var script: String + /// Extra arguments appended after the script. Free-form so the user can pass + /// `-- --port 3000` (npm-style separator) or plain flags. + public var arguments: String + + public init( + packageManager: PackageManager = .npm, + script: String = "", + arguments: String = "" + ) { + self.packageManager = packageManager + self.script = script + self.arguments = arguments + } +} diff --git a/Packages/Sources/RxCodeCore/Models/RunProfile.swift b/Packages/Sources/RxCodeCore/Models/RunProfile.swift index bbea1eb..9cef8dc 100644 --- a/Packages/Sources/RxCodeCore/Models/RunProfile.swift +++ b/Packages/Sources/RxCodeCore/Models/RunProfile.swift @@ -4,6 +4,10 @@ public enum RunProfileType: String, Codable, Sendable, CaseIterable, Hashable { case bash case xcode case make + /// Runs a `package.json` / `deno.json` script through a selected package + /// manager. Named `packageScript` to avoid the `package` access-control + /// keyword; the rawValue stays `"package"` so it renders as "Package". + case packageScript = "package" } public struct RunProfile: Identifiable, Codable, Sendable, Hashable { @@ -18,6 +22,9 @@ public struct RunProfile: Identifiable, Codable, Sendable, Hashable { /// Populated when `type == .make`. Optional so existing on-disk profiles /// (written before the Make type existed) decode cleanly. public var make: MakeRunConfig? + /// Populated when `type == .packageScript`. Optional so existing on-disk + /// profiles (written before the Package type existed) decode cleanly. + public var package: PackageRunConfig? public var beforeSteps: [RunStep] public var afterSteps: [RunStep] public var createdAt: Date @@ -31,6 +38,7 @@ public struct RunProfile: Identifiable, Codable, Sendable, Hashable { bash: BashRunConfig = BashRunConfig(), xcode: XcodeRunConfig? = nil, make: MakeRunConfig? = nil, + package: PackageRunConfig? = nil, beforeSteps: [RunStep] = [], afterSteps: [RunStep] = [], createdAt: Date = Date(), @@ -43,6 +51,7 @@ public struct RunProfile: Identifiable, Codable, Sendable, Hashable { self.bash = bash self.xcode = xcode self.make = make + self.package = package self.beforeSteps = beforeSteps self.afterSteps = afterSteps self.createdAt = createdAt diff --git a/Packages/Sources/RxCodeCore/RunProfile/DetectedRunnable.swift b/Packages/Sources/RxCodeCore/RunProfile/DetectedRunnable.swift index 12a86cb..9f6d62c 100644 --- a/Packages/Sources/RxCodeCore/RunProfile/DetectedRunnable.swift +++ b/Packages/Sources/RxCodeCore/RunProfile/DetectedRunnable.swift @@ -27,6 +27,10 @@ public struct DetectedRunnable: Identifiable, Codable, Hashable, Sendable { /// Present when `source == .make`. The dialog materializes these as /// `.make`-typed profiles instead of bash configurations. public let make: MakeRunConfig? + /// Present when `source == .npm`. The dialog materializes these as + /// `.packageScript`-typed profiles, carrying the detected package manager + /// and script name. + public let package: PackageRunConfig? public init( id: String, @@ -34,7 +38,8 @@ public struct DetectedRunnable: Identifiable, Codable, Hashable, Sendable { displayName: String, command: String, xcode: XcodeRunConfig? = nil, - make: MakeRunConfig? = nil + make: MakeRunConfig? = nil, + package: PackageRunConfig? = nil ) { self.id = id self.source = source @@ -42,6 +47,7 @@ public struct DetectedRunnable: Identifiable, Codable, Hashable, Sendable { self.command = command self.xcode = xcode self.make = make + self.package = package } } diff --git a/Packages/Sources/RxCodeCore/RunProfile/RunTaskExecutor.swift b/Packages/Sources/RxCodeCore/RunProfile/RunTaskExecutor.swift index 1400d69..ce7fd45 100644 --- a/Packages/Sources/RxCodeCore/RunProfile/RunTaskExecutor.swift +++ b/Packages/Sources/RxCodeCore/RunProfile/RunTaskExecutor.swift @@ -150,9 +150,26 @@ public enum RunTaskExecutor { case .make: guard let make = profile.make else { return [] } return makeScriptLines(make, projectPath: projectPath) + case .packageScript: + guard let pkg = profile.package else { return [] } + return packageScriptLines(pkg) } } + /// Synthesize ` run