Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Sources/AgentLayout/AgentLayout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,10 @@ public struct AgentLayout: View {
proxy.scrollTo(lastMessage.id, anchor: .top)
}
}
chatProvider.onError = { err in
error = err
showAlert = true
}
// Scroll to bottom when view first appears
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
scrollToBottom()
Expand Down
22 changes: 20 additions & 2 deletions Sources/AgentLayout/ChatProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ public class ChatProvider: ChatProviderProtocol {
public var onDelete: ((Int) -> Void)?
public var onEdit: ((Int, Message) -> Void)?
public var onMessageChange: (([Message]) -> Void)?
public var onError: ((Error) -> Void)?

// MARK: - Internal State (not observed)
@ObservationIgnored private var agentClient = AgentClient()
Expand Down Expand Up @@ -360,6 +361,7 @@ public class ChatProvider: ChatProviderProtocol {
self.currentStreamingMessageId = nil
} catch {
print("Error continuing conversation: \(error)")
self.onError?(error)
if let msgId = self.currentStreamingMessageId {
self.chat?.messages.removeAll { $0.id == msgId }
self.notifyMessageChange()
Expand Down Expand Up @@ -540,6 +542,7 @@ public class ChatProvider: ChatProviderProtocol {
self.currentStreamingMessageId = nil
} catch {
print("Error sending message: \(error)")
self.onError?(error)
if let msgId = self.currentStreamingMessageId {
self.chat?.messages.removeAll { $0.id == msgId }
self.notifyMessageChange()
Expand Down Expand Up @@ -569,7 +572,22 @@ public class ChatProvider: ChatProviderProtocol {
guard let index = chat.messages.firstIndex(where: { $0.id == messageId }) else { return }
guard let currentSource = currentSource, let currentModel = currentModel else { return }

// Find the user message content before the target message
let targetMessage = chat.messages[index]

// Check if target is a user message
if case .openai(let openAIMsg) = targetMessage,
case .user = openAIMsg
{
// User message: remove everything after it (keep the user message)
if index + 1 < chat.messages.count {
self.chat?.messages.removeSubrange((index + 1)...)
}
notifyMessageChange()
startGeneration(source: currentSource, model: currentModel)
return
}

// Assistant/other message: find preceding user message and remove from target onwards
var userMessageContent: String? = nil
for i in stride(from: index - 1, through: 0, by: -1) {
if case .openai(let openAIMsg) = chat.messages[i],
Expand All @@ -582,7 +600,7 @@ public class ChatProvider: ChatProviderProtocol {

guard userMessageContent != nil else { return }

// Remove the target message and all subsequent messages
// Remove target message and all subsequent messages
self.chat?.messages.removeSubrange(index...)
notifyMessageChange()

Expand Down
7 changes: 3 additions & 4 deletions Sources/AgentLayout/Message/ThinkingContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ struct ThinkingContentView: View {

@State private var isExpanded = false

/// Title text to display (summary or fallback)
/// Title text to display (summary with markdown stripped, or fallback)
private var titleText: String {
if let summary = summary, !summary.isEmpty {
return summary
return MarkdownStripper.stripMarkdown(summary)
}
return "Thinking..."
}
Expand Down Expand Up @@ -61,8 +61,7 @@ struct ThinkingContentView: View {
.foregroundColor(.orange.mix(with: .mint, by: 0.9))
.shimmering()
} else {
Markdown(titleText)
.markdownTheme(.chatTheme)
Text(titleText)
.lineLimit(1)
.foregroundColor(.orange.mix(with: .mint, by: 0.9))
}
Expand Down
119 changes: 80 additions & 39 deletions Sources/AgentLayout/ModelPicker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ struct ModelPicker: View {
let onClose: () -> Void

@State private var hoveredModel: Model?
@State private var searchText: String = ""

public init(
currentModel: Binding<Model>,
Expand All @@ -22,54 +23,94 @@ struct ModelPicker: View {
}

var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 5) {
ForEach(sources) { source in
Text(source.displayName)
.foregroundColor(Color.gray)
ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 5) {
ForEach(sources) { source in
Text(source.displayName)
.foregroundColor(Color.gray)

ForEach(source.models) { model in
HStack {
Text(model.displayName)
.padding(.vertical, 12)
.padding(.horizontal, 12)
.frame(maxWidth: .infinity, alignment: .leading)
// if model is custom model, show custom icon
if case .custom = model {
Image(systemName: "gear")
.padding()
ForEach(source.models) { model in
HStack {
Text(model.displayName)
.padding(.vertical, 12)
.padding(.horizontal, 12)
.frame(maxWidth: .infinity, alignment: .leading)
// if model is custom model, show custom icon
if case .custom = model {
Image(systemName: "gear")
.padding()
}
if model == currentModel {
Spacer()
Image(systemName: "checkmark")
.padding(.trailing, 12)
}
}
if model == currentModel {
Spacer()
Image(systemName: "checkmark")
.padding(.trailing, 12)
.id(model)
.onHover { hovering in
if hovering {
hoveredModel = model
} else {
hoveredModel = nil
}
}
}
.onHover { hovering in
if hovering {
hoveredModel = model
} else {
hoveredModel = nil
}
}
.background(
hoveredModel == model ? Color.gray.opacity(0.12) : Color.clear
)
.cornerRadius(10)
.frame(width: 220)
.clipShape(RoundedRectangle(cornerRadius: 10))
.onTapGesture {
withAnimation {
currentModel = model
.background(
hoveredModel == model ? Color.gray.opacity(0.12) : Color.clear
)
.cornerRadius(10)
.frame(width: 220)
.clipShape(RoundedRectangle(cornerRadius: 10))
.onTapGesture {
withAnimation {
currentModel = model
}
onClose()
}
onClose()
}
}
}
.padding()
}
.frame(maxHeight: 400)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
withAnimation {
proxy.scrollTo(currentModel, anchor: .center)
}
}
}
.onKeyPress { press in
if press.key == .escape {
searchText = ""
return .handled
}
if press.key == .delete {
if !searchText.isEmpty {
searchText.removeLast()
}
scrollToFirstMatch(proxy: proxy)
return .handled
}
if let char = press.characters.first, char.isLetter || char.isNumber {
searchText.append(char)
scrollToFirstMatch(proxy: proxy)
return .handled
}
return .ignored
}
}
}

private func scrollToFirstMatch(proxy: ScrollViewProxy) {
let allModels = sources.flatMap { $0.models }
if let match = allModels.first(where: {
$0.displayName.localizedCaseInsensitiveContains(searchText)
}) {
withAnimation {
proxy.scrollTo(match, anchor: .center)
}
.padding()
}
.frame(maxHeight: 400)
}
}

Expand Down
92 changes: 92 additions & 0 deletions Sources/AgentLayout/Utils/MarkdownStripper.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
//
// MarkdownStripper.swift
// AgentLayout
//
// Created by Claude on 12/2/25.
//

import Foundation

/// Utility for stripping markdown syntax from text
enum MarkdownStripper {
/// Strips common markdown syntax from text and returns plain text
/// - Parameter text: The markdown-formatted text
/// - Returns: Plain text with markdown syntax removed
static func stripMarkdown(_ text: String) -> String {
var result = text

// Remove bold: **text** or __text__
result = result.replacingOccurrences(
of: #"\*\*(.+?)\*\*"#,
with: "$1",
options: .regularExpression
)
result = result.replacingOccurrences(
of: #"__(.+?)__"#,
with: "$1",
options: .regularExpression
)

// Remove italic: *text* or _text_
result = result.replacingOccurrences(
of: #"\*(.+?)\*"#,
with: "$1",
options: .regularExpression
)
result = result.replacingOccurrences(
of: #"(?<![a-zA-Z0-9])_(.+?)_(?![a-zA-Z0-9])"#,
with: "$1",
options: .regularExpression
)

// Remove strikethrough: ~~text~~
result = result.replacingOccurrences(
of: #"~~(.+?)~~"#,
with: "$1",
options: .regularExpression
)

// Remove inline code: `text`
result = result.replacingOccurrences(
of: #"`(.+?)`"#,
with: "$1",
options: .regularExpression
)

// Remove headers: # text (at start of string or line)
result = result.replacingOccurrences(
of: #"(?m)^#{1,6}\s+"#,
with: "",
options: .regularExpression
)

// Remove images: ![alt](url) -> alt (before links to avoid conflict)
result = result.replacingOccurrences(
of: #"!\[([^\]]*)\]\([^)]+\)"#,
with: "$1",
options: .regularExpression
)

// Remove links: [text](url) -> text
result = result.replacingOccurrences(
of: #"\[([^\]]+)\]\([^)]+\)"#,
with: "$1",
options: .regularExpression
)

// Remove any remaining unmatched markdown characters (e.g., incomplete **)
// This handles cases like "**incomplete" where there's no closing **
result = result.replacingOccurrences(
of: #"^\*\*"#,
with: "",
options: .regularExpression
)
result = result.replacingOccurrences(
of: #"\*\*$"#,
with: "",
options: .regularExpression
)

return result
}
}
Loading