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
19 changes: 17 additions & 2 deletions apps/purepoint-macos/purepoint-macos/Models/AgentsHubModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ struct SavedPrompt: Identifiable {
var body: String
var source: String
var variables: [String]
var command: String?

init(from info: TemplateInfo) {
self.id = "\(info.source):\(info.name)"
Expand All @@ -17,16 +18,21 @@ struct SavedPrompt: Identifiable {
self.body = "" // TemplateInfo doesn't include body (list response)
self.source = info.source
self.variables = info.variables
self.command = info.command
}

init(name: String, description: String, agent: String, body: String, source: String, variables: [String]) {
init(
name: String, description: String, agent: String, body: String, source: String,
variables: [String], command: String? = nil
) {
self.id = "\(source):\(name)"
self.name = name
self.description = description
self.agent = agent
self.body = body
self.source = source
self.variables = variables
self.command = command
}
}

Expand All @@ -40,6 +46,7 @@ struct AgentDefinition: Identifiable {
var scope: String
var availableInCommandDialog: Bool
var icon: String?
var command: String?

init(from info: AgentDefInfo) {
self.id = "\(info.scope):\(info.name)"
Expand All @@ -51,11 +58,13 @@ struct AgentDefinition: Identifiable {
self.scope = info.scope
self.availableInCommandDialog = info.availableInCommandDialog
self.icon = info.icon
self.command = info.command
}

init(
name: String, agentType: String = "claude", template: String? = nil, inlinePrompt: String? = nil,
tags: [String] = [], scope: String = "local", availableInCommandDialog: Bool = true, icon: String? = nil
tags: [String] = [], scope: String = "local", availableInCommandDialog: Bool = true, icon: String? = nil,
command: String? = nil
) {
self.id = "\(scope):\(name)"
self.name = name
Expand All @@ -66,6 +75,7 @@ struct AgentDefinition: Identifiable {
self.scope = scope
self.availableInCommandDialog = availableInCommandDialog
self.icon = icon
self.command = command
}
}

Expand Down Expand Up @@ -126,6 +136,11 @@ enum PromptScopeChoice: String, CaseIterable, Identifiable {
}
}

enum AgentTypes {
static let all = ["claude", "codex", "opencode", "terminal"]
static let withAny = [""] + all
}

enum AgentPromptSourceMode: String, CaseIterable, Identifiable {
case library
case inline
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,12 @@ nonisolated struct AgentEntry: Codable, Sendable {
let pid: Int?
let sessionId: String?
let suspended: Bool?
let command: String?

// Explicit CodingKeys document the camelCase wire format
// matching Rust's #[serde(rename_all = "camelCase")] on types::AgentEntry.
private enum CodingKeys: String, CodingKey {
case id, name, status, prompt, error, pid, suspended
case id, name, status, prompt, error, pid, suspended, command
case agentType, startedAt, completedAt, exitCode, sessionId
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,15 @@ nonisolated struct AgentModel: Identifiable, Equatable, Sendable {
let startedAt: String
let sessionId: String?
let suspended: Bool
let command: String?

var displayName: String {
name.isEmpty ? id : name
}

init(
id: String, name: String, agentType: String, status: AgentStatus, prompt: String, startedAt: String,
sessionId: String? = nil, suspended: Bool = false
sessionId: String? = nil, suspended: Bool = false, command: String? = nil
) {
self.id = id
self.name = name
Expand All @@ -39,6 +40,7 @@ nonisolated struct AgentModel: Identifiable, Equatable, Sendable {
self.startedAt = startedAt
self.sessionId = sessionId
self.suspended = suspended
self.command = command
}

init(from entry: AgentEntry) {
Expand All @@ -50,7 +52,8 @@ nonisolated struct AgentModel: Identifiable, Equatable, Sendable {
prompt: entry.prompt ?? "",
startedAt: entry.startedAt,
sessionId: entry.sessionId,
suspended: entry.suspended ?? false
suspended: entry.suspended ?? false,
command: entry.command
)
}

Expand Down
26 changes: 17 additions & 9 deletions apps/purepoint-macos/purepoint-macos/Services/DaemonClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ nonisolated enum DaemonRequest: Encodable {
case spawn(
projectRoot: String, prompt: String, agent: String = "claude",
name: String? = nil, base: String? = nil, root: Bool = false,
worktree: String? = nil)
worktree: String? = nil, command: String? = nil)
case kill(projectRoot: String, target: KillTarget)
case rename(projectRoot: String, agentId: String, name: String)
case suspend(projectRoot: String, target: SuspendTarget)
Expand All @@ -205,12 +205,13 @@ nonisolated enum DaemonRequest: Encodable {
case listTemplates(projectRoot: String)
case getTemplate(projectRoot: String, name: String)
case saveTemplate(
projectRoot: String, name: String, description: String, agent: String, body: String, scope: String)
projectRoot: String, name: String, description: String, agent: String, body: String, scope: String,
command: String? = nil)
case deleteTemplate(projectRoot: String, name: String, scope: String)
case listAgentDefs(projectRoot: String)
case saveAgentDef(
projectRoot: String, name: String, agentType: String, template: String?, inlinePrompt: String?, tags: [String],
scope: String, availableInCommandDialog: Bool, icon: String?)
scope: String, availableInCommandDialog: Bool, icon: String?, command: String? = nil)
case deleteAgentDef(projectRoot: String, name: String, scope: String)
case listSwarmDefs(projectRoot: String)
case saveSwarmDef(
Expand Down Expand Up @@ -251,7 +252,7 @@ nonisolated enum DaemonRequest: Encodable {
try container.encode(agentId, forKey: .key("agent_id"))
try container.encode(cols, forKey: .key("cols"))
try container.encode(rows, forKey: .key("rows"))
case .spawn(let projectRoot, let prompt, let agent, let name, let base, let root, let worktree):
case .spawn(let projectRoot, let prompt, let agent, let name, let base, let root, let worktree, let command):
try container.encode("spawn", forKey: .key("type"))
try container.encode(projectRoot, forKey: .key("project_root"))
try container.encode(prompt, forKey: .key("prompt"))
Expand All @@ -260,6 +261,7 @@ nonisolated enum DaemonRequest: Encodable {
if let base { try container.encode(base, forKey: .key("base")) }
if root { try container.encode(root, forKey: .key("root")) }
if let worktree { try container.encode(worktree, forKey: .key("worktree")) }
if let command { try container.encode(command, forKey: .key("command")) }
case .kill(let projectRoot, let target):
try container.encode("kill", forKey: .key("type"))
try container.encode(projectRoot, forKey: .key("project_root"))
Expand Down Expand Up @@ -303,14 +305,15 @@ nonisolated enum DaemonRequest: Encodable {
try container.encode("get_template", forKey: .key("type"))
try container.encode(projectRoot, forKey: .key("project_root"))
try container.encode(name, forKey: .key("name"))
case .saveTemplate(let projectRoot, let name, let description, let agent, let body, let scope):
case .saveTemplate(let projectRoot, let name, let description, let agent, let body, let scope, let command):
try container.encode("save_template", forKey: .key("type"))
try container.encode(projectRoot, forKey: .key("project_root"))
try container.encode(name, forKey: .key("name"))
try container.encode(description, forKey: .key("description"))
try container.encode(agent, forKey: .key("agent"))
try container.encode(body, forKey: .key("body"))
try container.encode(scope, forKey: .key("scope"))
if let command { try container.encode(command, forKey: .key("command")) }
case .deleteTemplate(let projectRoot, let name, let scope):
try container.encode("delete_template", forKey: .key("type"))
try container.encode(projectRoot, forKey: .key("project_root"))
Expand All @@ -321,7 +324,7 @@ nonisolated enum DaemonRequest: Encodable {
try container.encode(projectRoot, forKey: .key("project_root"))
case .saveAgentDef(
let projectRoot, let name, let agentType, let template, let inlinePrompt, let tags, let scope,
let availableInCommandDialog, let icon):
let availableInCommandDialog, let icon, let command):
try container.encode("save_agent_def", forKey: .key("type"))
try container.encode(projectRoot, forKey: .key("project_root"))
try container.encode(name, forKey: .key("name"))
Expand All @@ -332,6 +335,7 @@ nonisolated enum DaemonRequest: Encodable {
try container.encode(scope, forKey: .key("scope"))
try container.encode(availableInCommandDialog, forKey: .key("available_in_command_dialog"))
if let icon { try container.encode(icon, forKey: .key("icon")) }
if let command { try container.encode(command, forKey: .key("command")) }
case .deleteAgentDef(let projectRoot, let name, let scope):
try container.encode("delete_agent_def", forKey: .key("type"))
try container.encode(projectRoot, forKey: .key("project_root"))
Expand Down Expand Up @@ -414,7 +418,8 @@ nonisolated enum DaemonResponse: Decodable {
case deleteWorktreeResult(worktreeId: String, killedAgents: [String])
case templateList(templates: [TemplateInfo])
case templateDetail(
name: String, description: String, agent: String, body: String, source: String, variables: [String])
name: String, description: String, agent: String, body: String, source: String, variables: [String],
command: String?)
case agentDefList(agentDefs: [AgentDefInfo])
case swarmDefList(swarmDefs: [SwarmDefInfo])
case runSwarmResult(spawnedAgents: [String])
Expand Down Expand Up @@ -492,7 +497,7 @@ nonisolated enum DaemonResponse: Decodable {
let p = try TemplateDetailPayload(from: decoder)
self = .templateDetail(
name: p.name, description: p.description, agent: p.agent, body: p.body, source: p.source,
variables: p.variables)
variables: p.variables, command: p.command)
case "agent_def_list":
let p = try AgentDefListPayload(from: decoder)
self = .agentDefList(agentDefs: p.agentDefs)
Expand Down Expand Up @@ -568,6 +573,7 @@ nonisolated struct TemplateInfo: Decodable {
let agent: String
let source: String
let variables: [String]
let command: String?
}

nonisolated struct AgentDefInfo: Decodable {
Expand All @@ -579,9 +585,10 @@ nonisolated struct AgentDefInfo: Decodable {
let scope: String
let availableInCommandDialog: Bool
let icon: String?
let command: String?

enum CodingKeys: String, CodingKey {
case name, template, tags, scope, icon
case name, template, tags, scope, icon, command
case agentType = "agent_type"
case inlinePrompt = "inline_prompt"
case availableInCommandDialog = "available_in_command_dialog"
Expand Down Expand Up @@ -617,6 +624,7 @@ private nonisolated struct TemplateDetailPayload: Decodable {
let body: String
let source: String
let variables: [String]
let command: String?
}

private nonisolated struct AgentDefListPayload: Decodable {
Expand Down
14 changes: 9 additions & 5 deletions apps/purepoint-macos/purepoint-macos/State/AgentsHubState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,16 @@ final class AgentsHubState {
func loadPromptDetail(projectRoot: String, name: String) async {
do {
let response = try await client.send(.getTemplate(projectRoot: projectRoot, name: name))
if case .templateDetail(let detailName, let description, let agent, let body, let source, let variables) =
response
if case .templateDetail(
let detailName, let description, let agent, let body,
let source, let variables, let command) = response
{
if let index = prompts.firstIndex(where: { $0.name == detailName && $0.source == source }) {
prompts[index].body = body
prompts[index].description = description
prompts[index].agent = agent
prompts[index].variables = variables
prompts[index].command = command
}
}
} catch {
Expand All @@ -106,13 +108,14 @@ final class AgentsHubState {
}

func saveTemplate(
projectRoot: String, name: String, description: String, agent: String, body: String, scope: String
projectRoot: String, name: String, description: String, agent: String, body: String, scope: String,
command: String? = nil
) async {
do {
_ = try await client.send(
.saveTemplate(
projectRoot: projectRoot, name: name, description: description, agent: agent, body: body,
scope: scope))
scope: scope, command: command))
await loadTemplates(projectRoot: projectRoot)
await loadPromptDetail(projectRoot: projectRoot, name: name)
} catch {
Expand Down Expand Up @@ -141,7 +144,8 @@ final class AgentsHubState {
tags: def.tags,
scope: def.scope,
availableInCommandDialog: def.availableInCommandDialog,
icon: def.icon
icon: def.icon,
command: def.command
))
await loadAgentDefs(projectRoot: projectRoot)
} catch {
Expand Down
10 changes: 7 additions & 3 deletions apps/purepoint-macos/purepoint-macos/State/ProjectState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,8 @@ final class ProjectState: Identifiable {
// MARK: - Agent Operations

func createAgent(
agent: String, prompt: String, name: String? = nil, isWorktree: Bool = false, selection: SidebarSelection?
agent: String, prompt: String, name: String? = nil, isWorktree: Bool = false, selection: SidebarSelection?,
command: String? = nil
) {
let root = projectRoot

Expand Down Expand Up @@ -173,7 +174,8 @@ final class ProjectState: Identifiable {
let response = try await client.send(
.spawn(
projectRoot: root, prompt: prompt, agent: agent,
name: name, root: spawnRoot, worktree: spawnWorktree
name: name, root: spawnRoot, worktree: spawnWorktree,
command: command
))
switch response {
case .spawnResult(_, let agentId, _):
Expand Down Expand Up @@ -245,7 +247,9 @@ final class ProjectState: Identifiable {
agent: variant.id, prompt: prompt ?? "", name: name, isWorktree: variant.kind == .worktree,
selection: selection)
case .spawnAgentDef(let def, let prompt):
createAgent(agent: def.agentType, prompt: prompt ?? def.inlinePrompt ?? "", selection: selection)
createAgent(
agent: def.agentType, prompt: prompt ?? def.inlinePrompt ?? "",
selection: selection, command: def.command)
case .runSwarm(let def):
let root = projectRoot
Task { await hub.runSwarm(projectRoot: root, name: def.name) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ struct AgentCreationSheet: View {
@State private var tags = ""
@State private var scope: PromptScopeChoice = .project
@State private var availableInCommandDialog = true
@State private var command = ""

private let agentTypes = ["claude", "codex", "opencode"]
private let agentTypes = AgentTypes.all

var body: some View {
VStack(spacing: 0) {
Expand Down Expand Up @@ -52,23 +53,30 @@ struct AgentCreationSheet: View {
}
}

Picker("Prompt source", selection: $promptMode) {
ForEach(AgentPromptSourceMode.allCases) { mode in
Text(mode.title).tag(mode)
}
if agentType == "terminal" {
TextField("Command (e.g. npm run dev)", text: $command)
.textFieldStyle(.roundedBorder)
}
.pickerStyle(.segmented)

if promptMode == .library {
TextField("Template name", text: $templateName)
.textFieldStyle(.roundedBorder)
} else {
TextEditor(text: $inlinePrompt)
.font(.system(size: 13, design: .monospaced))
.frame(minHeight: 100)
.padding(4)
.background(Color.primary.opacity(0.035))
.clipShape(RoundedRectangle(cornerRadius: 8))
if agentType != "terminal" {
Picker("Prompt source", selection: $promptMode) {
ForEach(AgentPromptSourceMode.allCases) { mode in
Text(mode.title).tag(mode)
}
}
.pickerStyle(.segmented)

if promptMode == .library {
TextField("Template name", text: $templateName)
.textFieldStyle(.roundedBorder)
} else {
TextEditor(text: $inlinePrompt)
.font(.system(size: 13, design: .monospaced))
.frame(minHeight: 100)
.padding(4)
.background(Color.primary.opacity(0.035))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}

TextField("Tags (comma-separated)", text: $tags)
Expand Down Expand Up @@ -102,14 +110,16 @@ struct AgentCreationSheet: View {
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }

let trimmedCommand = command.trimmingCharacters(in: .whitespaces)
let def = AgentDefinition(
name: name.trimmingCharacters(in: .whitespaces),
agentType: agentType,
template: promptMode == .library ? templateName : nil,
inlinePrompt: promptMode == .inline ? inlinePrompt : nil,
template: agentType != "terminal" && promptMode == .library ? templateName : nil,
inlinePrompt: agentType != "terminal" && promptMode == .inline ? inlinePrompt : nil,
tags: parsedTags,
scope: scope.wireValue,
availableInCommandDialog: availableInCommandDialog
availableInCommandDialog: availableInCommandDialog,
command: agentType == "terminal" && !trimmedCommand.isEmpty ? trimmedCommand : nil
)
Task {
await hubState.saveAgentDef(projectRoot: projectRoot, def: def)
Expand Down
Loading
Loading