Move connection policy out of ProAccess, keep terminal uploads with their session, and share question interaction and highlight editing operations. Consolidate CPU usage and settings snapshot handling, and remove obsolete terminal selection, theme helpers, and hard-coded latest-schema assertions. Fix draining connection-pool recovery and stale pane upload bindings. Keep screenshot matrices opt-in and retain behavioral migration coverage. Validation: 2468 macOS tests passed serially, 31 iOS tests passed, and both SSH forwarding regressions passed against the local OpenSSH fixture. Both app builds, Swift formatting, AppStrings, and Chinese localization checks passed. Parallel test runs still exhibit intermittent timeouts.
287 lines
11 KiB
Swift
287 lines
11 KiB
Swift
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 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"
|
|
}
|
|
}
|
|
}
|