Bug gixes, features added, GUI updates and more
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user