Files
hapi/ios/Packages/HapiKit/Sources/HapiClient/Chat/PermissionInputs.swift
T

221 lines
8.0 KiB
Swift

import Foundation
import HapiProtocol
// Question/option models + input parsers for the two interactive request
// tools, ported from `web/src/components/ToolCard/askUserQuestion.ts`,
// `cursorAskQuestion.ts` and `requestUserInput.ts` via the Android port's
// `PermissionInputs.kt`. Pure functions over `JSONValue` — unit-tested
// alongside the interactor.
public struct AskOption: Equatable, Sendable {
/// Stable option id (Cursor ACP); falls back to the label on submit.
public let id: String?
public let label: String
public let description: String?
public init(id: String?, label: String, description: String?) {
self.id = id
self.label = label
self.description = description
}
}
public struct AskQuestion: Equatable, Sendable {
/// Stable question id (Cursor ACP); falls back to the index on submit.
public let id: String?
public let header: String?
public let question: String
public let options: [AskOption]
public let multiSelect: Bool
public init(id: String?, header: String?, question: String, options: [AskOption], multiSelect: Bool) {
self.id = id
self.header = header
self.question = question
self.options = options
self.multiSelect = multiSelect
}
/// The flat-answers key: stable id when present, else the index (web parity).
public func answerKey(index: Int, useStableIds: Bool) -> String {
if useStableIds, let id, !id.isEmpty {
return id
}
return String(index)
}
}
public func isCursorAskQuestionToolName(_ toolName: String) -> Bool {
toolName == "CursorAskQuestion"
}
private func trimmedString(_ value: JSONValue?) -> String? {
value?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
}
/// `parseAskUserQuestionInput` / `parseCursorAskQuestionInput` merged: the
/// Cursor dialect adds `prompt`/`title`/`allowMultiple` synonyms and stable
/// ids; both collapse to the same `AskQuestion` list.
public func parseAskUserQuestions(_ input: JSONValue?, cursorDialect: Bool) -> [AskQuestion] {
guard let root = input?.objectValue,
let rawQuestions = root["questions"]?.arrayValue else {
return []
}
let requestTitle = cursorDialect ? (trimmedString(root["title"]) ?? "") : ""
var questions: [AskQuestion] = []
for raw in rawQuestions {
guard let object = raw.objectValue else { continue }
let question: String
let header: String
let multiSelect: Bool
let questionId: String?
if cursorDialect {
question = trimmedString(object["prompt"]) ?? trimmedString(object["question"]) ?? ""
header = trimmedString(object["title"]) ?? trimmedString(object["header"]) ?? ""
multiSelect = object["allowMultiple"]?.boolValue == true
|| object["multiSelect"]?.boolValue == true
let rawId = trimmedString(object["id"]) ?? ""
questionId = rawId.isEmpty ? String(questions.count) : rawId
} else {
question = trimmedString(object["question"]) ?? ""
header = trimmedString(object["header"]) ?? ""
multiSelect = object["multiSelect"]?.boolValue == true
questionId = nil
}
var options: [AskOption] = []
for rawOption in object["options"]?.arrayValue ?? [] {
guard let optionObject = rawOption.objectValue else { continue }
let label: String
if cursorDialect {
label = trimmedString(optionObject["label"]) ?? trimmedString(optionObject["id"]) ?? ""
} else {
label = trimmedString(optionObject["label"]) ?? ""
}
guard !label.isEmpty else { continue }
let optionId: String?
if cursorDialect {
let rawId = trimmedString(optionObject["id"]) ?? ""
optionId = rawId.isEmpty ? label : rawId
} else {
optionId = nil
}
let description = cursorDialect
? nil
: trimmedString(optionObject["description"]).flatMap { $0.isEmpty ? nil : $0 }
options.append(AskOption(id: optionId, label: label, description: description))
}
if question.isEmpty && options.isEmpty { continue }
let effectiveHeader = header.isEmpty ? (requestTitle.isEmpty ? nil : requestTitle) : header
questions.append(AskQuestion(
id: questionId,
header: effectiveHeader,
question: question,
options: options,
multiSelect: multiSelect
))
}
return questions
}
public struct RequestUserInputQuestion: Equatable, Sendable {
public let id: String
public let header: String?
public let question: String
public let required: Bool
public let multiple: Bool
public let options: [AskOption]
public let placeholder: String?
public let prefill: String?
public let inputType: String?
public init(
id: String,
question: String,
required: Bool,
multiple: Bool,
options: [AskOption],
placeholder: String?,
prefill: String?,
inputType: String? = nil,
header: String? = nil
) {
self.id = id
self.header = header
self.question = question
self.required = required
self.multiple = multiple
self.options = options
self.placeholder = placeholder
self.prefill = prefill
self.inputType = inputType
}
}
/// `parseRequestUserInputInput` (the URL-confirmation flow is web-only).
public func parseRequestUserInputQuestions(_ input: JSONValue?) -> [RequestUserInputQuestion] {
guard let root = input?.objectValue,
let rawQuestions = root["questions"]?.arrayValue else {
return []
}
var questions: [RequestUserInputQuestion] = []
for raw in rawQuestions {
guard let object = raw.objectValue else { continue }
let id = trimmedString(object["id"]) ?? ""
guard !id.isEmpty else { continue }
var options: [AskOption] = []
for rawOption in object["options"]?.arrayValue ?? [] {
guard let optionObject = rawOption.objectValue else { continue }
let label = trimmedString(optionObject["label"]) ?? ""
guard !label.isEmpty else { continue }
options.append(AskOption(
id: nil,
label: label,
description: trimmedString(optionObject["description"]).flatMap { $0.isEmpty ? nil : $0 }
))
}
questions.append(RequestUserInputQuestion(
id: id,
question: trimmedString(object["question"]) ?? "",
required: object["required"]?.boolValue != false,
multiple: object["multiple"]?.boolValue == true,
options: options,
placeholder: object["placeholder"]?.stringValue,
prefill: object["prefill"]?.stringValue,
inputType: object["inputType"]?.stringValue == "editor" ? "editor" : nil,
header: trimmedString(object["header"]).flatMap { $0.isEmpty ? nil : $0 }
))
}
return questions
}
/// `formatRequestUserInputAnswers` value building for ONE field: selected
/// option labels plus a trailing `user_note: <text>` entry when a note was
/// typed. The nested `{answers: [...]}` wrapper is applied by the interactor.
public func requestUserInputAnswerValues(selected: [String], note: String) -> [String] {
var values = selected
let trimmed = note.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
values.append("user_note: \(trimmed)")
}
return values
}
/// `isRequestUserInputQuestionAnswered`.
public func isRequestUserInputAnswered(
_ question: RequestUserInputQuestion,
selected: [String],
note: String
) -> Bool {
if !question.required { return true }
if !question.options.isEmpty { return !selected.isEmpty }
return !note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}