Files
Termind/TFI/Features/AI/MobileAssistantInteraction.swift

600 lines
24 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import AgentKit
import SwiftUI
import TermindApplication
import TermindPresentation
struct MobileAssistantInteraction: View {
@ScaledMetric(relativeTo: .caption) private var approvalHeight: CGFloat = 240
@Bindable var conversation: ConversationModel
let hosts: HostLibraryModel
let speech: SpeechTranscriptionCoordinator
@State private var interaction = UserInputInteractionModel()
private var speechSession: UserInputSpeechSession { interaction.speech }
@FocusState private var focusedInput: InputFocus?
@Environment(\.locale) private var locale
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var expandedApprovalID: UUID?
private enum InputFocus: Hashable {
case text(String)
case secret(String)
case custom(String)
}
var body: some View {
let pending = conversation.pendingInteraction
if case .approval(let approval)? = pending {
approvalCard(approval)
.mobileAssistantInteractionSurface()
} else if case .userInteraction(let interaction)? = pending {
switch interaction {
case .mcpAuthorization(let prompt):
MCPAuthorizationCard(prompt: prompt, conversation: conversation)
.id(prompt.id)
.mobileAssistantInteractionSurface()
case .sshAuthentication(let request):
MobileAssistantAdaptiveHeight {
VStack(alignment: .leading, spacing: 8) {
MobileAssistantQueueDepthLabel(remaining: conversation.pendingSSHAuthentications.count)
.frame(maxWidth: .infinity, alignment: .trailing)
if let signing = request.securityKey {
AgentSecurityKeySigningCard(request: signing) {
conversation.respondToSSHAuthentication($0, requestID: request.id)
}
} else {
MobileAssistantAuthenticationForm(
request: request, conversation: conversation, hosts: hosts)
}
}
}
.mobileAssistantInteractionSurface()
case .sshHostKey(let request):
MobileAssistantAdaptiveHeight {
VStack(alignment: .leading, spacing: 8) {
MobileAssistantQueueDepthLabel(remaining: conversation.pendingSSHHostKeys.count)
.frame(maxWidth: .infinity, alignment: .trailing)
MobileAssistantHostKeyForm(request: request, conversation: conversation)
}
}
.mobileAssistantInteractionSurface()
case .questions:
questions
.mobileAssistantInteractionSurface()
}
}
}
private func approvalCard(_ approval: AgentApprovalRequest) -> some View {
let presentation = TermindToolPresentationProfiles.renderer.approval(
for: approval,
presenter: MobileAppDependencies.toolPresenter(for: approval.descriptor),
hostName: approval.invocation.targetHostID.map(hostName),
locale: locale
)
return VStack(alignment: .leading, spacing: 12) {
HStack {
Label(AppStrings.Assistant.approvalRequired.swiftUIKey, systemImage: "hand.raised.fill")
.font(.headline)
Spacer()
MobileAssistantQueueDepthLabel(remaining: conversation.pendingApprovals.count)
}
HStack(alignment: .firstTextBaseline, spacing: 8) {
Image(systemName: presentation.symbol)
.foregroundStyle(.orange)
.accessibilityHidden(true)
Text(presentation.title)
.font(.callout.weight(.medium))
.lineLimit(3)
}
ViewThatFits(in: .vertical) {
approvalDetails(presentation, approval: approval)
.fixedSize(horizontal: false, vertical: true)
ScrollView {
approvalDetails(presentation, approval: approval)
.frame(maxWidth: .infinity, alignment: .leading)
}
.scrollBounceBehavior(.basedOnSize)
}
.frame(maxHeight: presentation.detail.layout.boundsApprovalHeight ? approvalHeight : nil)
HStack {
Button(AppStrings.Common.deny.swiftUIKey, systemImage: "xmark", role: .destructive) {
conversation.respondToApproval(approval.id, allow: false)
}
.frame(minHeight: 44)
.accessibilityIdentifier("AssistantApproval.Deny")
Spacer()
Button(AppStrings.Assistant.allowOnce.swiftUIKey, systemImage: "checkmark") {
conversation.respondToApproval(approval.id, allow: true)
}
.buttonStyle(.borderedProminent)
.frame(minHeight: 44)
.accessibilityIdentifier("AssistantApproval.AllowOnce")
}
.fixedSize(horizontal: false, vertical: true)
.layoutPriority(2)
}
.frame(maxWidth: .infinity, alignment: .topLeading)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("AssistantApproval.Card")
}
private func approvalDetails(
_ presentation: AgentApprovalPresentation,
approval: AgentApprovalRequest
) -> some View {
AssistantApprovalDetails(
detail: presentation.detail,
reasons: approval.localReasons,
showsSafetyDetails: safetyDetailsBinding(for: approval.id)
)
}
private func safetyDetailsBinding(for approvalID: UUID) -> Binding<Bool> {
Binding(
get: { expandedApprovalID == approvalID },
set: { expandedApprovalID = $0 ? approvalID : nil }
)
}
private func hostName(_ id: UUID) -> String {
hosts.host(id)?.name ?? String(id.uuidString.prefix(8))
}
private var questions: some View {
VStack(alignment: .leading, spacing: 12) {
if conversation.pendingUserInputs.count > 1 {
HStack {
Spacer()
MobileAssistantQueueDepthLabel(remaining: conversation.pendingUserInputs.count)
}
}
if let question = conversation.currentUserInputQuestion {
// Match TFM: the decision controls remain reachable while only
// the current question gives way to scrolling.
ViewThatFits(in: .vertical) {
questionBlock(question)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
scrollingQuestion(question)
}
footer(for: question)
}
}
.onChange(of: conversation.id) { _, _ in speechSession.cancel() }
.onDisappear { speechSession.cancel() }
.alert(
speechSession.error?.title ?? String(localized: AppStrings.Assistant.transcriptionFailed),
isPresented: Binding(
get: { speechSession.error != nil },
set: { if !$0 { speechSession.dismissError() } })
) {
if speechSession.error?.offersSystemSettings == true {
Button(AppStrings.Navigation.settings.swiftUIKey) {
if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) }
}
}
Button(AppStrings.Common.ok.swiftUIKey) { speechSession.dismissError() }
} message: {
Text(speechSession.error?.message ?? "")
}
}
private func scrollingQuestion(_ question: AgentUserQuestion) -> some View {
ScrollViewReader { proxy in
ScrollView {
questionBlock(question)
.frame(maxWidth: .infinity, alignment: .leading)
.id(question.id)
}
.scrollBounceBehavior(.basedOnSize)
.onChange(of: question.id, initial: true) { _, id in
proxy.scrollTo(id, anchor: .top)
}
}
}
@ViewBuilder
private func questionBlock(_ question: AgentUserQuestion) -> some View {
VStack(alignment: .leading, spacing: 8) {
if !question.header.isEmpty {
Text(question.header)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
}
Text(question.prompt)
.font(.callout.weight(.medium))
.fixedSize(horizontal: false, vertical: true)
switch question.kind {
case .secret:
secretField(for: question)
case .text:
textField(for: question)
case .choice(let choice):
choiceList(choice, in: question.id)
}
}
}
private func secretField(for question: AgentUserQuestion) -> some View {
HStack(spacing: 10) {
Image(systemName: "lock.fill")
.foregroundStyle(.secondary)
.frame(width: 20)
.accessibilityHidden(true)
RevealableSecretField(AppStrings.Keychain.secret, text: $conversation.secretInputDraft)
.textContentType(.password)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused($focusedInput, equals: .secret(question.id))
.submitLabel(isLastQuestion ? .done : .next)
.onSubmit { advanceFromKeyboard(question.id) }
}
.questionInputSurface()
.contentShape(.rect)
.onTapGesture { focusedInput = .secret(question.id) }
}
private func textField(for question: AgentUserQuestion) -> some View {
HStack(alignment: .center, spacing: 10) {
Image(systemName: "text.cursor")
.foregroundStyle(.secondary)
.frame(width: 20)
.accessibilityHidden(true)
TextField(AppStrings.Assistant.answer.swiftUIKey, text: answer(question.id), axis: .vertical)
.lineLimit(1...3)
.focused($focusedInput, equals: .text(question.id))
.submitLabel(isLastQuestion ? .done : .next)
.onSubmit { advanceFromKeyboard(question.id) }
.disabled(speechSession.isActive)
speechControls(for: .text(question.id))
}
.questionInputSurface()
.contentShape(.rect)
.onTapGesture { if !speechSession.isActive { focusedInput = .text(question.id) } }
}
private func choiceList(_ choice: AgentUserInputChoice, in questionID: String) -> some View {
VStack(alignment: .leading, spacing: 6) {
ForEach(choice.options) { option in
optionRow(option, choice: choice, in: questionID)
}
if choice.allowsCustomAnswer {
customRow(choice, in: questionID)
}
}
}
private func optionRow(
_ option: AgentUserInputOption,
choice: AgentUserInputChoice,
in questionID: String
) -> some View {
let isSelected = conversation.userInputDraft(for: questionID).selection.contains(option.id)
return Button {
interaction.selectOption(option.id, in: questionID, conversation: conversation)
} label: {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Image(systemName: marker(isSelected: isSelected, choice: choice))
.foregroundStyle(isSelected ? Color.accentColor : Color.secondary)
.frame(width: 20)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 3) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(option.label)
.font(.callout.weight(.medium))
if option.isRecommended {
Text(AppStrings.Assistant.recommended)
.font(.caption2.weight(.semibold))
.padding(.horizontal, 7)
.padding(.vertical, 2)
.background(Color.accentColor.opacity(0.14), in: .capsule)
.foregroundStyle(.tint)
.fixedSize()
}
}
if !option.detail.isEmpty {
Text(option.detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(3)
}
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
.padding(.horizontal, 12)
.padding(.vertical, 7)
.questionOptionSurface(isSelected: isSelected)
.contentShape(.rect)
}
.buttonStyle(.plain)
.hoverEffect(.highlight)
.disabled(speechSession.isActive)
.accessibilityElement(children: .combine)
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
private func customRow(_ choice: AgentUserInputChoice, in questionID: String) -> some View {
let typed = conversation.userInputDraft(for: questionID).text
let isSelected = !typed.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
return HStack(alignment: .center, spacing: 10) {
Image(systemName: marker(isSelected: isSelected, choice: choice))
.foregroundStyle(isSelected ? Color.accentColor : Color.secondary)
.frame(width: 20)
.accessibilityHidden(true)
customAnswerField(choice, in: questionID)
.focused($focusedInput, equals: .custom(questionID))
.submitLabel(isLastQuestion ? .done : .next)
.onSubmit { advanceFromKeyboard(questionID) }
.disabled(speechSession.isActive)
speechControls(for: .custom(questionID))
}
.frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
.padding(.horizontal, 12)
.padding(.vertical, 7)
.questionOptionSurface(isSelected: isSelected)
.contentShape(.rect)
.onTapGesture { if !speechSession.isActive { focusedInput = .custom(questionID) } }
}
@ViewBuilder
private func customAnswerField(_ choice: AgentUserInputChoice, in questionID: String) -> some View {
if choice.customPlaceholder.isEmpty {
TextField(AppStrings.Assistant.otherAnswer.swiftUIKey, text: answer(questionID))
} else {
TextField(choice.customPlaceholder, text: answer(questionID))
}
}
private func footer(for question: AgentUserQuestion) -> some View {
VStack(spacing: 8) {
Divider()
ViewThatFits(in: .horizontal) {
wideFooter(for: question, compactProgress: horizontalSizeClass == .compact)
wideFooter(for: question, compactProgress: true)
compactFooter(for: question)
}
}
}
private func wideFooter(for question: AgentUserQuestion, compactProgress: Bool) -> some View {
HStack(spacing: 10) {
leadingActions
.frame(maxWidth: .infinity, alignment: .leading)
questionProgress(compact: compactProgress)
trailingActions(for: question)
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
private func compactFooter(for question: AgentUserQuestion) -> some View {
VStack(spacing: 4) {
HStack {
questionProgress(compact: true)
Spacer(minLength: 8)
if conversation.canSkipUserInputQuestion {
skipButton
}
}
HStack(spacing: 10) {
leadingActions
Spacer(minLength: 8)
primaryButton(for: question)
}
}
}
private var leadingActions: some View {
HStack(spacing: 10) {
Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) {
interaction.cancelRequest(in: conversation)
}
.keyboardShortcut(MobileShortcut.formCancel)
if conversation.currentUserInputQuestionIndex > 0 {
Button(AppStrings.Common.back.swiftUIKey) {
interaction.previous(in: conversation)
}
}
}
.frame(minHeight: 44)
.fixedSize(horizontal: true, vertical: false)
}
private func trailingActions(for question: AgentUserQuestion) -> some View {
HStack(spacing: 10) {
if conversation.canSkipUserInputQuestion {
skipButton
}
primaryButton(for: question)
}
.frame(minHeight: 44)
}
private var skipButton: some View {
Button(AppStrings.Common.skip.swiftUIKey) {
interaction.skip(in: conversation)
}
.fixedSize(horizontal: true, vertical: false)
}
private func primaryButton(for question: AgentUserQuestion) -> some View {
Button(isLastQuestion ? AppStrings.Common.submit.swiftUIKey : AppStrings.Common.next.swiftUIKey) {
advanceFromKeyboard(question.id)
}
.buttonStyle(.borderedProminent)
.keyboardShortcut(MobileShortcut.formDefault)
.disabled(!interaction.canAdvance(in: conversation))
.fixedSize(horizontal: true, vertical: false)
}
@ViewBuilder
private func questionProgress(compact: Bool) -> some View {
let total = conversation.pendingUserInput?.questions.count ?? 0
let current = conversation.currentUserInputQuestionIndex + 1
if total > 1 {
Group {
if compact {
Text("\(current) / \(total)")
} else {
Text(AppStrings.Assistant.questionOf(current: current, total: total))
}
}
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
.fixedSize()
}
}
private var isLastQuestion: Bool {
conversation.currentUserInputQuestionIndex + 1
== (conversation.pendingUserInput?.questions.count ?? 0)
}
private func advanceFromKeyboard(_ questionID: String) {
interaction.advance(in: conversation, questionID: questionID)
}
private func marker(isSelected: Bool, choice: AgentUserInputChoice) -> String {
if choice.allowsMultipleAnswers {
isSelected ? "checkmark.square.fill" : "square"
} else {
isSelected ? "largecircle.fill.circle" : "circle"
}
}
private func speechControls(for focus: InputFocus) -> some View {
UserInputSpeechControls(
isActive: speechSession.isActive, isRecording: speechSession.isRecording,
canStart: speech.canStart(for: speechSession.ownerID),
start: {
focusedInput = nil
speechSession.start(coordinator: speech, conversation: conversation) { focusedInput = focus }
},
cancel: {
speechSession.cancel()
focusedInput = focus
}, stop: speechSession.stop
)
}
private func answer(_ id: String) -> Binding<String> {
Binding(
get: { conversation.userInputDraft(for: id).text },
set: { conversation.setUserInputCustomAnswer($0, for: id) })
}
}
private struct MobileAssistantQueueDepthLabel: View {
let remaining: Int
var body: some View {
if remaining > 1 {
Text(AppStrings.Assistant.firstQuestionOf(remaining))
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
}
extension View {
fileprivate func questionInputSurface() -> some View {
frame(maxWidth: .infinity, minHeight: 48, alignment: .leading)
.padding(.horizontal, 12)
.background(
Color(uiColor: .secondarySystemBackground),
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
)
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Color.primary.opacity(0.08), lineWidth: 1)
.allowsHitTesting(false)
}
}
fileprivate func questionOptionSurface(isSelected: Bool) -> some View {
background(
isSelected ? Color.accentColor.opacity(0.1) : Color(uiColor: .secondarySystemBackground),
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
)
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(
isSelected ? Color.accentColor.opacity(0.5) : Color.primary.opacity(0.08),
lineWidth: 1
)
.allowsHitTesting(false)
}
}
}
private struct MobileAssistantAdaptiveHeight<Content: View>: View {
@ViewBuilder let content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
// The Assistant's bottom safe-area inset supplies the real ceiling,
// including keyboard avoidance. A local cap wastes usable screen space.
ViewThatFits(in: .vertical) {
content
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
ScrollView {
content.frame(maxWidth: .infinity, alignment: .leading)
}
.scrollBounceBehavior(.basedOnSize)
}
.frame(maxWidth: .infinity, alignment: .topLeading)
}
}
private struct MobileAssistantAuthenticationForm: View {
let request: AgentSSHAuthenticationRequest
let conversation: ConversationModel
let hosts: HostLibraryModel
var body: some View {
SSHAuthenticationForm(
prompt: request.journeyPrompt,
submit: { identity, username, password, passphrase, key, save in
conversation.respondToSSHAuthentication(
.submit(
.init(
identityID: identity, identityName: nil,
username: username, password: password, passphrase: passphrase, keyID: key, save: save)),
requestID: request.id)
}, cancel: { conversation.respondToSSHAuthentication(.cancelled, requestID: request.id) },
usesContainerChrome: false,
library: hosts,
respondKeyboardInteractive: {
conversation.respondToSSHAuthentication(.keyboardInteractive($0), requestID: request.id)
}
)
.id(request.id)
}
}
private struct MobileAssistantHostKeyForm: View {
let request: AgentSSHHostKeyRequest
let conversation: ConversationModel
var body: some View {
SSHHostKeyConfirmationForm(
prompt: request.prompt, displayAddress: request.address,
proceed: { conversation.respondToSSHHostKey(.continueOnce) },
trust: request.allowsPersistentTrust ? { conversation.respondToSSHHostKey(.trustAndContinue) } : nil,
deny: { conversation.respondToSSHHostKey(.deny) }, usesContainerChrome: false
).id(request.id)
}
}