First public release v2.3.1
This commit is contained in:
@@ -46,6 +46,9 @@ struct Message: Identifiable, Codable, Equatable {
|
||||
// Streaming state (not persisted)
|
||||
var isStreaming: Bool = false
|
||||
|
||||
// Star state (not persisted in messages table — tracked via message_metadata for saved conversations)
|
||||
var isStarred: Bool = false
|
||||
|
||||
// Generated images from image-output models (base64-decoded PNG/JPEG data)
|
||||
var generatedImages: [Data]? = nil
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ struct SessionStats {
|
||||
var totalOutputTokens: Int = 0
|
||||
var totalCost: Double = 0.0
|
||||
var messageCount: Int = 0
|
||||
var hasCostData: Bool = false
|
||||
|
||||
var totalTokens: Int {
|
||||
totalInputTokens + totalOutputTokens
|
||||
@@ -46,14 +47,18 @@ struct SessionStats {
|
||||
}
|
||||
|
||||
var totalCostDisplay: String {
|
||||
String(format: "$%.4f", totalCost)
|
||||
hasCostData ? String(format: "$%.4f", totalCost) : "N/A"
|
||||
}
|
||||
|
||||
|
||||
var averageCostPerMessage: Double {
|
||||
guard messageCount > 0 else { return 0.0 }
|
||||
return totalCost / Double(messageCount)
|
||||
}
|
||||
|
||||
|
||||
var averageCostDisplay: String {
|
||||
hasCostData ? String(format: "$%.4f", averageCostPerMessage) : "N/A"
|
||||
}
|
||||
|
||||
mutating func addMessage(inputTokens: Int?, outputTokens: Int?, cost: Double?) {
|
||||
if let input = inputTokens {
|
||||
totalInputTokens += input
|
||||
@@ -63,14 +68,16 @@ struct SessionStats {
|
||||
}
|
||||
if let messageCost = cost {
|
||||
totalCost += messageCost
|
||||
hasCostData = true
|
||||
}
|
||||
messageCount += 1
|
||||
}
|
||||
|
||||
|
||||
mutating func reset() {
|
||||
totalInputTokens = 0
|
||||
totalOutputTokens = 0
|
||||
totalCost = 0.0
|
||||
messageCount = 0
|
||||
hasCostData = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,8 +70,9 @@ class AnthropicProvider: AIProvider {
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Models (hardcoded — Anthropic has no public models list endpoint)
|
||||
// MARK: - Models
|
||||
|
||||
/// Local metadata used to enrich API results (pricing, context length) and as offline fallback.
|
||||
private static let knownModels: [ModelInfo] = [
|
||||
ModelInfo(
|
||||
id: "claude-opus-4-6",
|
||||
@@ -123,12 +124,64 @@ class AnthropicProvider: AIProvider {
|
||||
),
|
||||
]
|
||||
|
||||
/// Fetch live model list from GET /v1/models, enriched with local pricing/context metadata.
|
||||
/// Falls back to knownModels if the request fails (no key, offline, etc.).
|
||||
func listModels() async throws -> [ModelInfo] {
|
||||
return Self.knownModels
|
||||
guard let url = URL(string: "\(baseURL)/models") else { return Self.knownModels }
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
do {
|
||||
try await applyAuth(to: &request)
|
||||
} catch {
|
||||
Log.api.warning("Anthropic listModels: auth failed, using fallback — \(error.localizedDescription)")
|
||||
return Self.knownModels
|
||||
}
|
||||
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
|
||||
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
Log.api.warning("Anthropic listModels: HTTP \(code), using fallback")
|
||||
return Self.knownModels
|
||||
}
|
||||
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let items = json["data"] as? [[String: Any]] else {
|
||||
Log.api.warning("Anthropic listModels: unexpected JSON shape, using fallback")
|
||||
return Self.knownModels
|
||||
}
|
||||
|
||||
let enrichment = Dictionary(uniqueKeysWithValues: Self.knownModels.map { ($0.id, $0) })
|
||||
|
||||
let models: [ModelInfo] = items.compactMap { item in
|
||||
guard let id = item["id"] as? String,
|
||||
id.hasPrefix("claude-") else { return nil }
|
||||
let displayName = item["display_name"] as? String ?? id
|
||||
if let known = enrichment[id] { return known }
|
||||
// Unknown new model — use display name and sensible defaults
|
||||
return ModelInfo(
|
||||
id: id,
|
||||
name: displayName,
|
||||
description: item["description"] as? String ?? "",
|
||||
contextLength: 200_000,
|
||||
pricing: .init(prompt: 0, completion: 0),
|
||||
capabilities: .init(vision: true, tools: true, online: false)
|
||||
)
|
||||
}
|
||||
|
||||
Log.api.info("Anthropic listModels: fetched \(models.count) model(s) from API")
|
||||
return models.isEmpty ? Self.knownModels : models
|
||||
|
||||
} catch {
|
||||
Log.api.warning("Anthropic listModels: network error (\(error.localizedDescription)), using fallback")
|
||||
return Self.knownModels
|
||||
}
|
||||
}
|
||||
|
||||
func getModel(_ id: String) async throws -> ModelInfo? {
|
||||
return Self.knownModels.first { $0.id == id }
|
||||
let models = try await listModels()
|
||||
return models.first { $0.id == id }
|
||||
}
|
||||
|
||||
// MARK: - Chat Completion
|
||||
@@ -212,7 +265,7 @@ class AnthropicProvider: AIProvider {
|
||||
var body: [String: Any] = [
|
||||
"model": model,
|
||||
"messages": conversationMessages,
|
||||
"max_tokens": maxTokens ?? 4096,
|
||||
"max_tokens": maxTokens ?? 16000,
|
||||
"stream": false
|
||||
]
|
||||
if let systemText = systemText {
|
||||
@@ -282,6 +335,7 @@ class AnthropicProvider: AIProvider {
|
||||
|
||||
var currentId = ""
|
||||
var currentModel = request.model
|
||||
var inputTokens = 0
|
||||
|
||||
for try await line in bytes.lines {
|
||||
// Anthropic SSE: "event: ..." and "data: {...}"
|
||||
@@ -299,6 +353,9 @@ class AnthropicProvider: AIProvider {
|
||||
if let message = event["message"] as? [String: Any] {
|
||||
currentId = message["id"] as? String ?? ""
|
||||
currentModel = message["model"] as? String ?? request.model
|
||||
if let usageDict = message["usage"] as? [String: Any] {
|
||||
inputTokens = usageDict["input_tokens"] as? Int ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
case "content_block_delta":
|
||||
@@ -321,7 +378,7 @@ class AnthropicProvider: AIProvider {
|
||||
var usage: ChatResponse.Usage? = nil
|
||||
if let usageDict = event["usage"] as? [String: Any] {
|
||||
let outputTokens = usageDict["output_tokens"] as? Int ?? 0
|
||||
usage = ChatResponse.Usage(promptTokens: 0, completionTokens: outputTokens, totalTokens: outputTokens)
|
||||
usage = ChatResponse.Usage(promptTokens: inputTokens, completionTokens: outputTokens, totalTokens: inputTokens + outputTokens)
|
||||
}
|
||||
continuation.yield(StreamChunk(
|
||||
id: currentId,
|
||||
@@ -431,7 +488,7 @@ class AnthropicProvider: AIProvider {
|
||||
var body: [String: Any] = [
|
||||
"model": request.model,
|
||||
"messages": apiMessages,
|
||||
"max_tokens": request.maxTokens ?? 4096,
|
||||
"max_tokens": request.maxTokens ?? 16000,
|
||||
"stream": stream
|
||||
]
|
||||
|
||||
|
||||
@@ -418,6 +418,40 @@ final class DatabaseService: Sendable {
|
||||
)
|
||||
}
|
||||
|
||||
/// Update an existing conversation in-place: rename it, replace all its messages.
|
||||
nonisolated func updateConversation(id: UUID, name: String, messages: [Message], primaryModel: String?) throws {
|
||||
let nowString = isoFormatter.string(from: Date())
|
||||
|
||||
let messageRecords = messages.enumerated().compactMap { index, msg -> MessageRecord? in
|
||||
guard msg.role != .system else { return nil }
|
||||
return MessageRecord(
|
||||
id: UUID().uuidString,
|
||||
conversationId: id.uuidString,
|
||||
role: msg.role.rawValue,
|
||||
content: msg.content,
|
||||
tokens: msg.tokens,
|
||||
cost: msg.cost,
|
||||
timestamp: isoFormatter.string(from: msg.timestamp),
|
||||
sortOrder: index,
|
||||
modelId: msg.modelId
|
||||
)
|
||||
}
|
||||
|
||||
try dbQueue.write { db in
|
||||
try db.execute(
|
||||
sql: "UPDATE conversations SET name = ?, updatedAt = ?, primaryModel = ? WHERE id = ?",
|
||||
arguments: [name, nowString, primaryModel, id.uuidString]
|
||||
)
|
||||
try db.execute(
|
||||
sql: "DELETE FROM messages WHERE conversationId = ?",
|
||||
arguments: [id.uuidString]
|
||||
)
|
||||
for record in messageRecords {
|
||||
try record.insert(db)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func loadConversation(id: UUID) throws -> (Conversation, [Message])? {
|
||||
try dbQueue.read { db in
|
||||
guard let convRecord = try ConversationRecord.fetchOne(db, key: id.uuidString) else {
|
||||
@@ -435,7 +469,8 @@ final class DatabaseService: Sendable {
|
||||
let timestamp = self.isoFormatter.date(from: record.timestamp)
|
||||
else { return nil }
|
||||
|
||||
return Message(
|
||||
let starred = (try? MessageMetadataRecord.fetchOne(db, key: record.id))?.user_starred == 1
|
||||
var message = Message(
|
||||
id: msgId,
|
||||
role: role,
|
||||
content: record.content,
|
||||
@@ -444,6 +479,8 @@ final class DatabaseService: Sendable {
|
||||
timestamp: timestamp,
|
||||
modelId: record.modelId
|
||||
)
|
||||
message.isStarred = starred
|
||||
return message
|
||||
}
|
||||
|
||||
guard let convId = UUID(uuidString: convRecord.id),
|
||||
|
||||
@@ -147,7 +147,7 @@ class MCPService {
|
||||
if canWriteFiles {
|
||||
tools.append(makeTool(
|
||||
name: "write_file",
|
||||
description: "Create or overwrite a file with the given content. Parent directories are created automatically.",
|
||||
description: "Create or overwrite a file with the given content. Parent directories are created automatically. WARNING: For existing files larger than ~200 lines, use edit_file instead — writing very large files in a single call may exceed output token limits and fail.",
|
||||
properties: [
|
||||
"file_path": prop("string", "The absolute path to the file to write"),
|
||||
"content": prop("string", "The text content to write to the file")
|
||||
|
||||
@@ -57,6 +57,16 @@ class ChatViewModel {
|
||||
var commandHistory: [String] = []
|
||||
var historyIndex: Int = 0
|
||||
var isAutoContinuing: Bool = false
|
||||
|
||||
// Save tracking
|
||||
var currentConversationId: UUID? = nil
|
||||
var currentConversationName: String? = nil
|
||||
private var savedMessageCount: Int = 0
|
||||
|
||||
var hasUnsavedChanges: Bool {
|
||||
let chatCount = messages.filter { $0.role != .system }.count
|
||||
return chatCount > 0 && chatCount != savedMessageCount
|
||||
}
|
||||
var autoContinueCountdown: Int = 0
|
||||
|
||||
// MARK: - Auto-Save Tracking
|
||||
@@ -126,6 +136,13 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
- If approaching limits, tell the user you need to continue in phases
|
||||
- Don't use tools to "verify" your work unless asked - trust your edits
|
||||
|
||||
**FILE EDITING — CRITICAL:**
|
||||
- NEVER use write_file to rewrite a large existing file (>200 lines or >8KB)
|
||||
- Large write_file calls exceed output token limits and will fail — the content will be truncated
|
||||
- For existing files: ALWAYS use edit_file to replace specific sections
|
||||
- Use write_file ONLY for new files or very small files (<100 lines)
|
||||
- If you need to make many changes to a large file, use multiple edit_file calls
|
||||
|
||||
**METHODOLOGY:**
|
||||
1. Use the tool to gather information (if needed)
|
||||
2. Use the tool to make changes (if needed)
|
||||
@@ -218,6 +235,9 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
messages = []
|
||||
sessionStats = SessionStats()
|
||||
inputText = ""
|
||||
currentConversationId = nil
|
||||
currentConversationName = nil
|
||||
savedMessageCount = 0
|
||||
}
|
||||
|
||||
/// Re-sync local state from SettingsService (called when Settings sheet dismisses)
|
||||
@@ -493,20 +513,81 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
}
|
||||
|
||||
func toggleMessageStar(messageId: UUID) {
|
||||
// Get current starred status
|
||||
let isStarred = (try? DatabaseService.shared.getMessageMetadata(messageId: messageId)?.user_starred == 1) ?? false
|
||||
// Update in-memory state first (works for both saved and unsaved messages)
|
||||
if let index = messages.firstIndex(where: { $0.id == messageId }) {
|
||||
messages[index].isStarred.toggle()
|
||||
let newStarred = messages[index].isStarred
|
||||
// Persist to DB if the message exists there (saved conversations only)
|
||||
do {
|
||||
try DatabaseService.shared.setMessageStarred(messageId: messageId, starred: newStarred)
|
||||
Log.ui.info("Message \(messageId) starred: \(newStarred)")
|
||||
} catch {
|
||||
// FK error is expected for unsaved messages — in-memory state is already updated
|
||||
Log.ui.debug("Star not persisted for unsaved message \(messageId): \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle starred status
|
||||
do {
|
||||
try DatabaseService.shared.setMessageStarred(messageId: messageId, starred: !isStarred)
|
||||
Log.ui.info("Message \(messageId) starred: \(!isStarred)")
|
||||
} catch {
|
||||
Log.ui.error("Failed to toggle star for message \(messageId): \(error)")
|
||||
// MARK: - Quick Save
|
||||
|
||||
/// Called from the File menu — re-saves if already named, shows NSAlert to name if not.
|
||||
func saveFromMenu() {
|
||||
let chatMessages = messages.filter { $0.role != .system }
|
||||
guard !chatMessages.isEmpty else { return }
|
||||
|
||||
if currentConversationName != nil {
|
||||
quickSave()
|
||||
} else {
|
||||
#if os(macOS)
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Save Chat"
|
||||
alert.informativeText = "Enter a name for this conversation:"
|
||||
alert.addButton(withTitle: "Save")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24))
|
||||
input.placeholderString = "Conversation name…"
|
||||
alert.accessoryView = input
|
||||
alert.window.initialFirstResponder = input
|
||||
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
||||
let name = input.stringValue.trimmingCharacters(in: .whitespaces)
|
||||
guard !name.isEmpty else { return }
|
||||
do {
|
||||
let saved = try DatabaseService.shared.saveConversation(name: name, messages: chatMessages)
|
||||
currentConversationId = saved.id
|
||||
currentConversationName = name
|
||||
savedMessageCount = chatMessages.count
|
||||
showSystemMessage("Saved as \"\(name)\"")
|
||||
} catch {
|
||||
showSystemMessage("Save failed: \(error.localizedDescription)")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-save the current conversation under its existing name, or prompt if never saved.
|
||||
func quickSave() {
|
||||
let chatMessages = messages.filter { $0.role != .system }
|
||||
guard !chatMessages.isEmpty else { return }
|
||||
|
||||
if let id = currentConversationId, let name = currentConversationName {
|
||||
// Update existing saved conversation
|
||||
do {
|
||||
try DatabaseService.shared.updateConversation(
|
||||
id: id, name: name, messages: chatMessages,
|
||||
primaryModel: selectedModel?.id
|
||||
)
|
||||
savedMessageCount = chatMessages.count
|
||||
showSystemMessage("Saved \"\(name)\"")
|
||||
} catch {
|
||||
showSystemMessage("Save failed: \(error.localizedDescription)")
|
||||
}
|
||||
} else {
|
||||
showSystemMessage("No name yet — use /save <name> to save this conversation")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Command Handling
|
||||
|
||||
|
||||
private func handleCommand(_ command: String) {
|
||||
guard let (cmd, args) = command.parseCommand() else {
|
||||
showSystemMessage("Invalid command")
|
||||
@@ -571,13 +652,17 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
return
|
||||
}
|
||||
do {
|
||||
let _ = try DatabaseService.shared.saveConversation(name: name, messages: chatMessages)
|
||||
let saved = try DatabaseService.shared.saveConversation(name: name, messages: chatMessages)
|
||||
currentConversationId = saved.id
|
||||
currentConversationName = name
|
||||
savedMessageCount = chatMessages.count
|
||||
showSystemMessage("Conversation saved as '\(name)'")
|
||||
} catch {
|
||||
showSystemMessage("Failed to save: \(error.localizedDescription)")
|
||||
}
|
||||
} else {
|
||||
showSystemMessage("Usage: /save <name>")
|
||||
// Re-save without a name if already saved; otherwise prompt
|
||||
quickSave()
|
||||
}
|
||||
|
||||
case "/load", "/list":
|
||||
@@ -808,8 +893,11 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
if let usage = response.usage {
|
||||
messages[index].tokens = usage.completionTokens
|
||||
if let model = selectedModel {
|
||||
let cost = (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) +
|
||||
(Double(usage.completionTokens) * model.pricing.completion / 1_000_000)
|
||||
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
|
||||
let cost: Double? = hasPricing
|
||||
? (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) +
|
||||
(Double(usage.completionTokens) * model.pricing.completion / 1_000_000)
|
||||
: nil
|
||||
messages[index].cost = cost
|
||||
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
|
||||
}
|
||||
@@ -856,8 +944,11 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
if let usage = totalTokens {
|
||||
messages[index].tokens = usage.completionTokens
|
||||
if let model = selectedModel {
|
||||
let cost = (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) +
|
||||
(Double(usage.completionTokens) * model.pricing.completion / 1_000_000)
|
||||
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
|
||||
let cost: Double? = hasPricing
|
||||
? (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) +
|
||||
(Double(usage.completionTokens) * model.pricing.completion / 1_000_000)
|
||||
: nil
|
||||
messages[index].cost = cost
|
||||
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
|
||||
}
|
||||
@@ -1345,8 +1436,11 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
|
||||
// Calculate cost
|
||||
if let usage = totalUsage, let model = selectedModel {
|
||||
let cost = (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) +
|
||||
(Double(usage.completionTokens) * model.pricing.completion / 1_000_000)
|
||||
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
|
||||
let cost: Double? = hasPricing
|
||||
? (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) +
|
||||
(Double(usage.completionTokens) * model.pricing.completion / 1_000_000)
|
||||
: nil
|
||||
if let index = messages.lastIndex(where: { $0.id == assistantMessage.id }) {
|
||||
messages[index].cost = cost
|
||||
}
|
||||
@@ -1453,7 +1547,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
modelInfoTarget = model
|
||||
}
|
||||
|
||||
private func exportConversation(format: String, filename: String) {
|
||||
func exportConversation(format: String, filename: String) {
|
||||
let chatMessages = messages.filter { $0.role != .system }
|
||||
guard !chatMessages.isEmpty else {
|
||||
showSystemMessage("Nothing to export — no messages")
|
||||
@@ -1655,6 +1749,9 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
messages: chatMessages
|
||||
)
|
||||
|
||||
currentConversationId = conversation.id
|
||||
currentConversationName = conversationName
|
||||
savedMessageCount = chatMessages.count
|
||||
Log.ui.info("Auto-saved conversation: \(conversationName)")
|
||||
|
||||
// Check if progressive summarization is needed
|
||||
|
||||
@@ -126,7 +126,12 @@ struct ChatView: View {
|
||||
)
|
||||
|
||||
// Footer
|
||||
FooterView(stats: viewModel.sessionStats)
|
||||
FooterView(
|
||||
stats: viewModel.sessionStats,
|
||||
conversationName: viewModel.currentConversationName,
|
||||
hasUnsavedChanges: viewModel.hasUnsavedChanges,
|
||||
onQuickSave: viewModel.quickSave
|
||||
)
|
||||
}
|
||||
.background(Color.oaiBackground)
|
||||
.sheet(isPresented: $viewModel.showShortcuts) {
|
||||
|
||||
@@ -46,6 +46,9 @@ struct ContentView: View {
|
||||
}
|
||||
.frame(minWidth: 640, minHeight: 400)
|
||||
#if os(macOS)
|
||||
.onAppear {
|
||||
NSApplication.shared.windows.forEach { $0.tabbingMode = .disallowed }
|
||||
}
|
||||
.onKeyPress(.return, phases: .down) { press in
|
||||
if press.modifiers.contains(.command) {
|
||||
chatViewModel.sendMessage()
|
||||
@@ -157,8 +160,7 @@ struct ContentView: View {
|
||||
Button(action: { chatViewModel.showStats = true }) {
|
||||
ToolbarLabel(title: "Stats", systemImage: "chart.bar", showLabels: showLabels, scale: scale)
|
||||
}
|
||||
.keyboardShortcut("s", modifiers: .command)
|
||||
.help("Session statistics (Cmd+S)")
|
||||
.help("Session statistics")
|
||||
|
||||
Button(action: { chatViewModel.showCredits = true }) {
|
||||
ToolbarLabel(title: "Credits", systemImage: "creditcard", showLabels: showLabels, scale: scale)
|
||||
|
||||
@@ -27,7 +27,20 @@ import SwiftUI
|
||||
|
||||
struct FooterView: View {
|
||||
let stats: SessionStats
|
||||
|
||||
let conversationName: String?
|
||||
let hasUnsavedChanges: Bool
|
||||
let onQuickSave: (() -> Void)?
|
||||
|
||||
init(stats: SessionStats,
|
||||
conversationName: String? = nil,
|
||||
hasUnsavedChanges: Bool = false,
|
||||
onQuickSave: (() -> Void)? = nil) {
|
||||
self.stats = stats
|
||||
self.conversationName = conversationName
|
||||
self.hasUnsavedChanges = hasUnsavedChanges
|
||||
self.onQuickSave = onQuickSave
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 20) {
|
||||
// Session summary
|
||||
@@ -58,9 +71,18 @@ struct FooterView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
// Save indicator (only when chat has messages)
|
||||
if stats.messageCount > 0 {
|
||||
SaveIndicator(
|
||||
conversationName: conversationName,
|
||||
hasUnsavedChanges: hasUnsavedChanges,
|
||||
onSave: onQuickSave
|
||||
)
|
||||
}
|
||||
|
||||
// Shortcuts hint
|
||||
#if os(macOS)
|
||||
Text("⌘M Model • ⌘K Clear • ⌘S Stats")
|
||||
Text("⌘N New • ⌘M Model • ⌘⇧S Save")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.oaiSecondary)
|
||||
#endif
|
||||
@@ -77,6 +99,62 @@ struct FooterView: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct SaveIndicator: View {
|
||||
let conversationName: String?
|
||||
let hasUnsavedChanges: Bool
|
||||
let onSave: (() -> Void)?
|
||||
|
||||
private let guiSize = SettingsService.shared.guiTextSize
|
||||
|
||||
private var isSaved: Bool { conversationName != nil }
|
||||
private var isModified: Bool { isSaved && hasUnsavedChanges }
|
||||
private var isUnsaved: Bool { !isSaved }
|
||||
|
||||
private var label: String {
|
||||
if let name = conversationName {
|
||||
return name.count > 20 ? String(name.prefix(20)) + "…" : name
|
||||
}
|
||||
return "Unsaved"
|
||||
}
|
||||
|
||||
private var icon: String {
|
||||
if isModified { return "circle.fill" }
|
||||
if isSaved { return "checkmark.circle.fill" }
|
||||
return "exclamationmark.circle"
|
||||
}
|
||||
|
||||
private var color: Color {
|
||||
if isModified { return .orange }
|
||||
if isSaved { return .green }
|
||||
return .secondary
|
||||
}
|
||||
|
||||
private var tooltip: String {
|
||||
if isModified { return "Click to re-save \"\(conversationName ?? "")\"" }
|
||||
if isSaved { return "Saved — no changes" }
|
||||
return "Not saved — use /save <name>"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: { if isModified { onSave?() } }) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: guiSize - 3))
|
||||
.foregroundColor(color)
|
||||
Text(label)
|
||||
.font(.system(size: guiSize - 2))
|
||||
.foregroundColor(isUnsaved ? .secondary : .oaiPrimary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(tooltip)
|
||||
.disabled(!isModified)
|
||||
.opacity(isUnsaved ? 0.6 : 1.0)
|
||||
.animation(.easeInOut(duration: 0.2), value: conversationName)
|
||||
.animation(.easeInOut(duration: 0.2), value: hasUnsavedChanges)
|
||||
}
|
||||
}
|
||||
|
||||
struct FooterItem: View {
|
||||
let icon: String
|
||||
let label: String
|
||||
@@ -177,14 +255,17 @@ struct SyncStatusFooter: View {
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VStack {
|
||||
let stats = SessionStats(
|
||||
totalInputTokens: 1250,
|
||||
totalOutputTokens: 3420,
|
||||
totalCost: 0.0152,
|
||||
messageCount: 12
|
||||
)
|
||||
return VStack(spacing: 0) {
|
||||
Spacer()
|
||||
FooterView(stats: SessionStats(
|
||||
totalInputTokens: 1250,
|
||||
totalOutputTokens: 3420,
|
||||
totalCost: 0.0152,
|
||||
messageCount: 12
|
||||
))
|
||||
FooterView(stats: stats, conversationName: nil, hasUnsavedChanges: true)
|
||||
FooterView(stats: stats, conversationName: "My Project", hasUnsavedChanges: true)
|
||||
FooterView(stats: stats, conversationName: "My Project", hasUnsavedChanges: false)
|
||||
}
|
||||
.background(Color.oaiBackground)
|
||||
}
|
||||
|
||||
@@ -279,6 +279,9 @@ struct MessageRow: View {
|
||||
private func loadStarredState() {
|
||||
if let metadata = try? DatabaseService.shared.getMessageMetadata(messageId: message.id) {
|
||||
isStarred = metadata.user_starred == 1
|
||||
} else {
|
||||
// Unsaved message — use in-memory flag on the Message struct
|
||||
isStarred = message.isStarred
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ struct StatsView: View {
|
||||
Section("Costs") {
|
||||
StatRow(label: "Total Cost", value: stats.totalCostDisplay)
|
||||
if stats.messageCount > 0 {
|
||||
StatRow(label: "Avg per Message", value: String(format: "$%.4f", stats.averageCostPerMessage))
|
||||
StatRow(label: "Avg per Message", value: stats.averageCostDisplay)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,24 +66,47 @@ struct oAIApp: App {
|
||||
.defaultSize(width: 1024, height: 800)
|
||||
.windowResizability(.contentMinSize)
|
||||
.commands {
|
||||
// ── Apple menu ────────────────────────────────────────────────
|
||||
CommandGroup(replacing: .appInfo) {
|
||||
Button("About oAI") {
|
||||
showAbout = true
|
||||
}
|
||||
Button("About oAI") { showAbout = true }
|
||||
}
|
||||
|
||||
CommandGroup(replacing: .appSettings) {
|
||||
Button("Settings...") {
|
||||
chatViewModel.showSettings = true
|
||||
}
|
||||
.keyboardShortcut(",", modifiers: .command)
|
||||
Button("Settings…") { chatViewModel.showSettings = true }
|
||||
.keyboardShortcut(",", modifiers: .command)
|
||||
}
|
||||
|
||||
CommandGroup(replacing: .help) {
|
||||
Button("oAI Help") {
|
||||
openHelp()
|
||||
// ── File menu ─────────────────────────────────────────────────
|
||||
// Replacing .newItem removes the auto-added "New Window" entry
|
||||
CommandGroup(replacing: .newItem) {
|
||||
Button("New Chat") { chatViewModel.newConversation() }
|
||||
.keyboardShortcut("n", modifiers: .command)
|
||||
}
|
||||
|
||||
CommandGroup(after: .newItem) {
|
||||
Button("Open Chat…") { chatViewModel.showConversations = true }
|
||||
.keyboardShortcut("o", modifiers: .command)
|
||||
}
|
||||
|
||||
CommandGroup(replacing: .saveItem) {
|
||||
Button("Save Chat") { chatViewModel.saveFromMenu() }
|
||||
.keyboardShortcut("s", modifiers: [.command, .shift])
|
||||
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
|
||||
}
|
||||
|
||||
CommandGroup(after: .importExport) {
|
||||
Button("Export as Markdown…") {
|
||||
let name = chatViewModel.currentConversationName ?? "conversation"
|
||||
let safe = name.components(separatedBy: .whitespacesAndNewlines).joined(separator: "-")
|
||||
chatViewModel.exportConversation(format: "md", filename: "\(safe).md")
|
||||
}
|
||||
.keyboardShortcut("?", modifiers: .command)
|
||||
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
|
||||
}
|
||||
|
||||
// ── Help menu ─────────────────────────────────────────────────
|
||||
CommandGroup(replacing: .help) {
|
||||
Button("oAI Help") { openHelp() }
|
||||
.keyboardShortcut("?", modifiers: .command)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
Reference in New Issue
Block a user