// // InputBar.swift // oAI // // Message input bar with status indicators // import SwiftUI struct InputBar: View { @Binding var text: String @Binding var commandHistory: [String] @Binding var historyIndex: Int let isGenerating: Bool let mcpStatus: String? let onlineMode: Bool let onSend: () -> Void let onCancel: () -> Void private let settings = SettingsService.shared @State private var showCommandDropdown = false @State private var selectedSuggestionIndex: Int = 0 @FocusState private var isInputFocused: Bool /// Commands that execute immediately without additional arguments private static let immediateCommands: Set = [ "/help", "/history", "/model", "/clear", "/retry", "/stats", "/config", "/settings", "/credits", "/list", "/load", "/memory on", "/memory off", "/online on", "/online off", "/mcp on", "/mcp off", "/mcp status", "/mcp list", "/mcp write on", "/mcp write off", "/export md", "/export json", ] var body: some View { VStack(spacing: 0) { // Command dropdown (if showing) if showCommandDropdown && text.hasPrefix("/") { CommandSuggestionsView( searchText: text, selectedIndex: selectedSuggestionIndex, onSelect: { command in selectCommand(command) } ) .frame(maxHeight: 200) .transition(.move(edge: .bottom).combined(with: .opacity)) } // Input area HStack(alignment: .bottom, spacing: 12) { // Status indicators HStack(spacing: 6) { if let mcp = mcpStatus { StatusBadge(text: mcp, color: .blue) } if onlineMode { StatusBadge(text: "🌐", color: .green) } } .frame(width: 80, alignment: .leading) // Text input ZStack(alignment: .topLeading) { if text.isEmpty { Text("Type a message or / for commands...") .font(.system(size: settings.inputTextSize)) .foregroundColor(.oaiSecondary) .padding(.horizontal, 12) .padding(.vertical, 10) } TextEditor(text: $text) .font(.system(size: settings.inputTextSize)) .foregroundColor(.oaiPrimary) .scrollContentBackground(.hidden) .frame(minHeight: 44, maxHeight: 120) .padding(.horizontal, 8) .padding(.vertical, 6) .focused($isInputFocused) .onChange(of: text) { showCommandDropdown = text.hasPrefix("/") selectedSuggestionIndex = 0 // Reset history index when user types historyIndex = commandHistory.count } #if os(macOS) .onKeyPress(.upArrow) { // If command dropdown is showing, navigate dropdown if showCommandDropdown { if selectedSuggestionIndex > 0 { selectedSuggestionIndex -= 1 } return .handled } // Otherwise, navigate command history if historyIndex > 0 { historyIndex -= 1 text = commandHistory[historyIndex] } return .handled } .onKeyPress(.downArrow) { // If command dropdown is showing, navigate dropdown if showCommandDropdown { let count = CommandSuggestionsView.filteredCommands(for: text).count if selectedSuggestionIndex < count - 1 { selectedSuggestionIndex += 1 } return .handled } // Otherwise, navigate command history if historyIndex < commandHistory.count - 1 { historyIndex += 1 text = commandHistory[historyIndex] } else if historyIndex == commandHistory.count - 1 { // At the end of history, clear text and move to "new" position historyIndex = commandHistory.count text = "" } return .handled } .onKeyPress(.escape) { // If command dropdown is showing, close it if showCommandDropdown { showCommandDropdown = false return .handled } // If model is generating, cancel it if isGenerating { onCancel() return .handled } return .ignored } .onKeyPress(.return) { // If command dropdown is showing, select the highlighted command if showCommandDropdown { let suggestions = CommandSuggestionsView.filteredCommands(for: text) if !suggestions.isEmpty && selectedSuggestionIndex < suggestions.count { selectCommand(suggestions[selectedSuggestionIndex].command) return .handled } } // Plain Return on single line: send if !text.contains("\n") && !text.isEmpty { onSend() return .handled } // Otherwise: let system handle (insert newline) return .ignored } #endif } .background(Color.oaiSurface) .cornerRadius(8) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(isInputFocused ? Color.oaiAccent : Color.oaiBorder, lineWidth: 1) ) // Action buttons VStack(spacing: 8) { #if os(macOS) // File attach button Button(action: pickFile) { Image(systemName: "paperclip") .font(.title2) .foregroundColor(.oaiPrimary.opacity(0.7)) } .buttonStyle(.plain) .help("Attach file") #endif if isGenerating { Button(action: onCancel) { Image(systemName: "stop.circle.fill") .font(.title) .foregroundColor(.oaiError.opacity(0.9)) } .buttonStyle(.plain) .help("Stop generation") } else { Button(action: onSend) { Image(systemName: "arrow.up.circle.fill") .font(.title) .foregroundColor(text.isEmpty ? .oaiPrimary.opacity(0.4) : .oaiAccent.opacity(0.9)) } .buttonStyle(.plain) .disabled(text.isEmpty) .help("Send message") } } .frame(width: 40) } .padding() .background(Color.oaiSurface) } .onAppear { isInputFocused = true } } private func selectCommand(_ command: String) { showCommandDropdown = false if Self.immediateCommands.contains(command) { // Execute immediately text = command onSend() } else { // Put in input for user to complete text = command + " " } } #if os(macOS) private func pickFile() { let panel = NSOpenPanel() panel.allowsMultipleSelection = true panel.canChooseDirectories = false panel.canChooseFiles = true panel.message = "Select files to attach" guard panel.runModal() == .OK else { return } let paths = panel.urls.map { $0.path } let attachmentText = paths.map { "@\($0)" }.joined(separator: " ") if text.isEmpty { text = attachmentText + " " } else { text += " " + attachmentText } } #endif } struct StatusBadge: View { let text: String let color: Color var body: some View { Text(text) .font(.caption) .foregroundColor(color) .padding(.horizontal, 6) .padding(.vertical, 3) .background(color.opacity(0.15)) .cornerRadius(4) } } struct CommandSuggestionsView: View { let searchText: String let selectedIndex: Int let onSelect: (String) -> Void static let allCommands: [(command: String, description: String)] = [ ("/help", "Show help and available commands"), ("/history", "View command history"), ("/model", "Select AI model"), ("/clear", "Clear chat history"), ("/retry", "Retry last message"), ("/memory on", "Enable conversation memory"), ("/memory off", "Disable conversation memory"), ("/online on", "Enable web search"), ("/online off", "Disable web search"), ("/stats", "Show session statistics"), ("/config", "Open settings"), ("/provider", "Switch AI provider"), ("/save", "Save conversation"), ("/load", "Load conversation"), ("/list", "List saved conversations"), ("/export md", "Export as Markdown"), ("/export json", "Export as JSON"), ("/info", "Show model information"), ("/credits", "Check account credits"), ("/mcp on", "Enable MCP (file access)"), ("/mcp off", "Disable MCP"), ("/mcp status", "Show MCP status"), ("/mcp list", "List MCP folders"), ("/mcp add", "Add folder for MCP"), ("/mcp write on", "Enable MCP write permissions"), ("/mcp write off", "Disable MCP write permissions"), ] static func filteredCommands(for searchText: String) -> [(command: String, description: String)] { let search = searchText.lowercased() return allCommands.filter { $0.command.contains(search) || search == "/" } } private var suggestions: [(command: String, description: String)] { Self.filteredCommands(for: searchText) } var body: some View { ScrollViewReader { proxy in ScrollView { VStack(spacing: 0) { ForEach(Array(suggestions.enumerated()), id: \.element.command) { index, suggestion in Button(action: { onSelect(suggestion.command) }) { HStack { VStack(alignment: .leading, spacing: 2) { Text(suggestion.command) .font(.body) .foregroundColor(.oaiPrimary) Text(suggestion.description) .font(.caption) .foregroundColor(.oaiSecondary) } Spacer() } .padding(.horizontal, 12) .padding(.vertical, 8) .background(index == selectedIndex ? Color.oaiAccent.opacity(0.2) : Color.oaiSurface) } .buttonStyle(.plain) .id(suggestion.command) if index < suggestions.count - 1 { Divider() .background(Color.oaiBorder) } } } } .onChange(of: selectedIndex) { if selectedIndex < suggestions.count { withAnimation { proxy.scrollTo(suggestions[selectedIndex].command, anchor: .center) } } } } .background(Color.oaiSurface) .cornerRadius(8) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(Color.oaiBorder, lineWidth: 1) ) .padding(.horizontal) } } #Preview { VStack { Spacer() InputBar( text: .constant(""), commandHistory: .constant([]), historyIndex: .constant(0), isGenerating: false, mcpStatus: "📁 Files", onlineMode: true, onSend: {}, onCancel: {} ) } .background(Color.oaiBackground) }