Files
Termind/TFM/Features/AI/AgentUserInputCard.swift

290 lines
12 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import AgentKit
import SwiftUI
import TermindApplication
import TermindPresentation
/// The agent asking the user to decide one or more things.
///
/// One step at a time rather than a tall form. Answers stay in the model while
/// the user moves back and forth, and Skip makes an intentional null distinct
/// from cancelling the whole request.
///
/// Options are a list rather than a `Picker` because each one carries a
/// sentence explaining what it costs, and a pop-up menu has nowhere to put
/// that. Nothing is preselected — including the recommendation, which is a
/// label and not a default, so that an answer always takes a deliberate click.
/// A question may also be explicitly skipped: the tool reports that as a null
/// and the model decides it itself.
struct AgentUserInputCard: View {
let request: AgentUserInputRequest
let conversation: ConversationModel
var isCompact = false
@Environment(AppModel.self) private var app
@State private var interaction = UserInputInteractionModel()
private var speechSession: UserInputSpeechSession { interaction.speech }
@FocusState private var focusedQuestionID: String?
private var isLastQuestion: Bool {
conversation.currentUserInputQuestionIndex == request.questions.count - 1
}
var body: some View {
VStack(alignment: .leading, spacing: Metrics.Space.m) {
if conversation.pendingUserInputs.count > 1 {
HStack {
Spacer()
QueueDepthLabel(remaining: conversation.pendingUserInputs.count)
}
}
// Prefer the step's natural height. When the surrounding VStack
// offers less room, the scrolling fallback accepts that proposal.
ViewThatFits(in: .vertical) {
currentQuestion
ScrollView { currentQuestion }
.scrollBounceBehavior(.basedOnSize)
}
footer
}
.activeFormKeyboardActions(
isDefaultEnabled: interaction.canAdvance(in: conversation),
onDefault: advanceQuestion,
onCancel: cancelRequest
)
.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.Assistant.openSystemSettings.swiftUIKey) { openSpeechSettings() }
}
Button(AppStrings.Common.ok.swiftUIKey, role: .cancel) { speechSession.dismissError() }
} message: {
Text(speechSession.error?.message ?? "")
}
}
@ViewBuilder
private var currentQuestion: some View {
if let question = conversation.currentUserInputQuestion {
questionBlock(question)
.frame(maxWidth: .infinity, alignment: .leading)
.id(question.id)
}
}
private var footer: some View {
HStack(spacing: Metrics.Space.m) {
HStack(spacing: Metrics.Space.s) {
Button(AppStrings.Common.cancel.swiftUIKey, action: cancelRequest)
.keyboardShortcut(.cancelAction)
if conversation.currentUserInputQuestionIndex > 0 {
Button(AppStrings.Common.back.swiftUIKey) {
interaction.previous(in: conversation)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
questionProgress
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
.fixedSize()
HStack(spacing: Metrics.Space.s) {
if conversation.canSkipUserInputQuestion {
Button(AppStrings.Common.skip.swiftUIKey) {
interaction.skip(in: conversation)
}
}
Button(isLastQuestion ? AppStrings.Common.submit.swiftUIKey : AppStrings.Common.next.swiftUIKey) {
advanceQuestion()
}
.transcriptProminentButtonStyle()
.keyboardShortcut(.defaultAction)
.disabled(!interaction.canAdvance(in: conversation))
}
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
@ViewBuilder
private var questionProgress: some View {
let current = conversation.currentUserInputQuestionIndex + 1
if request.questions.count > 1 {
if isCompact {
Text("\(current) / \(request.questions.count)")
} else {
Text(AppStrings.Assistant.questionOf(current: current, total: request.questions.count))
}
}
}
@ViewBuilder
private func questionBlock(_ question: AgentUserQuestion) -> some View {
@Bindable var conversation = conversation
VStack(alignment: .leading, spacing: Metrics.Space.s) {
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:
RevealableSecretField(
AppStrings.Keychain.secret, text: $conversation.secretInputDraft, appearance: .roundedBorder)
case .choice(let choice):
choiceList(choice, in: question.id)
case .text:
answerField(String(localized: AppStrings.Assistant.answer), questionID: question.id)
}
}
}
private func choiceList(_ choice: AgentUserInputChoice, in questionID: String) -> some View {
VStack(alignment: .leading, spacing: Metrics.Space.xs) {
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: Metrics.Space.s) {
Image(systemName: marker(isSelected: isSelected, choice: choice))
.foregroundStyle(isSelected ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary))
.frame(width: Metrics.Sidebar.rowIcon)
VStack(alignment: .leading, spacing: Metrics.Space.xxs) {
HStack(spacing: Metrics.Space.s) {
Text(option.label).font(.callout)
if option.isRecommended {
Text(AppStrings.Assistant.recommended)
.font(.caption2.weight(.medium))
.padding(.horizontal, Metrics.Space.s)
.padding(.vertical, 1)
.background(.tint.opacity(0.15), in: .capsule)
.foregroundStyle(.tint)
}
}
if !option.detail.isEmpty {
Text(option.detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(isCompact ? 2 : 3)
.truncationMode(.tail)
.help(Text(option.detail))
}
}
Spacer(minLength: 0)
}
.contentShape(.rect)
}
.buttonStyle(.plain)
.disabled(speechSession.isActive)
}
/// The escape hatch, sitting in the list as one more choice rather than
/// below it as an afterthought — an answer the model did not think of is
/// not a lesser kind of answer.
private func customRow(_ choice: AgentUserInputChoice, in questionID: String) -> some View {
let typed = conversation.userInputDraft(for: questionID).text
return HStack(alignment: .firstTextBaseline, spacing: Metrics.Space.s) {
Image(systemName: marker(isSelected: !typed.isEmpty, choice: choice))
.foregroundStyle(typed.isEmpty ? AnyShapeStyle(.secondary) : AnyShapeStyle(.tint))
.frame(width: Metrics.Sidebar.rowIcon)
answerField(
choice.customPlaceholder.isEmpty
? String(localized: AppStrings.Assistant.otherAnswer) : choice.customPlaceholder,
questionID: questionID
)
}
}
private func answerField(_ placeholder: String, questionID: String) -> some View {
HStack(spacing: Metrics.Space.xs) {
TextField(placeholder, text: customBinding(for: questionID))
.textFieldStyle(.plain)
.focused($focusedQuestionID, equals: questionID)
.disabled(speechSession.isActive)
UserInputSpeechControls(
isActive: speechSession.isActive, isRecording: speechSession.isRecording,
canStart: app.speechTranscription.canStart(for: speechSession.ownerID),
start: {
focusedQuestionID = nil
speechSession.start(coordinator: app.speechTranscription, conversation: conversation) {
focusedQuestionID = questionID
}
},
cancel: {
speechSession.cancel()
focusedQuestionID = questionID
}, stop: speechSession.stop
)
}
.padding(.leading, Metrics.Space.s)
.background(.background, in: .rect(cornerRadius: 5))
.overlay {
RoundedRectangle(cornerRadius: 5)
.strokeBorder(.secondary.opacity(0.25))
.allowsHitTesting(false)
}
}
private func advanceQuestion() {
interaction.advance(in: conversation)
}
private func cancelRequest() {
interaction.cancelRequest(in: conversation)
}
private func openSpeechSettings() {
let anchor =
speechSession.error?.code == .speechPermissionDenied
? "Privacy_SpeechRecognition" : "Privacy_Microphone"
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?\(anchor)") {
NSWorkspace.shared.open(url)
}
}
private func customBinding(for questionID: String) -> Binding<String> {
Binding(
get: { conversation.userInputDraft(for: questionID).text },
set: { conversation.setUserInputCustomAnswer($0, for: questionID) }
)
}
private func marker(isSelected: Bool, choice: AgentUserInputChoice) -> String {
if choice.allowsMultipleAnswers {
isSelected ? "checkmark.square.fill" : "square"
} else {
isSelected ? "largecircle.fill.circle" : "circle"
}
}
}