// // InputBar.swift // oAI // // Message input bar with resizable height and online toggle // // SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2026 Rune Olsen // // This file is part of oAI. // // oAI is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // oAI is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General // Public License for more details. // // You should have received a copy of the GNU Affero General Public // License along with oAI. If not, see . import SwiftUI #if os(macOS) import AppKit #endif struct InputBar: View { @Binding var text: String let isGenerating: Bool let onlineMode: Bool let onSend: () -> Void let onCancel: () -> Void let onToggleOnline: () -> Void private let settings = SettingsService.shared // Resizable input height — persisted to settings @State private var inputHeight: CGFloat = CGFloat(SettingsService.shared.inputBarHeight) @State private var dragStartHeight: CGFloat = CGFloat(SettingsService.shared.inputBarHeight) @State private var showCommandDropdown = false @State private var selectedSuggestionIndex: Int = 0 @State private var isInputFocused: Bool = false private static let minInputHeight: CGFloat = 56 private static let maxInputHeight: CGFloat = 320 /// Commands that execute immediately without additional arguments private static let immediateCommands: Set = [ "/help", "/history", "/model", "/clear", "/retry", "/stats", "/config", "/settings", "/credits", "/list", "/load", "/shortcuts", "/skills", "/jarvis", "/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("/") { HStack { CommandSuggestionsView( searchText: text, selectedIndex: selectedSuggestionIndex, onSelect: selectCommand ) .frame(width: 400) .frame(maxHeight: 200) .transition(.move(edge: .bottom).combined(with: .opacity)) Spacer() } .padding(.leading, 16) } // Drag-to-resize handle dragHandle // Input row HStack(alignment: .bottom, spacing: 12) { // Text input with globe toggle in bottom-left corner ZStack(alignment: .topLeading) { // Placeholder if text.isEmpty { Text("Type a message or / for commands...") .font(.system(size: settings.inputTextSize)) .foregroundColor(.oaiSecondary) .padding(.horizontal, 12) .padding(.top, 10) .allowsHitTesting(false) } // Editor — fills the fixed-height box, bottom area reserved for globe NativeTextEditor( text: $text, font: .systemFont(ofSize: settings.inputTextSize), textColor: NSColor(Color.oaiPrimary), isFocused: isInputFocused, onReturn: { if showCommandDropdown { let suggestions = CommandSuggestionsView.filteredCommands(for: text) if !suggestions.isEmpty && selectedSuggestionIndex < suggestions.count { selectCommand(suggestions[selectedSuggestionIndex].command) return true } } if !text.isEmpty { onSend(); return true } return true }, onEscape: { if showCommandDropdown { showCommandDropdown = false; return true } if isGenerating { onCancel(); return true } return false }, onUpArrow: { if showCommandDropdown && selectedSuggestionIndex > 0 { selectedSuggestionIndex -= 1; return true } return false }, onDownArrow: { if showCommandDropdown { let count = CommandSuggestionsView.filteredCommands(for: text).count if selectedSuggestionIndex < count - 1 { selectedSuggestionIndex += 1; return true } } return false }, onFocusChange: { focused in isInputFocused = focused } ) .frame(maxWidth: .infinity, maxHeight: .infinity) .onChange(of: text) { showCommandDropdown = text.hasPrefix("/") selectedSuggestionIndex = 0 } .padding(.bottom, 30) // Online / offline toggle — bottom-left of the text box VStack { Spacer() HStack { Button(action: onToggleOnline) { Image(systemName: onlineMode ? "globe" : "network.slash") .font(.system(size: 13, weight: .medium)) .foregroundStyle(onlineMode ? Color.green : Color.secondary) .padding(8) } .buttonStyle(.plain) .help(onlineMode ? "Online mode on — click to go offline" : "Offline — click to go online") Spacer() } } } .frame(height: inputHeight) .background(Color.oaiSurface) .cornerRadius(8) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(isInputFocused ? Color.oaiAccent : Color.oaiBorder, lineWidth: 1) ) // Send / stop + attach buttons VStack(spacing: 8) { #if os(macOS) 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 } } // MARK: - Drag handle private var dragHandle: some View { Color.clear .frame(height: 8) .frame(maxWidth: .infinity) .contentShape(Rectangle()) .overlay { Capsule() .fill(Color.secondary.opacity(0.25)) .frame(width: 36, height: 3) } .gesture( DragGesture(minimumDistance: 1) .onChanged { value in let proposed = dragStartHeight - value.translation.height inputHeight = max(Self.minInputHeight, min(Self.maxInputHeight, proposed)) } .onEnded { _ in dragStartHeight = inputHeight settings.inputBarHeight = Double(inputHeight) } ) #if os(macOS) .onHover { hovering in if hovering { NSCursor.resizeUpDown.push() } else { NSCursor.pop() } } #endif } // MARK: - Helpers private func selectCommand(_ command: String) { showCommandDropdown = false if Self.immediateCommands.contains(command) { text = command onSend() } else if let shortcut = SettingsService.shared.userShortcuts.first(where: { $0.command == command }) { text = shortcut.needsInput ? command + " " : command if !shortcut.needsInput { onSend() } } else { 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 attachmentText = panel.urls.map { "@<\($0.path)>" }.joined(separator: " ") text = text.isEmpty ? attachmentText + " " : text + " " + attachmentText } #endif } // MARK: - Command suggestions struct CommandSuggestionsView: View { let searchText: String let selectedIndex: Int let onSelect: (String) -> Void static let builtInCommands: [(command: String, description: LocalizedStringKey)] = [ ("/help", "Show help and available commands"), ("/history", "View command history"), ("/model", "Select AI model"), ("/clear", "Clear chat history"), ("/retry", "Retry last message"), ("/shortcuts", "Manage your prompt shortcuts"), ("/skills", "Manage your agent skills"), ("/jarvis", "Open Jarvis agent manager"), ("/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 allCommands() -> [(command: String, description: LocalizedStringKey)] { SettingsService.shared.userShortcuts.map { s in (s.command, LocalizedStringKey("⚡ \(s.description)")) } + builtInCommands } static func filteredCommands(for searchText: String) -> [(command: String, description: LocalizedStringKey)] { let search = searchText.lowercased() return allCommands().filter { $0.command.contains(search) || search == "/" } } private var suggestions: [(command: String, description: LocalizedStringKey)] { 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)) } } #Preview { VStack { Spacer() InputBar( text: .constant(""), isGenerating: false, onlineMode: true, onSend: {}, onCancel: {}, onToggleOnline: {} ) } .background(Color.oaiBackground) }