// // InputBarView.swift // Zyquo Agent // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // Floating input card docked at the bottom of the conversation column: // auto-growing multiline editor, attach-text-file button (contents are // appended to the prompt), and the violet circular Run button (⌘↩) that // becomes Stop (⌘.) while a run is live. Disabled states explain themselves // (no model / no key). // import SwiftUI import UniformTypeIdentifiers struct InputBarView: View { @Binding var text: String let isRunning: Bool /// Non-nil disables Run and shows why (no key / no model). let disabledReason: String? var onRun: () -> Void var onStop: () -> Void @State private var dropTargeted = false @EnvironmentObject private var appearance: AppearanceStore @FocusState private var editorFocused: Bool private var canRun: Bool { disabledReason == nil && !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } var body: some View { VStack(spacing: ZyquoSpacing.xxs) { if let reason = disabledReason { HStack(spacing: ZyquoSpacing.xxs) { Image(systemName: "exclamationmark.circle") .font(.system(size: 10)) Text(reason) .font(ZyquoFont.caption) } .foregroundStyle(ZyquoColor.warning) .frame(maxWidth: .infinity, alignment: .leading) } HStack(alignment: .bottom, spacing: ZyquoSpacing.xs) { attachButton editor runButton } } .padding(ZyquoSpacing.sm) .background( RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous) .fill(ZyquoColor.surface) .overlay( RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous) .strokeBorder( dropTargeted ? ZyquoColor.accent : ZyquoColor.border, style: StrokeStyle( lineWidth: dropTargeted ? 1.5 : ZyquoMetrics.hairline, dash: dropTargeted ? [6, 4] : [] ) ) ) ) .zyquoSoftShadow() .padding(.horizontal, ZyquoSpacing.sm) .padding(.bottom, ZyquoSpacing.sm) .onDrop(of: [.fileURL], isTargeted: $dropTargeted) { providers in handleDrop(providers) } } // MARK: - Pieces private var editor: some View { ZStack(alignment: .topLeading) { if text.isEmpty { Text("What should I do on your Mac?") .font(ZyquoFont.body(size: appearance.chatFontSize)) .foregroundStyle(ZyquoColor.textTertiary) .padding(.top, 2) .allowsHitTesting(false) } TextEditor(text: $text) .font(ZyquoFont.body(size: appearance.chatFontSize)) .foregroundStyle(ZyquoColor.textPrimary) .scrollContentBackground(.hidden) .frame(minHeight: 22, maxHeight: 200) .fixedSize(horizontal: false, vertical: text.count < 2000) .focused($editorFocused) .onAppear { editorFocused = true } } } private var attachButton: some View { Button { presentFilePicker() } label: { Image(systemName: "paperclip") .font(.system(size: 14)) .foregroundStyle(ZyquoColor.textSecondary) .frame(width: 26, height: 26) } .buttonStyle(.plain) .zyquoHoverHighlight() .help("Attach text files (contents are added to the prompt)") } private var runButton: some View { Button { isRunning ? onStop() : (canRun ? onRun() : ()) } label: { Image(systemName: isRunning ? "stop.fill" : "arrow.up") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(.white) .frame(width: 28, height: 28) .background( Circle().fill( isRunning ? ZyquoColor.danger : (canRun ? ZyquoColor.accent : ZyquoColor.textTertiary) ) ) } .buttonStyle(PressableButtonStyle()) .keyboardShortcut(.return, modifiers: .command) .help(isRunning ? "Stop the run (⌘.)" : "Run (⌘↩)") } // MARK: - Text-file intake private static let textExtensions: Set = [ "txt", "md", "markdown", "csv", "json", "yaml", "yml", "xml", "log", "swift", "py", "js", "ts", "jsx", "tsx", "html", "css", "sh", "zsh", "bash", "sql", "go", "rs", "c", "h", "cpp", "hpp", "m", "mm", "java", "rb", "php", "toml", "ini", "cfg", "tex", ] private func presentFilePicker() { let panel = NSOpenPanel() panel.allowsMultipleSelection = true panel.canChooseDirectories = false panel.begin { response in guard response == .OK else { return } for url in panel.urls { ingest(url: url) } } } private func handleDrop(_ providers: [NSItemProvider]) -> Bool { var handled = false for provider in providers where provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { handled = true provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier) { item, _ in guard let data = item as? Data, let url = URL(dataRepresentation: data, relativeTo: nil) else { return } DispatchQueue.main.async { ingest(url: url) } } } return handled } /// Appends a readable text file's contents to the prompt, fenced and /// labeled with its name. private func ingest(url: URL) { let ext = url.pathExtension.lowercased() guard let data = try? Data(contentsOf: url), Self.textExtensions.contains(ext) || (String(data: data, encoding: .utf8) != nil && data.count < 512_000), let content = String(data: data, encoding: .utf8) else { return } let block = "\n\n--- \(url.lastPathComponent) ---\n\(content)\n--- end \(url.lastPathComponent) ---\n" text += block } }