import AppKit import ByreAPI import SwiftUI typealias WorkflowSummary = Components.Schemas.WorkflowSummary let goKeys: Set = ["k", "n", "p", "l", "s", "/"] @MainActor func runGo(_ key: String, _ store: Store, _ overlays: Overlays) { overlays.image = nil switch key { case "k": overlays.openSwitcher(store) case "n": overlays.switcher?.close() if let project = store.focusedProject() ?? store.projects.first { createTask(project.id, .init(), store, overlays) } case "p": overlays.window?.makeFirstResponder(nil) overlays.projectFocus += 1 store.focusTab(.project) case "l": overlays.switcher?.close() overlays.leftTab = .lead overlays.leadFocus += 1 case "s": guard let taskId = store.selectedTaskId else { break } Task { if let task = store.findTask(taskId), task.state == .discardSuggested { await confirmDiscard(task, store, overlays) return } await confirmMerge(taskId, store, overlays) } case "/": overlays.showAlert(ShortcutsHelp()) default: break } } @MainActor func createTask(_ projectId: String, _ body: Components.Schemas.TaskCreate, _ store: Store, _ overlays: Overlays) { Task { do { let task = try await store.api.client.createTask(path: .init(projectId: projectId), body: .json(body)).created.body.json store.composerFocus = task.id store.focusTask(task.id, .transcript) } catch { overlays.showAlert(errorText(error)) } } } @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"), ("⌘N", "New task in the current project"), ("⌘P", "Open the Project tab"), ("⌘L", "Open the Lead tab and its composer"), ("⌘S", "Merge the right pane's task, or discard it when the agent suggests discarding"), ("⌘[ ⌘]", "Bring the previous or next server's window forward"), ("⌘/", "Show these shortcuts"), ("Esc", "Close the switcher or a dialog"), ]), ("Task switcher", [ ("↑ ↓", "Move the highlight"), ("Enter", "Pick the highlighted row"), ("Shift+Enter", "New line in the first message"), ("name: message", "Start the task in the project whose name starts with name"), ("workflow title", "Start the workflow named workflow, titled title"), ]), ("Composer and 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 first message to start one. Paste or drop images to attach them." private let projectPrefix = Re(#"(?s)^([^:\s]+):\s*(.*)$"#) struct SwitcherRow: Identifiable { enum Kind { case task(Project, ByreTask, Int) case project(Project) case newTask(Project) case startTask(Project, String, Int) case workflow(Project, String, WorkflowSummary) } let id: String let kind: Kind } @MainActor @Observable final class SwitcherModel { let store: Store let overlays: Overlays let anchor: CGRect let imageKey = "switcher:\(UUID().uuidString)" var text = "" var selected = 0 var workflows: [WorkflowSummary] = [] init(store: Store, overlays: Overlays, anchor: CGRect) { self.store = store self.overlays = overlays self.anchor = anchor Task { do { workflows = try await store.api.client.listWorkflows().ok.body.json } catch { overlays.showAlert(errorText(error)) } } } private func workflowMatch(_ title: String) -> (WorkflowSummary, String)? { for w in workflows.sorted(by: { $0.name.count > $1.name.count }) { guard title.lowercased().hasPrefix(w.name.lowercased()) else { continue } var rest = title.dropFirst(w.name.count) guard rest.first.map({ $0 == ":" || $0.isWhitespace }) ?? false else { continue } if rest.first == ":" { rest = rest.dropFirst() } let t = trimmed(String(rest)) if !t.isEmpty { return (w, t) } } return nil } func rows() -> (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 row.task.background { return false } 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, rank: store.taskRank(row.task), visited: row.task.id == store.selectedTaskId ? Double.infinity : visits[row.task.id] ?? 0) } .sorted { a, b in if a.rank != b.rank { return a.rank < b.rank } if a.visited != b.visited { return a.visited < b.visited } return a.i < b.i } let images = store.images.images(imageKey).count var rows: [SwitcherRow] = [] if images == 0 { 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 focused = store.focusedProject()?.id let used = store.projects.enumerated().map { i, p in (i: i, project: p, at: p.id == focused ? Double.infinity : (store.tasks[p.id] ?? []).map { visits[$0.id] ?? 0 }.max() ?? 0) } let recent = used.sorted { $0.at != $1.at ? $0.at > $1.at : $0.i < $1.i }.map(\.project) let workflow = images == 0 ? workflowMatch(title) : nil for project in targets ?? recent { if title.isEmpty && images == 0 { rows.append(SwitcherRow(id: "new:\(project.id)", kind: .newTask(project))) continue } if let (w, rest) = workflow { rows.append(SwitcherRow(id: "workflow:\(project.id)", kind: .workflow(project, rest, w))) } rows.append(SwitcherRow(id: "go:\(project.id)", kind: .startTask(project, title, images))) } return (rows, "No active tasks. Type a first message to start 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): create(p.id, .init()) case .startTask(let p, let message, _): start(p.id, message) case .workflow(let p, let title, let w): create(p.id, .init(title: title, workflow: w.name)) } } func close() { if overlays.switcher === self { overlays.switcher = nil _ = store.images.take(imageKey) } } private func go(_ taskId: String) { close() if store.inbox.contains(where: { $0.taskId == taskId }) { store.showQuestion(taskId) } else { store.composerFocus = taskId store.focusTask(taskId, .transcript) } } private func create(_ projectId: String, _ body: Components.Schemas.TaskCreate) { close() createTask(projectId, body, store, overlays) } private func start(_ projectId: String, _ message: String) { let imgs = store.images.take(imageKey) let typed = text close() Task { do { let ids = try await uploadImages(store.api, imgs) let task = try await store.api.client.createTask(path: .init(projectId: projectId), body: .json(.init(prompt: message.isEmpty ? nil : message, images: ids, model: store.defaultModel))).created.body.json store.composerFocus = task.id store.focusTask(task.id, .transcript) } catch { overlays.openSwitcher(store) if let s = overlays.switcher { s.setText(typed) store.images.restore(s.imageKey, imgs) } 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: max(80, min(g.size.height * 0.5, g.size.height - model.anchor.maxY - 420))) } } } } 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(720, 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 @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays @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) { if !store.images.images(model.imageKey).isEmpty { ScrollView { Thumbnails(key: model.imageKey) .padding(.trailing, 8) } .scrollBounceBehavior(.basedOnSize) .frame(maxHeight: 110) .fixedSize(horizontal: false, vertical: true) } editor 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: overlayShadow, radius: 14, y: 8) .onAppear { DispatchQueue.main.async { logFocus("switcher before focus") focused = true DispatchQueue.main.async { logFocus("switcher after focus focused=\(focused)") } } } } private var editor: some View { Text(model.text.isEmpty ? switcherPlaceholder : model.text + " ") .font(Fonts.font(13)) .frame(maxWidth: .infinity, minHeight: 54, maxHeight: 240, alignment: .topLeading) .padding(.horizontal, 10) .padding(.vertical, 6) .hidden() .overlay { TextEditor(text: Binding(get: { model.text }, set: { model.setText($0) })) .font(Fonts.font(13)) .scrollContentBackground(.hidden) .writingToolsBehavior(.disabled) .focused($focused) .padding(.horizontal, 5) .padding(.vertical, 6) } .background(Palette.raised, in: RoundedRectangle(cornerRadius: 6)) .overlay(alignment: .topLeading) { if model.text.isEmpty { Text(switcherPlaceholder) .foregroundStyle(Palette.muted) .padding(.horizontal, 10) .padding(.vertical, 6) .allowsHitTesting(false) } } .overlay(RoundedRectangle(cornerRadius: 6).stroke(focused ? Palette.accent : Palette.border)) .imageInput(model.imageKey) } } 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] StateDot(filled: ts.kind == .working || ts.kind == .merging ? Palette.ok : ts.needsYou ? Palette.warn : nil) .help(ts.title) Text(taskTitle(project, task, color)) .lineLimit(1) .truncationMode(.tail) .frame(maxWidth: .infinity, alignment: .leading) if count > 0 { Badge(count: count).help("open questions") } if ts.kind == .idle && 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.name), unread") } else { Text(ts.label) .font(Fonts.font(11)) .foregroundStyle(ts.needsYou ? Palette.warn : Palette.muted) .help(ts.title) } case .project(let p): StateDot(filled: nil) Text(p.name) .foregroundStyle(Palette.muted) .lineLimit(1) .truncationMode(.tail) .frame(maxWidth: .infinity, alignment: .leading) case .newTask(let p): Text(created([("New task in ", nil), (p.name, Palette.muted)])) .foregroundStyle(Palette.accent) .lineLimit(1) .truncationMode(.tail) .frame(maxWidth: .infinity, alignment: .leading) case .startTask(let p, let message, let count): let attached = count == 0 ? "" : count == 1 ? " + 1 image" : " + \(count) images" Text(created([("Start in ", nil), (p.name, Palette.muted), (message.isEmpty ? attached : ": \(message)\(attached)", nil)])) .foregroundStyle(Palette.accent) .lineLimit(1) .truncationMode(.tail) .frame(maxWidth: .infinity, alignment: .leading) case .workflow(let p, let title, let w): VStack(alignment: .leading, spacing: 0) { Text(workflowTitle(p, title, w)) .lineLimit(1) .truncationMode(.tail) if !w.description.isEmpty { Text(w.description) .foregroundStyle(Palette.muted) .lineLimit(1) .truncationMode(.tail) } } .foregroundStyle(Palette.accent) .frame(maxWidth: .infinity, alignment: .leading) } } } 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)?.name ?? parent) ↳ ", Palette.muted)) } parts.append((task.name, 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 } }