import AppKit import SwiftUI typealias WorkflowSummary = Components.Schemas.WorkflowSummary let goKeys: Set = ["k", "p", "l", "s", "/"] @MainActor func runGo(_ key: String, _ store: Store, _ overlays: Overlays) { switch key { case "k": overlays.openSwitcher(store) case "p": store.focusTab(.project) case "l": overlays.leftTab = .lead overlays.leadFocus += 1 case "s": if let taskId = store.selectedTaskId { Task { await confirmMerge(taskId, store, overlays) } } case "/": overlays.showAlert(ShortcutsHelp()) default: break } } @MainActor func ctrlGo(_ key: String, _ store: Store, _ overlays: Overlays) -> Bool { if terminalFocused() { return false } runGo(key, store, overlays) return true } private let shortcuts: [(String, [(String, String)])] = [ ("Anywhere", [ ("⌘K", "Switch task, or create one"), ("⌘P", "Open the Project tab"), ("⌘L", "Open the Lead tab and its composer"), ("⌘S", "Merge the right pane's task"), ("⌘/", "Show these shortcuts"), ("Esc", "Close the switcher or a dialog"), ]), ("Task switcher", [ ("↑ ↓", "Move the highlight"), ("Enter", "Pick the highlighted row"), ("name: title", "Create the task in the project whose name starts with name"), ("Esc", "Back from the workflow list to the task list"), ]), ("Composer and inbox answers", [ ("Enter", "Send"), ("Shift+Enter", "New line"), ("Enter / Space", "Pick the focused choice"), ]), ("Dialogs", [("Enter", "Confirm")]), ("Terminal", [("Ctrl+…", "Goes to the shell, not to byre")]), ] private struct ShortcutsHelp: View { var body: some View { VStack(alignment: .leading, spacing: 0) { Text("Keyboard shortcuts") .font(Fonts.font(14, weight: 700)) .padding(.bottom, 12) ForEach(shortcuts, id: \.0) { group, rows in Text(group) .font(Fonts.font(12, weight: 700)) .foregroundStyle(Palette.muted) .padding(.bottom, 6) Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 4) { ForEach(rows, id: \.0) { keys, what in GridRow { Text(keys) .padding(.horizontal, 6) .background(Palette.bg, in: RoundedRectangle(cornerRadius: 4)) .overlay(RoundedRectangle(cornerRadius: 4).stroke(Palette.border)) Text(what) .fixedSize(horizontal: false, vertical: true) } } } .padding(.bottom, 14) } Text("Ctrl works in place of ⌘.") .foregroundStyle(Palette.muted) } .frame(maxWidth: .infinity, alignment: .leading) } } private func includes(_ s: String, _ q: String) -> Bool { q.isEmpty || s.contains(q) } private let switcherPlaceholder = "Go to a task, or type a title to create one…" private let projectPrefix = Re(#"^([^:\s]+):\s*(.*)$"#) struct SwitcherRow: Identifiable { enum Kind { case task(Project, ByreTask, Int) case project(Project) case newTask(Project, String) case startTask(Project, String) case startWorkflow(Project, String) case workflow(Project, String, WorkflowSummary) } let id: String let kind: Kind } @MainActor @Observable final class SwitcherModel { struct Picking { let seq: Int let project: Project let title: String let typed: String var workflows: [WorkflowSummary]? } let store: Store let overlays: Overlays let anchor: CGRect var text = "" var selected = 0 var picking: Picking? var focusRequest = 0 @ObservationIgnored private var picks = 0 init(store: Store, overlays: Overlays, anchor: CGRect) { self.store = store self.overlays = overlays self.anchor = anchor } var placeholder: String { picking.map { "Pick a workflow to start in \($0.project.name): \($0.title)" } ?? switcherPlaceholder } func rows() -> (rows: [SwitcherRow], empty: String) { if let picking { return workflowRows(picking) } return taskRows() } private func workflowRows(_ p: Picking) -> (rows: [SwitcherRow], empty: String) { let q = trimmed(text).lowercased() let rows = (p.workflows ?? []) .filter { includes("\($0.name) \($0.description)".lowercased(), q) } .map { SwitcherRow(id: "workflow:\($0.name)", kind: .workflow(p.project, p.title, $0)) } guard let workflows = p.workflows else { return (rows, "Loading workflows…") } return (rows, workflows.isEmpty ? "No saved workflows." : "No workflow matches.") } private func taskRows() -> (rows: [SwitcherRow], empty: String) { let typed = trimmed(text) let prefix = projectPrefix.exec(typed) let named = prefix.map { m in store.projects.filter { $0.name.lowercased().hasPrefix(m[1]!.lowercased()) } } ?? [] let targets = named.isEmpty ? nil : named let title = targets != nil ? prefix![2]! : typed let q = title.lowercased() let visits = store.lastVisits() let matches = store.activeTasks() .filter { row in if let targets { return targets.contains { $0.id == row.project.id } && includes(row.task.title.lowercased(), q) } return includes("\(row.project.name) \(row.task.title)".lowercased(), q) } .enumerated() .map { i, row in (i: i, project: row.project, task: row.task, count: store.inbox.filter { $0.taskId == row.task.id }.count, visited: row.task.id == store.selectedTaskId ? Double.infinity : visits[row.task.id] ?? 0) } .sorted { a, b in if (a.count > 0) != (b.count > 0) { return a.count > 0 } if a.visited != b.visited { return a.visited < b.visited } return a.i < b.i } var rows = matches.map { SwitcherRow(id: "task:\($0.task.id)", kind: .task($0.project, $0.task, $0.count)) } for p in (targets ?? store.projects) where includes("\(p.name) project".lowercased(), q) { rows.append(SwitcherRow(id: "project:\(p.id)", kind: .project(p))) } let fallback = store.focusedProject() ?? store.projects.first for project in title.isEmpty ? [] : targets ?? (fallback.map { [$0] } ?? []) { rows.append(SwitcherRow(id: "new:\(project.id)", kind: .newTask(project, title))) rows.append(SwitcherRow(id: "go:\(project.id)", kind: .startTask(project, title))) rows.append(SwitcherRow(id: "start:\(project.id)", kind: .startWorkflow(project, title))) } return (rows, "No active tasks. Type a title to create one.") } func setText(_ value: String) { text = value selected = 0 } func move(_ by: Int) { let count = rows().rows.count selected = max(0, min(selected + by, count - 1)) } func pick() { let all = rows().rows guard !all.isEmpty else { return } pick(all[min(selected, all.count - 1)]) } func pick(_ row: SwitcherRow) { switch row.kind { case .task(_, let task, _): go(task.id) case .project(let p): close() store.focusProject(p.id) case .newTask(let p, let title): create(p.id, .init(title: title)) case .startTask(let p, let title): create(p.id, .init(title: title, prompt: title, model: models[0])) case .startWorkflow(let p, let title): pickWorkflow(p, title) case .workflow(let p, let title, let w): create(p.id, .init(title: title, workflow: w.name)) } } func escape() { guard let picking else { close() return } text = picking.typed self.picking = nil selected = 0 } func close() { if overlays.switcher === self { overlays.switcher = nil } } private func go(_ taskId: String) { close() if store.inbox.contains(where: { $0.taskId == taskId }) { store.focusTask(taskId, .inbox) } else if let task = store.findTask(taskId), store.unreadSummary(task) { store.openSummary(task) } else { store.focusTask(taskId, .transcript) } } private func create(_ projectId: String, _ body: Components.Schemas.TaskCreate) { close() Task { do { let task = try await store.api.client.createTask(path: .init(projectId: projectId), body: .json(body)).created.body.json go(task.id) } catch { overlays.showAlert(errorText(error)) } } } private func pickWorkflow(_ project: Project, _ title: String) { picks += 1 let seq = picks picking = Picking(seq: seq, project: project, title: title, typed: text) text = "" selected = 0 focusRequest += 1 Task { do { let list = try await store.api.client.listWorkflows().ok.body.json if picking?.seq == seq { picking?.workflows = list } } catch { close() overlays.showAlert(errorText(error)) } } } } struct SwitcherPlacement: View { let model: SwitcherModel var body: some View { GeometryReader { g in SwitcherLayout(anchor: model.anchor) { SwitcherBox(model: model, maxList: g.size.height * 0.5) } } } } private struct SwitcherLayout: Layout { let anchor: CGRect func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { proposal.replacingUnspecifiedDimensions() } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { let width = min(560, bounds.width * 0.9) let size = subviews[0].sizeThatFits(ProposedViewSize(width: width, height: nil)) let left = anchor.midX - width / 2 let x = max(overlayMargin, min(left, bounds.width - width - overlayMargin)) subviews[0].place(at: CGPoint(x: bounds.minX + x, y: bounds.minY + anchor.maxY + 4), proposal: ProposedViewSize(width: width, height: size.height)) } } private struct SwitcherBox: View { let model: SwitcherModel let maxList: CGFloat @FocusState private var focused: Bool var body: some View { let (rows, empty) = model.rows() let selected = min(model.selected, max(rows.count - 1, 0)) let selectedId = rows.isEmpty ? nil : rows[selected].id VStack(alignment: .leading, spacing: 8) { TextField(model.placeholder, text: Binding(get: { model.text }, set: { model.setText($0) })) .modifier(FieldStyle(focused: focused)) .focused($focused) ScrollViewReader { proxy in ScrollView { VStack(alignment: .leading, spacing: 0) { if rows.isEmpty { Text(empty) .foregroundStyle(Palette.muted) .padding(.horizontal, 10) .padding(.vertical, 6) } ForEach(Array(rows.enumerated()), id: \.element.id) { i, row in SwitcherRowView(row: row) .padding(.horizontal, 10) .padding(.vertical, 6) .frame(maxWidth: .infinity, alignment: .leading) .background(i == selected ? Palette.accentTint : .clear, in: RoundedRectangle(cornerRadius: 6)) .contentShape(Rectangle()) .onTapGesture { model.pick(row) } .id(row.id) } } .frame(maxWidth: .infinity, alignment: .leading) } .scrollBounceBehavior(.basedOnSize) .frame(maxHeight: maxList) .fixedSize(horizontal: false, vertical: true) .onChange(of: "\(selectedId ?? "")\n\(model.text)") { if let selectedId { proxy.scrollTo(selectedId) } } } } .padding(10) .background(Palette.raised, in: RoundedRectangle(cornerRadius: 10)) .overlay(RoundedRectangle(cornerRadius: 10).stroke(Palette.border)) .shadow(color: .black.opacity(0.18), radius: 14, y: 8) .onAppear { DispatchQueue.main.async { focused = true } } .onChange(of: model.focusRequest) { focused = true } } } private struct SwitcherRowView: View { let row: SwitcherRow @Environment(Store.self) private var store var body: some View { HStack(spacing: 8) { switch row.kind { case .task(let project, let task, let count): let ts = store.taskState(task) let color = taskPalette[task.color] StateMark(state: ts, color: color) Text(taskTitle(project, task, color)) .lineLimit(1) .truncationMode(.tail) .frame(maxWidth: .infinity, alignment: .leading) if count > 0 { Badge(count: count).help("inbox items") } if store.unreadSummary(task) { Text("new summary") .font(Fonts.font(11, weight: 700)) .foregroundStyle(Palette.accent) .help("A turn summary you haven't read") .accessibilityLabel("New summary from \(task.title), unread") } Text(ts.label) .font(Fonts.font(11)) .foregroundStyle(ts.kind == .answer ? Palette.warn : ts.kind == .error ? Palette.danger : Palette.muted) .help(ts.title) case .project(let p): StateDot(filled: nil) Text(p.name) .foregroundStyle(Palette.muted) .frame(maxWidth: .infinity, alignment: .leading) state("project") case .newTask(let p, let title): Text(created([("New task in ", nil), (p.name, Palette.muted), (": \(title)", nil)])) .foregroundStyle(Palette.accent) .frame(maxWidth: .infinity, alignment: .leading) state(models[0]) .help("Model for the first message") case .startTask(let p, let title): Text(created([("Start in ", nil), (p.name, Palette.muted), (": \(title)", nil)])) .foregroundStyle(Palette.accent) .frame(maxWidth: .infinity, alignment: .leading) state(models[0]) .help("The title is the agent's first message") case .startWorkflow(let p, let title): Text(created([("Start workflow… in ", nil), (p.name, Palette.muted), (": \(title)", nil)])) .foregroundStyle(Palette.accent) .frame(maxWidth: .infinity, alignment: .leading) state("workflow") case .workflow(let p, let title, let w): VStack(alignment: .leading, spacing: 0) { Text(workflowTitle(p, title, w)) if !w.description.isEmpty { Text(w.description) .foregroundStyle(Palette.muted) .fixedSize(horizontal: false, vertical: true) } } .foregroundStyle(Palette.accent) .frame(maxWidth: .infinity, alignment: .leading) state("workflow") } } } private func state(_ text: String) -> some View { Text(text) .font(Fonts.font(11)) .foregroundStyle(Palette.muted) } private func taskTitle(_ project: Project, _ task: ByreTask, _ color: Color) -> AttributedString { var parts: [(String, Color?)] = [(project.name, Palette.muted), (" ", nil)] if let parent = task.parentTaskId { parts.append(("\(store.findTask(parent)?.title ?? parent) ↳ ", Palette.muted)) } parts.append((task.title, color)) return created(parts) } private func workflowTitle(_ p: Project, _ title: String, _ w: WorkflowSummary) -> AttributedString { var out = AttributedString("Start ") var name = AttributedString(w.name) name.font = Fonts.font(13, weight: 700) out += name out += AttributedString(" in ") var project = AttributedString(p.name) project.foregroundColor = Palette.muted out += project out += AttributedString(": \(title)") return out } private func created(_ parts: [(String, Color?)]) -> AttributedString { var out = AttributedString() for (text, color) in parts { var a = AttributedString(text) if let color { a.foregroundColor = color } out += a } return out } }