import SwiftUI private let findOptions: String.CompareOptions = [.caseInsensitive, .diacriticInsensitive] extension EnvironmentValues { @Entry var findQuery = "" } struct FindHit { let id: Int let path: [Int] } func matches(_ text: String, _ query: String) -> Bool { !query.isEmpty && text.range(of: query, options: findOptions) != nil } func highlighted(_ a: AttributedString, _ query: String) -> AttributedString { if query.isEmpty { return a } var out = a var from = out.startIndex while let r = out[from...].range(of: query, options: findOptions) { out[r].backgroundColor = Palette.findHit #if os(macOS) out[r].appKit.backgroundColor = NSColor(Palette.findHit) #else out[r].uiKit.backgroundColor = UIColor(Palette.findHit) #endif from = r.upperBound } return out } func highlighted(_ s: String, _ query: String) -> AttributedString { highlighted(AttributedString(s), query) } func highlight(_ s: NSMutableAttributedString, _ query: String) { if query.isEmpty { return } let text = s.string as NSString var from = 0 while from < text.length { let r = text.range(of: query, options: findOptions, range: NSRange(location: from, length: text.length - from)) if r.location == NSNotFound { return } s.addAttribute(.backgroundColor, value: PlatformColor(Palette.findHit), range: r) from = NSMaxRange(r) } } func mdText(_ blocks: [MDBlock]) -> String { blocks.map(mdText).joined(separator: "\n") } private func mdText(_ block: MDBlock) -> String { switch block { case .paragraph(let inlines), .heading(_, let inlines): return inlineText(inlines) case .rule: return "" case .quote(let blocks): return mdText(blocks) case .list(_, _, let items, _): return items.map { inlineText($0.inline) + "\n" + mdText($0.blocks) }.joined(separator: "\n") case .code(let code), .diagram(let code): return code case .diff(let lines): return lines.joined(separator: "\n") case .details(let summary, let body): return inlineText(summary) + "\n" + mdText(body) case .table(let head, let rows): return ([head] + rows).map { $0.map(inlineText).joined(separator: "\t") }.joined(separator: "\n") } } private func inlineText(_ inlines: [MDInline]) -> String { inlines.map { item in switch item { case .text(let t), .code(let t): t case .strong(let inner), .em(let inner), .link(_, let inner), .mark(_, let inner): inlineText(inner) case .image(let src, let alt): alt.isEmpty ? src : alt case .lineBreak: "\n" } }.joined() } private func noteText(_ note: Note?) -> String { (note?.parts ?? []).map { part in if case .text(let t) = part { return t } return "" }.joined() } @MainActor private func ownText(_ row: Row) -> String { switch row.kind { case .user(_, _, _, let md): return mdText(md ?? []) case .byrePrompt(let brief, _, let md): return brief + "\n" + mdText(md) case .agent(let md), .subagentMessage(_, let md), .thinking(let md), .ready(_, let md, _): return mdText(md) case .stream: return mdText(row.md) case .tool(let name, let brief, let input): return [name, brief, input, row.result ?? ""].joined(separator: "\n") case .subagent(let title, let description, let prompt): return [title, description, mdText(prompt), row.result ?? ""].joined(separator: "\n") case .reviewer: return (row.review?.stage ?? "") + "\n" + row.text case .orphan(let text): return text case .turnEnd(let error): return error ?? "" case .note(let note): return noteText(note) case .review(let note, let md): return noteText(note) + "\n" + mdText(md) case .finding(let index): guard let review = row.review, findings(review).indices.contains(index) else { return "" } let f = findings(review)[index] return [str(f, "title") ?? "", str(f, "detail") ?? "", findingWhere(f)].joined(separator: "\n") case .findingResponse(_, _, let response, let reason): return response + "\n" + reason case .handoff: return noteText(row.summary) case .question: let p = row.question?.payload.value ?? [:] return (str(p, "question") ?? "") + "\n" + (str(p, "context") ?? "") case .group, .snapshot: return "" } } @MainActor func searchRows(_ rows: [Row], _ query: String) -> [FindHit] { var out: [FindHit] = [] func walk(_ rows: [Row], _ path: [Int]) { for row in rows where !row.hidden { if matches(ownText(row), query) { out.append(FindHit(id: row.id, path: path)) } if let body = row.body { walk(body.rows, path + [row.id]) } } } if !query.isEmpty { walk(rows, []) } return out } @MainActor struct FindField: View { let placeholder: String @Binding var text: String let focusSignal: Int var submit: (Bool) -> Void = { _ in } let end: () -> Void @Environment(Overlays.self) private var overlays @FocusState private var focused: Bool @ViewState private var token = newID("fnd") var body: some View { TextField(placeholder, text: $text) .modifier(FieldStyle(focused: focused)) .focused($focused) .onSubmit { #if os(macOS) submit(NSEvent.modifierFlags.contains(.shift)) #else submit(false) #endif focused = true } .onChange(of: focusSignal, initial: true) { old, new in if new != 0 && (old != new || !focused) { focused = true } } .onChange(of: focused, initial: true) { if focused { overlays.endFind = (token, end) } else if overlays.endFind?.token == token { overlays.endFind = nil } } .onDisappear { if overlays.endFind?.token == token { overlays.endFind = nil } } } } struct FindBar: View { let model: ChatModel var body: some View { @Bindable var model = model let count = model.findHits.count HStack(spacing: 8) { FindField(placeholder: "Find in transcript", text: $model.findQuery, focusSignal: model.findFocus) { back in model.stepFind(back ? -1 : 1) } end: { model.closeFind() } Text(model.findQuery.isEmpty ? "" : count == 0 ? "No matches" : "\(model.findIndex + 1) of \(count)") .font(Fonts.font(11)) .foregroundStyle(Palette.muted) .monospacedDigit() .fixedSize() Group { Button("↑") { model.stepFind(-1) } .help("Previous match (Shift+Enter)") .accessibilityLabel("Previous match") Button("↓") { model.stepFind(1) } .help("Next match (Enter)") .accessibilityLabel("Next match") } .disabled(count == 0) Button("Done") { model.closeFind() } .help("Close the find bar (Esc)") } .buttonStyle(WebButtonStyle()) .padding(.horizontal, 16) .padding(.vertical, 6) .overlay(alignment: .bottom) { Rectangle().fill(Palette.border).frame(height: 1) } } }