mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-20 21:26:04 +00:00
Part 1 — Simplified Chinese localization of the iOS app layer: - ios/Hapi/Resources/Localizable.xcstrings: hand-authored String Catalog (395 keys, zh-Hans; en implicit as source). Terminology mined from web/src/lib/locales/zh-CN.ts (会话/新建会话/权限模式/允许/拒绝/工作树/ 机器/语音输入/用量/草稿夹 …). - Mechanical edits only: merged multi-part string concatenations into single LocalizedStringKey literals, converted ternary/plain-String user copy to String(localized:), switched helper params (detailRow, DashboardCard, optionPicker, notice) to LocalizedStringKey. - LocalizedNoticeMapper (app layer): display-point translation of the 27 known HapiKit-emitted strings (ChatInteractor notices, dictation errors, window-sync warnings, files fallbacks, worktree-name validation) with verbatim passthrough for server-originated text; package stays language-free. - Deliberately untranslated (web parity / non-copy): event rows (EventPresentation — web renders presentation.ts verbatim), catalog option labels (Default/Auto/Sonnet/permission modes — web shows them verbatim), tool names, code-like titles (grep(pattern:), MCP:, Skill, Task), unit suffixes (B/KB/MB, m/h/d/w), decorative separators. - Language row wired: AppLanguage gains .system (follow system, new default); explicit picks write the AppleLanguages override, Follow system removes it; footer notes a relaunch applies it (no supported in-place SwiftUI locale swap). - project.pbxproj: zh-Hans added to knownRegions. Part 2 — #39 catalog dedupe (HapiProtocol): - NewSessionCatalogs.claudeModels/claudeEfforts now derive from ClaudeModels/ClaudeEfforts (single source shared with ModelCatalog); codexReasoningEfforts stays own data (web CODEX_REASONING_EFFORT_OPTIONS minus max); effortLabel delegates to ModelCatalog.capitalizedFirst. - New CatalogTests lock test for the derived option lists. Gate: bash ios/scripts/linux-test.sh — 459 tests green.
470 lines
18 KiB
Swift
470 lines
18 KiB
Swift
import HapiClient
|
|
import HapiProtocol
|
|
import HapiUI
|
|
import SwiftUI
|
|
|
|
/// Pending-permission footers (A-M3b): the approval buttons for ordinary tool
|
|
/// permissions plus the dedicated AskUserQuestion / request_user_input answer
|
|
/// forms. Flavor logic mirrors `PermissionFooter.tsx` via the Android port:
|
|
///
|
|
/// - codex family (incl. cursor and codex-dialect tool names): Allow
|
|
/// (`decision: approved`) / Abort (`decision: abort`) + overflow
|
|
/// Allow-for-session (`decision: approved_for_session`);
|
|
/// - everyone else: Allow (`{}`) / Deny (`{}`) + overflow Allow-for-session
|
|
/// (claude: `allowTools`) and, for claude edit tools, Allow-all-edits
|
|
/// (`mode: acceptEdits`).
|
|
struct PendingPermissionFooter: View {
|
|
let tool: ChatToolCall
|
|
let requestId: String
|
|
let interactions: ChatInteractor
|
|
|
|
var body: some View {
|
|
if isAskUserQuestionToolName(tool.name) {
|
|
AskUserQuestionFooter(tool: tool, requestId: requestId, interactions: interactions)
|
|
} else if isRequestUserInputToolName(tool.name) {
|
|
RequestUserInputFooter(tool: tool, requestId: requestId, interactions: interactions)
|
|
} else {
|
|
PermissionActionsRow(tool: tool, requestId: requestId, interactions: interactions)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Ordinary tool approvals
|
|
|
|
private struct PermissionActionsRow: View {
|
|
let tool: ChatToolCall
|
|
let requestId: String
|
|
let interactions: ChatInteractor
|
|
|
|
var body: some View {
|
|
let override = interactions.permissionOverrides[requestId]
|
|
if override == .alreadyHandled {
|
|
AlreadyHandledLine()
|
|
} else {
|
|
let resolving = override == .resolving
|
|
let codex = isCodexPermissionUX(flavor: interactions.flavor, toolName: tool.name)
|
|
let canAllowForSession = !codex && !PermissionGates.hideAllowForSession.contains(tool.name)
|
|
let canAllowAllEdits = interactions.flavor == "claude"
|
|
&& PermissionGates.editTools.contains(tool.name)
|
|
|
|
HStack(spacing: 8) {
|
|
Button {
|
|
interactions.resolvePermission(requestId: requestId, action: .allow)
|
|
} label: {
|
|
Text("Allow")
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.tint(.green)
|
|
|
|
Button {
|
|
interactions.resolvePermission(requestId: requestId, action: codex ? .abort : .deny)
|
|
} label: {
|
|
Text(codex ? String(localized: "Abort") : String(localized: "Deny"))
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.tint(.red)
|
|
|
|
if resolving {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
} else if codex || canAllowForSession || canAllowAllEdits {
|
|
Menu {
|
|
if codex || canAllowForSession {
|
|
Button("Allow for this session") {
|
|
interactions.resolvePermission(requestId: requestId, action: .allowForSession)
|
|
}
|
|
}
|
|
if canAllowAllEdits {
|
|
Button("Allow all edits") {
|
|
interactions.resolvePermission(requestId: requestId, action: .allowAllEdits)
|
|
}
|
|
}
|
|
} label: {
|
|
Image(systemName: "ellipsis")
|
|
.frame(width: 28, height: 28)
|
|
.contentShape(Rectangle())
|
|
}
|
|
}
|
|
}
|
|
.disabled(resolving)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 6)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct AlreadyHandledLine: View {
|
|
var body: some View {
|
|
Text("Already handled elsewhere")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 6)
|
|
}
|
|
}
|
|
|
|
// MARK: - AskUserQuestion
|
|
|
|
/// AskUserQuestion answer form: every question as a card section — options as
|
|
/// tappable rows (radio/checkbox per `multiSelect`), an "Other" free-text
|
|
/// field, one Submit. Answers post flat `{key: [labels…]}` where the key is
|
|
/// the index (or the Cursor stable id) — `AskUserQuestionFooter.tsx` parity.
|
|
private struct AskUserQuestionFooter: View {
|
|
let tool: ChatToolCall
|
|
let requestId: String
|
|
let interactions: ChatInteractor
|
|
|
|
private let cursorDialect: Bool
|
|
private let questions: [AskQuestion]
|
|
|
|
/// Selection state per question index.
|
|
@State private var selected: [Int: Set<Int>] = [:]
|
|
@State private var otherText: [Int: String] = [:]
|
|
@State private var validationError: String?
|
|
|
|
init(tool: ChatToolCall, requestId: String, interactions: ChatInteractor) {
|
|
self.tool = tool
|
|
self.requestId = requestId
|
|
self.interactions = interactions
|
|
self.cursorDialect = isCursorAskQuestionToolName(tool.name)
|
|
self.questions = parseAskUserQuestions(tool.input, cursorDialect: cursorDialect)
|
|
}
|
|
|
|
var body: some View {
|
|
let override = interactions.permissionOverrides[requestId]
|
|
if override == .alreadyHandled {
|
|
AlreadyHandledLine()
|
|
} else {
|
|
let resolving = override == .resolving
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
if questions.isEmpty {
|
|
// Fallback: free-text answer keyed "0" (web parity).
|
|
TextField(
|
|
"Type your answer…",
|
|
text: bindingForOther(0),
|
|
axis: .vertical
|
|
)
|
|
.lineLimit(2...4)
|
|
.textFieldStyle(.roundedBorder)
|
|
.disabled(resolving)
|
|
} else {
|
|
ForEach(questions.indices, id: \.self) { index in
|
|
questionSection(index: index, enabled: !resolving)
|
|
}
|
|
}
|
|
|
|
if let validationError {
|
|
Text(validationError)
|
|
.font(.caption)
|
|
.foregroundStyle(.red)
|
|
}
|
|
|
|
HStack {
|
|
Spacer()
|
|
if resolving {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
} else {
|
|
Button("Submit") {
|
|
submit()
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 6)
|
|
}
|
|
}
|
|
|
|
private func bindingForOther(_ index: Int) -> Binding<String> {
|
|
Binding(
|
|
get: { otherText[index] ?? "" },
|
|
set: { text in
|
|
otherText[index] = text
|
|
if let question = questions.indices.contains(index) ? questions[index] : nil,
|
|
!question.multiSelect,
|
|
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
// Typing a custom answer replaces a single-select choice.
|
|
selected[index] = []
|
|
}
|
|
validationError = nil
|
|
}
|
|
)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func questionSection(index: Int, enabled: Bool) -> some View {
|
|
let question = questions[index]
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
if let header = question.header {
|
|
Text(header)
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
if !question.question.isEmpty {
|
|
Text(question.question)
|
|
.font(.subheadline)
|
|
}
|
|
ForEach(question.options.indices, id: \.self) { optionIndex in
|
|
let option = question.options[optionIndex]
|
|
PermissionOptionRow(
|
|
label: option.label,
|
|
description: option.description,
|
|
checked: selected[index]?.contains(optionIndex) == true,
|
|
multiple: question.multiSelect,
|
|
enabled: enabled
|
|
) {
|
|
toggleOption(question: question, index: index, optionIndex: optionIndex)
|
|
}
|
|
}
|
|
TextField("Other…", text: bindingForOther(index))
|
|
.textFieldStyle(.roundedBorder)
|
|
.disabled(!enabled)
|
|
}
|
|
}
|
|
|
|
private func toggleOption(question: AskQuestion, index: Int, optionIndex: Int) {
|
|
let current = selected[index] ?? []
|
|
if question.multiSelect {
|
|
selected[index] = current.contains(optionIndex)
|
|
? current.subtracting([optionIndex])
|
|
: current.union([optionIndex])
|
|
} else {
|
|
selected[index] = [optionIndex]
|
|
}
|
|
validationError = nil
|
|
}
|
|
|
|
private func submit() {
|
|
var answers: [String: [String]] = [:]
|
|
if questions.isEmpty {
|
|
let text = (otherText[0] ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !text.isEmpty else {
|
|
validationError = String(localized: "Type an answer first")
|
|
return
|
|
}
|
|
answers["0"] = [text]
|
|
} else {
|
|
for (index, question) in questions.enumerated() {
|
|
var values: [String] = []
|
|
for optionIndex in (selected[index] ?? []).sorted()
|
|
where question.options.indices.contains(optionIndex) {
|
|
let option = question.options[optionIndex]
|
|
if cursorDialect {
|
|
let id = option.id?.trimmingCharacters(in: .whitespaces) ?? ""
|
|
values.append(id.isEmpty ? option.label : id)
|
|
} else {
|
|
values.append(option.label)
|
|
}
|
|
}
|
|
let free = (otherText[index] ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !free.isEmpty {
|
|
values.append(free)
|
|
}
|
|
guard !values.isEmpty else {
|
|
validationError = String(localized: "Answer every question first")
|
|
return
|
|
}
|
|
answers[question.answerKey(index: index, useStableIds: cursorDialect)] = values
|
|
}
|
|
}
|
|
interactions.resolvePermission(requestId: requestId, action: .flatAnswers(answers))
|
|
}
|
|
}
|
|
|
|
// MARK: - request_user_input
|
|
|
|
/// request_user_input answer form: per-field option rows plus a free-text
|
|
/// note; answers post nested `{fieldId: {answers: [labels…, "user_note: …"]}}`
|
|
/// (`RequestUserInputFooter.tsx` parity; the web-only URL confirmation flow
|
|
/// is not ported).
|
|
private struct RequestUserInputFooter: View {
|
|
let tool: ChatToolCall
|
|
let requestId: String
|
|
let interactions: ChatInteractor
|
|
|
|
private let questions: [RequestUserInputQuestion]
|
|
|
|
@State private var selected: [String: Set<String>] = [:]
|
|
@State private var notes: [String: String]
|
|
@State private var validationError: String?
|
|
|
|
init(tool: ChatToolCall, requestId: String, interactions: ChatInteractor) {
|
|
self.tool = tool
|
|
self.requestId = requestId
|
|
self.interactions = interactions
|
|
let parsed = parseRequestUserInputQuestions(tool.input)
|
|
self.questions = parsed
|
|
// Duplicate field ids are malformed input; keep the first rather
|
|
// than trapping.
|
|
_notes = State(initialValue: Dictionary(
|
|
parsed.map { ($0.id, $0.prefill ?? "") },
|
|
uniquingKeysWith: { first, _ in first }
|
|
))
|
|
}
|
|
|
|
var body: some View {
|
|
let override = interactions.permissionOverrides[requestId]
|
|
if override == .alreadyHandled {
|
|
AlreadyHandledLine()
|
|
} else {
|
|
let resolving = override == .resolving
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
ForEach(questions, id: \.id) { question in
|
|
questionSection(question, enabled: !resolving)
|
|
}
|
|
|
|
if let validationError {
|
|
Text(validationError)
|
|
.font(.caption)
|
|
.foregroundStyle(.red)
|
|
}
|
|
|
|
HStack {
|
|
Spacer()
|
|
if resolving {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
} else {
|
|
Button("Submit") {
|
|
submit()
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 6)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func questionSection(_ question: RequestUserInputQuestion, enabled: Bool) -> some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
if !question.question.isEmpty {
|
|
Text(question.required
|
|
? question.question
|
|
: String(format: String(localized: "%@ (optional)"), question.question))
|
|
.font(.subheadline)
|
|
}
|
|
ForEach(question.options.indices, id: \.self) { optionIndex in
|
|
let option = question.options[optionIndex]
|
|
PermissionOptionRow(
|
|
label: option.label,
|
|
description: option.description,
|
|
checked: selected[question.id]?.contains(option.label) == true,
|
|
multiple: question.multiple,
|
|
enabled: enabled
|
|
) {
|
|
toggleOption(question: question, label: option.label)
|
|
}
|
|
}
|
|
TextField(
|
|
question.placeholder ?? String(localized: "Add a note…"),
|
|
text: Binding(
|
|
get: { notes[question.id] ?? "" },
|
|
set: { text in
|
|
notes[question.id] = text
|
|
validationError = nil
|
|
}
|
|
)
|
|
)
|
|
.textFieldStyle(.roundedBorder)
|
|
.disabled(!enabled)
|
|
}
|
|
}
|
|
|
|
private func toggleOption(question: RequestUserInputQuestion, label: String) {
|
|
let current = selected[question.id] ?? []
|
|
if question.multiple {
|
|
selected[question.id] = current.contains(label)
|
|
? current.subtracting([label])
|
|
: current.union([label])
|
|
} else {
|
|
selected[question.id] = [label]
|
|
}
|
|
validationError = nil
|
|
}
|
|
|
|
private func submit() {
|
|
for question in questions {
|
|
let questionSelected = Array(selected[question.id] ?? [])
|
|
let note = notes[question.id] ?? ""
|
|
guard isRequestUserInputAnswered(question, selected: questionSelected, note: note) else {
|
|
validationError = String(localized: "Answer every required question first")
|
|
return
|
|
}
|
|
}
|
|
var answers: [String: [String]] = [:]
|
|
for question in questions {
|
|
// Preserve the question's own option order for multi-selects.
|
|
let picked = selected[question.id] ?? []
|
|
let ordered = question.options.map(\.label).filter(picked.contains)
|
|
answers[question.id] = requestUserInputAnswerValues(
|
|
selected: ordered,
|
|
note: notes[question.id] ?? ""
|
|
)
|
|
}
|
|
interactions.resolvePermission(requestId: requestId, action: .nestedAnswers(answers))
|
|
}
|
|
}
|
|
|
|
// MARK: - Shared option row
|
|
|
|
private struct PermissionOptionRow: View {
|
|
let label: String
|
|
let description: String?
|
|
let checked: Bool
|
|
let multiple: Bool
|
|
let enabled: Bool
|
|
let onTap: () -> Void
|
|
|
|
@Environment(\.hapiTheme) private var theme
|
|
|
|
var body: some View {
|
|
Button(action: onTap) {
|
|
HStack(alignment: .top, spacing: 8) {
|
|
Image(systemName: indicatorSymbol)
|
|
.font(.subheadline)
|
|
.foregroundStyle(checked ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary))
|
|
.padding(.top, 1)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(label)
|
|
.font(.subheadline)
|
|
.foregroundStyle(theme.textPrimary)
|
|
.multilineTextAlignment(.leading)
|
|
if let description {
|
|
Text(description)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.leading)
|
|
}
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 8)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(
|
|
checked ? AnyShapeStyle(.tint.opacity(0.14)) : AnyShapeStyle(theme.surface),
|
|
in: RoundedRectangle(cornerRadius: 10, style: .continuous)
|
|
)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(!enabled)
|
|
}
|
|
|
|
private var indicatorSymbol: String {
|
|
if multiple {
|
|
return checked ? "checkmark.square.fill" : "square"
|
|
}
|
|
return checked ? "largecircle.fill.circle" : "circle"
|
|
}
|
|
}
|