macOS client panes and transcript

This commit is contained in:
Ian Gulliver
2026-10-16 23:30:57 -07:00
parent 14d7ad4631
commit 00cd1ed958
23 changed files with 5059 additions and 739 deletions
+425
View File
@@ -0,0 +1,425 @@
import SwiftUI
enum MDSegment {
case text(AttributedString)
case image(ImageSource, src: String)
}
enum AppLink {
static func task(_ id: String) -> URL {
var c = URLComponents()
c.scheme = "byre-app"
c.host = "task"
c.queryItems = [URLQueryItem(name: "id", value: id)]
return c.url!
}
static func todo(_ projectId: String, _ title: String, discuss: Bool) -> URL {
var c = URLComponents()
c.scheme = "byre-app"
c.host = "todo"
c.queryItems = [URLQueryItem(name: "project", value: projectId), URLQueryItem(name: "title", value: title), URLQueryItem(name: "discuss", value: discuss ? "1" : "0")]
return c.url!
}
}
@MainActor
func openAppLink(_ url: URL, _ store: Store, _ overlays: Overlays) -> OpenURLAction.Result {
guard url.scheme == "byre-app", let c = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return .systemAction
}
let q = Dictionary((c.queryItems ?? []).map { ($0.name, $0.value ?? "") }, uniquingKeysWith: { a, _ in a })
switch c.host {
case "task":
store.focusTask(q["id"] ?? "")
case "todo":
store.startTodo(q["project"] ?? "", q["title"] ?? "", discuss: q["discuss"] == "1", overlays)
default:
break
}
return .handled
}
private let httpLink = Re(#"^https?://"#, caseless: true)
private let taskHref = Re(#"^#/task/(\w+)(/\w+)?$"#)
private let todoHref = Re(#"^#/todo/(\w+)/(.+)$"#)
@MainActor
struct InlineRenderer {
let store: Store
let size: CGFloat
let taskId: String?
private struct Style {
var bold = false
var italic = false
var link: URL?
var color: Color?
var small = false
}
func segments(_ items: [MDInline]) -> [MDSegment] {
var out: [MDSegment] = []
render(items, Style(), &out)
return out
}
func text(_ items: [MDInline]) -> AttributedString {
var out = AttributedString()
for seg in segments(items) {
if case .text(let a) = seg {
out += a
}
}
return out
}
private func run(_ s: String, _ style: Style) -> AttributedString {
var a = AttributedString(s)
a.font = Fonts.font(style.small ? 11 : size, weight: style.bold ? 700 : 400, italic: style.italic)
if let link = style.link {
a.link = link
}
if let color = style.color {
a.foregroundColor = color
}
return a
}
private func append(_ a: AttributedString, _ out: inout [MDSegment]) {
if case .text(let prev) = out.last {
out[out.count - 1] = .text(prev + a)
return
}
out.append(.text(a))
}
private func render(_ items: [MDInline], _ style: Style, _ out: inout [MDSegment]) {
for item in items {
switch item {
case .text(let t):
append(run(t, style), &out)
case .lineBreak:
append(run("\n", style), &out)
case .code(let c):
var a = run(c, style)
a.backgroundColor = Palette.surface
append(a, &out)
case .strong(let inner):
var s = style
s.bold = true
render(inner, s, &out)
case .em(let inner):
var s = style
s.italic = true
render(inner, s, &out)
case .image(let src, let alt):
if let source = worktreeImageSource(src, taskId) {
out.append(.image(source, src: src))
} else {
link(src, [.text(alt.isEmpty ? src : alt)], style, &out)
}
case .link(let href, let inner):
link(href, inner, style, &out)
}
}
}
private func link(_ href: String, _ inner: [MDInline], _ style: Style, _ out: inout [MDSegment]) {
var s = style
if httpLink.test(href), let url = URL(string: href) {
s.link = url
s.color = Palette.accent
render(inner, s, &out)
return
}
if let m = taskHref.exec(href) {
s.link = AppLink.task(m[1]!)
s.color = store.taskColor(m[1]!) ?? Palette.accent
render(inner, s, &out)
return
}
if let m = todoHref.exec(href), let title = m[2]!.removingPercentEncoding {
todo(m[1]!, title, inner, style, &out)
return
}
render(inner, style, &out)
}
private func todo(_ projectId: String, _ title: String, _ inner: [MDInline], _ style: Style, _ out: inout [MDSegment]) {
let task = (store.tasks[projectId] ?? []).first { $0.status == .active && $0.todoItem == title }
var s = style
if let task {
s.color = taskPalette[task.color]
render(inner, s, &out)
append(run(" ", style), &out)
let state = store.currentSession(task.id)?.state
var dot = Style(link: AppLink.task(task.id), color: state == .running ? Palette.ok : Palette.muted, small: true)
append(run(state == .running ? "●" : "○", dot), &out)
dot.color = Palette.muted
append(run(" \(state?.rawValue ?? "no agent")", dot), &out)
return
}
render(inner, style, &out)
append(run(" ", style), &out)
if store.todoStarting.contains("\(projectId)\n\(title)") {
append(run("starting…", Style(color: Palette.muted, small: true)), &out)
return
}
append(run("start", Style(link: AppLink.todo(projectId, title, discuss: false), color: Palette.accent, small: true)), &out)
append(run(" ", style), &out)
append(run("discuss", Style(link: AppLink.todo(projectId, title, discuss: true), color: Palette.accent, small: true)), &out)
}
}
struct MDContext {
var size: CGFloat = 13
var color: Color = Palette.text
var taskId: String?
var choice: ((MDItem) -> AnyView)?
}
struct MarkdownView: View {
let blocks: [MDBlock]
var ctx = MDContext()
var body: some View {
VStack(alignment: .leading, spacing: 8) {
ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
BlockView(block: block, ctx: ctx)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
struct InlineView: View {
let inlines: [MDInline]
let ctx: MDContext
@Environment(Store.self) private var store
var body: some View {
let segs = InlineRenderer(store: store, size: ctx.size, taskId: ctx.taskId).segments(inlines)
VStack(alignment: .leading, spacing: 4) {
ForEach(Array(segs.enumerated()), id: \.offset) { _, seg in
switch seg {
case .text(let a):
Text(a)
.font(Fonts.font(ctx.size))
.foregroundStyle(ctx.color)
.lineSpacing(3)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
case .image(let source, let src):
ByreImage(source: source, src: src)
}
}
}
}
}
private struct BlockView: View {
let block: MDBlock
let ctx: MDContext
var body: some View {
switch block {
case .paragraph(let inlines):
InlineView(inlines: inlines, ctx: ctx)
case .heading(let level, let inlines):
InlineView(inlines: [.strong(inlines)], ctx: MDContext(size: level == 3 ? 14 : 13, color: ctx.color, taskId: ctx.taskId))
.padding(.top, 4)
case .rule:
Rectangle()
.fill(Palette.border)
.frame(height: 1)
.padding(.vertical, 2)
case .quote(let blocks):
MarkdownView(blocks: blocks, ctx: MDContext(size: ctx.size, color: Palette.muted, taskId: ctx.taskId))
.padding(.leading, 13)
.overlay(alignment: .leading) {
Rectangle().fill(Palette.border).frame(width: 3)
}
case .list(let ordered, let start, let items, let grid):
ListBlock(ordered: ordered, start: start, items: items, grid: grid, ctx: ctx)
case .code(let code):
Copyable(text: code) {
ScrollView(.horizontal) {
Text(verbatim: code)
.font(Fonts.font(ctx.size))
.foregroundStyle(Palette.text)
.textSelection(.enabled)
.fixedSize()
.padding(.horizontal, 8)
.padding(.vertical, 6)
}
.scrollBounceBehavior(.basedOnSize)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Palette.surface, in: RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border))
}
case .diff(let lines):
DiffBlock(lines: lines, size: ctx.size)
case .details(let summary, let body):
DetailsBlock(summary: summary, blocks: body, ctx: ctx)
case .diagram(let source):
DiagramView(source: source)
case .table(let head, let rows):
TableBlock(head: head, rows: rows, ctx: ctx)
}
}
}
private struct ListBlock: View {
let ordered: Bool
let start: Int
let items: [MDItem]
let grid: Bool
let ctx: MDContext
var body: some View {
if grid, let choice = ctx.choice {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8, alignment: .top), count: 4), spacing: 8) {
ForEach(Array(items.enumerated()), id: \.offset) { _, item in
choice(item)
}
}
} else {
VStack(alignment: .leading, spacing: 2) {
ForEach(Array(items.enumerated()), id: \.offset) { n, item in
if item.choice != nil, let choice = ctx.choice {
choice(item)
} else {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(verbatim: ordered ? "\(start + n)." : "•")
.font(Fonts.font(ctx.size))
.foregroundStyle(ctx.color)
ItemBody(item: item, ctx: ctx)
}
}
}
}
}
}
}
struct ItemBody: View {
let item: MDItem
let ctx: MDContext
var body: some View {
VStack(alignment: .leading, spacing: 6) {
if !item.inline.isEmpty {
InlineView(inlines: item.inline, ctx: ctx)
}
if !item.blocks.isEmpty {
MarkdownView(blocks: item.blocks, ctx: ctx)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private struct DiffBlock: View {
let lines: [String]
let size: CGFloat
var body: some View {
Copyable(text: lines.map { $0 + "\n" }.joined()) {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
Text(verbatim: line.isEmpty ? " " : line)
.font(Fonts.font(size))
.foregroundStyle(line.hasPrefix("@@") ? Palette.muted : Palette.text)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 8)
.background(background(line))
}
}
.padding(.vertical, 6)
.background(Palette.surface, in: RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border))
}
}
private func background(_ l: String) -> Color {
if l.hasPrefix("@@") {
return Palette.surface
}
if l.hasPrefix("+++") || l.hasPrefix("---") {
return .clear
}
if l.hasPrefix("+") {
return Palette.ok.opacity(0.14)
}
if l.hasPrefix("-") {
return Palette.danger.opacity(0.14)
}
return .clear
}
}
private struct DetailsBlock: View {
let summary: [MDInline]
let blocks: [MDBlock]
let ctx: MDContext
@ViewState private var open = false
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Button {
open.toggle()
} label: {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(open ? "▾" : "▸")
.foregroundStyle(Palette.accent)
InlineView(inlines: summary, ctx: MDContext(size: ctx.size, color: Palette.accent, taskId: ctx.taskId))
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if open {
MarkdownView(blocks: blocks, ctx: ctx)
.padding(.leading, 11)
.overlay(alignment: .leading) {
Rectangle().fill(Palette.border).frame(width: 1)
}
}
}
.padding(.vertical, 2)
}
}
private struct TableBlock: View {
let head: [[MDInline]]
let rows: [[[MDInline]]]
let ctx: MDContext
var body: some View {
Grid(alignment: .topLeading, horizontalSpacing: 0, verticalSpacing: 0) {
GridRow {
ForEach(Array(head.enumerated()), id: \.offset) { _, cell in
cellView([.strong(cell)])
.background(Palette.surface)
}
}
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
GridRow {
ForEach(Array(row.enumerated()), id: \.offset) { _, cell in
cellView(cell)
}
}
}
}
.overlay(Rectangle().stroke(Palette.border))
}
private func cellView(_ cell: [MDInline]) -> some View {
InlineView(inlines: cell, ctx: ctx)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.overlay(Rectangle().stroke(Palette.border, lineWidth: 0.5))
}
}