diff --git a/cmd/byre/clientexec.go b/cmd/byre/clientexec.go index b4fe141..3a9fdeb 100644 --- a/cmd/byre/clientexec.go +++ b/cmd/byre/clientexec.go @@ -13,6 +13,7 @@ import ( "time" "g.fc.run/ai/byre/internal/api" + "g.fc.run/ai/byre/internal/shell" ) const ( @@ -36,7 +37,7 @@ func clientExecCmd(args []string) { words := make([]string, fs.NArg()) for i, w := range fs.Args() { - words[i] = "'" + strings.ReplaceAll(w, "'", `'\''`) + "'" + words[i] = shell.Quote(w) } req := api.ClientExec{Command: strings.Join(words, " "), TimeoutMs: *timeout * 1000} if *client != "" { diff --git a/cmd/byre/main.go b/cmd/byre/main.go index 199e507..98b1a53 100644 --- a/cmd/byre/main.go +++ b/cmd/byre/main.go @@ -20,8 +20,6 @@ func main() { checkCmd() case "install-tool": installToolCmd(os.Args[2:]) - case "run": - runCmd(os.Args[2:]) case "signing": signingCmd(os.Args[2:]) case "grep": @@ -38,7 +36,7 @@ func main() { } func usage() { - fmt.Fprintln(os.Stderr, "usage: byre serve | byre install | byre check | byre install-tool NAME | byre run [--resume ID] | byre signing CHECKOUT | byre grep [--glob GLOB] [--ignore-case] [--files-only] PATTERN PATH | byre list PATH | byre client-exec [--client NAME] [--timeout SECS] -- CMD... | byre screen") + fmt.Fprintln(os.Stderr, "usage: byre serve | byre install | byre check | byre install-tool NAME | byre signing CHECKOUT | byre grep [--glob GLOB] [--ignore-case] [--files-only] PATTERN PATH | byre list PATH | byre client-exec [--client NAME] [--timeout SECS] -- CMD... | byre screen") os.Exit(2) } diff --git a/cmd/byre/run.go b/cmd/byre/run.go deleted file mode 100644 index 2f75865..0000000 --- a/cmd/byre/run.go +++ /dev/null @@ -1,173 +0,0 @@ -package main - -import ( - "bufio" - "context" - "encoding/json" - "flag" - "fmt" - "log/slog" - "os" - "os/exec" - "os/signal" - "strings" - - "g.fc.run/ai/byre/internal/claude" - "g.fc.run/ai/byre/internal/git" -) - -func runCmd(args []string) { - fs := flag.NewFlagSet("run", flag.ExitOnError) - resume := fs.String("resume", "", "claude session id to resume") - fs.Parse(args) - - dir, err := os.Getwd() - if err != nil { - fatal("getwd", "err", err) - } - - ctx := context.Background() - s, err := claude.Start(ctx, claude.Options{ - Cwd: dir, - Resume: *resume, - Env: git.IdentityEnv(gitConfig(dir, "user.name"), gitConfig(dir, "user.email")), - }) - if err != nil { - fatal("start claude", "err", err) - } - - sigs := make(chan os.Signal, 1) - signal.Notify(sigs, os.Interrupt) - - lines := make(chan string) - go readLines(lines) - - for { - fmt.Print("> ") - - select { - case line, ok := <-lines: - if !ok { - shutdown(s) - return - } - if strings.TrimSpace(line) == "" { - continue - } - err := s.Send(line) - if err != nil { - fatal("send", "err", err) - } - runTurn(ctx, s, sigs) - - case <-sigs: - fmt.Println() - shutdown(s) - return - } - } -} - -func runTurn(ctx context.Context, s *claude.Session, sigs <-chan os.Signal) { - for { - select { - case ev, ok := <-s.Events(): - if !ok { - fatal("claude exited") - } - if printEvent(ev) { - return - } - - case <-sigs: - fmt.Println("\n[interrupting]") - err := s.Interrupt(ctx) - if err != nil { - slog.Error("interrupt", "err", err) - } - } - } -} - -func printEvent(ev claude.Event) bool { - switch { - case ev.Type == "system" && ev.Subtype == "init": - info := struct { - SessionID string `json:"session_id"` - Model string `json:"model"` - }{} - err := json.Unmarshal(ev.Raw, &info) - if err != nil { - fatal("parse init", "err", err) - } - fmt.Printf("[session %s, %s]\n", info.SessionID, info.Model) - - case ev.Type == "assistant": - blocks, err := ev.Blocks() - if err != nil { - fatal("parse assistant", "err", err) - } - for _, b := range blocks { - switch b.Type { - case "text": - fmt.Println(b.Text) - case "tool_use": - fmt.Printf("→ %s %s\n", b.Name, oneLine(string(b.Input), 160)) - } - } - - case ev.Type == "user": - blocks, err := ev.Blocks() - if err != nil { - fatal("parse user", "err", err) - } - for _, b := range blocks { - if b.Type == "tool_result" && b.IsError { - fmt.Printf("← error %s\n", oneLine(string(b.Content), 160)) - } - } - - case ev.Type == "result": - res, err := ev.Result() - if err != nil { - fatal("parse result", "err", err) - } - fmt.Printf("[%s, %d turns, %.1fs, $%.4f]\n", res.Subtype, res.NumTurns, float64(res.DurationMS)/1000, res.TotalCostUSD) - return true - } - - return false -} - -func readLines(out chan<- string) { - defer close(out) - sc := bufio.NewScanner(os.Stdin) - for sc.Scan() { - out <- sc.Text() - } -} - -func shutdown(s *claude.Session) { - err := s.Close() - if err != nil { - slog.Warn("claude exit", "err", err) - } -} - -func gitConfig(dir, key string) string { - cmd := exec.Command("git", "config", key) - cmd.Dir = dir - out, err := cmd.Output() - if err != nil { - fatal("git config", "key", key, "err", err) - } - return strings.TrimSpace(string(out)) -} - -func oneLine(s string, max int) string { - s = strings.Join(strings.Fields(s), " ") - if len(s) > max { - return s[:max] + "…" - } - return s -} diff --git a/cmd/byre/tool.go b/cmd/byre/tool.go index 8db402d..406c656 100644 --- a/cmd/byre/tool.go +++ b/cmd/byre/tool.go @@ -7,6 +7,8 @@ import ( "path/filepath" "strings" "syscall" + + "g.fc.run/ai/byre/internal/shell" ) type brewTool struct { @@ -75,7 +77,7 @@ func installCommand(name string) string { if err != nil { fatal("find executable", "err", err) } - return "'" + strings.ReplaceAll(exe, "'", `'\''`) + "' install-tool " + name + return shell.Quote(exe) + " install-tool " + name } func codexHome() string { diff --git a/internal/agents/clients.go b/internal/agents/clients.go index 624af35..0b0265f 100644 --- a/internal/agents/clients.go +++ b/internal/agents/clients.go @@ -16,6 +16,7 @@ import ( "g.fc.run/ai/byre/internal/api" "g.fc.run/ai/byre/internal/clients" "g.fc.run/ai/byre/internal/mcp" + "g.fc.run/ai/byre/internal/shell" ) const ( @@ -407,7 +408,7 @@ func (m *Manager) clientOpen(ctx context.Context, l *live, raw json.RawMessage) return "", err } - command := "open " + dir + "/" + shellQuote(name) + command := "open " + dir + "/" + shell.Quote(name) exitCode, output, err := m.runClientBash(ctx, args.Client, command) if err != nil { return "", err @@ -702,10 +703,6 @@ type clientGrepArgs struct { grepArgs } -func shellQuote(s string) string { - return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" -} - func (m *Manager) clientGrep(ctx context.Context, raw json.RawMessage) (string, error) { args := clientGrepArgs{} err := json.Unmarshal(raw, &args) @@ -718,7 +715,7 @@ func (m *Manager) clientGrep(ctx context.Context, raw json.RawMessage) (string, } flags := "" if args.Glob != "" { - flags += " --glob " + shellQuote(args.Glob) + flags += " --glob " + shell.Quote(args.Glob) } if args.IgnoreCase { flags += " --ignore-case" @@ -726,7 +723,7 @@ func (m *Manager) clientGrep(ctx context.Context, raw json.RawMessage) (string, if args.FilesOnly { flags += " --files-only" } - return m.runClientByre(ctx, args.Client, "grep", flags+" -- "+shellQuote(args.Pattern)+" "+path) + return m.runClientByre(ctx, args.Client, "grep", flags+" -- "+shell.Quote(args.Pattern)+" "+path) } type clientListArgs struct { @@ -754,9 +751,9 @@ func clientShellPath(p string) (string, error) { } rest, ok := strings.CutPrefix(p, "~/") if ok { - return "~/" + shellQuote(rest), nil + return "~/" + shell.Quote(rest), nil } - return shellQuote(p), nil + return shell.Quote(p), nil } func (m *Manager) runClientByre(ctx context.Context, client, sub, args string) (string, error) { diff --git a/internal/agents/inbox.go b/internal/agents/inbox.go index ca3c3b1..b920f03 100644 --- a/internal/agents/inbox.go +++ b/internal/agents/inbox.go @@ -558,11 +558,7 @@ func deliveryMessage(pending []store.Pending) string { fmt.Fprintf(b, "%s added: %s\n", who, *r.Text) } case api.Other: - text := "" - if r.Text != nil { - text = *r.Text - } - fmt.Fprintf(b, "%s answered (none of your options): %s\n", who, text) + fmt.Fprintf(b, "%s answered (none of your options): %s\n", who, api.Deref(r.Text)) } if len(r.Images) > 0 { diff --git a/internal/agents/lead.go b/internal/agents/lead.go index dd10ca5..96c1845 100644 --- a/internal/agents/lead.go +++ b/internal/agents/lead.go @@ -522,7 +522,7 @@ func (m *Manager) freedText(ctx context.Context, owner string, ev api.Event) (st if err != nil { return "", err } - if f.TaskId == nil || *f.TaskId != deref(ev.TaskId) { + if f.TaskId == nil || *f.TaskId != api.Deref(ev.TaskId) { return "", nil } finished, err := m.paneFinished(ctx, f) @@ -540,7 +540,7 @@ func (m *Manager) waitingText(ctx context.Context, owner string, ev api.Event) ( if err != nil { return "", err } - if f.TaskId == nil || *f.TaskId != deref(ev.TaskId) { + if f.TaskId == nil || *f.TaskId != api.Deref(ev.TaskId) { return "", nil } waiting, err := m.paneWaiting(ctx, owner, f) @@ -576,10 +576,10 @@ func (m *Manager) signalText(ctx context.Context, ev api.Event) (string, error) return m.itemText(ctx, item), nil case "session.error": - return fmt.Sprintf("%s hit an error: %v", m.taskLabel(ctx, deref(ev.TaskId)), ev.Payload["error"]), nil + return fmt.Sprintf("%s hit an error: %v", m.taskLabel(ctx, api.Deref(ev.TaskId)), ev.Payload["error"]), nil case "session.rate_limited": - return fmt.Sprintf("%s was rate limited: %v", m.taskLabel(ctx, deref(ev.TaskId)), ev.Payload["error"]), nil + return fmt.Sprintf("%s was rate limited: %v", m.taskLabel(ctx, api.Deref(ev.TaskId)), ev.Payload["error"]), nil case "task.created": task := api.Task{} @@ -602,50 +602,50 @@ func (m *Manager) signalText(ctx context.Context, ev api.Event) (string, error) return fmt.Sprintf("%s was created by %s from TODO item %q.", m.taskLabel(ctx, task.Id), ev.Actor, *task.TodoItem), nil case "task.merge_queued": - return fmt.Sprintf("%s's merge was requested by %s and is held until the merge in progress in its project is done.", m.taskLabel(ctx, deref(ev.TaskId)), ev.Actor), nil + return fmt.Sprintf("%s's merge was requested by %s and is held until the merge in progress in its project is done.", m.taskLabel(ctx, api.Deref(ev.TaskId)), ev.Actor), nil case "task.merge_after_review": - return fmt.Sprintf("%s was set by %s to merge once the reviews of snapshot #%v finish with no findings.", m.taskLabel(ctx, deref(ev.TaskId)), ev.Actor, ev.Payload["snapshot_n"]), nil + return fmt.Sprintf("%s was set by %s to merge once the reviews of snapshot #%v finish with no findings.", m.taskLabel(ctx, api.Deref(ev.TaskId)), ev.Actor, ev.Payload["snapshot_n"]), nil case "task.merge_after_review_canceled": - return fmt.Sprintf("%s's merge after review was canceled by %s.", m.taskLabel(ctx, deref(ev.TaskId)), ev.Actor), nil + return fmt.Sprintf("%s's merge after review was canceled by %s.", m.taskLabel(ctx, api.Deref(ev.TaskId)), ev.Actor), nil case "task.merge_after_review_dropped": - return fmt.Sprintf("%s didn't merge after the review: %v", m.taskLabel(ctx, deref(ev.TaskId)), ev.Payload["reason"]), nil + return fmt.Sprintf("%s didn't merge after the review: %v", m.taskLabel(ctx, api.Deref(ev.TaskId)), ev.Payload["reason"]), nil case "task.merge_started": - return fmt.Sprintf("%s is merging, started by %s.", m.taskLabel(ctx, deref(ev.TaskId)), ev.Actor), nil + return fmt.Sprintf("%s is merging, started by %s.", m.taskLabel(ctx, api.Deref(ev.TaskId)), ev.Actor), nil case "task.merged": commit, _ := ev.Payload["commit"].(string) if commit == "" { - return fmt.Sprintf("%s was merged by %s with nothing to commit.", m.taskLabel(ctx, deref(ev.TaskId)), ev.Actor), nil + return fmt.Sprintf("%s was merged by %s with nothing to commit.", m.taskLabel(ctx, api.Deref(ev.TaskId)), ev.Actor), nil } if into, ok := ev.Payload["parent_task_id"].(string); ok { - return fmt.Sprintf("%s was merged by %s into %s, now at %s.", m.taskLabel(ctx, deref(ev.TaskId)), ev.Actor, m.taskLabel(ctx, into), short(commit)), nil + return fmt.Sprintf("%s was merged by %s into %s, now at %s.", m.taskLabel(ctx, api.Deref(ev.TaskId)), ev.Actor, m.taskLabel(ctx, into), short(commit)), nil } - return fmt.Sprintf("%s was merged by %s as %s and pushed to %v.", m.taskLabel(ctx, deref(ev.TaskId)), ev.Actor, short(commit), ev.Payload["pushed"]), nil + return fmt.Sprintf("%s was merged by %s as %s and pushed to %v.", m.taskLabel(ctx, api.Deref(ev.TaskId)), ev.Actor, short(commit), ev.Payload["pushed"]), nil case "task.discarded": - return fmt.Sprintf("%s was discarded by %s.", m.taskLabel(ctx, deref(ev.TaskId)), ev.Actor), nil + return fmt.Sprintf("%s was discarded by %s.", m.taskLabel(ctx, api.Deref(ev.TaskId)), ev.Actor), nil case "task.ready": - return fmt.Sprintf("%s's agent says the task is ready to merge:\n%s", m.taskLabel(ctx, deref(ev.TaskId)), clip(fmt.Sprint(ev.Payload["overview"]), 3000)), nil + return fmt.Sprintf("%s's agent says the task is ready to merge:\n%s", m.taskLabel(ctx, api.Deref(ev.TaskId)), clip(fmt.Sprint(ev.Payload["overview"]), 3000)), nil case "task.discard_suggested": - return fmt.Sprintf("%s's agent suggests discarding the task:\n%s", m.taskLabel(ctx, deref(ev.TaskId)), clip(fmt.Sprint(ev.Payload["reason"]), 3000)), nil + return fmt.Sprintf("%s's agent suggests discarding the task:\n%s", m.taskLabel(ctx, api.Deref(ev.TaskId)), clip(fmt.Sprint(ev.Payload["reason"]), 3000)), nil case "task.ready_cleared": if ev.Actor == agentActor { - return fmt.Sprintf("%s's agent withdrew its ready for merge or discard suggestion.", m.taskLabel(ctx, deref(ev.TaskId))), nil + return fmt.Sprintf("%s's agent withdrew its ready for merge or discard suggestion.", m.taskLabel(ctx, api.Deref(ev.TaskId))), nil } - return fmt.Sprintf("%s's ready for merge or discard suggestion was dismissed by %s.", m.taskLabel(ctx, deref(ev.TaskId)), ev.Actor), nil + return fmt.Sprintf("%s's ready for merge or discard suggestion was dismissed by %s.", m.taskLabel(ctx, api.Deref(ev.TaskId)), ev.Actor), nil case "task.workflow_stage": - return fmt.Sprintf("%s started stage %q of workflow %q.", m.taskLabel(ctx, deref(ev.TaskId)), fmt.Sprint(ev.Payload["stage"]), ev.Payload["workflow"]), nil + return fmt.Sprintf("%s started stage %q of workflow %q.", m.taskLabel(ctx, api.Deref(ev.TaskId)), fmt.Sprint(ev.Payload["stage"]), ev.Payload["workflow"]), nil case "task.workflow_finished": - return fmt.Sprintf("%s finished workflow %q:\n%s", m.taskLabel(ctx, deref(ev.TaskId)), ev.Payload["workflow"], clip(fmt.Sprint(ev.Payload["output"]), 1500)), nil + return fmt.Sprintf("%s finished workflow %q:\n%s", m.taskLabel(ctx, api.Deref(ev.TaskId)), ev.Payload["workflow"], clip(fmt.Sprint(ev.Payload["output"]), 1500)), nil case "review.started", "review.finished": r := api.Review{} @@ -660,21 +660,21 @@ func (m *Manager) signalText(ctx context.Context, ev api.Event) (string, error) case "task.state": if ev.Payload["from"] == "" { - return fmt.Sprintf("%s state: %v.", m.taskLabel(ctx, deref(ev.TaskId)), ev.Payload["to"]), nil + return fmt.Sprintf("%s state: %v.", m.taskLabel(ctx, api.Deref(ev.TaskId)), ev.Payload["to"]), nil } - return fmt.Sprintf("%s state: %v → %v.", m.taskLabel(ctx, deref(ev.TaskId)), ev.Payload["from"], ev.Payload["to"]), nil + return fmt.Sprintf("%s state: %v → %v.", m.taskLabel(ctx, api.Deref(ev.TaskId)), ev.Payload["from"], ev.Payload["to"]), nil case "user.message": - return fmt.Sprintf("%s sent %s a message; its agent is working on it:\n%v", ev.Actor, m.taskLabel(ctx, deref(ev.TaskId)), ev.Payload["text"]), nil + return fmt.Sprintf("%s sent %s a message; its agent is working on it:\n%v", ev.Actor, m.taskLabel(ctx, api.Deref(ev.TaskId)), ev.Payload["text"]), nil case "turn.ended": if ev.Payload["summary"] != true { return "", nil } if member, _ := ev.Payload["member"].(string); member != "" { - return fmt.Sprintf("%s's agent %q finished a turn:\n%s", m.taskLabel(ctx, deref(ev.TaskId)), member, clip(fmt.Sprint(ev.Payload["result"]), 1500)), nil + return fmt.Sprintf("%s's agent %q finished a turn:\n%s", m.taskLabel(ctx, api.Deref(ev.TaskId)), member, clip(fmt.Sprint(ev.Payload["result"]), 1500)), nil } - return fmt.Sprintf("%s finished a turn:\n%s", m.taskLabel(ctx, deref(ev.TaskId)), clip(fmt.Sprint(ev.Payload["result"]), 1500)), nil + return fmt.Sprintf("%s finished a turn:\n%s", m.taskLabel(ctx, api.Deref(ev.TaskId)), clip(fmt.Sprint(ev.Payload["result"]), 1500)), nil } return "", nil } @@ -754,10 +754,3 @@ func short(sha string) string { } return sha } - -func deref(s *string) string { - if s == nil { - return "" - } - return *s -} diff --git a/internal/agents/leadnotes.go b/internal/agents/leadnotes.go index 4e4d160..bda79d3 100644 --- a/internal/agents/leadnotes.go +++ b/internal/agents/leadnotes.go @@ -170,7 +170,7 @@ func (m *Manager) notesText(ctx context.Context, owner, onlyProject string) (str func writeNotes(b *strings.Builder, notes []api.LeadNote, projectID, heading string) { scoped := []api.LeadNote{} for _, n := range notes { - if deref(n.ProjectId) == projectID { + if api.Deref(n.ProjectId) == projectID { scoped = append(scoped, n) } } diff --git a/internal/agents/leadtools.go b/internal/agents/leadtools.go index ad35969..2b2f63e 100644 --- a/internal/agents/leadtools.go +++ b/internal/agents/leadtools.go @@ -682,11 +682,11 @@ func (m *Manager) needsHumanElsewhere(ctx context.Context, login, shown string) } func sameView(a, b api.Focus) bool { - at, bt := deref(a.TaskId), deref(b.TaskId) + at, bt := api.Deref(a.TaskId), api.Deref(b.TaskId) if at != "" || bt != "" { return at == bt } - return deref(a.ProjectId) == deref(b.ProjectId) + return api.Deref(a.ProjectId) == api.Deref(b.ProjectId) } func (m *Manager) paneFinished(ctx context.Context, f api.Focus) (string, error) { diff --git a/internal/agents/manager.go b/internal/agents/manager.go index ad801a2..5e90f36 100644 --- a/internal/agents/manager.go +++ b/internal/agents/manager.go @@ -661,13 +661,7 @@ func (m *Manager) deliverRecorded(ctx context.Context, sess api.AgentSession, ta } text, _ := ev.Payload["text"].(string) client, _ := ev.Payload["client"].(string) - ids := []string{} - raw, _ := ev.Payload["images"].([]any) - for _, id := range raw { - s, _ := id.(string) - ids = append(ids, s) - } - imgs, err := m.readImages(ctx, ids) + imgs, err := m.readImages(ctx, anyStrings(ev.Payload["images"])) if err != nil { m.failStart(ctx, sess.Id, ev.Actor, err) return true, err diff --git a/internal/agents/review.go b/internal/agents/review.go index 84fbca7..b2f1d68 100644 --- a/internal/agents/review.go +++ b/internal/agents/review.go @@ -410,12 +410,8 @@ func findingsMessage(n int, reviews []api.Review) string { fmt.Fprintf(b, "\n\n## Reviewer %q", r.Stage) for i, f := range findings { fm, _ := f.(map[string]any) - where := "" - if p, ok := fm["path"].(string); ok && p != "" { - where = p - if line, ok := fm["line"].(float64); ok && line > 0 { - where += fmt.Sprintf(":%d", int(line)) - } + where := findingLocation(fm) + if where != "" { where = " (`" + where + "`)" } fmt.Fprintf(b, "\n\n**Finding %d: [%v] %v**%s\n\n%s", i+1, fm["severity"], fm["title"], where, strings.TrimSpace(fmt.Sprint(fm["detail"]))) @@ -683,10 +679,7 @@ func reviewLine(r api.Review) string { if r.State == api.ReviewStateSkipped { return fmt.Sprintf("%s skipped: no changes", reviewName(r)) } - res := map[string]any{} - if r.Result != nil { - res = *r.Result - } + res := api.Deref(r.Result) b := &strings.Builder{} fmt.Fprintf(b, "%s: %s", reviewName(r), clip(strings.TrimSpace(fmt.Sprint(res["summary"])), 600)) if c, ok := res["change"].(string); ok && c != "" { @@ -697,27 +690,32 @@ func reviewLine(r api.Review) string { } func reviewText(r api.Review) string { - res := map[string]any{} - if r.Result != nil { - res = *r.Result - } + res := api.Deref(r.Result) b := &strings.Builder{} b.WriteString(strings.TrimSpace(fmt.Sprint(res["summary"]))) writeFindings(b, res, "\n", 4000) return b.String() } +func findingLocation(finding map[string]any) string { + path, _ := finding["path"].(string) + if path == "" { + return "" + } + line, _ := finding["line"].(float64) + if line > 0 { + return fmt.Sprintf("%s:%d", path, int(line)) + } + return path +} + func writeFindings(b *strings.Builder, res map[string]any, lead string, limit int) { findings, _ := res["findings"].([]any) for _, f := range findings { fm, _ := f.(map[string]any) - where := "" - if p, ok := fm["path"].(string); ok && p != "" { - where = " (" + p - if line, ok := fm["line"].(float64); ok && line > 0 { - where += fmt.Sprintf(":%d", int(line)) - } - where += ")" + where := findingLocation(fm) + if where != "" { + where = " (" + where + ")" } fmt.Fprintf(b, "%s- [%v] %v%s: %s", lead, fm["severity"], fm["title"], where, clip(strings.TrimSpace(fmt.Sprint(fm["detail"])), limit)) } diff --git a/internal/agents/schedule.go b/internal/agents/schedule.go index 8549b94..0bec280 100644 --- a/internal/agents/schedule.go +++ b/internal/agents/schedule.go @@ -19,7 +19,7 @@ func (m *Manager) SaveSchedule(ctx context.Context, projectID, scheduleID string if err != nil { return api.Schedule{}, err } - sc.Prompt = strings.TrimSpace(deref(save.Prompt)) + sc.Prompt = strings.TrimSpace(api.Deref(save.Prompt)) sc.Workflow = strings.TrimSpace(save.Workflow) _, err = m.store.GetWorkflow(ctx, sc.Workflow) if err != nil { diff --git a/internal/agents/workflows.go b/internal/agents/workflows.go index b256c97..764d79d 100644 --- a/internal/agents/workflows.go +++ b/internal/agents/workflows.go @@ -116,13 +116,7 @@ func (m *Manager) WorkflowRun(ctx context.Context, taskID string) (api.WorkflowR for _, ev := range events { switch ev.Kind { case "task.workflow_stage": - ids := []string{} - list, _ := ev.Payload["after"].([]any) - for _, v := range list { - id, _ := v.(string) - ids = append(ids, id) - } - after[*ev.TaskId] = ids + after[*ev.TaskId] = anyStrings(ev.Payload["after"]) case "task.discarded": discardedBy[*ev.TaskId] = ev.Actor } diff --git a/internal/api/deref.go b/internal/api/deref.go new file mode 100644 index 0000000..2a5ad86 --- /dev/null +++ b/internal/api/deref.go @@ -0,0 +1,9 @@ +package api + +func Deref[T any](p *T) T { + var zero T + if p == nil { + return zero + } + return *p +} diff --git a/internal/server/changes.go b/internal/server/changes.go index fadde2b..819589d 100644 --- a/internal/server/changes.go +++ b/internal/server/changes.go @@ -17,7 +17,7 @@ func (s *Server) GetTaskChanges(w http.ResponseWriter, r *http.Request, taskID s return } - step := deref(params.Step) + step := api.Deref(params.Step) if params.Snapshot != nil { if params.Since != nil { writeError(w, http.StatusBadRequest, errors.New("since and snapshot can't be used together")) @@ -93,7 +93,7 @@ func (s *Server) GetTaskDiff(w http.ResponseWriter, r *http.Request, taskID stri return } - patch, err := git.FileDiff(r.Context(), t.WorktreePath, params.Base, params.Tree, params.Path, deref(params.OldPath)) + patch, err := git.FileDiff(r.Context(), t.WorktreePath, params.Base, params.Tree, params.Path, api.Deref(params.OldPath)) if err != nil { writeError(w, http.StatusInternalServerError, err) return diff --git a/internal/server/clients.go b/internal/server/clients.go index 7405797..8dcad69 100644 --- a/internal/server/clients.go +++ b/internal/server/clients.go @@ -79,10 +79,7 @@ func (s *Server) ExecClient(w http.ResponseWriter, r *http.Request) { return } - name := "" - if req.Client != nil { - name = *req.Client - } + name := api.Deref(req.Client) if name == "" { recent := s.clients.RecentMacs() if len(recent) == 0 { diff --git a/internal/server/files.go b/internal/server/files.go index 950a76e..d79d42f 100644 --- a/internal/server/files.go +++ b/internal/server/files.go @@ -42,7 +42,7 @@ func (s *Server) GetProjectDir(w http.ResponseWriter, r *http.Request, projectID } func serveDir(w http.ResponseWriter, r *http.Request, root string, p *string) { - rel := deref(p) + rel := api.Deref(p) name, full := taskPath(root, rel) f, err := os.Open(full) diff --git a/internal/server/inbox.go b/internal/server/inbox.go index 18e8071..5804477 100644 --- a/internal/server/inbox.go +++ b/internal/server/inbox.go @@ -8,7 +8,7 @@ import ( ) func (s *Server) ListInbox(w http.ResponseWriter, r *http.Request, params api.ListInboxParams) { - f := store.InboxFilter{State: api.Open, ProjectID: deref(params.ProjectId), TaskID: deref(params.TaskId)} + f := store.InboxFilter{State: api.Open, ProjectID: api.Deref(params.ProjectId), TaskID: api.Deref(params.TaskId)} if params.State != nil { f.State = *params.State } diff --git a/internal/server/lead.go b/internal/server/lead.go index 70c5f28..7272193 100644 --- a/internal/server/lead.go +++ b/internal/server/lead.go @@ -4,9 +4,7 @@ import ( "errors" "math" "net/http" - "strings" - "g.fc.run/ai/byre/internal/agents" "g.fc.run/ai/byre/internal/api" ) @@ -38,27 +36,17 @@ func (s *Server) ListLeadEvents(w http.ResponseWriter, r *http.Request, params a } func (s *Server) MessageLead(w http.ResponseWriter, r *http.Request) { - req := api.MessageCreate{} - if !readJSON(w, r, &req) { - return - } - - images, ok := s.images(w, r, req.Images) + prompt, ok := s.readMessage(w, r) if !ok { return } - if strings.TrimSpace(req.Text) == "" && len(images) == 0 { - writeError(w, http.StatusBadRequest, errors.New("text or images are required")) - return - } - u, ok := s.actingUser(w, r) if !ok { return } - sess, err := s.agents.MessageLead(r.Context(), u, agents.Prompt{Text: req.Text, Model: deref(req.Model), Effort: deref(req.Effort), SendID: deref(req.SendId), Client: r.Header.Get("Byre-Client"), Images: images}) + sess, err := s.agents.MessageLead(r.Context(), u, prompt) if err != nil { writeAgentError(w, err) return diff --git a/internal/server/server.go b/internal/server/server.go index ade4280..8451266 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -273,14 +273,6 @@ func (s *Server) actingUser(w http.ResponseWriter, r *http.Request) (api.User, b return u, true } -func deref[T any](p *T) T { - var zero T - if p == nil { - return zero - } - return *p -} - func (s *Server) GetMe(w http.ResponseWriter, r *http.Request) { u, ok := s.actingUser(w, r) if !ok { @@ -393,7 +385,7 @@ func (s *Server) CreateTask(w http.ResponseWriter, r *http.Request, projectID st return } - title := strings.TrimSpace(deref(req.Title)) + title := strings.TrimSpace(api.Deref(req.Title)) p, err := s.store.GetProject(r.Context(), projectID) if err != nil { @@ -441,7 +433,7 @@ func (s *Server) CreateTask(w http.ResponseWriter, r *http.Request, projectID st return } - prompt := deref(req.Prompt) + prompt := api.Deref(req.Prompt) if strings.TrimSpace(prompt) == "" && len(images) == 0 { writeError(w, http.StatusBadRequest, errors.New("prompt is empty")) return @@ -450,7 +442,7 @@ func (s *Server) CreateTask(w http.ResponseWriter, r *http.Request, projectID st if !ok { return } - t, err := s.agents.StartTask(r.Context(), p, u, title, agents.Prompt{Text: prompt, Model: deref(req.Model), Effort: deref(req.Effort), Client: r.Header.Get("Byre-Client"), Images: images}) + t, err := s.agents.StartTask(r.Context(), p, u, title, agents.Prompt{Text: prompt, Model: api.Deref(req.Model), Effort: api.Deref(req.Effort), Client: r.Header.Get("Byre-Client"), Images: images}) if err != nil { writeAgentError(w, err) return @@ -495,7 +487,7 @@ func (s *Server) StartTodoItem(w http.ResponseWriter, r *http.Request, projectID return } - tasks, err := s.agents.StartTodo(r.Context(), p, u, req.Title, deref(req.Model), deref(req.Effort), u.Login, "", deref(req.Discuss)) + tasks, err := s.agents.StartTodo(r.Context(), p, u, req.Title, api.Deref(req.Model), api.Deref(req.Effort), u.Login, "", api.Deref(req.Discuss)) if err != nil { writeAgentError(w, err) return @@ -536,8 +528,8 @@ func (s *Server) MergeTask(w http.ResponseWriter, r *http.Request, taskID string } target = *req.Target } - afterReview := deref(req.AfterReview) - keepOpen := deref(req.KeepOpen) + afterReview := api.Deref(req.AfterReview) + keepOpen := api.Deref(req.KeepOpen) if keepOpen && (target != api.MergeTargetMain || afterReview) { writeError(w, http.StatusBadRequest, errors.New("keep_open is only for target main, without after_review")) return @@ -830,19 +822,27 @@ func (s *Server) ForkSnapshot(w http.ResponseWriter, r *http.Request, taskID str writeJSON(w, http.StatusCreated, fork) } -func (s *Server) ContinueSession(w http.ResponseWriter, r *http.Request, sessionID string) { +func (s *Server) readMessage(w http.ResponseWriter, r *http.Request) (agents.Prompt, bool) { req := api.MessageCreate{} if !readJSON(w, r, &req) { - return + return agents.Prompt{}, false } images, ok := s.images(w, r, req.Images) if !ok { - return + return agents.Prompt{}, false } if strings.TrimSpace(req.Text) == "" && len(images) == 0 { writeError(w, http.StatusBadRequest, errors.New("text or images are required")) + return agents.Prompt{}, false + } + return agents.Prompt{Text: req.Text, Model: api.Deref(req.Model), Effort: api.Deref(req.Effort), SendID: api.Deref(req.SendId), Client: r.Header.Get("Byre-Client"), Images: images}, true +} + +func (s *Server) ContinueSession(w http.ResponseWriter, r *http.Request, sessionID string) { + prompt, ok := s.readMessage(w, r) + if !ok { return } @@ -866,7 +866,7 @@ func (s *Server) ContinueSession(w http.ResponseWriter, r *http.Request, session return } - task, err := s.agents.Continue(r.Context(), src, t, p, u, agents.Prompt{Text: req.Text, Model: deref(req.Model), Effort: deref(req.Effort), SendID: deref(req.SendId), Client: r.Header.Get("Byre-Client"), Images: images}) + task, err := s.agents.Continue(r.Context(), src, t, p, u, prompt) if err != nil { writeAgentError(w, err) return @@ -914,7 +914,7 @@ func (s *Server) CreateSession(w http.ResponseWriter, r *http.Request, taskID st return } - sess, err := s.agents.Create(r.Context(), t, u, agents.Prompt{Text: req.Prompt, Model: deref(req.Model), Effort: deref(req.Effort), SendID: deref(req.SendId), Client: r.Header.Get("Byre-Client"), Images: images}) + sess, err := s.agents.Create(r.Context(), t, u, agents.Prompt{Text: req.Prompt, Model: api.Deref(req.Model), Effort: api.Deref(req.Effort), SendID: api.Deref(req.SendId), Client: r.Header.Get("Byre-Client"), Images: images}) if err != nil { writeAgentError(w, err) return @@ -932,22 +932,12 @@ func (s *Server) GetSession(w http.ResponseWriter, r *http.Request, sessionID st } func (s *Server) SendMessage(w http.ResponseWriter, r *http.Request, sessionID string) { - req := api.MessageCreate{} - if !readJSON(w, r, &req) { - return - } - - images, ok := s.images(w, r, req.Images) + prompt, ok := s.readMessage(w, r) if !ok { return } - if strings.TrimSpace(req.Text) == "" && len(images) == 0 { - writeError(w, http.StatusBadRequest, errors.New("text or images are required")) - return - } - - sess, _, err := s.agents.Message(r.Context(), sessionID, actor(r), agents.Prompt{Text: req.Text, Model: deref(req.Model), Effort: deref(req.Effort), SendID: deref(req.SendId), Client: r.Header.Get("Byre-Client"), Images: images}) + sess, _, err := s.agents.Message(r.Context(), sessionID, actor(r), prompt) if err != nil { writeAgentError(w, err) return @@ -969,7 +959,7 @@ func (s *Server) CompactSession(w http.ResponseWriter, r *http.Request, sessionI if !readJSON(w, r, &req) { return } - sess, err := s.agents.Compact(r.Context(), sessionID, deref(req.Model), deref(req.Effort)) + sess, err := s.agents.Compact(r.Context(), sessionID, api.Deref(req.Model), api.Deref(req.Effort)) if err != nil { writeAgentError(w, err) return diff --git a/internal/server/sse.go b/internal/server/sse.go index d5515df..50cadbc 100644 --- a/internal/server/sse.go +++ b/internal/server/sse.go @@ -20,8 +20,8 @@ func (s *Server) StreamEvents(w http.ResponseWriter, r *http.Request, params api last = *params.LastEventID } viewer := actor(r) - lead := deref(params.Lead) - taskID := deref(params.TaskId) + lead := api.Deref(params.Lead) + taskID := api.Deref(params.TaskId) if params.TaskId != nil { if !s.taskExists(w, r, taskID) { return diff --git a/internal/server/workflows.go b/internal/server/workflows.go index dc1d45d..aad32b1 100644 --- a/internal/server/workflows.go +++ b/internal/server/workflows.go @@ -34,10 +34,7 @@ func (s *Server) PutWorkflow(w http.ResponseWriter, r *http.Request, name string writeError(w, http.StatusBadRequest, fmt.Errorf("read workflow: %w", err)) return } - from := "" - if params.From != nil { - from = *params.From - } + from := api.Deref(params.From) summary, err := s.agents.SaveWorkflow(r.Context(), name, from, body, actor(r)) if err != nil { writeAgentError(w, err) diff --git a/internal/shell/shell.go b/internal/shell/shell.go new file mode 100644 index 0000000..4788bc2 --- /dev/null +++ b/internal/shell/shell.go @@ -0,0 +1,7 @@ +package shell + +import "strings" + +func Quote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} diff --git a/internal/store/store.go b/internal/store/store.go index 32f9d6c..1ab39c0 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -380,34 +380,16 @@ func (s *Store) CreateTask(ctx context.Context, t api.Task, frozen *FrozenWorkfl workflowBrief = frozen.Brief workflowTitle = frozen.Title } - workflowStage := "" - if t.WorkflowStage != nil { - workflowStage = *t.WorkflowStage - } - todoItem := "" - if t.TodoItem != nil { - todoItem = *t.TodoItem - } + workflowStage := api.Deref(t.WorkflowStage) + todoItem := api.Deref(t.TodoItem) mergeTitle := "" if t.MergeTitle != t.Title { mergeTitle = t.MergeTitle } - forkedFrom := "" - if t.ForkedFrom != nil { - forkedFrom = *t.ForkedFrom - } - forkedAt := 0 - if t.ForkedAt != nil { - forkedAt = *t.ForkedAt - } - parentTaskID := "" - if t.ParentTaskId != nil { - parentTaskID = *t.ParentTaskId - } - audit := "" - if t.Audit != nil { - audit = *t.Audit - } + forkedFrom := api.Deref(t.ForkedFrom) + forkedAt := api.Deref(t.ForkedAt) + parentTaskID := api.Deref(t.ParentTaskId) + audit := api.Deref(t.Audit) return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error { if parentTaskID != "" { open := false diff --git a/mac/Sources/Byre/Attention.swift b/mac/Sources/Byre/Attention.swift index 1a06f33..74e1775 100644 --- a/mac/Sources/Byre/Attention.swift +++ b/mac/Sources/Byre/Attention.swift @@ -4,7 +4,7 @@ let otherStop = "\u{0}other" @MainActor func otherOpen(_ store: Store, _ key: String) -> Bool { - (store.drafts?.hasTextAnswer(key) ?? false) || store.images.has(key) + store.drafts?.textAnswer(key) != nil || store.images.has(key) } struct AttentionCard: View { diff --git a/mac/Sources/Byre/BoxDiagram.swift b/mac/Sources/Byre/BoxDiagram.swift index 40c9406..8ff9d75 100644 --- a/mac/Sources/Byre/BoxDiagram.swift +++ b/mac/Sources/Byre/BoxDiagram.swift @@ -350,6 +350,16 @@ struct DiagramLayout: Identifiable { } } +private func ancestry(_ id: String, _ boxes: [PlacedBox], _ index: [String: Int]) -> [String] { + var out: [String] = [] + var p = boxes[index[id]!].parent + while let id = p { + out.append(id) + p = boxes[index[id]!].parent + } + return out +} + struct BoxLayout { let level: String let boxes: [PlacedBox] @@ -359,13 +369,7 @@ struct BoxLayout { let size: CGSize func ancestors(_ id: String) -> [String] { - var out: [String] = [] - var p = boxes[index[id]!].parent - while let id = p { - out.append(id) - p = boxes[index[id]!].parent - } - return out + ancestry(id, boxes, index) } func opener(at p: CGPoint) -> String? { @@ -802,15 +806,6 @@ private func anchors(_ edges: [BoxEdge], _ points: [[CGPoint]], _ boxes: [Placed let room = min(spread, (s.hi - s.lo) / 4) return v > s.lo + room && v < s.hi - room } - func lineage(_ id: String) -> [String] { - var out: [String] = [] - var p = boxes[index[id]!].parent - while let id = p { - out.append(id) - p = boxes[index[id]!].parent - } - return out - } func clear(_ v: CGFloat, _ a: End, _ b: End) -> Bool { let ra = boxes[index[a.box]!].rect let rb = boxes[index[b.box]!].rect @@ -818,7 +813,7 @@ private func anchors(_ edges: [BoxEdge], _ points: [[CGPoint]], _ boxes: [Placed guard gap.width > 0, gap.height > 0 else { return false } - let open = Set(lineage(a.box) + lineage(b.box)) + let open = Set(ancestry(a.box, boxes, index) + ancestry(b.box, boxes, index)) return !boxes.contains { box in box.node.id != a.box && box.node.id != b.box && (open.contains(box.node.id) ? box.header : box.rect).intersects(gap) } @@ -970,13 +965,7 @@ private struct Router { } private func lineage(_ id: String) -> [String] { - var out: [String] = [] - var p = boxes[index[id]!].parent - while let id = p { - out.append(id) - p = boxes[index[id]!].parent - } - return out + ancestry(id, boxes, index) } private func blocked(_ e: BoxEdge) -> (grid: [Bool], upright: [Bool], level: [Bool]) { diff --git a/mac/Sources/Byre/Chat.swift b/mac/Sources/Byre/Chat.swift index b175ffe..8fe1852 100644 --- a/mac/Sources/Byre/Chat.swift +++ b/mac/Sources/Byre/Chat.swift @@ -717,12 +717,7 @@ private struct ChatHead: View { HStack(spacing: 8) { content() } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .overlay(alignment: .bottom) { - Rectangle().fill(Palette.border).frame(height: 1) - } + .modifier(HeaderBar()) } } @@ -845,7 +840,6 @@ struct CompactButton: View { let usage: Usage? @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays - @Environment(\.isEnabled) private var enabled @ViewState private var sending = false var body: some View { diff --git a/mac/Sources/Byre/Drafts.swift b/mac/Sources/Byre/Drafts.swift index fa9db6f..580c35d 100644 --- a/mac/Sources/Byre/Drafts.swift +++ b/mac/Sources/Byre/Drafts.swift @@ -272,10 +272,6 @@ final class Drafts { save() } - func hasTextAnswer(_ id: String) -> Bool { - texts[id] != nil - } - func textAnswer(_ id: String) -> String? { texts[id] } diff --git a/mac/Sources/Byre/Format.swift b/mac/Sources/Byre/Format.swift index 40f17e1..aeda545 100644 --- a/mac/Sources/Byre/Format.swift +++ b/mac/Sources/Byre/Format.swift @@ -467,7 +467,6 @@ private struct WebButton: View { struct SmallLinkButtonStyle: ButtonStyle { var color = Palette.accent - var border = Palette.border @Environment(\.isEnabled) private var enabled func makeBody(configuration: ButtonStyleConfiguration) -> some View { @@ -476,7 +475,7 @@ struct SmallLinkButtonStyle: ButtonStyle { .foregroundStyle(color) .padding(.horizontal, 6) .padding(.vertical, touchOnly ? 3 : 0) - .overlay(RoundedRectangle(cornerRadius: 4).stroke(border)) + .overlay(RoundedRectangle(cornerRadius: 4).stroke(Palette.border)) .padding(.vertical, touchOnly ? 5 : 0) .opacity(enabled ? 1 : 0.45) .contentShape(Rectangle()) @@ -484,7 +483,6 @@ struct SmallLinkButtonStyle: ButtonStyle { } struct FieldStyle: ViewModifier { - var accent: Color? = nil var focused = false func body(content: Content) -> some View { @@ -493,7 +491,41 @@ struct FieldStyle: ViewModifier { .padding(.horizontal, 10) .padding(.vertical, 6) .background(Palette.raised, in: RoundedRectangle(cornerRadius: 6)) - .overlay(RoundedRectangle(cornerRadius: 6).stroke(focused ? (accent ?? Palette.accent) : (accent ?? Palette.border))) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(focused ? Palette.accent : Palette.border)) + } +} + +struct DropdownMenu: View { + let label: String + var boxed = false + var size: CGFloat = 12 + var color = Palette.text + @ViewBuilder let items: Items + + var body: some View { + Menu { + items + } label: { + let row = HStack(spacing: boxed ? 8 : 6) { + Text(label) + Image(systemName: "chevron.up.chevron.down") + .font(Fonts.font(boxed ? 9 : 8, weight: 600)) + } + .font(boxed ? nil : Fonts.font(size)) + .foregroundStyle(color) + if boxed { + row + .modifier(FieldStyle()) + .contentShape(Rectangle()) + } else { + row + .contentShape(Rectangle()) + } + } + .menuStyle(.button) + .buttonStyle(.plain) + .menuIndicator(.hidden) + .fixedSize() } } @@ -632,7 +664,7 @@ struct StretchColumn: Layout { } struct TrailingBubble: Layout { - var fraction: CGFloat = 0.85 + private let fraction: CGFloat = 0.85 func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { let width = proposal.width ?? 600 diff --git a/mac/Sources/Byre/MarkdownView.swift b/mac/Sources/Byre/MarkdownView.swift index eb9a5b3..2af8c54 100644 --- a/mac/Sources/Byre/MarkdownView.swift +++ b/mac/Sources/Byre/MarkdownView.swift @@ -6,51 +6,39 @@ enum MDSegment { } enum AppLink { - static func task(_ id: String, tab: String? = nil) -> URL { - route("#/task/\(id)\(tab.map { "/\($0)" } ?? "")") + private static func link(_ host: String, _ items: KeyValuePairs) -> URL { + var c = URLComponents() + c.scheme = "byre-app" + c.host = host + c.queryItems = items.map { URLQueryItem(name: $0.key, value: $0.value) } + return c.url! + } + + static func task(_ id: String) -> URL { + route("#/task/\(id)") } static func route(_ hash: String) -> URL { - var c = URLComponents() - c.scheme = "byre-app" - c.host = "route" - c.queryItems = [URLQueryItem(name: "hash", value: hash)] - return c.url! + link("route", ["hash": hash]) } static func file(_ root: FileRoot, _ path: String) -> URL { - var c = URLComponents() - c.scheme = "byre-app" - c.host = "file" - c.queryItems = switch root { - case .task(let id): [URLQueryItem(name: "task", value: id), URLQueryItem(name: "path", value: path)] - case .project(let id): [URLQueryItem(name: "project", value: id), URLQueryItem(name: "path", value: path)] + switch root { + case .task(let id): link("file", ["task": id, "path": path]) + case .project(let id): link("file", ["project": id, "path": path]) } - return c.url! } static func preview(_ taskId: String, _ port: Int) -> URL { - var c = URLComponents() - c.scheme = "byre-app" - c.host = "preview" - c.queryItems = [URLQueryItem(name: "task", value: taskId), URLQueryItem(name: "port", value: String(port))] - return c.url! + link("preview", ["task": taskId, "port": String(port)]) } 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! + link("todo", ["project": projectId, "title": title, "discuss": discuss ? "1" : "0"]) } static func suggest(_ projectId: String, _ title: String, _ brief: String, discuss: Bool) -> URL { - var c = URLComponents() - c.scheme = "byre-app" - c.host = "suggest" - c.queryItems = [URLQueryItem(name: "project", value: projectId), URLQueryItem(name: "title", value: title), URLQueryItem(name: "brief", value: brief), URLQueryItem(name: "discuss", value: discuss ? "1" : "0")] - return c.url! + link("suggest", ["project": projectId, "title": title, "brief": brief, "discuss": discuss ? "1" : "0"]) } } @@ -428,7 +416,7 @@ struct InlineRenderer { } render(inner, style, &out) append(run(" ", style), &out) - let busy = store.todoBusy["\(projectId)\n\(title)"] + let busy = store.todoBusy(projectId, title) func action(_ discuss: Bool, _ label: String, _ busyLabel: String, _ tip: String) { let url = brief.map { AppLink.suggest(projectId, title, $0, discuss: discuss) } ?? AppLink.todo(projectId, title, discuss: discuss) let mine = busy == (discuss ? "discuss" : "start") @@ -1080,14 +1068,15 @@ private struct DiffBlock: View { ScrollView(.horizontal) { StretchColumn(minWidth: width) { ForEach(Array(lines.enumerated()), id: \.offset) { _, line in + let kind = line.hasPrefix("+++") || line.hasPrefix("---") ? PatchLine.meta : PatchLine(line) Text(verbatim: line.isEmpty ? " " : line) .font(Fonts.font(size, mono: true)) - .foregroundStyle(line.hasPrefix("@@") || line.hasPrefix("+++") || line.hasPrefix("---") ? Palette.muted : Palette.text) + .foregroundStyle(kind.color) .textSelection(.enabled) .fixedSize() .padding(.horizontal, 8) .frame(maxWidth: .infinity, alignment: .leading) - .background(background(line)) + .background(kind.background) } } .padding(.vertical, 6) @@ -1099,22 +1088,6 @@ private struct DiffBlock: View { .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 { diff --git a/mac/Sources/Byre/MergeDialog.swift b/mac/Sources/Byre/MergeDialog.swift index 6d78d06..533677a 100644 --- a/mac/Sources/Byre/MergeDialog.swift +++ b/mac/Sources/Byre/MergeDialog.swift @@ -14,7 +14,6 @@ typealias Leave = @MainActor (@escaping @MainActor () -> Void) -> Void struct MergeWarning { let text: String var open: (@MainActor () -> Void)? - var detail = "" } @MainActor @@ -287,7 +286,6 @@ private struct MergeContent: View { } else { Text("⚠ \(w.text)") .fixedSize(horizontal: false, vertical: true) - .help(w.detail) } } } diff --git a/mac/Sources/Byre/Notes.swift b/mac/Sources/Byre/Notes.swift index 1c2942c..eb8925b 100644 --- a/mac/Sources/Byre/Notes.swift +++ b/mac/Sources/Byre/Notes.swift @@ -28,7 +28,6 @@ struct NotesView: View { private struct LeadNotes: View { @Environment(Store.self) private var store - @Environment(Overlays.self) private var overlays @ViewState private var list: [LeadNote]? @ViewState private var failure: String? @ViewState private var loads = 0 @@ -45,27 +44,14 @@ private struct LeadNotes: View { let current = groups.contains { $0.0 == project } ? project : "" let shown = list.filter { ($0.projectId ?? "") == current } let label = { (g: (String, String)) in "\(g.1) (\(list.filter { ($0.projectId ?? "") == g.0 }.count))" } - Menu { + DropdownMenu(label: label(groups.first { $0.0 == current } ?? groups[0]), boxed: true) { ForEach(groups, id: \.0) { g in Button(label(g)) { project = g.0 notesProject = g.0 } } - } label: { - HStack(spacing: 8) { - Text(label(groups.first { $0.0 == current } ?? groups[0])) - Image(systemName: "chevron.up.chevron.down") - .font(Fonts.font(9, weight: 600)) - } - .foregroundStyle(Palette.text) - .modifier(FieldStyle()) - .contentShape(Rectangle()) } - .menuStyle(.button) - .buttonStyle(.plain) - .menuIndicator(.hidden) - .fixedSize() .help("Which notes to show") .padding(.bottom, 8) if shown.isEmpty { diff --git a/mac/Sources/Byre/PaneFocus.swift b/mac/Sources/Byre/PaneFocus.swift index 1d69002..bd89589 100644 --- a/mac/Sources/Byre/PaneFocus.swift +++ b/mac/Sources/Byre/PaneFocus.swift @@ -73,16 +73,16 @@ final class PaneFocus { let center = NotificationCenter.default observers = [ center.addObserver(of: window, for: .didBecomeKey) { [weak self] _ in - self?.changed(NSWindow.didBecomeKeyNotification) + self?.update() }, center.addObserver(of: window, for: .didResignKey) { [weak self] _ in - self?.changed(NSWindow.didResignKeyNotification) + self?.update() }, center.addObserver(of: NSApplication.self, for: .didBecomeActive) { [weak self] _ in - self?.changed(NSApplication.didBecomeActiveNotification) + self?.update() }, center.addObserver(of: NSApplication.self, for: .didResignActive) { [weak self] _ in - self?.changed(NSApplication.didResignActiveNotification) + self?.update() }, ] responder = window.observe(\.firstResponder, options: [.new]) { [weak self] _, _ in @@ -183,10 +183,6 @@ final class PaneFocus { return CGPoint(x: r.midX, y: content.isFlipped ? r.midY : content.bounds.height - r.midY) } - private func changed(_ name: Notification.Name) { - update() - } - private func responderChanged() { guard let view = window?.firstResponder as? NSView, view !== window?.contentView, !view.visibleRect.isEmpty else { update() diff --git a/mac/Sources/Byre/Project.swift b/mac/Sources/Byre/Project.swift index a15ee9a..9a45fb2 100644 --- a/mac/Sources/Byre/Project.swift +++ b/mac/Sources/Byre/Project.swift @@ -112,25 +112,12 @@ private struct ProjectWorkflowMenu: View { var body: some View { let current = audit ? project.auditWorkflow : project.reviewWorkflow - Menu { + DropdownMenu(label: current.isEmpty ? "Off" : current, size: 11, color: Palette.muted) { Button("Off") { set("") } ForEach(workflows, id: \.self) { name in Button(name) { set(name) } } - } label: { - HStack(spacing: 6) { - Text(current.isEmpty ? "Off" : current) - Image(systemName: "chevron.up.chevron.down") - .font(Fonts.font(8, weight: 600)) - } - .font(Fonts.font(11)) - .foregroundStyle(Palette.muted) - .contentShape(Rectangle()) } - .menuStyle(.button) - .buttonStyle(.plain) - .menuIndicator(.hidden) - .fixedSize() .help(audit ? "The workflow byre runs in the background on \(project.defaultBranch)'s newest commit to check the whole codebase" : "The review workflow whose reviewers review every snapshot of this project's tasks") .task { do { @@ -633,7 +620,7 @@ private struct TodoRow: View { .buttonStyle(.plain) .fixedSize() } else { - let busy = store.todoStarting.contains("\(project.id)\n\(item.title)") + let busy = store.todoBusy(project.id, item.title) != nil HStack(spacing: 6) { Button(busy ? "Starting…" : "Start") { store.startTodo(project.id, item.title, discuss: false, overlays) diff --git a/mac/Sources/Byre/QuickRun.swift b/mac/Sources/Byre/QuickRun.swift index 436bcbe..dd63a3e 100644 --- a/mac/Sources/Byre/QuickRun.swift +++ b/mac/Sources/Byre/QuickRun.swift @@ -10,7 +10,7 @@ struct QuickRunSetting: View { var body: some View { let current = store.me?.quickRun ?? .init(projectId: "", workflow: "") HStack(spacing: 6) { - menu(current.workflow.isEmpty ? "None" : current.workflow, help: "Workflow the Action button starts") { + DropdownMenu(label: current.workflow.isEmpty ? "None" : current.workflow, boxed: true) { Button("None") { save(.init(projectId: "", workflow: "")) } @@ -20,16 +20,18 @@ struct QuickRunSetting: View { } } } + .help("Workflow the Action button starts") if !current.workflow.isEmpty { Text("in") .foregroundStyle(Palette.muted) - menu(store.projects.first { $0.id == current.projectId }?.name ?? current.projectId, help: "Project the workflow runs in") { + DropdownMenu(label: store.projects.first { $0.id == current.projectId }?.name ?? current.projectId, boxed: true) { ForEach(store.projects, id: \.id) { p in Button(p.name) { save(.init(projectId: p.id, workflow: current.workflow)) } } } + .help("Project the workflow runs in") } } .padding(.top, 8) @@ -47,26 +49,6 @@ struct QuickRunSetting: View { } } - private func menu(_ label: String, help: String, @ViewBuilder _ items: () -> some View) -> some View { - Menu { - items() - } label: { - HStack(spacing: 8) { - Text(label) - Image(systemName: "chevron.up.chevron.down") - .font(Fonts.font(9, weight: 600)) - } - .foregroundStyle(Palette.text) - .modifier(FieldStyle()) - .contentShape(Rectangle()) - } - .menuStyle(.button) - .buttonStyle(.plain) - .menuIndicator(.hidden) - .fixedSize() - .help(help) - } - private func load() { Task { do { diff --git a/mac/Sources/Byre/Settings.swift b/mac/Sources/Byre/Settings.swift index 3029554..11c3b31 100644 --- a/mac/Sources/Byre/Settings.swift +++ b/mac/Sources/Byre/Settings.swift @@ -431,7 +431,7 @@ private struct PickMenu: View { @Environment(Overlays.self) private var overlays var body: some View { - Menu { + DropdownMenu(label: label(current), boxed: true) { ForEach(options, id: \.self) { m in Button(label(m)) { Task { @@ -443,20 +443,7 @@ private struct PickMenu: View { } } } - } label: { - HStack(spacing: 8) { - Text(label(current)) - Image(systemName: "chevron.up.chevron.down") - .font(Fonts.font(9, weight: 600)) - } - .foregroundStyle(Palette.text) - .modifier(FieldStyle()) - .contentShape(Rectangle()) } - .menuStyle(.button) - .buttonStyle(.plain) - .menuIndicator(.hidden) - .fixedSize() .help(help) } } diff --git a/mac/Sources/Byre/Store.swift b/mac/Sources/Byre/Store.swift index 4d91097..20dcb2b 100644 --- a/mac/Sources/Byre/Store.swift +++ b/mac/Sources/Byre/Store.swift @@ -83,7 +83,7 @@ final class Store { var composerFocus: String? var summaryReads: [String: Int64] = [:] private(set) var leadMore = false - var todoBusy: [String: String] = [:] + private var todoBusyKinds: [String: String] = [:] var pickedMembers: [String: String] = [:] var filePlaces: [FileRoot: FilePlace] = [:] private(set) var drafts: Drafts? @@ -158,57 +158,43 @@ final class Store { drafts!.prune(self) } - var todoStarting: Set { - Set(todoBusy.keys) + func todoBusy(_ projectId: String, _ title: String) -> String? { + todoBusyKinds["\(projectId)\n\(title)"] } - func startTodo(_ projectId: String, _ title: String, discuss: Bool, _ overlays: Overlays) { + private func busyTodo(_ projectId: String, _ title: String, _ kind: String, _ overlays: Overlays, _ op: @escaping @MainActor () async throws -> Void) { let key = "\(projectId)\n\(title)" - if todoBusy[key] != nil { + if todoBusyKinds[key] != nil { return } - todoBusy[key] = discuss ? "discuss" : "start" + todoBusyKinds[key] = kind Task { do { - _ = try await api.client.startTodoItem(path: .init(projectId: projectId), body: .json(.init(title: title, model: defaultModel, effort: defaultEffort, discuss: discuss))).created + try await op() } catch { overlays.showAlert(errorText(error)) } - todoBusy[key] = nil + todoBusyKinds[key] = nil + } + } + + func startTodo(_ projectId: String, _ title: String, discuss: Bool, _ overlays: Overlays) { + busyTodo(projectId, title, discuss ? "discuss" : "start", overlays) { + _ = try await self.api.client.startTodoItem(path: .init(projectId: projectId), body: .json(.init(title: title, model: self.defaultModel, effort: self.defaultEffort, discuss: discuss))).created } } func startSuggestion(_ projectId: String, _ title: String, brief: String, _ overlays: Overlays) { - let key = "\(projectId)\n\(title)" - if todoBusy[key] != nil { - return - } - todoBusy[key] = "start" - Task { - do { - _ = try await api.client.createTask(path: .init(projectId: projectId), body: .json(.init(title: title, prompt: brief, model: defaultModel, effort: defaultEffort))).created - } catch { - overlays.showAlert(errorText(error)) - } - todoBusy[key] = nil + busyTodo(projectId, title, "start", overlays) { + _ = try await self.api.client.createTask(path: .init(projectId: projectId), body: .json(.init(title: title, prompt: brief, model: self.defaultModel, effort: self.defaultEffort))).created } } func discussSuggestion(_ projectId: String, _ title: String, brief: String, _ overlays: Overlays) { - let key = "\(projectId)\n\(title)" - if todoBusy[key] != nil { - return - } - todoBusy[key] = "discuss" let lead = currentLead() let live = lead != nil && lead!.state != .completed - Task { - do { - _ = try await api.client.messageLead(body: .json(.init(text: "Let's discuss your suggestion \"\(title)\" before starting it:\n\n\(brief)", model: live ? nil : defaultModel, effort: live ? nil : defaultEffort))).accepted - } catch { - overlays.showAlert(errorText(error)) - } - todoBusy[key] = nil + busyTodo(projectId, title, "discuss", overlays) { + _ = try await self.api.client.messageLead(body: .json(.init(text: "Let's discuss your suggestion \"\(title)\" before starting it:\n\n\(brief)", model: live ? nil : self.defaultModel, effort: live ? nil : self.defaultEffort))).accepted } } diff --git a/mac/Sources/Byre/TranscriptBuilder.swift b/mac/Sources/Byre/TranscriptBuilder.swift index 47cfcef..7349dff 100644 --- a/mac/Sources/Byre/TranscriptBuilder.swift +++ b/mac/Sources/Byre/TranscriptBuilder.swift @@ -171,7 +171,6 @@ struct PendingSend: Identifiable { let text: String let images: [PendingImage] let imageIds: [String] - let local: Bool let queued: Bool let md: [MDBlock] let sendID: String @@ -286,7 +285,7 @@ final class TranscriptBuilder { // The returned closure drops the send and says whether it was still unconfirmed. func showPending(_ text: String, _ images: [PendingImage], _ sendID: String) -> () -> Bool { local.ids -= 1 - let entry = PendingSend(id: local.ids, text: text, images: images, imageIds: [], local: true, queued: running(), md: trimmed(text).isEmpty ? [] : parseMarkdown(text), sendID: sendID) + let entry = PendingSend(id: local.ids, text: text, images: images, imageIds: [], queued: running(), md: trimmed(text).isEmpty ? [] : parseMarkdown(text), sendID: sendID) local.items.append(entry) ctx.run = nil return { [local] in @@ -499,7 +498,7 @@ final class TranscriptBuilder { let text = str(p, "text") ?? "" pendingIds += 1 let images = (p["images"] as? [(any Sendable)?] ?? []).map { jsString($0) } - queued.append(PendingSend(id: pendingIds, text: text, images: [], imageIds: images, local: false, queued: true, md: trimmed(text).isEmpty ? [] : parseMarkdown(text), sendID: str(p, "send_id") ?? "", inputId: int(p, "id"))) + queued.append(PendingSend(id: pendingIds, text: text, images: [], imageIds: images, queued: true, md: trimmed(text).isEmpty ? [] : parseMarkdown(text), sendID: str(p, "send_id") ?? "", inputId: int(p, "id"))) } continue } diff --git a/mac/Sources/Byre/Workflow.swift b/mac/Sources/Byre/Workflow.swift index e6e96cc..9f7cdbb 100644 --- a/mac/Sources/Byre/Workflow.swift +++ b/mac/Sources/Byre/Workflow.swift @@ -60,7 +60,6 @@ private func taskLink(_ taskId: String, _ label: String, bold: Bool = false) -> private func stateColor(_ state: String) -> Color { switch state { case "running": Palette.accent - case "idle", "discarding": Palette.muted case "finished", "merged": Palette.ok default: Palette.muted } diff --git a/mac/Sources/Byre/WorkflowEditor.swift b/mac/Sources/Byre/WorkflowEditor.swift index 9372168..b66f683 100644 --- a/mac/Sources/Byre/WorkflowEditor.swift +++ b/mac/Sources/Byre/WorkflowEditor.swift @@ -572,24 +572,11 @@ struct ChoiceMenu: View { var body: some View { let label = options.first { $0.0 == value }?.1 - Menu { + DropdownMenu(label: label ?? placeholder, color: label == nil ? Palette.muted : Palette.text) { ForEach(options, id: \.0) { option in Button(option.1) { value = option.0 } } - } label: { - HStack(spacing: 6) { - Text(label ?? placeholder) - Image(systemName: "chevron.up.chevron.down") - .font(Fonts.font(8, weight: 600)) - } - .font(Fonts.font(12)) - .foregroundStyle(label == nil ? Palette.muted : Palette.text) - .contentShape(Rectangle()) } - .menuStyle(.button) - .buttonStyle(.plain) - .menuIndicator(.hidden) - .fixedSize() } }