// // InputBar.swift // Zyquo Local // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // import SwiftUI import UniformTypeIdentifiers /// Floating input card (radius 14, soft shadow): attach text files, prompt /// templates, params quick-toggle, circular emerald send (⌘↩), stop while /// generating. Supports drag & drop of text files. struct InputBar: View { let conversation: Conversation @Environment(AppModel.self) private var app @State private var text = "" @State private var showParams = false @State private var showTemplates = false @State private var dropTargeted = false @FocusState private var focused: Bool private static let allowedTypes: [UTType] = [.plainText, .utf8PlainText, .sourceCode, .json, .commaSeparatedText, .fileURL] private static let allowedExtensions: Set = [ "txt", "md", "markdown", "swift", "py", "js", "ts", "html", "css", "c", "cpp", "h", "m", "rs", "go", "java", "kt", "rb", "sh", "yaml", "yml", "toml", "json", "csv", "xml", "sql", "log", ] private var modelReady: Bool { if case .ready = app.engineState { return true } if case .generating = app.engineState { return true } return false } var body: some View { VStack(spacing: 0) { HStack(alignment: .bottom, spacing: ZyquoTheme.Spacing.xs) { attachButton templatesButton TextField(inputHint, text: $text, axis: .vertical) .textFieldStyle(.plain) .font(ZyquoTheme.chatBody) .lineLimit(1...10) .focused($focused) .disabled(!modelReady && !app.store.models.isEmpty) .onSubmit(send) paramsButton sendOrStopButton } .padding(ZyquoTheme.Spacing.s) .background(ZyquoTheme.surface, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.l)) .overlay( RoundedRectangle(cornerRadius: ZyquoTheme.Radius.l) .stroke( dropTargeted ? ZyquoTheme.accent : ZyquoTheme.border, lineWidth: dropTargeted ? 1.5 : ZyquoTheme.hairline ) ) .floatingShadow() .padding(.horizontal, ZyquoTheme.Spacing.xl) .padding(.bottom, ZyquoTheme.Spacing.m) .padding(.top, ZyquoTheme.Spacing.xs) .frame(maxWidth: ZyquoTheme.messageColumnMaxWidth + ZyquoTheme.Spacing.xl * 2) } .onDrop(of: Self.allowedTypes, isTargeted: $dropTargeted) { providers in handleDrop(providers) } .onReceive(NotificationCenter.default.publisher(for: .zyquoQuoteReply)) { note in if let quoted = note.object as? String { text = quoted + text focused = true } } .onChange(of: conversation.id) { focused = true } } private var inputHint: String { if app.store.models.isEmpty { return "Download a model to start chatting" } if !modelReady { return "Load a model to start chatting" } return "Message \(shortModelName(app.loadedModelID ?? ""))…" } private var attachButton: some View { Button { attachFiles() } label: { Image(systemName: "paperclip") .font(.system(size: 14)) .foregroundStyle(ZyquoTheme.textSecondary) } .buttonStyle(.plain) .help("Attach text files (txt, md, code, csv, json)") } private var templatesButton: some View { Button { showTemplates.toggle() } label: { Image(systemName: "text.badge.star") .font(.system(size: 14)) .foregroundStyle(ZyquoTheme.textSecondary) } .buttonStyle(.plain) .popover(isPresented: $showTemplates, arrowEdge: .top) { PromptTemplatePicker { rendered in text = rendered focused = true } } .help("Prompt library") } private var paramsButton: some View { Button { showParams.toggle() } label: { Image(systemName: "slider.horizontal.3") .font(.system(size: 14)) .foregroundStyle(ZyquoTheme.textSecondary) } .buttonStyle(.plain) .popover(isPresented: $showParams, arrowEdge: .top) { ParamsEditor( params: Binding( get: { app.selectedConversation?.params ?? conversation.params }, set: { newParams in var c = app.selectedConversation ?? conversation c.params = newParams app.update(c, touch: false) } ) ) .padding(ZyquoTheme.Spacing.m) .frame(width: 300) } .help("Generation parameters") } @ViewBuilder private var sendOrStopButton: some View { if app.chat.isGenerating { Button { app.chat.stop() } label: { Image(systemName: "stop.fill") .font(.system(size: 12, weight: .bold)) .foregroundStyle(.white) .frame(width: 28, height: 28) .background(ZyquoTheme.danger, in: Circle()) } .buttonStyle(PressableButtonStyle()) .help("Stop generating") } else { Button(action: send) { Image(systemName: "arrow.up") .font(.system(size: 13, weight: .bold)) .foregroundStyle(.white) .frame(width: 28, height: 28) .background(canSend ? ZyquoTheme.accent : ZyquoTheme.textTertiary, in: Circle()) } .buttonStyle(PressableButtonStyle()) .keyboardShortcut(.return, modifiers: .command) .disabled(!canSend) .help("Send (⌘↩)") } } private var canSend: Bool { modelReady && !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !app.chat.isGenerating } private func send() { guard canSend else { return } let prompt = text.trimmingCharacters(in: .whitespacesAndNewlines) text = "" app.chat.send(prompt: prompt, in: conversation) } // MARK: - Attachments private func attachFiles() { let panel = NSOpenPanel() panel.allowsMultipleSelection = true panel.canChooseDirectories = false panel.allowedContentTypes = [.plainText, .sourceCode, .json, .commaSeparatedText, .text] if panel.runModal() == .OK { for url in panel.urls { inject(fileURL: url) } } } private func handleDrop(_ providers: [NSItemProvider]) -> Bool { var handled = false for provider in providers where provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { handled = true _ = provider.loadObject(ofClass: URL.self) { url, _ in if let url { Task { @MainActor in inject(fileURL: url) } } } } return handled } /// Injects a text file's content into the draft, fenced and labeled. private func inject(fileURL: URL) { let ext = fileURL.pathExtension.lowercased() guard Self.allowedExtensions.contains(ext) || ext.isEmpty else { app.lastError = "\(fileURL.lastPathComponent) is not a supported text file." return } guard let data = try? Data(contentsOf: fileURL), data.count <= 2_000_000, let content = String(data: data, encoding: .utf8) else { app.lastError = "Could not read \(fileURL.lastPathComponent) as UTF-8 text (2 MB max)." return } let fence = ext == "md" || ext.isEmpty ? "" : ext text += (text.isEmpty ? "" : "\n") + "[\(fileURL.lastPathComponent)]\n```\(fence)\n\(content)\n```\n" } } /// Popover for browsing and filling prompt templates. struct PromptTemplatePicker: View { let onUse: (String) -> Void @Environment(AppModel.self) private var app @Environment(\.dismiss) private var dismiss @State private var selected: PromptTemplate? @State private var values: [String: String] = [:] @State private var filter = "" var body: some View { HSplitView { list .frame(width: 230) detail .frame(width: 300) } .frame(height: 340) } private var list: some View { VStack(spacing: 0) { TextField("Filter templates", text: $filter) .textFieldStyle(.roundedBorder) .padding(ZyquoTheme.Spacing.xs) List(selection: $selected) { ForEach(app.promptLibrary.categories, id: \.self) { category in let matching = app.promptLibrary.all.filter { $0.category == category && (filter.isEmpty || $0.name.localizedCaseInsensitiveContains(filter)) } if !matching.isEmpty { Section(category) { ForEach(matching) { template in Text(template.name) .font(ZyquoTheme.body) .tag(template) } } } } } .listStyle(.sidebar) } } @ViewBuilder private var detail: some View { if let template = selected { VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.s) { Text(template.name) .font(ZyquoTheme.bodyEmphasis) ScrollView { Text(template.template) .font(ZyquoTheme.caption) .foregroundStyle(ZyquoTheme.textSecondary) .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxHeight: 110) ForEach(template.variables, id: \.self) { variable in TextField( variable, text: Binding( get: { values[variable] ?? "" }, set: { values[variable] = $0 } ), axis: .vertical ) .textFieldStyle(.roundedBorder) .lineLimit(1...4) } Spacer() Button("Use Template") { onUse(template.render(values: values)) dismiss() } .buttonStyle(.borderedProminent) .tint(ZyquoTheme.accent) .frame(maxWidth: .infinity) } .padding(ZyquoTheme.Spacing.m) } else { Text("Select a template") .font(ZyquoTheme.body) .foregroundStyle(ZyquoTheme.textTertiary) .frame(maxWidth: .infinity, maxHeight: .infinity) } } }