Files
byre/mac/Sources/Byre/Transcript.swift
T

223 lines
7.6 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
struct Row: Identifiable {
let id: Int64
let kind: String
var text: String
var tools: [String] = []
}
func buildRows(_ events: [ByreEvent]) -> [Row] {
var rows: [Row] = []
for e in events {
switch e.kind {
case "user.message", "agent.message", "agent.thinking":
rows.append(Row(id: e.seq, kind: e.kind, text: e.payload.value["text"] as? String ?? ""))
case "agent.tool_call":
let name = e.payload.value["name"] as? String ?? "?"
let input = e.payload.value["input"] as? [String: (any Sendable)?]
let detail = input?["file_path"] as? String ?? input?["command"] as? String ?? input?["pattern"] as? String ?? ""
let line = "\(name) \(detail)"
if rows.last?.kind == "tools" {
rows[rows.count - 1].tools.append(line)
} else {
rows.append(Row(id: e.seq, kind: "tools", text: "", tools: [line]))
}
default:
continue
}
}
return rows
}
func toolSummary(_ tools: [String]) -> String {
var order: [String] = []
var counts: [String: Int] = [:]
for t in tools {
let name = String(t.split(separator: " ").first ?? "")
if counts[name] == nil {
order.append(name)
}
counts[name, default: 0] += 1
}
return order.map { counts[$0]! > 1 ? "\($0) ×\(counts[$0]!)" : $0 }.joined(separator: ", ")
}
struct RowView: View {
let row: Row
@ViewState private var open = false
var body: some View {
switch row.kind {
case "tools":
VStack(alignment: .leading, spacing: 2) {
Button((open ? "▾ " : "▸ ") + toolSummary(row.tools)) {
open.toggle()
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
if open {
ForEach(Array(row.tools.enumerated()), id: \.offset) { _, t in
Text(t)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
}
case "user.message":
Text(markdown(row.text))
.textSelection(.enabled)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.accentColor.opacity(0.12))
case "agent.thinking":
Text(row.text)
.italic()
.foregroundStyle(.secondary)
.textSelection(.enabled)
default:
Text(markdown(row.text))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private func markdown(_ s: String) -> AttributedString {
(try? AttributedString(markdown: s, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(s)
}
}
enum Container: String, CaseIterable {
case lazyVStack = "ScrollView + LazyVStack"
case list = "List"
}
struct TranscriptSpike: View {
@Environment(Store.self) private var store
@ViewState private var all: [Row] = []
@ViewState private var shown = 0
@ViewState private var container = Container.lazyVStack
@ViewState private var playing = false
@ViewState private var streaming = false
@ViewState private var pinned = true
@ViewState private var distance: CGFloat = 0
@ViewState private var tick = 0
@ViewState private var position = ScrollPosition(edge: .bottom)
@ViewState private var events = 0
@ViewState private var loadTime = ""
private var rows: ArraySlice<Row> {
all.prefix(shown)
}
var body: some View {
VStack(alignment: .leading) {
HStack {
Picker("", selection: $container) {
ForEach(Container.allCases, id: \.self) { c in
Text(c.rawValue).tag(c)
}
}
.pickerStyle(.segmented)
.frame(width: 320)
Toggle("Play held-back rows", isOn: $playing)
Toggle("Stream a message", isOn: $streaming)
Text("\(events) events, \(shown)/\(all.count) rows \(loadTime) · \(pinned ? "stuck to bottom" : "scrolled up") · \(Int(distance)) pt from bottom")
.foregroundStyle(.secondary)
}
switch container {
case .lazyVStack:
ScrollView {
LazyVStack(alignment: .leading, spacing: 10) {
ForEach(rows) { r in
RowView(row: r)
}
}
.scrollTargetLayout()
.padding(.horizontal, 8)
}
.scrollPosition($position)
.defaultScrollAnchor(.bottom)
.onScrollGeometryChange(for: CGFloat.self) { g in
g.contentSize.height - g.visibleRect.maxY
} action: { _, d in
distance = d
pinned = d <= 80
}
.onChange(of: tick) {
if pinned {
position.scrollTo(edge: .bottom)
}
}
case .list:
ScrollViewReader { proxy in
List(rows) { r in
RowView(row: r)
}
.onScrollGeometryChange(for: CGFloat.self) { g in
g.contentSize.height - g.visibleRect.maxY
} action: { _, d in
distance = d
pinned = d <= 80
}
.onChange(of: tick) {
if pinned, let last = rows.last {
proxy.scrollTo(last.id, anchor: .bottom)
}
}
.onAppear {
if let last = rows.last {
proxy.scrollTo(last.id, anchor: .bottom)
}
}
}
}
}
.onChange(of: store.transcript.count, initial: true) {
load()
}
.task(id: playing) {
while playing && shown < all.count {
shown += 1
tick += 1
try? await Task.sleep(for: .milliseconds(150))
}
}
.task(id: streaming) {
await stream()
}
}
private func load() {
guard let feed = store.selectedFeed, feed.ready else {
return
}
let start = Date()
let got = feed.events
events = got.count
all = buildRows(got)
shown = all.count > 200 ? all.count - 100 : all.count
loadTime = String(format: "in %.2fs", Date().timeIntervalSince(start))
tick += 1
}
private func stream() async {
guard streaming, let source = all.filter({ $0.kind == "agent.message" }).max(by: { $0.text.count < $1.text.count }) else {
return
}
let id = (all.last?.id ?? 0) + 1_000_000
all.insert(Row(id: id, kind: "agent.message", text: ""), at: shown)
shown += 1
let index = shown - 1
for word in source.text.split(separator: " ", omittingEmptySubsequences: false) {
if !streaming {
break
}
all[index].text += (all[index].text.isEmpty ? "" : " ") + word
tick += 1
try? await Task.sleep(for: .milliseconds(30))
}
streaming = false
}
}