253 lines
7.8 KiB
Swift
253 lines
7.8 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|