Added a lot of functionality. Bugfixes and changes

This commit is contained in:
2026-02-15 16:46:06 +01:00
parent 2434e554f8
commit 04c9b8da1e
31 changed files with 6653 additions and 239 deletions

View File

@@ -36,6 +36,12 @@ struct ChatView: View {
.id(message.id)
}
// Processing indicator
if viewModel.isGenerating && viewModel.messages.last?.isStreaming != true {
ProcessingIndicator()
.padding(.horizontal)
}
// Invisible bottom anchor for auto-scroll
Color.clear
.frame(height: 1)
@@ -57,6 +63,40 @@ struct ChatView: View {
}
}
// Auto-continue countdown banner
if viewModel.isAutoContinuing {
HStack(spacing: 12) {
ProgressView()
.progressViewStyle(.circular)
.scaleEffect(0.7)
VStack(alignment: .leading, spacing: 2) {
Text(ThinkingVerbs.random())
.font(.system(size: 13, weight: .medium))
.foregroundColor(.oaiPrimary)
Text("Continuing in \(viewModel.autoContinueCountdown)s")
.font(.system(size: 11))
.foregroundColor(.oaiSecondary)
}
Spacer()
Button("Cancel") {
viewModel.cancelAutoContinue()
}
.buttonStyle(.bordered)
.controlSize(.small)
.tint(.red)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(Color.blue.opacity(0.1))
.overlay(
Rectangle()
.stroke(Color.blue.opacity(0.3), lineWidth: 1)
)
}
// Input bar
InputBar(
text: $viewModel.inputText,
@@ -74,6 +114,41 @@ struct ChatView: View {
}
}
struct ProcessingIndicator: View {
@State private var animating = false
@State private var thinkingText = ThinkingVerbs.random()
var body: some View {
HStack(spacing: 8) {
Text(thinkingText)
.font(.system(size: 14, weight: .medium))
.foregroundColor(.oaiSecondary)
HStack(spacing: 4) {
ForEach(0..<3) { index in
Circle()
.fill(Color.oaiSecondary)
.frame(width: 6, height: 6)
.scaleEffect(animating ? 1.0 : 0.5)
.animation(
.easeInOut(duration: 0.6)
.repeatForever()
.delay(Double(index) * 0.2),
value: animating
)
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color.oaiSecondary.opacity(0.05))
.cornerRadius(8)
.onAppear {
animating = true
}
}
}
#Preview {
ChatView(onModelSelect: {}, onProviderChange: { _ in })
.environment(ChatViewModel())

View File

@@ -41,9 +41,14 @@ struct ContentView: View {
models: chatViewModel.availableModels,
selectedModel: chatViewModel.selectedModel,
onSelect: { model in
let oldModel = chatViewModel.selectedModel
chatViewModel.selectedModel = model
SettingsService.shared.defaultModel = model.id
chatViewModel.showModelSelector = false
// Trigger auto-save on model switch
Task {
await chatViewModel.onModelSwitch(from: oldModel, to: model)
}
}
)
.task {
@@ -88,22 +93,26 @@ struct ContentView: View {
#if os(macOS)
@ToolbarContentBuilder
private var macOSToolbar: some ToolbarContent {
let settings = SettingsService.shared
let showLabels = settings.showToolbarLabels
let scale = iconScale(for: settings.toolbarIconSize)
ToolbarItemGroup(placement: .automatic) {
// New conversation
Button(action: { chatViewModel.newConversation() }) {
Label("New Chat", systemImage: "square.and.pencil")
ToolbarLabel(title: "New Chat", systemImage: "square.and.pencil", showLabels: showLabels, scale: scale)
}
.keyboardShortcut("n", modifiers: .command)
.help("New conversation")
Button(action: { chatViewModel.showConversations = true }) {
Label("Conversations", systemImage: "clock.arrow.circlepath")
ToolbarLabel(title: "Conversations", systemImage: "clock.arrow.circlepath", showLabels: showLabels, scale: scale)
}
.keyboardShortcut("l", modifiers: .command)
.help("Saved conversations (Cmd+L)")
Button(action: { chatViewModel.showHistory = true }) {
Label("History", systemImage: "list.bullet")
ToolbarLabel(title: "History", systemImage: "list.bullet", showLabels: showLabels, scale: scale)
}
.keyboardShortcut("h", modifiers: .command)
.help("Command history (Cmd+H)")
@@ -111,7 +120,7 @@ struct ContentView: View {
Spacer()
Button(action: { chatViewModel.showModelSelector = true }) {
Label("Model", systemImage: "cpu")
ToolbarLabel(title: "Model", systemImage: "cpu", showLabels: showLabels, scale: scale)
}
.keyboardShortcut("m", modifiers: .command)
.help("Select AI model (Cmd+M)")
@@ -121,39 +130,68 @@ struct ContentView: View {
chatViewModel.modelInfoTarget = model
}
}) {
Label("Model Info", systemImage: "info.circle")
ToolbarLabel(title: "Info", systemImage: "info.circle", showLabels: showLabels, scale: scale)
}
.keyboardShortcut("i", modifiers: .command)
.help("Model info (Cmd+I)")
.disabled(chatViewModel.selectedModel == nil)
Button(action: { chatViewModel.showStats = true }) {
Label("Stats", systemImage: "chart.bar")
ToolbarLabel(title: "Stats", systemImage: "chart.bar", showLabels: showLabels, scale: scale)
}
.keyboardShortcut("s", modifiers: .command)
.help("Session statistics (Cmd+S)")
Button(action: { chatViewModel.showCredits = true }) {
Label("Credits", systemImage: "creditcard")
ToolbarLabel(title: "Credits", systemImage: "creditcard", showLabels: showLabels, scale: scale)
}
.help("Check API credits")
Spacer()
Button(action: { chatViewModel.showSettings = true }) {
Label("Settings", systemImage: "gearshape")
ToolbarLabel(title: "Settings", systemImage: "gearshape", showLabels: showLabels, scale: scale)
}
.keyboardShortcut(",", modifiers: .command)
.help("Settings (Cmd+,)")
Button(action: { chatViewModel.showHelp = true }) {
Label("Help", systemImage: "questionmark.circle")
ToolbarLabel(title: "Help", systemImage: "questionmark.circle", showLabels: showLabels, scale: scale)
}
.keyboardShortcut("/", modifiers: .command)
.help("Help & commands (Cmd+/)")
}
}
#endif
// Helper function to convert icon size to imageScale
private func iconScale(for size: Double) -> Image.Scale {
switch size {
case ...18: return .small
case 19...24: return .medium
default: return .large
}
}
}
// Helper view for toolbar labels
struct ToolbarLabel: View {
let title: String
let systemImage: String
let showLabels: Bool
let scale: Image.Scale
var body: some View {
if showLabels {
Label(title, systemImage: systemImage)
.labelStyle(.titleAndIcon)
.imageScale(scale)
} else {
Label(title, systemImage: systemImage)
.labelStyle(.iconOnly)
.imageScale(scale)
}
}
}
#Preview {

View File

@@ -19,22 +19,27 @@ struct FooterView: View {
label: "Messages",
value: "\(stats.messageCount)"
)
FooterItem(
icon: "chart.bar.xaxis",
label: "Tokens",
value: "\(stats.totalTokens) (\(stats.totalInputTokens) in, \(stats.totalOutputTokens) out)"
)
FooterItem(
icon: "dollarsign.circle",
label: "Cost",
value: stats.totalCostDisplay
)
// Git sync status (if enabled)
if SettingsService.shared.syncEnabled && SettingsService.shared.syncAutoSave {
SyncStatusFooter()
}
}
Spacer()
// Shortcuts hint
#if os(macOS)
Text("⌘M Model • ⌘K Clear • ⌘S Stats")
@@ -77,6 +82,72 @@ struct FooterItem: View {
}
}
struct SyncStatusFooter: View {
private let gitSync = GitSyncService.shared
private let guiSize = SettingsService.shared.guiTextSize
@State private var syncText = "Not Synced"
@State private var syncColor: Color = .secondary
var body: some View {
HStack(spacing: 6) {
Image(systemName: "arrow.triangle.2.circlepath")
.font(.system(size: guiSize - 2))
.foregroundColor(syncColor)
Text(syncText)
.font(.system(size: guiSize - 2, weight: .medium))
.foregroundColor(syncColor)
}
.onAppear {
updateSyncStatus()
}
.onChange(of: gitSync.syncStatus.lastSyncTime) {
updateSyncStatus()
}
.onChange(of: gitSync.lastSyncError) {
updateSyncStatus()
}
.onChange(of: gitSync.isSyncing) {
updateSyncStatus()
}
}
private func updateSyncStatus() {
if let error = gitSync.lastSyncError {
syncText = "Error With Sync"
syncColor = .red
} else if gitSync.isSyncing {
syncText = "Syncing..."
syncColor = .orange
} else if let lastSync = gitSync.syncStatus.lastSyncTime {
syncText = "Last Sync: \(timeAgo(lastSync))"
syncColor = .green
} else if gitSync.syncStatus.isCloned {
syncText = "Not Synced"
syncColor = .secondary
} else {
syncText = "Not Configured"
syncColor = .secondary
}
}
private func timeAgo(_ date: Date) -> String {
let seconds = Int(Date().timeIntervalSince(date))
if seconds < 60 {
return "just now"
} else if seconds < 3600 {
let minutes = seconds / 60
return "\(minutes)m ago"
} else if seconds < 86400 {
let hours = seconds / 3600
return "\(hours)h ago"
} else {
let days = seconds / 86400
return "\(days)d ago"
}
}
}
#Preview {
VStack {
Spacer()

View File

@@ -18,6 +18,7 @@ struct HeaderView: View {
let onProviderChange: (Settings.Provider) -> Void
private let settings = SettingsService.shared
private let registry = ProviderRegistry.shared
private let gitSync = GitSyncService.shared
var body: some View {
HStack(spacing: 12) {
@@ -120,10 +121,13 @@ struct HeaderView: View {
if mcpEnabled {
StatusPill(icon: "folder", label: "MCP", color: .blue)
}
if settings.syncEnabled && settings.syncAutoSave {
SyncStatusPill()
}
}
// Divider between status and stats
if onlineMode || mcpEnabled || model?.capabilities.imageGeneration == true {
if onlineMode || mcpEnabled || model?.capabilities.imageGeneration == true || (settings.syncEnabled && settings.syncAutoSave) {
Divider()
.frame(height: 16)
.opacity(0.5)
@@ -186,6 +190,81 @@ struct StatusPill: View {
}
}
struct SyncStatusPill: View {
private let gitSync = GitSyncService.shared
@State private var syncColor: Color = .secondary
@State private var syncLabel: String = "Sync"
@State private var tooltipText: String = ""
var body: some View {
HStack(spacing: 3) {
Circle()
.fill(syncColor)
.frame(width: 6, height: 6)
Text(syncLabel)
.font(.system(size: 10, weight: .medium))
.foregroundColor(.oaiSecondary)
}
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(syncColor.opacity(0.1), in: Capsule())
.help(tooltipText)
.onAppear {
updateState()
}
.onChange(of: gitSync.syncStatus) {
updateState()
}
.onChange(of: gitSync.isSyncing) {
updateState()
}
.onChange(of: gitSync.lastSyncError) {
updateState()
}
}
private func updateState() {
// Determine sync state
if let error = gitSync.lastSyncError {
syncColor = .red
syncLabel = "Error"
tooltipText = "Sync failed: \(error)"
} else if gitSync.isSyncing {
syncColor = .orange
syncLabel = "Syncing"
tooltipText = "Syncing..."
} else if gitSync.syncStatus.isCloned {
syncColor = .green
syncLabel = "Synced"
if let lastSync = gitSync.syncStatus.lastSyncTime {
tooltipText = "Last synced: \(timeAgo(lastSync))"
} else {
tooltipText = "Synced"
}
} else {
syncColor = .secondary
syncLabel = "Sync"
tooltipText = "Sync not configured"
}
}
private func timeAgo(_ date: Date) -> String {
let seconds = Int(Date().timeIntervalSince(date))
if seconds < 60 {
return "just now"
} else if seconds < 3600 {
let minutes = seconds / 60
return "\(minutes)m ago"
} else if seconds < 86400 {
let hours = seconds / 3600
return "\(hours)h ago"
} else {
let days = seconds / 86400
return "\(days)d ago"
}
}
}
#Preview {
VStack {
HeaderView(

View File

@@ -34,15 +34,20 @@ struct InputBar: 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))
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
@@ -127,13 +132,13 @@ struct InputBar: View {
return .handled
}
}
// Plain Return on single line: send
if !text.contains("\n") && !text.isEmpty {
// Return (plain or with Cmd): send message
if !text.isEmpty {
onSend()
return .handled
}
// Otherwise: let system handle (insert newline)
return .ignored
// Empty text: do nothing
return .handled
}
#endif
}
@@ -323,7 +328,6 @@ struct CommandSuggestionsView: View {
RoundedRectangle(cornerRadius: 8)
.stroke(Color.oaiBorder, lineWidth: 1)
)
.padding(.horizontal)
}
}

View File

@@ -0,0 +1,169 @@
//
// SyncStatusIndicator.swift
// oAI
//
// Git sync status indicator (bottom-right corner)
//
import SwiftUI
enum SyncState {
case disabled // Gray - sync not configured or disabled
case synced // Green - successfully synced
case syncing // Yellow - sync in progress
case error(String) // Red - sync failed with error message
var color: Color {
switch self {
case .disabled: return .secondary
case .synced: return .green
case .syncing: return .orange
case .error: return .red
}
}
var icon: String {
switch self {
case .disabled: return "arrow.triangle.2.circlepath"
case .synced: return "checkmark.circle.fill"
case .syncing: return "arrow.triangle.2.circlepath"
case .error: return "exclamationmark.triangle.fill"
}
}
var tooltipText: String {
switch self {
case .disabled:
return "Auto-sync disabled"
case .synced:
if let lastSync = GitSyncService.shared.syncStatus.lastSyncTime {
return "Last synced: \(timeAgo(lastSync))"
} else {
return "Synced"
}
case .syncing:
return "Syncing..."
case .error(let message):
return "Sync failed: \(message)"
}
}
private func timeAgo(_ date: Date) -> String {
let seconds = Int(Date().timeIntervalSince(date))
if seconds < 60 {
return "just now"
} else if seconds < 3600 {
let minutes = seconds / 60
return "\(minutes)m ago"
} else if seconds < 86400 {
let hours = seconds / 3600
return "\(hours)h ago"
} else {
let days = seconds / 86400
return "\(days)d ago"
}
}
}
struct SyncStatusIndicator: View {
@State private var isHovering = false
@State private var syncState: SyncState = .disabled
@State private var showSettings = false
private let settings = SettingsService.shared
private let gitSync = GitSyncService.shared
var body: some View {
VStack {
Spacer()
HStack {
Spacer()
// Floating indicator
ZStack {
// Background circle
Circle()
.fill(Color(nsColor: .windowBackgroundColor))
.shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2)
.frame(width: 40, height: 40)
// Icon
statusIcon
}
.scaleEffect(isHovering ? 1.1 : 1.0)
.animation(.spring(response: 0.3), value: isHovering)
.onHover { hovering in
isHovering = hovering
}
.help(syncState.tooltipText)
.onTapGesture {
if case .error = syncState {
// Open settings on error
showSettings = true
}
}
.sheet(isPresented: $showSettings) {
SettingsView()
}
.padding(.trailing, 16)
.padding(.bottom, 16)
}
}
.onAppear {
updateState()
}
.onChange(of: gitSync.syncStatus) {
updateState()
}
.onChange(of: gitSync.isSyncing) {
updateState()
}
.onChange(of: gitSync.lastSyncError) {
updateState()
}
.onChange(of: settings.syncEnabled) {
updateState()
}
.onChange(of: settings.syncAutoSave) {
updateState()
}
}
private var statusIcon: some View {
Image(systemName: syncState.icon)
.font(.system(size: 18))
.foregroundStyle(syncState.color)
}
private func updateState() {
// Determine current sync state
guard settings.syncEnabled && settings.syncConfigured else {
syncState = .disabled
return
}
guard gitSync.syncStatus.isCloned else {
syncState = .disabled
return
}
// Check for error
if let error = gitSync.lastSyncError {
syncState = .error(error)
return
}
// Check if currently syncing
if gitSync.isSyncing {
syncState = .syncing
return
}
// All good
syncState = .synced
}
}
#Preview {
SyncStatusIndicator()
}