New version v2.3.6
This commit is contained in:
@@ -200,7 +200,7 @@ struct ContentView: View {
|
||||
|
||||
// Helper view for toolbar labels
|
||||
struct ToolbarLabel: View {
|
||||
let title: String
|
||||
let title: LocalizedStringKey
|
||||
let systemImage: String
|
||||
let showLabels: Bool
|
||||
let scale: Image.Scale
|
||||
|
||||
@@ -47,19 +47,19 @@ struct FooterView: View {
|
||||
HStack(spacing: 16) {
|
||||
FooterItem(
|
||||
icon: "message",
|
||||
label: "Messages",
|
||||
label: "Messages:",
|
||||
value: "\(stats.messageCount)"
|
||||
)
|
||||
|
||||
FooterItem(
|
||||
icon: "chart.bar.xaxis",
|
||||
label: "Tokens",
|
||||
label: "Tokens:",
|
||||
value: "\(stats.totalTokens) (\(stats.totalInputTokens) in, \(stats.totalOutputTokens) out)"
|
||||
)
|
||||
|
||||
FooterItem(
|
||||
icon: "dollarsign.circle",
|
||||
label: "Cost",
|
||||
label: "Cost:",
|
||||
value: stats.totalCostDisplay
|
||||
)
|
||||
|
||||
@@ -134,7 +134,7 @@ struct SaveIndicator: View {
|
||||
return .secondary
|
||||
}
|
||||
|
||||
private var tooltip: String {
|
||||
private var tooltip: LocalizedStringKey {
|
||||
if isModified { return "Click to re-save \"\(conversationName ?? "")\"" }
|
||||
if isSaved { return "Saved — no changes" }
|
||||
return "Not saved — use /save <name>"
|
||||
@@ -162,7 +162,7 @@ struct SaveIndicator: View {
|
||||
|
||||
struct FooterItem: View {
|
||||
let icon: String
|
||||
let label: String
|
||||
let label: LocalizedStringKey
|
||||
let value: String
|
||||
private let guiSize = SettingsService.shared.guiTextSize
|
||||
|
||||
@@ -172,7 +172,7 @@ struct FooterItem: View {
|
||||
.font(.system(size: guiSize - 2))
|
||||
.foregroundColor(.oaiSecondary)
|
||||
|
||||
Text(label + ":")
|
||||
Text(label)
|
||||
.font(.system(size: guiSize - 2))
|
||||
.foregroundColor(.oaiSecondary)
|
||||
|
||||
@@ -187,9 +187,24 @@ struct SyncStatusFooter: View {
|
||||
private let gitSync = GitSyncService.shared
|
||||
private let settings = SettingsService.shared
|
||||
private let guiSize = SettingsService.shared.guiTextSize
|
||||
@State private var syncText = "Not Synced"
|
||||
|
||||
private enum SyncState { case off, notInitialized, ready, syncing, error, synced(Date) }
|
||||
@State private var syncState: SyncState = .off
|
||||
@State private var syncColor: Color = .secondary
|
||||
|
||||
private var syncText: LocalizedStringKey {
|
||||
switch syncState {
|
||||
case .off: return "Sync: Off"
|
||||
case .notInitialized: return "Sync: Not Initialized"
|
||||
case .ready: return "Sync: Ready"
|
||||
case .syncing: return "Syncing..."
|
||||
case .error: return "Sync Error"
|
||||
case .synced(let date):
|
||||
let rel = RelativeDateTimeFormatter().localizedString(for: date, relativeTo: .now)
|
||||
return "Last Sync: \(rel)"
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
@@ -200,61 +215,27 @@ struct SyncStatusFooter: View {
|
||||
.font(.system(size: guiSize - 2, weight: .medium))
|
||||
.foregroundColor(syncColor)
|
||||
}
|
||||
.onAppear {
|
||||
updateSyncStatus()
|
||||
}
|
||||
.onChange(of: gitSync.syncStatus.lastSyncTime) {
|
||||
updateSyncStatus()
|
||||
}
|
||||
.onChange(of: gitSync.syncStatus.isCloned) {
|
||||
updateSyncStatus()
|
||||
}
|
||||
.onChange(of: gitSync.lastSyncError) {
|
||||
updateSyncStatus()
|
||||
}
|
||||
.onChange(of: gitSync.isSyncing) {
|
||||
updateSyncStatus()
|
||||
}
|
||||
.onChange(of: settings.syncConfigured) {
|
||||
updateSyncStatus()
|
||||
}
|
||||
.onAppear { updateSyncStatus() }
|
||||
.onChange(of: gitSync.syncStatus.lastSyncTime) { updateSyncStatus() }
|
||||
.onChange(of: gitSync.syncStatus.isCloned) { updateSyncStatus() }
|
||||
.onChange(of: gitSync.lastSyncError) { updateSyncStatus() }
|
||||
.onChange(of: gitSync.isSyncing) { updateSyncStatus() }
|
||||
.onChange(of: settings.syncConfigured) { updateSyncStatus() }
|
||||
}
|
||||
|
||||
private func updateSyncStatus() {
|
||||
if gitSync.lastSyncError != nil {
|
||||
syncText = "Sync Error"
|
||||
syncColor = .red
|
||||
syncState = .error; syncColor = .red
|
||||
} else if gitSync.isSyncing {
|
||||
syncText = "Syncing..."
|
||||
syncColor = .orange
|
||||
syncState = .syncing; syncColor = .orange
|
||||
} else if let lastSync = gitSync.syncStatus.lastSyncTime {
|
||||
syncText = "Last Sync: \(timeAgo(lastSync))"
|
||||
syncColor = .green
|
||||
syncState = .synced(lastSync); syncColor = .green
|
||||
} else if gitSync.syncStatus.isCloned {
|
||||
syncText = "Sync: Ready"
|
||||
syncColor = .secondary
|
||||
syncState = .ready; syncColor = .secondary
|
||||
} else if settings.syncConfigured {
|
||||
syncText = "Sync: Not Initialized"
|
||||
syncColor = .orange
|
||||
syncState = .notInitialized; syncColor = .orange
|
||||
} else {
|
||||
syncText = "Sync: Off"
|
||||
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"
|
||||
syncState = .off; syncColor = .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ struct StatItem: View {
|
||||
|
||||
struct StatusPill: View {
|
||||
let icon: String
|
||||
let label: String
|
||||
let label: LocalizedStringKey
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
@@ -210,9 +210,31 @@ struct StatusPill: View {
|
||||
|
||||
struct SyncStatusPill: View {
|
||||
private let gitSync = GitSyncService.shared
|
||||
|
||||
private enum SyncPillState { case notConfigured, syncing, error(String), synced(Date?) }
|
||||
@State private var syncState: SyncPillState = .notConfigured
|
||||
@State private var syncColor: Color = .secondary
|
||||
@State private var syncLabel: String = "Sync"
|
||||
@State private var tooltipText: String = ""
|
||||
|
||||
private var syncLabel: LocalizedStringKey {
|
||||
switch syncState {
|
||||
case .notConfigured: return "Sync"
|
||||
case .syncing: return "Syncing"
|
||||
case .error: return "Error"
|
||||
case .synced: return "Synced"
|
||||
}
|
||||
}
|
||||
|
||||
private var tooltipText: LocalizedStringKey {
|
||||
switch syncState {
|
||||
case .notConfigured: return "Sync not configured"
|
||||
case .syncing: return "Syncing..."
|
||||
case .error(let msg): return "Sync failed: \(msg)"
|
||||
case .synced(let date):
|
||||
guard let date else { return "Synced" }
|
||||
let rel = RelativeDateTimeFormatter().localizedString(for: date, relativeTo: .now)
|
||||
return "Last synced: \(rel)"
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 3) {
|
||||
@@ -227,58 +249,21 @@ struct SyncStatusPill: View {
|
||||
.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()
|
||||
}
|
||||
.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)"
|
||||
syncState = .error(error); syncColor = .red
|
||||
} else if gitSync.isSyncing {
|
||||
syncColor = .orange
|
||||
syncLabel = "Syncing"
|
||||
tooltipText = "Syncing..."
|
||||
syncState = .syncing; syncColor = .orange
|
||||
} else if gitSync.syncStatus.isCloned {
|
||||
syncColor = .green
|
||||
syncLabel = "Synced"
|
||||
if let lastSync = gitSync.syncStatus.lastSyncTime {
|
||||
tooltipText = "Last synced: \(timeAgo(lastSync))"
|
||||
} else {
|
||||
tooltipText = "Synced"
|
||||
}
|
||||
syncState = .synced(gitSync.syncStatus.lastSyncTime); syncColor = .green
|
||||
} 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"
|
||||
syncState = .notConfigured; syncColor = .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,7 +271,7 @@ struct CommandSuggestionsView: View {
|
||||
let selectedIndex: Int
|
||||
let onSelect: (String) -> Void
|
||||
|
||||
static let builtInCommands: [(command: String, description: String)] = [
|
||||
static let builtInCommands: [(command: String, description: LocalizedStringKey)] = [
|
||||
("/help", "Show help and available commands"),
|
||||
("/history", "View command history"),
|
||||
("/model", "Select AI model"),
|
||||
@@ -302,19 +302,19 @@ struct CommandSuggestionsView: View {
|
||||
("/mcp write off", "Disable MCP write permissions"),
|
||||
]
|
||||
|
||||
static func allCommands() -> [(command: String, description: String)] {
|
||||
static func allCommands() -> [(command: String, description: LocalizedStringKey)] {
|
||||
let shortcuts = SettingsService.shared.userShortcuts.map { s in
|
||||
(s.command, "⚡ \(s.description)")
|
||||
(s.command, LocalizedStringKey("⚡ \(s.description)"))
|
||||
}
|
||||
return builtInCommands + shortcuts
|
||||
}
|
||||
|
||||
static func filteredCommands(for searchText: String) -> [(command: String, description: String)] {
|
||||
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: String)] {
|
||||
private var suggestions: [(command: String, description: LocalizedStringKey)] {
|
||||
Self.filteredCommands(for: searchText)
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ struct MessageRow: View {
|
||||
private let settings = SettingsService.shared
|
||||
|
||||
@State private var isExpanded = false
|
||||
@State private var isThinkingExpanded = true // auto-expand while streaming, collapse after
|
||||
|
||||
#if os(macOS)
|
||||
@State private var isHovering = false
|
||||
@@ -107,6 +108,27 @@ struct MessageRow: View {
|
||||
.foregroundColor(.oaiSecondary)
|
||||
}
|
||||
|
||||
// Thinking / reasoning block (collapsible)
|
||||
if let thinking = message.thinkingContent, !thinking.isEmpty {
|
||||
thinkingBlock(thinking)
|
||||
.onChange(of: message.content) { _, newContent in
|
||||
// Auto-collapse when response content starts arriving
|
||||
if !newContent.isEmpty && isThinkingExpanded && !message.isStreaming {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isThinkingExpanded = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: message.isStreaming) { _, streaming in
|
||||
// Collapse when streaming finishes
|
||||
if !streaming && !message.content.isEmpty {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
isThinkingExpanded = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Content
|
||||
if !message.content.isEmpty {
|
||||
messageContent
|
||||
@@ -186,6 +208,64 @@ struct MessageRow: View {
|
||||
// Close standardMessageLayout - the above closing braces close it
|
||||
// The body: some View now handles the split between compact and standard
|
||||
|
||||
// MARK: - Thinking Block
|
||||
|
||||
@ViewBuilder
|
||||
private func thinkingBlock(_ thinking: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Header button
|
||||
Button(action: {
|
||||
withAnimation(.easeInOut(duration: 0.18)) { isThinkingExpanded.toggle() }
|
||||
}) {
|
||||
HStack(spacing: 6) {
|
||||
if message.isStreaming && message.content.isEmpty {
|
||||
ProgressView()
|
||||
.scaleEffect(0.5)
|
||||
.frame(width: 12, height: 12)
|
||||
Text("Thinking…")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Image(systemName: "brain")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Reasoning")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: isThinkingExpanded ? "chevron.up" : "chevron.down")
|
||||
.font(.system(size: 9, weight: .medium))
|
||||
.foregroundStyle(.secondary.opacity(0.5))
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if isThinkingExpanded {
|
||||
Divider()
|
||||
.padding(.horizontal, 6)
|
||||
ScrollView {
|
||||
Text(thinking)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
}
|
||||
.frame(maxHeight: 220)
|
||||
}
|
||||
}
|
||||
.background(Color.secondary.opacity(0.07))
|
||||
.cornerRadius(6)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.strokeBorder(Color.secondary.opacity(0.15), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Compact System Message
|
||||
|
||||
@ViewBuilder
|
||||
|
||||
Reference in New Issue
Block a user