mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-10 02:01:40 +00:00
Wires the merged M2 pieces into a usable pair -> list -> live chat flow: - pbxproj: link HapiUI into the app target (UUIDs C7/C8, mirroring the existing HapiProtocol/HapiClient product references; only project change). - HubSession: MessageWindowControllers registry + per-chat ChatSession factory with in-memory per-session SSE resume cursors and scene-phase forwarding to the active chat. - ChatSession (app): session-scope SSEClient while the chat is open; window opened/activated before subscribing; a single consume task awaits every event into the window actor, giving Android-channel-equivalent arrival-order ingestion; session-updated and friends route through the shared SyncEventRouter (detail patching), session-removed clears the window, a gap handshake triggers full resync + detail refetch + catch-up tail sync; stop hands the cursor back for seamless reopen. - ChatPipeline (HapiClient): actor running the reduction path off-main - queued-row filter, normalization memoized by row instance identity, reduce + buildVisibleChatBlocks with previousGroups-stable group ids. - ChatModel: window state + detail agentState + machine labels -> serial ~100 ms-coalesced pipeline loop (first run immediate, publishes strictly ordered), header title cascade, loading/empty/error states, loadOlder / retry, last-seen stamping on every update. - ChatView: bottom-anchored ScrollView/LazyVStack (defaultScrollAnchor + scrollPosition(id:)), auto-stick at bottom, new-messages pill, top sentinel paging with documented scroll re-anchoring, degraded banners. - Block views: user bubble w/ attachment chips, agent markdown, collapsed reasoning, tool cards (knownTools.tsx-parity presentation, status chip, read-only permission row w/ pending banner, per-tool bodies: terminal, before/after edits, write content, unified-diff detection -> DiffTextView, checklists, read-only questions, pretty-JSON fallback, children rail), tool groups, centered event rows (presentation.ts port), cli output, generated images (authed bytes + in-memory cache + full-screen viewer), codex review verdict card. - Navigation/links: session list now pushes ChatView; app-level \.hapiOpenURL handler (https/http -> SFSafariViewController, custom schemes confirm first, hapi-file:// -> M4 placeholder) + theme injection. - Tests: ChatPipelineTests drive the runner with fixture-derived windows (stable ids, memo-stable recomputes, queued filter, group-id stability across an older-page arrival). - README: M2f app-layer description.
505 lines
18 KiB
Swift
505 lines
18 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 ?? "Terminal"
|
|
}
|
|
|
|
private func terminalSubtitle(_ input: JSONValue?, description: String?) -> String? {
|
|
let command = chatTerminalCommand(input)
|
|
return command == terminalTitle(input, description) ? nil : command
|
|
}
|
|
|
|
// MARK: - Questions
|
|
|
|
private func questionTitle(_ input: JSONValue?) -> String {
|
|
let questions = input?[chatKey: "questions"]?.chatArray ?? []
|
|
if questions.count > 1 { return "\(questions.count) Questions" }
|
|
let header = questions.first?[chatKey: "header"]?.chatString?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
return header.isEmpty ? "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 chatTruncate(question, 100) + " (+\(questions.count - 1) more)"
|
|
}
|
|
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 = 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: "Read file"),
|
|
subtitle: nil
|
|
)
|
|
|
|
case "NotebookRead":
|
|
return ToolCardPresentation(
|
|
icon: ToolIcon.read,
|
|
title: filePathTitle(["notebook_path"], fallback: "Read notebook"),
|
|
subtitle: nil
|
|
)
|
|
|
|
case "Edit":
|
|
return ToolCardPresentation(
|
|
icon: ToolIcon.edit,
|
|
title: filePathTitle(["file_path", "path"], fallback: "Edit file"),
|
|
subtitle: nil
|
|
)
|
|
|
|
case "MultiEdit":
|
|
guard let file = chatInputString(input, ["file_path", "path"]) else {
|
|
return ToolCardPresentation(icon: ToolIcon.edit, title: "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 ? "\(path) (\(count) edits)" : 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 ? "\(lines) lines" : "\(text.count) chars"
|
|
}
|
|
return ToolCardPresentation(
|
|
icon: ToolIcon.edit,
|
|
title: filePathTitle(["file_path", "path"], fallback: "Write file"),
|
|
subtitle: subtitle
|
|
)
|
|
|
|
case "NotebookEdit":
|
|
return ToolCardPresentation(
|
|
icon: ToolIcon.edit,
|
|
title: filePathTitle(["notebook_path"], fallback: "Edit notebook"),
|
|
subtitle: chatInputString(input, ["edit_mode"]).map { "mode: \($0)" }
|
|
)
|
|
|
|
case "Glob":
|
|
return ToolCardPresentation(
|
|
icon: ToolIcon.search,
|
|
title: chatInputString(input, ["pattern"]) ?? "Search files",
|
|
subtitle: nil
|
|
)
|
|
|
|
case "Grep":
|
|
let pattern = chatInputString(input, ["pattern"])
|
|
return ToolCardPresentation(
|
|
icon: ToolIcon.search,
|
|
title: pattern.map { "grep(pattern: \($0))" } ?? "Search content",
|
|
subtitle: nil
|
|
)
|
|
|
|
case "LS":
|
|
return ToolCardPresentation(
|
|
icon: ToolIcon.search,
|
|
title: filePathTitle(["path"], fallback: "List files"),
|
|
subtitle: nil
|
|
)
|
|
|
|
case "WebFetch":
|
|
guard let url = chatInputString(input, ["url"]) else {
|
|
return ToolCardPresentation(icon: ToolIcon.web, title: "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 ?? "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 = "Agent: \(inputName)"
|
|
} else {
|
|
title = chatInputString(input, ["description"])
|
|
?? (name == "Task" ? "Task" : "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 = "Spawn agent"
|
|
case "resume_agent": title = "Resume agent"
|
|
case "wait_agent": title = "Wait for agent"
|
|
case "close_agent": title = "Close agent"
|
|
case "interrupt_agent": title = "Interrupt agent"
|
|
default: title = "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 = "Broadcast"
|
|
} else if messageType == "shutdown_request" {
|
|
title = "Shutdown: \(recipient ?? "agent")"
|
|
} else if messageType == "shutdown_response" {
|
|
title = "Shutdown Response"
|
|
} else if let recipient {
|
|
title = "Message: \(recipient)"
|
|
} else {
|
|
title = "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: "List agents", subtitle: nil)
|
|
|
|
case "TeamCreate":
|
|
let teamName = chatInputString(input, ["team_name"])
|
|
return ToolCardPresentation(
|
|
icon: ToolIcon.team,
|
|
title: teamName.map { "Team: \($0)" } ?? "Create Team",
|
|
subtitle: chatInputString(input, ["description"])
|
|
)
|
|
|
|
case "TeamDelete":
|
|
return ToolCardPresentation(icon: ToolIcon.team, title: "Delete Team", subtitle: nil)
|
|
|
|
case "TodoWrite":
|
|
return ToolCardPresentation(icon: ToolIcon.idea, title: "Todo list", subtitle: nil)
|
|
|
|
case "update_plan":
|
|
return ToolCardPresentation(icon: ToolIcon.plan, title: "Plan", subtitle: nil)
|
|
|
|
case "ExitPlanMode", "exit_plan_mode":
|
|
return ToolCardPresentation(icon: ToolIcon.plan, title: "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"]) ?? "Reasoning",
|
|
subtitle: nil
|
|
)
|
|
|
|
case "CodexPermission":
|
|
let permissionTool = chatInputString(input, ["tool"])
|
|
return ToolCardPresentation(
|
|
icon: ToolIcon.question,
|
|
title: permissionTool.map { "Permission: \($0)" } ?? "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: "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 { "\($0) log" } ?? "Inspecting task log",
|
|
subtitle: nil
|
|
)
|
|
|
|
case "AgyAsyncTask":
|
|
return ToolCardPresentation(
|
|
icon: ToolIcon.plan,
|
|
title: description ?? "Background task",
|
|
subtitle: nil
|
|
)
|
|
|
|
case "AgyError":
|
|
return ToolCardPresentation(icon: ToolIcon.warning, title: description ?? "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 = "Read file"
|
|
} else if command != nil {
|
|
title = "Run shell"
|
|
} else if pattern != nil {
|
|
title = "Search"
|
|
} else if url != nil {
|
|
title = "Open URL"
|
|
} else if query != nil {
|
|
title = "Query"
|
|
}
|
|
}
|
|
return ToolCardPresentation(
|
|
icon: ToolIcon.wrench,
|
|
title: title,
|
|
subtitle: subtitle.flatMap { $0 == title ? nil : chatTruncate($0, 80) }
|
|
)
|
|
}
|