383 lines
15 KiB
Swift
383 lines
15 KiB
Swift
//
|
|
// InputBar.swift
|
|
// oAI
|
|
//
|
|
// Message input bar with status indicators
|
|
//
|
|
// 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 <https://www.gnu.org/licenses/>.
|
|
|
|
|
|
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)
|
|
}
|