Files
oai-swift/oAI/Views/Main/InputBar.swift

365 lines
14 KiB
Swift

//
// InputBar.swift
// oAI
//
// Message input bar with status indicators
//
import SwiftUI
struct InputBar: View {
@Binding var text: String
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<String> = [
"/help", "/history", "/model", "/clear", "/retry", "/stats", "/config",
"/settings", "/credits", "/list", "/load", "/shortcuts", "/skills",
"/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: { command in
selectCommand(command)
}
)
.frame(width: 400)
.frame(maxHeight: 200)
.transition(.move(edge: .bottom).combined(with: .opacity))
Spacer()
}
.padding(.leading, 96) // Align with input box (status badges + spacing)
}
// 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
}
#if os(macOS)
.onKeyPress(.upArrow) {
// Navigate command dropdown
if showCommandDropdown && selectedSuggestionIndex > 0 {
selectedSuggestionIndex -= 1
return .handled
}
return .ignored
}
.onKeyPress(.downArrow) {
// Navigate command dropdown
if showCommandDropdown {
let count = CommandSuggestionsView.filteredCommands(for: text).count
if selectedSuggestionIndex < count - 1 {
selectedSuggestionIndex += 1
return .handled
}
}
return .ignored
}
.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, phases: .down) { press in
// Shift+Return: always insert newline (let system handle)
if press.modifiers.contains(.shift) {
return .ignored
}
// 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
}
}
// Return (plain or with Cmd): send message
if !text.isEmpty {
onSend()
return .handled
}
// Empty text: do nothing
return .handled
}
#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 if let shortcut = SettingsService.shared.userShortcuts.first(where: { $0.command == command }) {
if shortcut.needsInput {
text = command + " "
} else {
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 }
// Use @<path> format (angle brackets) to safely handle paths with spaces
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 builtInCommands: [(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"),
("/shortcuts", "Manage your prompt shortcuts"),
("/skills", "Manage your agent skills"),
("/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: String)] {
let shortcuts = SettingsService.shared.userShortcuts.map { s in
(s.command, "\(s.description)")
}
return builtInCommands + shortcuts
}
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)
)
}
}
#Preview {
VStack {
Spacer()
InputBar(
text: .constant(""),
isGenerating: false,
mcpStatus: "📁 Files",
onlineMode: true,
onSend: {},
onCancel: {}
)
}
.background(Color.oaiBackground)
}