mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +00:00
530 lines
20 KiB
Swift
530 lines
20 KiB
Swift
import Foundation
|
|
import HapiProtocol
|
|
|
|
/// Collapsed tool-card header: SF Symbol + title + optional subtitle. Port of
|
|
/// the presentation registry in `web/src/components/ToolCard/knownTools.tsx`
|
|
/// (read-only subset), matching the Android port's `ToolCardPresentation`
|
|
/// line for line so the three clients label tools identically. Kept in the
|
|
/// app layer — `HapiProtocol.ToolPresentation` stays the thin
|
|
/// fixture-adjacent helper it is.
|
|
struct ToolCardPresentation: Equatable {
|
|
/// SF Symbol name.
|
|
var icon: String
|
|
var title: String
|
|
var subtitle: String?
|
|
}
|
|
|
|
private enum ToolIcon {
|
|
static let terminal = "terminal"
|
|
static let read = "eye"
|
|
static let search = "magnifyingglass"
|
|
static let edit = "pencil"
|
|
static let web = "globe"
|
|
static let agent = "paperplane"
|
|
static let question = "questionmark.circle"
|
|
static let plan = "list.clipboard"
|
|
static let idea = "lightbulb"
|
|
static let puzzle = "puzzlepiece.extension"
|
|
static let message = "bubble.left"
|
|
static let team = "person.2"
|
|
static let warning = "exclamationmark.triangle"
|
|
static let wrench = "wrench.and.screwdriver"
|
|
}
|
|
|
|
// MARK: - Path display
|
|
|
|
/// Strip the session root so paths read workspace-relative
|
|
/// (web `resolveDisplayPath`).
|
|
func chatDisplayPath(_ path: String, basePath: String?) -> String {
|
|
guard let basePath, !basePath.isEmpty else { return path }
|
|
let root = trimTrailingSlashes(basePath)
|
|
if path == root { return "." }
|
|
if path.hasPrefix(root + "/") {
|
|
return String(path.dropFirst(root.count + 1))
|
|
}
|
|
return path
|
|
}
|
|
|
|
private func trimTrailingSlashes(_ path: String) -> String {
|
|
var value = path
|
|
while value.hasSuffix("/") {
|
|
value = String(value.dropLast())
|
|
}
|
|
return value
|
|
}
|
|
|
|
private func chatBasename(_ path: String) -> String {
|
|
let trimmed = trimTrailingSlashes(path)
|
|
return trimmed.split(separator: "/").last.map(String.init) ?? trimmed
|
|
}
|
|
|
|
// MARK: - Terminal parsing (web `formatTerminalCommandTitle`)
|
|
|
|
private let commandsWithSubcommand: Set<String> = [
|
|
"git", "bun", "npm", "pnpm", "yarn", "docker", "systemctl", "cargo", "go",
|
|
]
|
|
|
|
private func isAssignment(_ part: String?) -> Bool {
|
|
guard let part, let first = part.first,
|
|
first == "_" || first.isLetter else { return false }
|
|
var seenEquals = false
|
|
for (index, character) in part.enumerated() {
|
|
if character == "=" {
|
|
seenEquals = index > 0
|
|
break
|
|
}
|
|
let valid = character == "_" || character.isLetter || (index > 0 && character.isNumber)
|
|
if !valid { return false }
|
|
}
|
|
return seenEquals
|
|
}
|
|
|
|
private func hasAmbiguousShellSyntax(_ command: String) -> Bool {
|
|
command.contains(where: { ";&|<>$`(){}\n\r".contains($0) })
|
|
}
|
|
|
|
/// The leading executable(+subcommand) of a simple command; nil when the
|
|
/// command carries shell syntax that makes the guess ambiguous.
|
|
func formatTerminalCommandTitle(_ command: String?) -> String? {
|
|
guard let command, !command.isEmpty, !hasAmbiguousShellSyntax(command) else { return nil }
|
|
|
|
let parts = command
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.split(whereSeparator: { $0.isWhitespace })
|
|
.map(String.init)
|
|
var index = 0
|
|
func part(_ offset: Int) -> String? {
|
|
let position = index + offset
|
|
return position >= 0 && position < parts.count ? parts[position] : nil
|
|
}
|
|
|
|
while isAssignment(part(0)) { index += 1 }
|
|
if part(0) == "env" {
|
|
index += 1
|
|
while part(0) == "-i" || part(0) == "--ignore-environment" || isAssignment(part(0)) {
|
|
index += 1
|
|
}
|
|
}
|
|
if part(0) == "sudo" {
|
|
index += 1
|
|
while ["-n", "--non-interactive", "-E", "--preserve-env"].contains(part(0) ?? "") {
|
|
index += 1
|
|
}
|
|
}
|
|
if part(0)?.hasPrefix("-") == true { return nil }
|
|
|
|
guard let head = part(0) else { return nil }
|
|
let executable = chatBasename(head)
|
|
guard !executable.isEmpty else { return nil }
|
|
|
|
guard let subcommand = part(1), !subcommand.hasPrefix("-"),
|
|
commandsWithSubcommand.contains(executable) else {
|
|
return executable
|
|
}
|
|
if ["bun", "npm", "pnpm", "yarn"].contains(executable), subcommand == "run" {
|
|
if let script = part(2), !script.hasPrefix("-") {
|
|
return "\(executable) run \(script)"
|
|
}
|
|
return "\(executable) run"
|
|
}
|
|
if executable == "docker", subcommand == "compose" {
|
|
if let action = part(2), !action.hasPrefix("-") {
|
|
return "docker compose \(action)"
|
|
}
|
|
return "docker compose"
|
|
}
|
|
return "\(executable) \(subcommand)"
|
|
}
|
|
|
|
/// The command string, joining Codex-style `command: string[]` arrays.
|
|
func chatTerminalCommand(_ input: JSONValue?) -> String? {
|
|
if let command = chatInputString(input, ["command", "cmd"]) {
|
|
return command
|
|
}
|
|
guard let array = input?[chatKey: "command"]?.chatArray else { return nil }
|
|
let parts = array.compactMap { $0.chatString }.filter { !$0.isEmpty }
|
|
return parts.isEmpty ? nil : parts.joined(separator: " ")
|
|
}
|
|
|
|
private func terminalTitle(_ input: JSONValue?, description: String?) -> String {
|
|
let command = chatTerminalCommand(input)
|
|
if let description, description != command {
|
|
return description
|
|
}
|
|
return formatTerminalCommandTitle(command) ?? description ?? String(localized: "Terminal")
|
|
}
|
|
|
|
private func terminalSubtitle(_ input: JSONValue?, description: String?) -> String? {
|
|
let command = chatTerminalCommand(input)
|
|
return command == terminalTitle(input, description: description) ? nil : command
|
|
}
|
|
|
|
// MARK: - Questions
|
|
|
|
private func questionTitle(_ input: JSONValue?) -> String {
|
|
let questions = input?[chatKey: "questions"]?.chatArray ?? []
|
|
if questions.count > 1 {
|
|
return String(format: String(localized: "%lld Questions"), Int64(questions.count))
|
|
}
|
|
let header = questions.first?[chatKey: "header"]?.chatString?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
return header.isEmpty ? String(localized: "Question") : header
|
|
}
|
|
|
|
private func questionSubtitle(_ input: JSONValue?) -> String? {
|
|
let questions = input?[chatKey: "questions"]?.chatArray ?? []
|
|
let question = questions.first?[chatKey: "question"]?.chatString?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
if questions.count > 1, !question.isEmpty {
|
|
return String(
|
|
format: String(localized: "%@ (+%lld more)"),
|
|
chatTruncate(question, 100),
|
|
Int64(questions.count - 1)
|
|
)
|
|
}
|
|
return question.isEmpty ? nil : chatTruncate(question, 120)
|
|
}
|
|
|
|
// MARK: - MCP names
|
|
|
|
private func snakeToTitle(_ value: String) -> String {
|
|
value.split(separator: "_")
|
|
.filter { !$0.isEmpty }
|
|
.map { $0.lowercased().prefix(1).uppercased() + $0.lowercased().dropFirst() }
|
|
.joined(separator: " ")
|
|
}
|
|
|
|
private func mcpTitle(_ toolName: String) -> String {
|
|
let withoutPrefix = toolName.hasPrefix("mcp__")
|
|
? String(toolName.dropFirst("mcp__".count))
|
|
: toolName
|
|
let parts = withoutPrefix.components(separatedBy: "__")
|
|
if parts.count >= 2 {
|
|
let rest = parts.dropFirst().joined(separator: "_")
|
|
return "MCP: \(snakeToTitle(parts[0])) \(snakeToTitle(rest))"
|
|
}
|
|
return "MCP: \(snakeToTitle(withoutPrefix))"
|
|
}
|
|
|
|
// MARK: - Entry point
|
|
|
|
func toolCardPresentation(_ tool: ChatToolCall, basePath: String?) -> ToolCardPresentation {
|
|
let input = tool.input
|
|
let name = toolPresentationName(tool.name)
|
|
let description = tool.description
|
|
|
|
if name.hasPrefix("mcp__") {
|
|
return ToolCardPresentation(icon: ToolIcon.puzzle, title: mcpTitle(name), subtitle: nil)
|
|
}
|
|
if isAskUserQuestionToolName(name) || isRequestUserInputToolName(name) {
|
|
return ToolCardPresentation(
|
|
icon: ToolIcon.question,
|
|
title: questionTitle(input),
|
|
subtitle: questionSubtitle(input)
|
|
)
|
|
}
|
|
|
|
func filePathTitle(_ keys: [String], fallback: String) -> String {
|
|
chatInputString(input, keys).map { chatDisplayPath($0, basePath: basePath) } ?? fallback
|
|
}
|
|
|
|
switch name {
|
|
case "Bash", "CodexBash", "shell_command", "run_shell_command":
|
|
// A CodexBash single parsed read renders as the file it reads.
|
|
if name == "CodexBash",
|
|
let parsed = input?[chatKey: "parsed_cmd"]?.chatArray,
|
|
parsed.count == 1,
|
|
parsed[0][chatKey: "type"]?.chatString == "read",
|
|
let file = parsed[0][chatKey: "name"]?.chatString {
|
|
return ToolCardPresentation(
|
|
icon: ToolIcon.read,
|
|
title: chatDisplayPath(file, basePath: basePath),
|
|
subtitle: terminalSubtitle(input, description: description)
|
|
)
|
|
}
|
|
return ToolCardPresentation(
|
|
icon: ToolIcon.terminal,
|
|
title: terminalTitle(input, description: description),
|
|
subtitle: terminalSubtitle(input, description: description)
|
|
)
|
|
|
|
case "Read":
|
|
return ToolCardPresentation(
|
|
icon: ToolIcon.read,
|
|
title: filePathTitle(["file_path", "path", "file"], fallback: String(localized: "Read file")),
|
|
subtitle: nil
|
|
)
|
|
|
|
case "NotebookRead":
|
|
return ToolCardPresentation(
|
|
icon: ToolIcon.read,
|
|
title: filePathTitle(["notebook_path"], fallback: String(localized: "Read notebook")),
|
|
subtitle: nil
|
|
)
|
|
|
|
case "Edit":
|
|
return ToolCardPresentation(
|
|
icon: ToolIcon.edit,
|
|
title: filePathTitle(["file_path", "path"], fallback: String(localized: "Edit file")),
|
|
subtitle: nil
|
|
)
|
|
|
|
case "MultiEdit":
|
|
guard let file = chatInputString(input, ["file_path", "path"]) else {
|
|
return ToolCardPresentation(
|
|
icon: ToolIcon.edit,
|
|
title: String(localized: "Edit file"),
|
|
subtitle: nil
|
|
)
|
|
}
|
|
let count = input?[chatKey: "edits"]?.chatArray?.count ?? 0
|
|
let path = chatDisplayPath(file, basePath: basePath)
|
|
return ToolCardPresentation(
|
|
icon: ToolIcon.edit,
|
|
title: count > 1
|
|
? String(format: String(localized: "%@ (%lld edits)"), path, Int64(count))
|
|
: path,
|
|
subtitle: nil
|
|
)
|
|
|
|
case "Write":
|
|
let content = chatInputString(input, ["content", "text"])
|
|
let subtitle = content.map { text -> String in
|
|
let lines = text.split(separator: "\n", omittingEmptySubsequences: false).count
|
|
return lines > 1
|
|
? String(format: String(localized: "%lld lines"), Int64(lines))
|
|
: String(format: String(localized: "%lld chars"), Int64(text.count))
|
|
}
|
|
return ToolCardPresentation(
|
|
icon: ToolIcon.edit,
|
|
title: filePathTitle(["file_path", "path"], fallback: String(localized: "Write file")),
|
|
subtitle: subtitle
|
|
)
|
|
|
|
case "NotebookEdit":
|
|
return ToolCardPresentation(
|
|
icon: ToolIcon.edit,
|
|
title: filePathTitle(["notebook_path"], fallback: String(localized: "Edit notebook")),
|
|
subtitle: chatInputString(input, ["edit_mode"]).map { "mode: \($0)" }
|
|
)
|
|
|
|
case "Glob":
|
|
return ToolCardPresentation(
|
|
icon: ToolIcon.search,
|
|
title: chatInputString(input, ["pattern"]) ?? String(localized: "Search files"),
|
|
subtitle: nil
|
|
)
|
|
|
|
case "Grep":
|
|
let pattern = chatInputString(input, ["pattern"])
|
|
return ToolCardPresentation(
|
|
icon: ToolIcon.search,
|
|
title: pattern.map { "grep(pattern: \($0))" } ?? String(localized: "Search content"),
|
|
subtitle: nil
|
|
)
|
|
|
|
case "LS":
|
|
return ToolCardPresentation(
|
|
icon: ToolIcon.search,
|
|
title: filePathTitle(["path"], fallback: String(localized: "List files")),
|
|
subtitle: nil
|
|
)
|
|
|
|
case "WebFetch":
|
|
guard let url = chatInputString(input, ["url"]) else {
|
|
return ToolCardPresentation(
|
|
icon: ToolIcon.web,
|
|
title: String(localized: "Web fetch"),
|
|
subtitle: nil
|
|
)
|
|
}
|
|
let host = URL(string: url)?.host ?? url
|
|
return ToolCardPresentation(icon: ToolIcon.web, title: host, subtitle: url)
|
|
|
|
case "WebSearch":
|
|
let query = chatInputString(input, ["query"])
|
|
return ToolCardPresentation(
|
|
icon: ToolIcon.web,
|
|
title: query ?? String(localized: "Web search"),
|
|
subtitle: query.map { chatTruncate($0, 80) }
|
|
)
|
|
|
|
case "Task", "Agent":
|
|
let inputName = chatInputString(input, ["name"])
|
|
let teamName = chatInputString(input, ["team_name"])
|
|
let title: String
|
|
if name == "Task", let inputName, teamName != nil {
|
|
title = String(format: String(localized: "Agent: %@"), inputName)
|
|
} else {
|
|
title = chatInputString(input, ["description"])
|
|
?? (name == "Task" ? "Task" : String(localized: "Launch Agent"))
|
|
}
|
|
let subtitle = chatInputString(input, ["prompt"]).map { chatTruncate($0, 120) }
|
|
?? chatInputString(input, ["subagent_type"])
|
|
return ToolCardPresentation(icon: ToolIcon.agent, title: title, subtitle: subtitle)
|
|
|
|
case "CodexAgent", "spawn_agent", "resume_agent", "wait_agent", "close_agent", "interrupt_agent":
|
|
let title: String
|
|
switch name {
|
|
case "spawn_agent": title = String(localized: "Spawn agent")
|
|
case "resume_agent": title = String(localized: "Resume agent")
|
|
case "wait_agent": title = String(localized: "Wait for agent")
|
|
case "close_agent": title = String(localized: "Close agent")
|
|
case "interrupt_agent": title = String(localized: "Interrupt agent")
|
|
default: title = String(localized: "Agent")
|
|
}
|
|
let prompt = chatInputString(input, ["prompt", "summary"])
|
|
return ToolCardPresentation(
|
|
icon: ToolIcon.agent,
|
|
title: title,
|
|
subtitle: prompt.map { chatTruncate($0, 120) }
|
|
)
|
|
|
|
case "SendMessage", "send_input", "send_message", "followup_task":
|
|
let recipient = chatInputString(input, ["recipient"])
|
|
let messageType = chatInputString(input, ["type"])
|
|
let title: String
|
|
if messageType == "broadcast" {
|
|
title = String(localized: "Broadcast")
|
|
} else if messageType == "shutdown_request" {
|
|
title = String(format: String(localized: "Shutdown: %@"), recipient ?? "agent")
|
|
} else if messageType == "shutdown_response" {
|
|
title = String(localized: "Shutdown Response")
|
|
} else if let recipient {
|
|
title = String(format: String(localized: "Message: %@"), recipient)
|
|
} else {
|
|
title = String(localized: "Message agent")
|
|
}
|
|
let summary = chatInputString(input, ["summary"])
|
|
return ToolCardPresentation(
|
|
icon: ToolIcon.message,
|
|
title: title,
|
|
subtitle: summary.map { chatTruncate($0, 120) }
|
|
)
|
|
|
|
case "list_agents":
|
|
return ToolCardPresentation(icon: ToolIcon.team, title: String(localized: "List agents"), subtitle: nil)
|
|
|
|
case "TeamCreate":
|
|
let teamName = chatInputString(input, ["team_name"])
|
|
return ToolCardPresentation(
|
|
icon: ToolIcon.team,
|
|
title: teamName.map { String(format: String(localized: "Team: %@"), $0) }
|
|
?? String(localized: "Create Team"),
|
|
subtitle: chatInputString(input, ["description"])
|
|
)
|
|
|
|
case "TeamDelete":
|
|
return ToolCardPresentation(icon: ToolIcon.team, title: String(localized: "Delete Team"), subtitle: nil)
|
|
|
|
case "TodoWrite":
|
|
return ToolCardPresentation(icon: ToolIcon.idea, title: String(localized: "Todo list"), subtitle: nil)
|
|
|
|
case "update_plan":
|
|
return ToolCardPresentation(icon: ToolIcon.plan, title: String(localized: "Plan"), subtitle: nil)
|
|
|
|
case "ExitPlanMode", "exit_plan_mode":
|
|
return ToolCardPresentation(icon: ToolIcon.plan, title: String(localized: "Plan proposal"), subtitle: nil)
|
|
|
|
case "Skill":
|
|
let skill = chatInputString(input, ["skill"])
|
|
return ToolCardPresentation(
|
|
icon: ToolIcon.puzzle,
|
|
title: skill.map { "Skill: \($0)" } ?? "Skill",
|
|
subtitle: nil
|
|
)
|
|
|
|
case "CodexReasoning":
|
|
return ToolCardPresentation(
|
|
icon: ToolIcon.idea,
|
|
title: chatInputString(input, ["title"]) ?? String(localized: "Reasoning"),
|
|
subtitle: nil
|
|
)
|
|
|
|
case "CodexPermission":
|
|
let permissionTool = chatInputString(input, ["tool"])
|
|
return ToolCardPresentation(
|
|
icon: ToolIcon.question,
|
|
title: permissionTool.map { String(format: String(localized: "Permission: %@"), $0) }
|
|
?? String(localized: "Permission request"),
|
|
subtitle: chatInputString(input, ["message", "command"])
|
|
)
|
|
|
|
case "CodexPatch":
|
|
let files = input?[chatKey: "changes"]?.chatObject.map { Array($0.keys).sorted() } ?? []
|
|
let subtitle = files.first.map { first -> String in
|
|
let display = chatBasename(chatDisplayPath(first, basePath: basePath))
|
|
return files.count > 1 ? "\(display) (+\(files.count - 1))" : display
|
|
}
|
|
return ToolCardPresentation(icon: ToolIcon.edit, title: String(localized: "Apply changes"), subtitle: subtitle)
|
|
|
|
case "CodexDiff":
|
|
let unified = chatInputString(input, ["unified_diff"])
|
|
let subtitle = unified?
|
|
.split(separator: "\n", omittingEmptySubsequences: false)
|
|
.first(where: { $0.hasPrefix("+++ ") })
|
|
.map { line -> String in
|
|
var value = String(line.dropFirst("+++ ".count))
|
|
if value.hasPrefix("b/") { value = String(value.dropFirst(2)) }
|
|
return chatBasename(value)
|
|
}
|
|
return ToolCardPresentation(icon: ToolIcon.edit, title: "Diff", subtitle: subtitle)
|
|
|
|
case "AgyTaskLog":
|
|
let task = chatInputString(input, ["task"])
|
|
return ToolCardPresentation(
|
|
icon: ToolIcon.message,
|
|
title: task.map { String(format: String(localized: "%@ log"), $0) }
|
|
?? String(localized: "Inspecting task log"),
|
|
subtitle: nil
|
|
)
|
|
|
|
case "AgyAsyncTask":
|
|
return ToolCardPresentation(
|
|
icon: ToolIcon.plan,
|
|
title: description ?? String(localized: "Background task"),
|
|
subtitle: nil
|
|
)
|
|
|
|
case "AgyError":
|
|
return ToolCardPresentation(
|
|
icon: ToolIcon.warning,
|
|
title: description ?? String(localized: "Error"),
|
|
subtitle: nil
|
|
)
|
|
|
|
default:
|
|
break
|
|
}
|
|
|
|
// Generic fallback (web `getToolPresentation` tail): promote a semantic
|
|
// label when an ACP agent's title is the verbatim argument.
|
|
let filePath = chatInputString(input, ["file_path", "path", "filePath", "file"])
|
|
let command = chatInputString(input, ["command", "cmd"])
|
|
let pattern = chatInputString(input, ["pattern"])
|
|
let url = chatInputString(input, ["url"])
|
|
let query = chatInputString(input, ["query"])
|
|
let nameInput = chatInputString(input, ["name"])
|
|
let subtitle = filePath ?? command ?? pattern ?? url ?? query ?? nameInput
|
|
|
|
var title = description ?? name
|
|
if let subtitle, subtitle == title {
|
|
if filePath != nil {
|
|
title = String(localized: "Read file")
|
|
} else if command != nil {
|
|
title = String(localized: "Run shell")
|
|
} else if pattern != nil {
|
|
title = String(localized: "Search")
|
|
} else if url != nil {
|
|
title = String(localized: "Open URL")
|
|
} else if query != nil {
|
|
title = String(localized: "Query")
|
|
}
|
|
}
|
|
return ToolCardPresentation(
|
|
icon: ToolIcon.wrench,
|
|
title: title,
|
|
subtitle: subtitle.flatMap { $0 == title ? nil : chatTruncate($0, 80) }
|
|
)
|
|
}
|