Bug gixes, features added, GUI updates and more

This commit is contained in:
2026-02-12 14:29:35 +01:00
parent 52447b5e17
commit 7265d22438
21 changed files with 2187 additions and 123 deletions

View File

@@ -60,6 +60,8 @@ struct ChatView: View {
// Input bar
InputBar(
text: $viewModel.inputText,
commandHistory: $viewModel.commandHistory,
historyIndex: $viewModel.historyIndex,
isGenerating: viewModel.isGenerating,
mcpStatus: viewModel.mcpStatus,
onlineMode: viewModel.onlineMode,

View File

@@ -42,6 +42,7 @@ struct ContentView: View {
selectedModel: chatViewModel.selectedModel,
onSelect: { model in
chatViewModel.selectedModel = model
SettingsService.shared.defaultModel = model.id
chatViewModel.showModelSelector = false
}
)
@@ -77,6 +78,11 @@ struct ContentView: View {
.sheet(item: $vm.modelInfoTarget) { model in
ModelInfoView(model: model)
}
.sheet(isPresented: $vm.showHistory) {
HistoryView(onSelect: { input in
chatViewModel.inputText = input
})
}
}
#if os(macOS)
@@ -91,11 +97,17 @@ struct ContentView: View {
.help("New conversation")
Button(action: { chatViewModel.showConversations = true }) {
Label("History", systemImage: "clock.arrow.circlepath")
Label("Conversations", systemImage: "clock.arrow.circlepath")
}
.keyboardShortcut("l", modifiers: .command)
.help("Saved conversations (Cmd+L)")
Button(action: { chatViewModel.showHistory = true }) {
Label("History", systemImage: "list.bullet")
}
.keyboardShortcut("h", modifiers: .command)
.help("Command history (Cmd+H)")
Spacer()
Button(action: { chatViewModel.showModelSelector = true }) {

View File

@@ -9,6 +9,8 @@ import SwiftUI
struct InputBar: View {
@Binding var text: String
@Binding var commandHistory: [String]
@Binding var historyIndex: Int
let isGenerating: Bool
let mcpStatus: String?
let onlineMode: Bool
@@ -22,7 +24,7 @@ struct InputBar: View {
/// Commands that execute immediately without additional arguments
private static let immediateCommands: Set<String> = [
"/help", "/model", "/clear", "/retry", "/stats", "/config",
"/help", "/history", "/model", "/clear", "/retry", "/stats", "/config",
"/settings", "/credits", "/list", "/load",
"/memory on", "/memory off", "/online on", "/online off",
"/mcp on", "/mcp off", "/mcp status", "/mcp list",
@@ -79,28 +81,56 @@ struct InputBar: View {
.onChange(of: text) {
showCommandDropdown = text.hasPrefix("/")
selectedSuggestionIndex = 0
// Reset history index when user types
historyIndex = commandHistory.count
}
#if os(macOS)
.onKeyPress(.upArrow) {
guard showCommandDropdown else { return .ignored }
if selectedSuggestionIndex > 0 {
selectedSuggestionIndex -= 1
// If command dropdown is showing, navigate dropdown
if showCommandDropdown {
if selectedSuggestionIndex > 0 {
selectedSuggestionIndex -= 1
}
return .handled
}
// Otherwise, navigate command history
if historyIndex > 0 {
historyIndex -= 1
text = commandHistory[historyIndex]
}
return .handled
}
.onKeyPress(.downArrow) {
guard showCommandDropdown else { return .ignored }
let count = CommandSuggestionsView.filteredCommands(for: text).count
if selectedSuggestionIndex < count - 1 {
selectedSuggestionIndex += 1
// If command dropdown is showing, navigate dropdown
if showCommandDropdown {
let count = CommandSuggestionsView.filteredCommands(for: text).count
if selectedSuggestionIndex < count - 1 {
selectedSuggestionIndex += 1
}
return .handled
}
// Otherwise, navigate command history
if historyIndex < commandHistory.count - 1 {
historyIndex += 1
text = commandHistory[historyIndex]
} else if historyIndex == commandHistory.count - 1 {
// At the end of history, clear text and move to "new" position
historyIndex = commandHistory.count
text = ""
}
return .handled
}
.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) {
@@ -227,6 +257,7 @@ struct CommandSuggestionsView: View {
static let allCommands: [(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"),
@@ -316,6 +347,8 @@ struct CommandSuggestionsView: View {
Spacer()
InputBar(
text: .constant(""),
commandHistory: .constant([]),
historyIndex: .constant(0),
isGenerating: false,
mcpStatus: "📁 Files",
onlineMode: true,

View File

@@ -16,7 +16,7 @@ struct MarkdownContentView: View {
var body: some View {
let segments = parseSegments(content)
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 12) {
ForEach(segments.indices, id: \.self) { index in
switch segments[index] {
case .text(let text):
@@ -25,6 +25,8 @@ struct MarkdownContentView: View {
}
case .codeBlock(let language, let code):
CodeBlockView(language: language, code: code, fontSize: fontSize)
case .table(let tableText):
TableView(content: tableText, fontSize: fontSize)
}
}
}
@@ -32,16 +34,18 @@ struct MarkdownContentView: View {
@ViewBuilder
private func markdownText(_ text: String) -> some View {
if let attrString = try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) {
if let attrString = try? AttributedString(markdown: text, options: .init(interpretedSyntax: .full)) {
Text(attrString)
.font(.system(size: fontSize))
.foregroundColor(.oaiPrimary)
.lineSpacing(4)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
} else {
Text(text)
.font(.system(size: fontSize))
.foregroundColor(.oaiPrimary)
.lineSpacing(4)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
@@ -52,6 +56,7 @@ struct MarkdownContentView: View {
enum Segment {
case text(String)
case codeBlock(language: String?, code: String)
case table(String)
}
private func parseSegments(_ content: String) -> [Segment] {
@@ -61,10 +66,17 @@ struct MarkdownContentView: View {
var inCodeBlock = false
var codeLanguage: String? = nil
var codeContent = ""
var inTable = false
var tableLines: [String] = []
for line in lines {
if !inCodeBlock && line.hasPrefix("```") {
// Start of code block
if inTable {
segments.append(.table(tableLines.joined(separator: "\n")))
tableLines = []
inTable = false
}
if !currentText.isEmpty {
segments.append(.text(currentText))
currentText = ""
@@ -75,7 +87,6 @@ struct MarkdownContentView: View {
codeContent = ""
} else if inCodeBlock && line.hasPrefix("```") {
// End of code block
// Remove trailing newline from code
if codeContent.hasSuffix("\n") {
codeContent = String(codeContent.dropLast())
}
@@ -85,7 +96,25 @@ struct MarkdownContentView: View {
codeContent = ""
} else if inCodeBlock {
codeContent += line + "\n"
} else if line.contains("|") && (line.hasPrefix("|") || line.filter({ $0 == "|" }).count >= 2) {
// Markdown table line
if !inTable {
// Start of table
if !currentText.isEmpty {
segments.append(.text(currentText))
currentText = ""
}
inTable = true
}
tableLines.append(line)
} else {
// Regular text
if inTable {
// End of table
segments.append(.table(tableLines.joined(separator: "\n")))
tableLines = []
inTable = false
}
currentText += line + "\n"
}
}
@@ -98,9 +127,13 @@ struct MarkdownContentView: View {
segments.append(.codeBlock(language: codeLanguage, code: codeContent))
}
// Handle unclosed table
if inTable {
segments.append(.table(tableLines.joined(separator: "\n")))
}
// Remaining text
if !currentText.isEmpty {
// Remove trailing newline
if currentText.hasSuffix("\n") {
currentText = String(currentText.dropLast())
}
@@ -113,6 +146,136 @@ struct MarkdownContentView: View {
}
}
// MARK: - Table View
struct TableView: View {
let content: String
let fontSize: Double
private struct TableData {
let headers: [String]
let alignments: [TextAlignment]
let rows: [[String]]
}
private var tableData: TableData {
parseTable(content)
}
var body: some View {
let data = tableData
guard !data.headers.isEmpty else {
return AnyView(EmptyView())
}
return AnyView(
VStack(alignment: .leading, spacing: 0) {
// Headers
HStack(spacing: 0) {
ForEach(data.headers.indices, id: \.self) { index in
if index < data.headers.count {
Text(data.headers[index].trimmingCharacters(in: .whitespaces))
.font(.system(size: fontSize, weight: .semibold))
.foregroundColor(.oaiPrimary)
.frame(maxWidth: .infinity, alignment: alignmentFor(
index < data.alignments.count ? data.alignments[index] : .leading
))
.padding(8)
.background(Color.oaiSecondary.opacity(0.1))
if index < data.headers.count - 1 {
Divider()
}
}
}
}
.overlay(
Rectangle()
.stroke(Color.oaiSecondary.opacity(0.3), lineWidth: 1)
)
// Rows
ForEach(data.rows.indices, id: \.self) { rowIndex in
if rowIndex < data.rows.count {
HStack(spacing: 0) {
ForEach(0..<data.headers.count, id: \.self) { colIndex in
let cellContent = colIndex < data.rows[rowIndex].count ? data.rows[rowIndex][colIndex] : ""
let alignment = colIndex < data.alignments.count ? data.alignments[colIndex] : .leading
Text(cellContent.trimmingCharacters(in: .whitespaces))
.font(.system(size: fontSize))
.foregroundColor(.oaiPrimary)
.frame(maxWidth: .infinity, alignment: alignmentFor(alignment))
.padding(8)
if colIndex < data.headers.count - 1 {
Divider()
}
}
}
.background(rowIndex % 2 == 0 ? Color.clear : Color.oaiSecondary.opacity(0.05))
.overlay(
Rectangle()
.stroke(Color.oaiSecondary.opacity(0.3), lineWidth: 1)
)
}
}
}
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(Color.oaiSecondary.opacity(0.3), lineWidth: 1)
)
)
}
private func alignmentFor(_ textAlignment: TextAlignment) -> Alignment {
switch textAlignment {
case .leading: return .leading
case .center: return .center
case .trailing: return .trailing
}
}
private func parseTable(_ content: String) -> TableData {
let lines = content.components(separatedBy: "\n").filter { !$0.isEmpty }
guard lines.count >= 2 else {
return TableData(headers: [], alignments: [], rows: [])
}
// Parse headers (first line)
let headers = lines[0].components(separatedBy: "|")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
// Parse alignment row (second line with dashes)
let alignmentLine = lines[1]
let alignments = alignmentLine.components(separatedBy: "|")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
.map { cell -> TextAlignment in
let trimmed = cell.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix(":") && trimmed.hasSuffix(":") {
return .center
} else if trimmed.hasSuffix(":") {
return .trailing
} else {
return .leading
}
}
// Parse data rows (remaining lines)
let rows = lines.dropFirst(2).map { line in
line.components(separatedBy: "|")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
}
return TableData(headers: headers, alignments: alignments, rows: rows)
}
}
// MARK: - Code Block View
struct CodeBlockView: View {

View File

@@ -84,19 +84,38 @@ struct MessageRow: View {
.padding(.top, 4)
}
// Token/cost info
if let tokens = message.tokens, let cost = message.cost {
// Token/cost/time info
if message.role == .assistant && (message.tokens != nil || message.cost != nil || message.responseTime != nil || message.wasInterrupted) {
HStack(spacing: 8) {
Label("\(tokens)", systemImage: "chart.bar.xaxis")
Text("\u{2022}")
Text(String(format: "$%.4f", cost))
if let tokens = message.tokens {
Label("\(tokens)", systemImage: "chart.bar.xaxis")
if message.cost != nil || message.responseTime != nil || message.wasInterrupted {
Text("\u{2022}")
}
}
if let cost = message.cost {
Text(String(format: "$%.4f", cost))
if message.responseTime != nil || message.wasInterrupted {
Text("\u{2022}")
}
}
if let responseTime = message.responseTime {
Text(String(format: "%.1fs", responseTime))
if message.wasInterrupted {
Text("\u{2022}")
}
}
if message.wasInterrupted {
Text("⚠️ interrupted")
.foregroundColor(.orange)
}
}
.font(.caption2)
.foregroundColor(.oaiSecondary)
}
}
}
.padding(12)
.padding(16)
.background(Color.messageBackground(for: message.role))
.cornerRadius(8)
.overlay(