From c98a8bf02fc4809644098b9d1162496e60b1f30f Mon Sep 17 00:00:00 2001 From: Maxime Bourret Date: Tue, 21 Jul 2026 17:25:32 -0400 Subject: [PATCH 1/3] Add FormRunner with huh Agent and Queue pickers. Operators pick Agent kind on Init and confirm/reorder Tickets on Queue via keyboard-friendly forms; tests stub FormRunner without a live TUI. Co-authored-by: Cursor --- cmd/ship/main.go | 6 +- go.mod | 34 +++++- go.sum | 75 ++++++++++++ internal/run/form.go | 116 ++++++++++++++++++ internal/run/form_agent.go | 90 ++++++++++++++ internal/run/form_agent_test.go | 111 +++++++++++++++++ internal/run/form_queue.go | 193 ++++++++++++++++++++++++++++++ internal/run/form_queue_test.go | 176 +++++++++++++++++++++++++++ internal/run/form_test.go | 169 ++++++++++++++++++++++++++ internal/run/orchestrator_test.go | 3 - internal/run/picker.go | 72 ++--------- internal/run/picker_test.go | 36 +++--- internal/run/shipfile.go | 45 ++----- internal/run/shipfile_test.go | 62 ---------- internal/theme/theme.go | 15 +++ internal/theme/theme_test.go | 29 +++++ 16 files changed, 1044 insertions(+), 188 deletions(-) create mode 100644 internal/run/form.go create mode 100644 internal/run/form_agent.go create mode 100644 internal/run/form_agent_test.go create mode 100644 internal/run/form_queue.go create mode 100644 internal/run/form_queue_test.go create mode 100644 internal/run/form_test.go create mode 100644 internal/theme/theme.go create mode 100644 internal/theme/theme_test.go diff --git a/cmd/ship/main.go b/cmd/ship/main.go index b349225..209ed71 100644 --- a/cmd/ship/main.go +++ b/cmd/ship/main.go @@ -47,7 +47,7 @@ func MainWith(args []string, stdout, stderr io.Writer, dir string, up update.Por return 0 } - created, err := (run.Init{Out: stderr}).Config(dir) + created, err := (run.Init{}).Config(dir) if err != nil { fmt.Fprintln(stderr, err) return 1 @@ -80,7 +80,7 @@ func MainWith(args []string, stdout, stderr io.Writer, dir string, up update.Por gh := &ticket.GitHub{} orchestrator := run.Orchestrator{ Tickets: gh, - Queue: run.Interactive{Out: stderr}, + Queue: run.Interactive{}, Agent: port, PRs: gh, Repo: gitops.Repo{Dir: dir}, @@ -120,7 +120,7 @@ func runUpdate(stdout, stderr io.Writer, up update.Port) int { } func runInit(stderr io.Writer, dir string) int { - created, err := (run.Init{Out: stderr}).Config(dir) + created, err := (run.Init{}).Config(dir) if err != nil { fmt.Fprintln(stderr, err) return 1 diff --git a/go.mod b/go.mod index f0f6c27..52bba3e 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,36 @@ module github.com/maxBRT/ship-cli go 1.26.5 -require gopkg.in/yaml.v3 v3.0.1 +require ( + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 + github.com/charmbracelet/bubbletea v1.3.6 + github.com/charmbracelet/huh v1.0.0 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/x/ansi v0.9.3 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.15.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.23.0 // indirect +) diff --git a/go.sum b/go.sum index a62c313..4238f8d 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,78 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/run/form.go b/internal/run/form.go new file mode 100644 index 0000000..84a0da5 --- /dev/null +++ b/internal/run/form.go @@ -0,0 +1,116 @@ +package run + +import ( + "bufio" + "errors" + "fmt" + "io" + "strconv" + "strings" + + "github.com/maxBRT/ship-cli/internal/ticket" +) + +// FormRunner is the operator prompt seam behind Init and Interactive Queue +// confirmation. Production uses Forms (huh); tests inject stubs. Queue and +// the orchestrator stay unaware of this seam. +type FormRunner interface { + // PickAgent returns the Agent kind chosen for a new Shipfile. + PickAgent() (string, error) + // ConfirmQueue returns the ordered Ticket set for a Run, or ErrCanceled. + ConfirmQueue(candidates []ticket.Ticket) ([]ticket.Ticket, error) +} + +// Forms is the production FormRunner: huh Agent picker on Init and huh +// multi-select Queue confirmation (with Shift+↑/↓ reorder). Methods live in +// form_agent.go and form_queue.go. +type Forms struct{} + +var _ FormRunner = Forms{} + +// TypedLines is a FormRunner of typed-line prompts on In/Out. +// Kept for tests and non-huh callers; production TTY uses Forms. +type TypedLines struct { + In io.Reader + Out io.Writer +} + +func (t TypedLines) PickAgent() (string, error) { + out := t.Out + if out == nil { + out = io.Discard + } + fmt.Fprintln(out, "Choose an Agent kind for this checkout:") + fmt.Fprintln(out, " cursor, pi, codex, claude") + fmt.Fprint(out, "> ") + + in := t.In + if in == nil { + in = strings.NewReader("") + } + line, err := bufio.NewReader(in).ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return "", fmt.Errorf("read agent kind: %w", err) + } + kind := strings.TrimSpace(line) + if err := validateAgentKind(kind); err != nil { + return "", err + } + return kind, nil +} + +func (t TypedLines) ConfirmQueue(candidates []ticket.Ticket) ([]ticket.Ticket, error) { + in := t.In + if in == nil { + in = strings.NewReader("") + } + out := t.Out + if out == nil { + out = io.Discard + } + + fmt.Fprintln(out, "ship Tickets - confirm the Run queue.") + fmt.Fprintln(out) + for i, tk := range candidates { + fmt.Fprintf(out, " %d. #%d %s\n", i+1, tk.Number, tk.Title) + } + fmt.Fprintln(out) + fmt.Fprintln(out, "Enter numbers in Run order (e.g. 2 1), empty line for all,") + fmt.Fprintln(out, "'none' for empty selection, or 'q' to cancel:") + fmt.Fprint(out, "> ") + + line, err := bufio.NewReader(in).ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return nil, fmt.Errorf("read picker input: %w", err) + } + line = strings.TrimSpace(line) + + switch { + case line == "q" || line == "cancel": + return nil, ErrCanceled + case line == "none": + return []ticket.Ticket{}, nil + case line == "" || line == "all": + outTickets := make([]ticket.Ticket, len(candidates)) + copy(outTickets, candidates) + return outTickets, nil + } + + fields := strings.Fields(line) + selected := make([]ticket.Ticket, 0, len(fields)) + seen := make(map[int]struct{}, len(fields)) + for _, f := range fields { + n, err := strconv.Atoi(f) + if err != nil || n < 1 || n > len(candidates) { + return nil, fmt.Errorf("invalid picker selection %q (want 1..%d)", f, len(candidates)) + } + if _, ok := seen[n]; ok { + continue + } + seen[n] = struct{}{} + selected = append(selected, candidates[n-1]) + } + return selected, nil +} + +var _ FormRunner = TypedLines{} diff --git a/internal/run/form_agent.go b/internal/run/form_agent.go new file mode 100644 index 0000000..54a27b4 --- /dev/null +++ b/internal/run/form_agent.go @@ -0,0 +1,90 @@ +package run + +import ( + "errors" + "fmt" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/huh" + "github.com/maxBRT/ship-cli/internal/theme" +) + +func (Forms) PickAgent() (string, error) { + var kind string + if err := mapAgentPickerErr(newAgentPickerForm(&kind).Run()); err != nil { + return "", err + } + if err := validateAgentKind(kind); err != nil { + return "", err + } + return kind, nil +} + +func mapAgentPickerErr(err error) error { + if err == nil { + return nil + } + if errors.Is(err, huh.ErrUserAborted) { + return ErrCanceled + } + return err +} + +type agentChoice struct { + kind string + blurb string +} + +func agentChoices() []agentChoice { + return []agentChoice{ + {AgentKindCursor, "Cursor Agent CLI"}, + {AgentKindPi, "Pi coding agent"}, + {AgentKindCodex, "OpenAI Codex CLI"}, + {AgentKindClaude, "Claude Code"}, + } +} + +const agentPickerKeyhints = "↑↓ move · enter confirm · q cancel" + +func newAgentPickerForm(kind *string) *huh.Form { + opts := make([]huh.Option[string], 0, len(agentChoices())) + for _, c := range agentChoices() { + label := fmt.Sprintf("%-8s %s", c.kind, c.blurb) + opts = append(opts, huh.NewOption(label, c.kind)) + } + *kind = AgentKindCursor + + km := huh.NewDefaultKeyMap() + km.Quit = key.NewBinding( + key.WithKeys("ctrl+c", "q"), + key.WithHelp("q", "cancel"), + ) + km.Select.Filter.SetEnabled(false) + km.Select.Up = key.NewBinding( + key.WithKeys("up", "k", "ctrl+k", "ctrl+p"), + key.WithHelp("↑↓", "move"), + ) + km.Select.Down = key.NewBinding( + key.WithKeys("down", "j", "ctrl+j", "ctrl+n"), + key.WithHelp("↑↓", "move"), + ) + km.Select.Next = key.NewBinding( + key.WithKeys("enter", "tab"), + key.WithHelp("enter", "confirm"), + ) + km.Select.Submit = key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "confirm"), + ) + + title := theme.Brand + " Choose Agent for this checkout" + return huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title(title). + Description(agentPickerKeyhints). + Options(opts...). + Value(kind), + ), + ).WithKeyMap(km).WithTheme(huh.ThemeBase()) +} diff --git a/internal/run/form_agent_test.go b/internal/run/form_agent_test.go new file mode 100644 index 0000000..1cb22ac --- /dev/null +++ b/internal/run/form_agent_test.go @@ -0,0 +1,111 @@ +package run + +import ( + "errors" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + "github.com/charmbracelet/x/ansi" +) + +func TestAgentPickerForm_showsKindsBlurbsAndKeyhints(t *testing.T) { + var kind string + f := newAgentPickerForm(&kind) + f.Update(f.Init()) + view := ansi.Strip(f.View()) + + // Independent source: lavished Init mockup (issue #66 / #68). + for _, kindName := range []string{"cursor", "pi", "codex", "claude"} { + if !strings.Contains(view, kindName) { + t.Errorf("picker missing kind %q; view:\n%s", kindName, view) + } + } + for _, blurb := range []string{ + "Cursor Agent CLI", + "Pi coding agent", + "OpenAI Codex CLI", + "Claude Code", + } { + if !strings.Contains(view, blurb) { + t.Errorf("picker missing blurb %q; view:\n%s", blurb, view) + } + } + if !strings.Contains(view, "ship") { + t.Errorf("picker missing ship brand; view:\n%s", view) + } + if !strings.Contains(view, "Choose Agent") { + t.Errorf("picker missing title; view:\n%s", view) + } + for _, hint := range []string{"move", "confirm", "cancel"} { + if !strings.Contains(view, hint) { + t.Errorf("picker missing keyhint %q; view:\n%s", hint, view) + } + } +} + +func TestAgentPickerForm_qAborts(t *testing.T) { + var kind string + f := newAgentPickerForm(&kind) + f.Update(f.Init()) + + m, _ := f.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + f, ok := m.(*huh.Form) + if !ok { + t.Fatalf("Update returned %T, want *huh.Form", m) + } + if f.State != huh.StateAborted { + t.Fatalf("State = %v, want StateAborted", f.State) + } +} + +func TestAgentPickerErr_userAbortedIsCanceled(t *testing.T) { + err := mapAgentPickerErr(huh.ErrUserAborted) + if !errors.Is(err, ErrCanceled) { + t.Fatalf("mapAgentPickerErr = %v, want ErrCanceled", err) + } +} + +func TestAgentPickerForm_enterConfirmsFocusedKind(t *testing.T) { + var kind string + f := newAgentPickerForm(&kind) + f.Update(f.Init()) + + // Move to pi, then confirm. + m, cmd := f.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + m = batchUpdate(m, cmd) + m, cmd = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = batchUpdate(m, cmd) + + f, ok := m.(*huh.Form) + if !ok { + t.Fatalf("Update returned %T, want *huh.Form", m) + } + if f.State != huh.StateCompleted { + t.Fatalf("State = %v, want StateCompleted; kind=%q", f.State, kind) + } + if kind != AgentKindPi { + t.Errorf("kind = %q, want pi", kind) + } +} + +func batchUpdate(m tea.Model, cmd tea.Cmd) tea.Model { + if cmd == nil { + return m + } + msg := cmd() + if msg == nil { + return m + } + switch msg := msg.(type) { + case tea.BatchMsg: + for _, c := range msg { + m = batchUpdate(m, c) + } + return m + default: + m, cmd = m.Update(msg) + return batchUpdate(m, cmd) + } +} diff --git a/internal/run/form_queue.go b/internal/run/form_queue.go new file mode 100644 index 0000000..fd6854a --- /dev/null +++ b/internal/run/form_queue.go @@ -0,0 +1,193 @@ +package run + +import ( + "errors" + "fmt" + "slices" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + "github.com/maxBRT/ship-cli/internal/theme" + "github.com/maxBRT/ship-cli/internal/ticket" +) + +func (Forms) ConfirmQueue(candidates []ticket.Ticket) ([]ticket.Ticket, error) { + if len(candidates) == 0 { + return []ticket.Ticket{}, nil + } + var selected []int + picker := newQueuePickerForm(candidates, &selected) + picker.form.SubmitCmd = tea.Quit + picker.form.CancelCmd = tea.Interrupt + + final, err := tea.NewProgram(picker).Run() + if errors.Is(err, tea.ErrInterrupted) { + return nil, ErrCanceled + } + if err != nil { + return nil, err + } + q, ok := final.(*queuePicker) + if !ok { + return nil, fmt.Errorf("queue picker: unexpected model %T", final) + } + if q.form.State == huh.StateAborted { + return nil, ErrCanceled + } + + byNum := make(map[int]ticket.Ticket, len(candidates)) + for _, tk := range candidates { + byNum[tk.Number] = tk + } + out := make([]ticket.Ticket, 0, len(selected)) + for _, n := range selected { + tk, ok := byNum[n] + if !ok { + continue + } + out = append(out, tk) + } + return out, nil +} + +const queuePickerKeyhints = "↑↓ move · space toggle · shift+↑↓ reorder · enter confirm · q cancel" + +// queuePicker wraps a huh MultiSelect form so Shift+↑/↓ can reorder the +// focused selected Ticket. Selected list order is Run order. +type queuePicker struct { + form *huh.Form + field *huh.MultiSelect[int] + options []huh.Option[int] + selected *[]int +} + +func newQueuePickerForm(candidates []ticket.Ticket, selected *[]int) *queuePicker { + opts := make([]huh.Option[int], 0, len(candidates)) + for _, tk := range candidates { + label := fmt.Sprintf("#%d %s", tk.Number, tk.Title) + opts = append(opts, huh.NewOption(label, tk.Number).Selected(true)) + } + *selected = make([]int, len(candidates)) + for i, tk := range candidates { + (*selected)[i] = tk.Number + } + + km := huh.NewDefaultKeyMap() + km.Quit = key.NewBinding( + key.WithKeys("ctrl+c", "q"), + key.WithHelp("q", "cancel"), + ) + km.MultiSelect.Filter.SetEnabled(false) + km.MultiSelect.Toggle = key.NewBinding( + key.WithKeys(" ", "x"), + key.WithHelp("space", "toggle"), + ) + km.MultiSelect.Up = key.NewBinding( + key.WithKeys("up", "k", "ctrl+k", "ctrl+p"), + key.WithHelp("↑↓", "move"), + ) + km.MultiSelect.Down = key.NewBinding( + key.WithKeys("down", "j", "ctrl+j", "ctrl+n"), + key.WithHelp("↑↓", "move"), + ) + km.MultiSelect.Next = key.NewBinding( + key.WithKeys("enter", "tab"), + key.WithHelp("enter", "confirm"), + ) + km.MultiSelect.Submit = key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "confirm"), + ) + + field := huh.NewMultiSelect[int](). + Title(theme.Brand + " Confirm Run queue"). + Description(queuePickerKeyhints). + Options(opts...). + Value(selected). + Filterable(false) + + form := huh.NewForm(huh.NewGroup(field)).WithKeyMap(km).WithTheme(huh.ThemeBase()) + return &queuePicker{ + form: form, + field: field, + options: opts, + selected: selected, + } +} + +func (q *queuePicker) Init() tea.Cmd { + return q.form.Init() +} + +func (q *queuePicker) View() string { + return q.form.View() +} + +func (q *queuePicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if km, ok := msg.(tea.KeyMsg); ok { + switch km.String() { + case "shift+up": + q.reorder(-1) + return q, nil + case "shift+down": + q.reorder(1) + return q, nil + } + } + m, cmd := q.form.Update(msg) + q.form = m.(*huh.Form) + return q, cmd +} + +func (q *queuePicker) reorder(delta int) { + hovered, ok := q.field.Hovered() + if !ok || !slices.Contains(*q.selected, hovered) { + return + } + idx := -1 + for i, o := range q.options { + if o.Value == hovered { + idx = i + break + } + } + if idx < 0 { + return + } + j := idx + delta + if j < 0 || j >= len(q.options) { + return + } + q.options[idx], q.options[j] = q.options[j], q.options[idx] + q.field.Options(q.options...) + q.nudgeCursorTo(j) +} + +// nudgeCursorTo moves the MultiSelect cursor to target after Options() resets +// it to the first selected row. +func (q *queuePicker) nudgeCursorTo(target int) { + firstSel := -1 + for i, o := range q.options { + if slices.Contains(*q.selected, o.Value) { + firstSel = i + break + } + } + if firstSel < 0 { + return + } + steps := target - firstSel + keyType := tea.KeyDown + if steps < 0 { + keyType = tea.KeyUp + steps = -steps + } + for i := 0; i < steps; i++ { + m, cmd := q.form.Update(tea.KeyMsg{Type: keyType}) + q.form = m.(*huh.Form) + if cmd != nil { + _ = cmd + } + } +} diff --git a/internal/run/form_queue_test.go b/internal/run/form_queue_test.go new file mode 100644 index 0000000..70135f0 --- /dev/null +++ b/internal/run/form_queue_test.go @@ -0,0 +1,176 @@ +package run + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + "github.com/charmbracelet/x/ansi" + "github.com/maxBRT/ship-cli/internal/ticket" +) + +func TestQueuePickerForm_showsTicketsAndKeyhints(t *testing.T) { + candidates := []ticket.Ticket{ + {Number: 7, Title: "seven"}, + {Number: 8, Title: "eight"}, + {Number: 9, Title: "nine"}, + } + var selected []int + f := newQueuePickerForm(candidates, &selected) + f.Update(f.Init()) + view := ansi.Strip(f.View()) + + // Independent source: issue #66 / #69 Queue confirmation mockup. + for _, want := range []string{"#7", "seven", "#8", "eight", "#9", "nine"} { + if !strings.Contains(view, want) { + t.Errorf("picker missing %q; view:\n%s", want, view) + } + } + if !strings.Contains(view, "ship") { + t.Errorf("picker missing ship brand; view:\n%s", view) + } + for _, hint := range []string{"move", "toggle", "reorder", "confirm", "cancel"} { + if !strings.Contains(view, hint) { + t.Errorf("picker missing keyhint %q; view:\n%s", hint, view) + } + } +} + +func TestQueuePickerForm_toggleAndConfirmReturnsListOrder(t *testing.T) { + candidates := []ticket.Ticket{ + {Number: 7, Title: "seven"}, + {Number: 8, Title: "eight"}, + {Number: 9, Title: "nine"}, + } + var selected []int + f := newQueuePickerForm(candidates, &selected) + f.Update(f.Init()) + + // Drop #7 (focused first), move to #9, drop #9 → keep #8 only. + m, cmd := f.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) + m = batchUpdate(m, cmd) + m, cmd = m.Update(tea.KeyMsg{Type: tea.KeyDown}) + m = batchUpdate(m, cmd) + m, cmd = m.Update(tea.KeyMsg{Type: tea.KeyDown}) + m = batchUpdate(m, cmd) + m, cmd = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) + m = batchUpdate(m, cmd) + m, cmd = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = batchUpdate(m, cmd) + + f, ok := m.(*queuePicker) + if !ok { + t.Fatalf("Update returned %T, want *queuePicker", m) + } + if f.form.State != huh.StateCompleted { + t.Fatalf("State = %v, want StateCompleted; selected=%v", f.form.State, selected) + } + if len(selected) != 1 || selected[0] != 8 { + t.Fatalf("selected = %v, want [8]", selected) + } +} + +func TestQueuePickerForm_qAborts(t *testing.T) { + var selected []int + f := newQueuePickerForm([]ticket.Ticket{{Number: 7, Title: "seven"}}, &selected) + f.Update(f.Init()) + + m, _ := f.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + f, ok := m.(*queuePicker) + if !ok { + t.Fatalf("Update returned %T, want *queuePicker", m) + } + if f.form.State != huh.StateAborted { + t.Fatalf("State = %v, want StateAborted", f.form.State) + } +} + +func TestQueuePickerForm_emptySelection(t *testing.T) { + candidates := []ticket.Ticket{ + {Number: 7, Title: "seven"}, + {Number: 8, Title: "eight"}, + } + var selected []int + f := newQueuePickerForm(candidates, &selected) + f.Update(f.Init()) + + // Deselect both. + m, cmd := f.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) + m = batchUpdate(m, cmd) + m, cmd = m.Update(tea.KeyMsg{Type: tea.KeyDown}) + m = batchUpdate(m, cmd) + m, cmd = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) + m = batchUpdate(m, cmd) + m, cmd = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = batchUpdate(m, cmd) + + f, ok := m.(*queuePicker) + if !ok { + t.Fatalf("Update returned %T, want *queuePicker", m) + } + if f.form.State != huh.StateCompleted { + t.Fatalf("State = %v, want StateCompleted; selected=%v", f.form.State, selected) + } + if len(selected) != 0 { + t.Fatalf("selected = %v, want empty", selected) + } +} + +func TestQueuePickerForm_shiftDownReordersFocusedSelected(t *testing.T) { + candidates := []ticket.Ticket{ + {Number: 7, Title: "seven"}, + {Number: 8, Title: "eight"}, + {Number: 9, Title: "nine"}, + } + var selected []int + f := newQueuePickerForm(candidates, &selected) + f.Update(f.Init()) + + // Focus starts on #7 (selected). Shift+↓ swaps with #8 → run order 8, 7, 9. + m, cmd := f.Update(tea.KeyMsg{Type: tea.KeyShiftDown}) + m = batchUpdate(m, cmd) + m, cmd = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = batchUpdate(m, cmd) + + f, ok := m.(*queuePicker) + if !ok { + t.Fatalf("Update returned %T, want *queuePicker", m) + } + if f.form.State != huh.StateCompleted { + t.Fatalf("State = %v, want StateCompleted; selected=%v", f.form.State, selected) + } + if len(selected) != 3 || selected[0] != 8 || selected[1] != 7 || selected[2] != 9 { + t.Fatalf("selected = %v, want [8 7 9]", selected) + } +} + +func TestQueuePickerForm_shiftUpReordersFocusedSelected(t *testing.T) { + candidates := []ticket.Ticket{ + {Number: 7, Title: "seven"}, + {Number: 8, Title: "eight"}, + {Number: 9, Title: "nine"}, + } + var selected []int + f := newQueuePickerForm(candidates, &selected) + f.Update(f.Init()) + + // Move focus to #8, then Shift+↑ swaps with #7 → run order 8, 7, 9. + m, cmd := f.Update(tea.KeyMsg{Type: tea.KeyDown}) + m = batchUpdate(m, cmd) + m, cmd = m.Update(tea.KeyMsg{Type: tea.KeyShiftUp}) + m = batchUpdate(m, cmd) + m, cmd = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = batchUpdate(m, cmd) + + f, ok := m.(*queuePicker) + if !ok { + t.Fatalf("Update returned %T, want *queuePicker", m) + } + if f.form.State != huh.StateCompleted { + t.Fatalf("State = %v, want StateCompleted; selected=%v", f.form.State, selected) + } + if len(selected) != 3 || selected[0] != 8 || selected[1] != 7 || selected[2] != 9 { + t.Fatalf("selected = %v, want [8 7 9]", selected) + } +} diff --git a/internal/run/form_test.go b/internal/run/form_test.go new file mode 100644 index 0000000..b92b724 --- /dev/null +++ b/internal/run/form_test.go @@ -0,0 +1,169 @@ +package run_test + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/maxBRT/ship-cli/internal/run" + "github.com/maxBRT/ship-cli/internal/ticket" +) + +// stubForms returns canned FormRunner answers for Init and Queue tests. +type stubForms struct { + agent string + agentErr error + queue []ticket.Ticket + queueErr error +} + +func (s stubForms) PickAgent() (string, error) { + return s.agent, s.agentErr +} + +func (s stubForms) ConfirmQueue([]ticket.Ticket) ([]ticket.Ticket, error) { + return s.queue, s.queueErr +} + +func TestInit_formRunnerPickWritesAgent(t *testing.T) { + dir := t.TempDir() + + created, err := (run.Init{ + Forms: stubForms{agent: "claude"}, + IsTerminal: func() bool { + return true + }, + }).Config(dir) + if err != nil { + t.Fatalf("Init.Config: %v", err) + } + if !created { + t.Fatal("Init.Config: want created=true") + } + + cfg, err := run.LoadConfig(dir) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if cfg.Agent != "claude" { + t.Errorf("Agent = %q, want claude", cfg.Agent) + } +} + +func TestInit_formRunnerErrorDoesNotWriteShipfile(t *testing.T) { + dir := t.TempDir() + + created, err := (run.Init{ + Forms: stubForms{agentErr: run.ErrCanceled}, + IsTerminal: func() bool { + return true + }, + }).Config(dir) + if err == nil { + t.Fatal("Init.Config: want error when FormRunner fails") + } + if created { + t.Fatal("Init.Config: want created=false on FormRunner error") + } + if _, statErr := os.Stat(shipConfigPath(dir)); !os.IsNotExist(statErr) { + t.Fatalf("Shipfile should not exist after cancel; stat=%v", statErr) + } +} + +func TestInteractive_formRunnerConfirmReturnsSelection(t *testing.T) { + candidates := []ticket.Ticket{ + {Number: 7, Title: "seven"}, + {Number: 8, Title: "eight"}, + {Number: 9, Title: "nine"}, + } + p := run.Interactive{ + Forms: stubForms{queue: []ticket.Ticket{candidates[2], candidates[0]}}, + IsTerminal: func() bool { + return true + }, + } + + got, err := p.Confirm(context.Background(), candidates) + if err != nil { + t.Fatalf("Confirm: %v", err) + } + if len(got) != 2 || got[0].Number != 9 || got[1].Number != 7 { + t.Fatalf("Confirm = %v, want [9 7]", ticketNumbers(got)) + } +} + +func TestInteractive_formRunnerCancelReturnsErrCanceled(t *testing.T) { + p := run.Interactive{ + Forms: stubForms{queueErr: run.ErrCanceled}, + IsTerminal: func() bool { + return true + }, + } + + got, err := p.Confirm(context.Background(), []ticket.Ticket{{Number: 7}}) + if !errors.Is(err, run.ErrCanceled) { + t.Fatalf("Confirm error = %v, want ErrCanceled", err) + } + if got != nil { + t.Errorf("Confirm tickets = %v, want nil", got) + } +} + +func TestInteractive_formRunnerEmptySelection(t *testing.T) { + p := run.Interactive{ + Forms: stubForms{queue: []ticket.Ticket{}}, + IsTerminal: func() bool { + return true + }, + } + + got, err := p.Confirm(context.Background(), []ticket.Ticket{{Number: 7}, {Number: 8}}) + if err != nil { + t.Fatalf("Confirm: %v", err) + } + if len(got) != 0 { + t.Fatalf("Confirm = %v, want empty selection", ticketNumbers(got)) + } +} + +func TestInit_nonTTYIgnoresFormRunner(t *testing.T) { + dir := t.TempDir() + + created, err := (run.Init{ + Forms: stubForms{agentErr: errors.New("FormRunner must not run off TTY")}, + IsTerminal: func() bool { + return false + }, + }).Config(dir) + if err != nil { + t.Fatalf("Init.Config: %v", err) + } + if !created { + t.Fatal("Init.Config: want created=true") + } + cfg, err := run.LoadConfig(dir) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if cfg.Agent != "cursor" { + t.Errorf("Agent = %q, want cursor", cfg.Agent) + } +} + +func TestInteractive_nonTTYIgnoresFormRunner(t *testing.T) { + p := run.Interactive{ + Forms: stubForms{queue: []ticket.Ticket{{Number: 99}}}, + IsTerminal: func() bool { + return false + }, + } + + got, err := p.Confirm(context.Background(), []ticket.Ticket{{Number: 7}}) + if !errors.Is(err, run.ErrNonInteractive) { + t.Fatalf("Confirm error = %v, want ErrNonInteractive", err) + } + if got != nil { + t.Errorf("Confirm tickets = %v, want nil", got) + } +} diff --git a/internal/run/orchestrator_test.go b/internal/run/orchestrator_test.go index 8aab0f3..0c439bb 100644 --- a/internal/run/orchestrator_test.go +++ b/internal/run/orchestrator_test.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "io" "os" "os/exec" "path/filepath" @@ -326,8 +325,6 @@ func TestRun_nonInteractivePickerFailsWithoutStamping(t *testing.T) { tickets := &fakeTickets{ready: []ticket.Ticket{{Number: 7, Title: "seven"}}} ag := &fakeAgent{} queue := run.Interactive{ - In: strings.NewReader(""), - Out: io.Discard, IsTerminal: func() bool { return false }, diff --git a/internal/run/picker.go b/internal/run/picker.go index 1fd8bbf..4bb4284 100644 --- a/internal/run/picker.go +++ b/internal/run/picker.go @@ -1,14 +1,9 @@ package run import ( - "bufio" "context" "errors" - "fmt" - "io" "os" - "strconv" - "strings" "github.com/maxBRT/ship-cli/internal/ticket" ) @@ -23,62 +18,25 @@ var ErrCanceled = errors.New("queue confirmation canceled") // Interactive is the production Queue: an interactive Ticket picker over // ship-labeled candidates. Confirm, drop, and reorder happen before any Phase. type Interactive struct { - In io.Reader // optional; default os.Stdin - Out io.Writer // optional; default os.Stderr // IsTerminal reports whether an interactive session is available. // Optional; default requires stdin and stderr to be character devices. IsTerminal func() bool + // Forms prompts for Queue confirmation on a TTY. Optional; default Forms (huh). + Forms FormRunner } func (p Interactive) Confirm(_ context.Context, candidates []ticket.Ticket) ([]ticket.Ticket, error) { if !p.terminal() { return nil, ErrNonInteractive } - in := p.in() - out := p.out() - - fmt.Fprintln(out, "ship Tickets - confirm the Run queue.") - fmt.Fprintln(out) - for i, t := range candidates { - fmt.Fprintf(out, " %d. #%d %s\n", i+1, t.Number, t.Title) - } - fmt.Fprintln(out) - fmt.Fprintln(out, "Enter numbers in Run order (e.g. 2 1), empty line for all,") - fmt.Fprintln(out, "'none' for empty selection, or 'q' to cancel:") - fmt.Fprint(out, "> ") - - line, err := bufio.NewReader(in).ReadString('\n') - if err != nil && !errors.Is(err, io.EOF) { - return nil, fmt.Errorf("read picker input: %w", err) - } - line = strings.TrimSpace(line) - - switch { - case line == "q" || line == "cancel": - return nil, ErrCanceled - case line == "none": - return []ticket.Ticket{}, nil - case line == "" || line == "all": - outTickets := make([]ticket.Ticket, len(candidates)) - copy(outTickets, candidates) - return outTickets, nil - } + return p.forms().ConfirmQueue(candidates) +} - fields := strings.Fields(line) - selected := make([]ticket.Ticket, 0, len(fields)) - seen := make(map[int]struct{}, len(fields)) - for _, f := range fields { - n, err := strconv.Atoi(f) - if err != nil || n < 1 || n > len(candidates) { - return nil, fmt.Errorf("invalid picker selection %q (want 1..%d)", f, len(candidates)) - } - if _, ok := seen[n]; ok { - continue - } - seen[n] = struct{}{} - selected = append(selected, candidates[n-1]) +func (p Interactive) forms() FormRunner { + if p.Forms != nil { + return p.Forms } - return selected, nil + return Forms{} } func (p Interactive) terminal() bool { @@ -88,20 +46,6 @@ func (p Interactive) terminal() bool { return isTerminalFile(os.Stdin) && isTerminalFile(os.Stderr) } -func (p Interactive) in() io.Reader { - if p.In != nil { - return p.In - } - return os.Stdin -} - -func (p Interactive) out() io.Writer { - if p.Out != nil { - return p.Out - } - return os.Stderr -} - func isTerminalFile(f *os.File) bool { fi, err := f.Stat() if err != nil { diff --git a/internal/run/picker_test.go b/internal/run/picker_test.go index 7e584f4..d489542 100644 --- a/internal/run/picker_test.go +++ b/internal/run/picker_test.go @@ -1,9 +1,9 @@ package run_test import ( - "bytes" "context" "errors" + "io" "strings" "testing" @@ -12,10 +12,7 @@ import ( ) func TestInteractive_nonTTYFailsClearly(t *testing.T) { - var out bytes.Buffer p := run.Interactive{ - In: strings.NewReader(""), - Out: &out, IsTerminal: func() bool { return false }, @@ -35,16 +32,14 @@ func TestInteractive_nonTTYFailsClearly(t *testing.T) { if got != nil { t.Errorf("Confirm tickets = %v, want nil", got) } - if out.Len() != 0 { - t.Errorf("picker wrote %q before failing non-interactively", out.String()) - } } func TestInteractive_confirmAllKeepsListedOrder(t *testing.T) { - var out bytes.Buffer p := run.Interactive{ - In: strings.NewReader("\n"), - Out: &out, + Forms: run.TypedLines{ + In: strings.NewReader("\n"), + Out: io.Discard, + }, IsTerminal: func() bool { return true }, @@ -64,10 +59,11 @@ func TestInteractive_confirmAllKeepsListedOrder(t *testing.T) { } func TestInteractive_dropAndReorderViaIndexList(t *testing.T) { - var out bytes.Buffer p := run.Interactive{ - In: strings.NewReader("3 1\n"), - Out: &out, + Forms: run.TypedLines{ + In: strings.NewReader("3 1\n"), + Out: io.Discard, + }, IsTerminal: func() bool { return true }, @@ -88,10 +84,11 @@ func TestInteractive_dropAndReorderViaIndexList(t *testing.T) { } func TestInteractive_cancelReturnsErrCanceled(t *testing.T) { - var out bytes.Buffer p := run.Interactive{ - In: strings.NewReader("q\n"), - Out: &out, + Forms: run.TypedLines{ + In: strings.NewReader("q\n"), + Out: io.Discard, + }, IsTerminal: func() bool { return true }, @@ -107,10 +104,11 @@ func TestInteractive_cancelReturnsErrCanceled(t *testing.T) { } func TestInteractive_noneConfirmsEmptySelection(t *testing.T) { - var out bytes.Buffer p := run.Interactive{ - In: strings.NewReader("none\n"), - Out: &out, + Forms: run.TypedLines{ + In: strings.NewReader("none\n"), + Out: io.Discard, + }, IsTerminal: func() bool { return true }, diff --git a/internal/run/shipfile.go b/internal/run/shipfile.go index 08fa4e4..9374614 100644 --- a/internal/run/shipfile.go +++ b/internal/run/shipfile.go @@ -1,10 +1,7 @@ package run import ( - "bufio" - "errors" "fmt" - "io" "os" "path/filepath" "strings" @@ -22,14 +19,14 @@ func shipConfigPath(dir string) string { return filepath.Join(dir, shipDir, shipConfigFile) } -// Init creates .ship/config.yaml. On a TTY it lists Agent kinds and reads a -// typed choice; otherwise it records agent: cursor with no prompt. +// Init creates .ship/config.yaml. On a TTY it asks FormRunner for an Agent +// kind; otherwise it records agent: cursor with no prompt. type Init struct { - In io.Reader // optional; default os.Stdin - Out io.Writer // optional; default os.Stderr // IsTerminal reports whether an interactive session is available. // Optional; default requires stdin and stderr to be character devices. IsTerminal func() bool + // Forms prompts for Agent kind on a TTY. Optional; default Forms (huh). + Forms FormRunner } // Config writes .ship/config.yaml with filled defaults and the chosen Agent kind. @@ -43,7 +40,7 @@ func (i Init) Config(dir string) (created bool, err error) { case os.IsNotExist(err): cfg := defaultConfig() if i.terminal() { - kind, err := i.promptAgentKind() + kind, err := i.forms().PickAgent() if err != nil { return false, err } @@ -67,21 +64,11 @@ timeout: %s } } -func (i Init) promptAgentKind() (string, error) { - out := i.out() - fmt.Fprintln(out, "Choose an Agent kind for this checkout:") - fmt.Fprintln(out, " cursor, pi, codex, claude") - fmt.Fprint(out, "> ") - - line, err := bufio.NewReader(i.in()).ReadString('\n') - if err != nil && !errors.Is(err, io.EOF) { - return "", fmt.Errorf("read agent kind: %w", err) - } - kind := strings.TrimSpace(line) - if err := validateAgentKind(kind); err != nil { - return "", err +func (i Init) forms() FormRunner { + if i.Forms != nil { + return i.Forms } - return kind, nil + return Forms{} } func (i Init) terminal() bool { @@ -91,20 +78,6 @@ func (i Init) terminal() bool { return isTerminalFile(os.Stdin) && isTerminalFile(os.Stderr) } -func (i Init) in() io.Reader { - if i.In != nil { - return i.In - } - return os.Stdin -} - -func (i Init) out() io.Writer { - if i.Out != nil { - return i.Out - } - return os.Stderr -} - // InitConfig writes .ship/config.yaml with filled defaults. // If the file already exists, created is false and the file is not overwritten. // On a TTY it prompts for Agent kind; otherwise it records agent: cursor. diff --git a/internal/run/shipfile_test.go b/internal/run/shipfile_test.go index 7ec5592..fc8c183 100644 --- a/internal/run/shipfile_test.go +++ b/internal/run/shipfile_test.go @@ -27,10 +27,8 @@ func writeShipConfig(t *testing.T, dir, content string) { func TestInit_nonTTYWritesCursorWithoutPrompt(t *testing.T) { dir := t.TempDir() - var out strings.Builder created, err := (run.Init{ - Out: &out, IsTerminal: func() bool { return false }, @@ -41,9 +39,6 @@ func TestInit_nonTTYWritesCursorWithoutPrompt(t *testing.T) { if !created { t.Fatal("Init.Config: want created=true") } - if out.Len() != 0 { - t.Errorf("non-TTY Init should not prompt; got %q", out.String()) - } cfg, err := run.LoadConfig(dir) if err != nil { @@ -54,63 +49,6 @@ func TestInit_nonTTYWritesCursorWithoutPrompt(t *testing.T) { } } -func TestInit_ttyWritesTypedAgentKind(t *testing.T) { - dir := t.TempDir() - var out strings.Builder - in := strings.NewReader("pi\n") - - created, err := (run.Init{ - In: in, - Out: &out, - IsTerminal: func() bool { - return true - }, - }).Config(dir) - if err != nil { - t.Fatalf("Init.Config: %v", err) - } - if !created { - t.Fatal("Init.Config: want created=true") - } - - printed := out.String() - for _, kind := range []string{"cursor", "pi", "codex", "claude"} { - if !strings.Contains(printed, kind) { - t.Errorf("prompt missing kind %q; got:\n%s", kind, printed) - } - } - - cfg, err := run.LoadConfig(dir) - if err != nil { - t.Fatalf("LoadConfig: %v", err) - } - if cfg.Agent != "pi" { - t.Errorf("Agent = %q, want pi", cfg.Agent) - } -} - -func TestInit_ttyInvalidKindDoesNotWriteShipfile(t *testing.T) { - dir := t.TempDir() - var out strings.Builder - - created, err := (run.Init{ - In: strings.NewReader("opencode\n"), - Out: &out, - IsTerminal: func() bool { - return true - }, - }).Config(dir) - if err == nil { - t.Fatal("Init.Config: want error for invalid kind") - } - if created { - t.Fatal("Init.Config: want created=false on invalid kind") - } - if _, statErr := os.Stat(shipConfigPath(dir)); !os.IsNotExist(statErr) { - t.Fatalf("Shipfile should not exist after invalid kind; stat=%v", statErr) - } -} - func initNonTTY(dir string) (bool, error) { return (run.Init{ IsTerminal: func() bool { return false }, diff --git a/internal/theme/theme.go b/internal/theme/theme.go new file mode 100644 index 0000000..0a1ed18 --- /dev/null +++ b/internal/theme/theme.go @@ -0,0 +1,15 @@ +// Package theme holds shared Lipgloss visual tokens for Ship chrome: +// amber wait, green success, red failure, and the ship brand prefix. +package theme + +import "github.com/charmbracelet/lipgloss" + +// Brand is the framing prefix for interactive frames and help headers. +const Brand = "ship" + +// Palette matches throbber wait / success / fail RGB. +const ( + Amber = lipgloss.Color("#E6AF5A") // 230, 175, 90 + Green = lipgloss.Color("#78C88C") // 120, 200, 140 + Red = lipgloss.Color("#DC5A5A") // 220, 90, 90 +) diff --git a/internal/theme/theme_test.go b/internal/theme/theme_test.go new file mode 100644 index 0000000..f0aa5cf --- /dev/null +++ b/internal/theme/theme_test.go @@ -0,0 +1,29 @@ +package theme_test + +import ( + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/maxBRT/ship-cli/internal/theme" +) + +func TestTheme_brandAndPalette(t *testing.T) { + if theme.Brand != "ship" { + t.Errorf("Brand = %q, want ship", theme.Brand) + } + // Independent source: throbber RGB (230,175,90), (120,200,140), (220,90,90). + assertColor(t, "Amber", theme.Amber, "#E6AF5A") + assertColor(t, "Green", theme.Green, "#78C88C") + assertColor(t, "Red", theme.Red, "#DC5A5A") +} + +func assertColor(t *testing.T, name string, got lipgloss.TerminalColor, wantHex string) { + t.Helper() + c, ok := got.(lipgloss.Color) + if !ok { + t.Fatalf("%s: got %T, want lipgloss.Color", name, got) + } + if string(c) != wantHex { + t.Errorf("%s = %q, want %q", name, c, wantHex) + } +} From 532722c1181fb35cd760441c037ac3d90e6a7bc9 Mon Sep 17 00:00:00 2001 From: Maxime Bourret Date: Tue, 21 Jul 2026 17:25:37 -0400 Subject: [PATCH 2/3] Style ship --help with Lipgloss flag columns. Brand Usage/Flags/Config headings and render each flag as name, meaning, and default on one aligned row. Co-authored-by: Cursor --- internal/run/config.go | 53 ++++++++++++++++++++++++++------ internal/run/usage_test.go | 63 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 10 deletions(-) create mode 100644 internal/run/usage_test.go diff --git a/internal/run/config.go b/internal/run/config.go index b503d03..c4e2b7c 100644 --- a/internal/run/config.go +++ b/internal/run/config.go @@ -6,6 +6,9 @@ import ( "io" "strings" "time" + + "github.com/charmbracelet/lipgloss" + "github.com/maxBRT/ship-cli/internal/theme" ) // Config holds the Run configuration parsed from .ship/config.yaml and flags. @@ -34,33 +37,63 @@ var knownAgentKinds = map[string]struct{}{ // WriteUsage prints CLI help using Ship domain language. func WriteUsage(w io.Writer) { - fmt.Fprintf(w, `ship - run a sequential Ticket Run in the current checkout. + heading := lipgloss.NewStyle().Foreground(theme.Amber).Bold(true) + fmt.Fprintf(w, `%s - run a sequential Ticket Run in the current checkout. A Run confirms a ship queue from ship-labeled Tickets via an interactive picker, processes each through one Iteration (Implement Phase then Review Phase), then a Final Phase. -Usage: +%s ship [flags] ship init ship update ship --version -Flags: -`) - defaults := defaultConfig() - fs := newFlagSet(&defaults) - fs.SetOutput(w) - fs.PrintDefaults() +%s +`, theme.Brand, heading.Render(theme.Brand+" Usage"), heading.Render(theme.Brand+" Flags")) + writeFlagColumns(w) fmt.Fprintf(w, ` Commands: init create .ship/config.yaml with defaults update download the latest GitHub Release and replace this binary -Config: +%s .ship/config.yaml at the checkout root (defaults → YAML → flags). Run "ship init" to create one with filled defaults. -`) +`, heading.Render(theme.Brand+" Config")) +} + +func writeFlagColumns(w io.Writer) { + defaults := defaultConfig() + fs := newFlagSet(&defaults) + + type row struct { + name, meaning, def string + } + var rows []row + nameWidth, meaningWidth := 0, 0 + fs.VisitAll(func(f *flag.Flag) { + name := "-" + f.Name + r := row{name: name, meaning: f.Usage, def: f.DefValue} + rows = append(rows, r) + if len(name) > nameWidth { + nameWidth = len(name) + } + if len(f.Usage) > meaningWidth { + meaningWidth = len(f.Usage) + } + }) + + nameStyle := lipgloss.NewStyle().Width(nameWidth + 2) + meaningStyle := lipgloss.NewStyle().Width(meaningWidth + 2).Foreground(lipgloss.Color("245")) + defStyle := lipgloss.NewStyle().Foreground(theme.Green) + for _, r := range rows { + fmt.Fprint(w, " ") + fmt.Fprint(w, nameStyle.Render(r.name)) + fmt.Fprint(w, meaningStyle.Render(r.meaning)) + fmt.Fprintln(w, defStyle.Render(r.def)) + } } func defaultConfig() Config { diff --git a/internal/run/usage_test.go b/internal/run/usage_test.go new file mode 100644 index 0000000..efd7359 --- /dev/null +++ b/internal/run/usage_test.go @@ -0,0 +1,63 @@ +package run_test + +import ( + "bytes" + "strings" + "testing" + + "github.com/maxBRT/ship-cli/internal/run" + "github.com/maxBRT/ship-cli/internal/theme" +) + +func TestWriteUsage_sectionHeadingsIncludeShipBrand(t *testing.T) { + var buf bytes.Buffer + run.WriteUsage(&buf) + out := buf.String() + + for _, section := range []string{"Usage", "Flags", "Config"} { + found := false + for _, line := range strings.Split(out, "\n") { + if strings.Contains(line, section) && strings.Contains(line, theme.Brand) { + found = true + break + } + } + if !found { + t.Errorf("section %q heading should include %q brand framing; got:\n%s", section, theme.Brand, out) + } + } +} + +func TestWriteUsage_flagsShowNameMeaningAndDefaultOnOneLine(t *testing.T) { + var buf bytes.Buffer + run.WriteUsage(&buf) + out := buf.String() + + // Literals from today's flag set and defaults (independent of render layout). + cases := []struct { + name, meaning, def string + }{ + {"-agent", "Agent kind for every Phase", "cursor"}, + {"-branch", "git branch for the Run", ""}, + {"-max-iterations", "max Iterations (one Ticket each) before Final", "10"}, + {"-model", "optional model for the Agent", ""}, + {"-timeout", "per-Phase timeout (Go duration)", "20m0s"}, + } + for _, tc := range cases { + found := false + for _, line := range strings.Split(out, "\n") { + if !strings.Contains(line, tc.name) || !strings.Contains(line, tc.meaning) { + continue + } + if tc.def != "" && !strings.Contains(line, tc.def) { + continue + } + found = true + break + } + if !found { + t.Errorf("flag %q should appear with meaning %q and default %q on one line; got:\n%s", + tc.name, tc.meaning, tc.def, out) + } + } +} From c21cf984e7b064a2c8e9f746e511d32ab73cf8c2 Mon Sep 17 00:00:00 2001 From: Maxime Bourret Date: Tue, 21 Jul 2026 17:25:37 -0400 Subject: [PATCH 3/3] Add Lipgloss Run chrome and denser observe strip. Print a once-per-Run agent/branch header, queue-remaining throbber hint, compact tools/in/out success dump, and louder Abort framing; gate color on stderr TTY and keep the two-line throbber from scrolling. Co-authored-by: Cursor --- cmd/ship/main.go | 22 ++++- internal/observe/doc.go | 4 +- internal/observe/observer.go | 70 +++++++++++---- internal/observe/observer_test.go | 80 ++++++++++++----- internal/run/chrome.go | 39 +++++++++ internal/run/chrome_test.go | 116 +++++++++++++++++++++++++ internal/run/orchestrator.go | 31 +++++-- internal/run/orchestrator_test.go | 20 +++-- internal/throbber/line.go | 29 ++++++- internal/throbber/line_test.go | 138 ++++++++++++++++++++++++++++++ internal/throbber/port.go | 1 + internal/throbber/port_test.go | 24 ++++++ 12 files changed, 514 insertions(+), 60 deletions(-) create mode 100644 internal/run/chrome.go create mode 100644 internal/run/chrome_test.go create mode 100644 internal/throbber/line_test.go diff --git a/cmd/ship/main.go b/cmd/ship/main.go index 209ed71..6da9039 100644 --- a/cmd/ship/main.go +++ b/cmd/ship/main.go @@ -78,6 +78,9 @@ func MainWith(args []string, stdout, stderr io.Writer, dir string, up update.Por } gh := &ticket.GitHub{} + color := colorEnabled(stderr) + obs := observe.New(dir, stderr) + obs.Color = color orchestrator := run.Orchestrator{ Tickets: gh, Queue: run.Interactive{}, @@ -85,9 +88,11 @@ func MainWith(args []string, stdout, stderr io.Writer, dir string, up update.Por PRs: gh, Repo: gitops.Repo{Dir: dir}, Config: cfg, - Throbber: throbber.Line{Out: stderr, Color: true}, - Observer: observe.New(dir, stderr), + Throbber: throbber.Line{Out: stderr, Color: color}, + Observer: obs, Herdr: herdr.Reporter{}, + Header: stderr, + Color: color, Stdout: stdout, } if err := orchestrator.Run(context.Background()); err != nil { @@ -150,3 +155,16 @@ func wantsVersion(args []string) bool { } return false } + +// colorEnabled is true when w is a terminal character device (TTY). +func colorEnabled(w io.Writer) bool { + f, ok := w.(*os.File) + if !ok { + return false + } + fi, err := f.Stat() + if err != nil { + return false + } + return fi.Mode()&os.ModeCharDevice != 0 +} diff --git a/internal/observe/doc.go b/internal/observe/doc.go index d1be63f..bf2f275 100644 --- a/internal/observe/doc.go +++ b/internal/observe/doc.go @@ -1,5 +1,5 @@ // Package observe defines curated Run observability events, the Sink Agent // adapters emit into, and the Run-owned Observer that writes Phase logs under -// .ship/runs, dumps one high-signal tools+tokens line after each Phase, and -// prints an Abort banner with report paths and last tools on Phase failure. +// .ship/runs, dumps a dense tools + in/out strip after each Phase, and prints +// a loud Abort banner with report paths and last tools on Phase failure. package observe diff --git a/internal/observe/observer.go b/internal/observe/observer.go index 5226d81..d56da04 100644 --- a/internal/observe/observer.go +++ b/internal/observe/observer.go @@ -9,6 +9,9 @@ import ( "strings" "sync" "time" + + "github.com/charmbracelet/lipgloss" + "github.com/maxBRT/ship-cli/internal/theme" ) // Port is the Run-owned observability surface: it receives curated events @@ -18,11 +21,11 @@ type Port interface { // BeginPhase starts buffering for one Phase and returns the Sink Agent // adapters emit into. Mid-Phase Emit must not write tool lines to Out. BeginPhase(phase string) Sink - // EndPhase dumps one high-signal line with the tool total and token - // totals collected since BeginPhase, then clears the buffer. + // EndPhase dumps one dense strip with the tool total and compact + // in/out token totals collected since BeginPhase, then clears the buffer. EndPhase() - // Abort prints a stderr banner with the Run report directory, Phase log - // path, and last tool lines, then clears the buffer. Used on Phase + // Abort prints a loud stderr banner with the Run report directory, Phase + // log path, and last tool lines, then clears the buffer. Used on Phase // failure instead of EndPhase so the banner does not rely on the // success dump. Abort() @@ -32,8 +35,9 @@ type Port interface { // when EndPhase is called. Safe for concurrent Emit from an Agent adapter. // Dir is the workspace root; Phase reports land under Dir/.ship/runs//. type Observer struct { - Dir string - Out io.Writer + Dir string + Out io.Writer + Color bool // red Abort chrome; plain when false mu sync.Mutex events []Event @@ -96,8 +100,9 @@ func (o *Observer) Emit(e Event) { _ = enc.Encode(e) } -// EndPhase writes one high-signal line with the tool total and any token -// totals, then clears the buffer. Per-tool detail stays in the Phase log. +// EndPhase writes one dense strip with the tool total and compact in/out +// token totals, then clears the buffer. Per-tool detail and cache fields +// stay in the Phase log. func (o *Observer) EndPhase() { o.mu.Lock() events := o.events @@ -120,16 +125,16 @@ func (o *Observer) EndPhase() { return } if tokens == nil { - fmt.Fprintf(o.Out, "tools %d\n", tools) + fmt.Fprintf(o.Out, " tools %d\n", tools) return } - fmt.Fprintf(o.Out, "tools %d tokens input=%d output=%d cache_read=%d cache_write=%d\n", - tools, tokens.Input, tokens.Output, tokens.CacheRead, tokens.CacheWrite) + fmt.Fprintf(o.Out, " tools %d · in %s · out %s\n", + tools, compactCount(tokens.Input), compactCount(tokens.Output)) } -// Abort writes a banner with Run report and Phase log paths plus the last -// handful of tool one-liners, then clears the buffer without running the -// success dump. +// Abort writes a loud banner with Run report and Phase log paths plus the +// last handful of tool one-liners, then clears the buffer without running the +// success dump. When Color is false, marks stay plain (no ANSI). func (o *Observer) Abort() { o.mu.Lock() runDir := o.runDir @@ -138,12 +143,19 @@ func (o *Observer) Abort() { o.events = nil o.mu.Unlock() - fmt.Fprintf(o.Out, "Abort: Phase failed\n") + mark := "✗ Abort" + sep := "────────────────────────────────────────────" + if o.Color { + style := lipgloss.NewStyle().Foreground(theme.Red) + mark = style.Render("✗ Abort") + sep = style.Render(sep) + } + fmt.Fprintf(o.Out, "%s Phase failed\n", mark) if runDir != "" { - fmt.Fprintf(o.Out, "Run report: %s\n", runDir) + fmt.Fprintf(o.Out, " report %s\n", runDir) } if phaseLog != "" { - fmt.Fprintf(o.Out, "Phase log: %s\n", phaseLog) + fmt.Fprintf(o.Out, " phase log %s\n", phaseLog) } var tools []Event @@ -155,9 +167,31 @@ func (o *Observer) Abort() { if n := len(tools); n > abortToolLimit { tools = tools[n-abortToolLimit:] } + if len(tools) > 0 { + fmt.Fprintln(o.Out, sep) + } for _, e := range tools { - fmt.Fprintf(o.Out, "tool %s %dms %s\n", e.Name, e.DurationMS, e.Status) + fmt.Fprintf(o.Out, " %s %dms %s\n", e.Name, e.DurationMS, e.Status) + } +} + +// compactCount renders token totals for the dense observe strip (e.g. 18.4k). +func compactCount(n int64) string { + if n < 1000 { + return fmt.Sprintf("%d", n) + } + if n < 1_000_000 { + v := float64(n) / 1000 + if v == float64(int64(v)) { + return fmt.Sprintf("%.0fk", v) + } + return fmt.Sprintf("%.1fk", v) + } + v := float64(n) / 1_000_000 + if v == float64(int64(v)) { + return fmt.Sprintf("%.0fM", v) } + return fmt.Sprintf("%.1fM", v) } // abortToolLimit is how many trailing tool lines the Abort banner keeps. diff --git a/internal/observe/observer_test.go b/internal/observe/observer_test.go index db73288..5c8a75b 100644 --- a/internal/observe/observer_test.go +++ b/internal/observe/observer_test.go @@ -8,8 +8,8 @@ import ( ) func TestObserver_EndPhase_dumpsOneToolsAndTokensLine(t *testing.T) { - // Terminal dump is one high-signal line: tool total plus tokens. - // Per-tool detail stays in the Phase JSON log only. + // Terminal dump is one dense strip: tool total plus compact in/out tokens. + // Per-tool detail and cache fields stay in the Phase JSON log only. var out strings.Builder obs := observe.New(t.TempDir(), &out) @@ -30,8 +30,8 @@ func TestObserver_EndPhase_dumpsOneToolsAndTokensLine(t *testing.T) { Kind: observe.KindPhaseEnd, Outcome: observe.OutcomeSuccess, Tokens: &observe.TokenCounts{ - Input: 120, - Output: 45, + Input: 18420, + Output: 931, CacheRead: 10, CacheWrite: 2, }, @@ -39,9 +39,17 @@ func TestObserver_EndPhase_dumpsOneToolsAndTokensLine(t *testing.T) { obs.EndPhase() got := out.String() - want := "tools 2 tokens input=120 output=45 cache_read=10 cache_write=2\n" - if got != want { - t.Errorf("dump = %q, want %q", got, want) + if !strings.Contains(got, "tools") || !strings.Contains(got, "2") { + t.Errorf("dump missing tools count; got %q", got) + } + if !strings.Contains(got, "in") || !strings.Contains(got, "18.4k") { + t.Errorf("dump missing compact input tokens; got %q", got) + } + if !strings.Contains(got, "out") || !strings.Contains(got, "931") { + t.Errorf("dump missing output tokens; got %q", got) + } + if strings.Contains(got, "cache_read") || strings.Contains(got, "cache_write") { + t.Errorf("dense strip must omit cache fields; got %q", got) } if strings.Contains(got, "tool Read") || strings.Contains(got, "tool Write") { t.Errorf("dump must not list per-tool lines; got:\n%s", got) @@ -66,11 +74,11 @@ func TestObserver_EndPhase_toolsOnlyWhenUsageAbsent(t *testing.T) { obs.EndPhase() got := out.String() - want := "tools 1\n" + want := " tools 1\n" if got != want { t.Errorf("dump = %q, want %q", got, want) } - if strings.Contains(got, "tokens") { + if strings.Contains(got, "tokens") || strings.Contains(got, "in ") { t.Errorf("dump = %q, want no tokens when usage absent", got) } } @@ -93,13 +101,13 @@ func TestObserver_Emit_doesNotWriteUntilEndPhase(t *testing.T) { } obs.EndPhase() - if got := out.String(); got != "tools 1\n" { + if got := out.String(); got != " tools 1\n" { t.Errorf("after EndPhase dump = %q, want tools 1\\n", got) } } func TestObserver_Abort_keepsLastHandfulOfToolLines(t *testing.T) { - // Abort banner keeps only the trailing handful of tool lines. + // Abort banner keeps only the trailing handful of tool lines and stays loud. var out strings.Builder obs := observe.New(t.TempDir(), &out) sink := obs.BeginPhase("Implement") @@ -115,16 +123,46 @@ func TestObserver_Abort_keepsLastHandfulOfToolLines(t *testing.T) { obs.Abort() got := out.String() - if strings.Contains(got, "tool A 1ms ok") { - t.Errorf("Abort kept tool A beyond handful; got:\n%s", got) - } - for _, want := range []string{ - "tool B 2ms ok", - "tool C 3ms ok", - "tool D 4ms ok", - "tool E 5ms ok", - "tool F 6ms ok", - } { + if !strings.Contains(got, "Abort") { + t.Errorf("Abort banner missing Abort; got:\n%s", got) + } + if !strings.Contains(got, "✗") { + t.Errorf("Abort banner missing red failure mark; got:\n%s", got) + } + if strings.Contains(got, " A ") && strings.Contains(got, "1ms") { + lines := strings.Split(got, "\n") + for _, line := range lines { + if strings.Contains(line, " A ") && strings.Contains(line, "1ms") { + t.Errorf("Abort kept tool A beyond handful; got:\n%s", got) + break + } + } + } + for _, want := range []string{"B", "C", "D", "E", "F"} { + if !strings.Contains(got, want) { + t.Errorf("Abort banner missing tool %q; got:\n%s", want, got) + } + } +} + +func TestObserver_Abort_plainWhenColorOff(t *testing.T) { + var out strings.Builder + obs := observe.New(t.TempDir(), &out) + obs.Color = false + sink := obs.BeginPhase("Implement") + sink.Emit(observe.Event{ + Kind: observe.KindTool, + Name: "Read", + DurationMS: 42, + Status: observe.ToolOK, + }) + obs.Abort() + + got := out.String() + if strings.Contains(got, "\033[") { + t.Errorf("plain Abort must not emit ANSI; got %q", got) + } + for _, want := range []string{"Abort", "✗", "report", "phase log", "Read", "42ms", "ok"} { if !strings.Contains(got, want) { t.Errorf("Abort banner missing %q; got:\n%s", want, got) } diff --git a/internal/run/chrome.go b/internal/run/chrome.go new file mode 100644 index 0000000..bf63945 --- /dev/null +++ b/internal/run/chrome.go @@ -0,0 +1,39 @@ +package run + +import ( + "fmt" + "io" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/maxBRT/ship-cli/internal/theme" +) + +// WriteRunHeader prints a one-time Lipgloss Run frame with Agent kind and branch. +// When color is false, marks and rules stay plain (no ANSI). +func WriteRunHeader(w io.Writer, agent, branch string, color bool) { + sep := "────────────────────────────────────────────" + if color { + brand := lipgloss.NewStyle().Foreground(theme.Amber).Bold(true) + dim := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + fmt.Fprintln(w, brand.Render(theme.Brand)) + fmt.Fprintf(w, " run · agent %s · branch %s\n", agent, branch) + fmt.Fprintln(w, dim.Render(sep)) + return + } + fmt.Fprintln(w, theme.Brand) + fmt.Fprintf(w, " run · agent %s · branch %s\n", agent, branch) + fmt.Fprintln(w, sep) +} + +// FormatQueueRemaining builds the dim queue-remainder hint under the throbber. +func FormatQueueRemaining(numbers []int) string { + if len(numbers) == 0 { + return "" + } + parts := make([]string, len(numbers)) + for i, n := range numbers { + parts[i] = fmt.Sprintf("#%d", n) + } + return "queue remaining · " + strings.Join(parts, " · ") +} diff --git a/internal/run/chrome_test.go b/internal/run/chrome_test.go new file mode 100644 index 0000000..054ddeb --- /dev/null +++ b/internal/run/chrome_test.go @@ -0,0 +1,116 @@ +package run_test + +import ( + "context" + "strings" + "testing" + + "github.com/maxBRT/ship-cli/internal/gitops" + "github.com/maxBRT/ship-cli/internal/run" + "github.com/maxBRT/ship-cli/internal/ticket" +) + +func TestWriteRunHeader_includesAgentAndBranch(t *testing.T) { + var b strings.Builder + run.WriteRunHeader(&b, "cursor", "feature/ship", true) + got := b.String() + for _, want := range []string{"ship", "cursor", "feature/ship", "agent", "branch"} { + if !strings.Contains(got, want) { + t.Errorf("WriteRunHeader missing %q; got:\n%s", want, got) + } + } +} + +func TestWriteRunHeader_plainWhenColorOff(t *testing.T) { + var b strings.Builder + run.WriteRunHeader(&b, "cursor", "feature/ship", false) + got := b.String() + if strings.Contains(got, "\033[") { + t.Errorf("plain WriteRunHeader must not emit ANSI; got %q", got) + } + for _, want := range []string{"ship", "cursor", "feature/ship", "agent", "branch"} { + if !strings.Contains(got, want) { + t.Errorf("WriteRunHeader missing %q; got:\n%s", want, got) + } + } +} + +func TestFormatQueueRemaining_listsTicketNumbers(t *testing.T) { + got := run.FormatQueueRemaining([]int{7, 9}) + if !strings.Contains(got, "#7") || !strings.Contains(got, "#9") { + t.Fatalf("FormatQueueRemaining = %q, want #7 and #9", got) + } + if !strings.Contains(strings.ToLower(got), "remaining") && !strings.Contains(got, "queue") { + t.Fatalf("FormatQueueRemaining = %q, want queue remaining hint", got) + } +} + +func TestFormatQueueRemaining_emptyIsBlank(t *testing.T) { + if got := run.FormatQueueRemaining(nil); got != "" { + t.Fatalf("FormatQueueRemaining(nil) = %q, want empty", got) + } +} + +func TestRun_printsChromeHeaderOnce(t *testing.T) { + dir := initTempRepo(t) + tickets := &fakeTickets{ready: []ticket.Ticket{{Number: 7, Title: "seven"}}} + prs := &fakePRs{} + ag := committingAgent(t, prs, "ship/run") + var chrome strings.Builder + + r := run.Orchestrator{ + Tickets: tickets, + Agent: ag, + PRs: prs, + Repo: gitops.Repo{Dir: dir}, + Config: run.Config{Branch: "ship/run", Agent: "cursor", MaxIterations: 10}, + Header: &chrome, + Stdout: &strings.Builder{}, + } + if err := r.Run(context.Background()); err != nil { + t.Fatalf("Run: %v", err) + } + got := chrome.String() + if c := strings.Count(got, "run · agent"); c != 1 { + t.Fatalf("run header count = %d, want 1; got:\n%s", c, got) + } + if !strings.Contains(got, "cursor") || !strings.Contains(got, "ship/run") { + t.Fatalf("header missing agent/branch; got:\n%s", got) + } +} + +func TestRun_passesQueueRemainingToThrobber(t *testing.T) { + dir := initTempRepo(t) + tickets := &fakeTickets{ready: []ticket.Ticket{ + {Number: 8, Title: "eight"}, + {Number: 7, Title: "seven"}, + {Number: 9, Title: "nine"}, + }} + prs := &fakePRs{} + ag := committingAgent(t, prs, "ship/run") + th := &recordingThrobber{} + + r := run.Orchestrator{ + Tickets: tickets, + Agent: ag, + PRs: prs, + Repo: gitops.Repo{Dir: dir}, + Config: run.Config{Branch: "ship/run", MaxIterations: 1}, + Throbber: th, + Stdout: &strings.Builder{}, + } + if err := r.Run(context.Background()); err != nil { + t.Fatalf("Run: %v", err) + } + if len(th.remainings) < 2 { + t.Fatalf("throbber remainings = %v, want Implement+Review hints", th.remainings) + } + for _, got := range th.remainings[:2] { + if !strings.Contains(got, "#7") || !strings.Contains(got, "#9") { + t.Errorf("Remaining = %q, want #7 and #9", got) + } + if !strings.Contains(got, "queue remaining") { + t.Errorf("Remaining = %q, want queue remaining label", got) + } + } +} diff --git a/internal/run/orchestrator.go b/internal/run/orchestrator.go index c7d8ae5..c39c11d 100644 --- a/internal/run/orchestrator.go +++ b/internal/run/orchestrator.go @@ -27,9 +27,11 @@ type Orchestrator struct { PRs PullRequests Repo gitops.Repo Config Config - Throbber throbber.Port // optional; nil means no wait UI - Observer observe.Port // optional; nil means no Phase observability - Herdr herdr.Port // optional; nil means no multiplexer agent-state reports + Throbber throbber.Port // optional; nil means no wait UI + Observer observe.Port // optional; nil means no Phase observability + Herdr herdr.Port // optional; nil means no multiplexer agent-state reports + Header io.Writer // optional; Run header (stderr in production) + Color bool // Run header color; plain when false Stdout io.Writer } @@ -84,13 +86,17 @@ func (r Orchestrator) Run(ctx context.Context) error { if err != nil { return fmt.Errorf("prepare Run branch: %w", err) } + if r.Header != nil { + WriteRunHeader(r.Header, r.Config.Agent, branch, r.Color) + } var done []ticket.Ticket iterations := 0 for iterations < r.Config.MaxIterations && iterations < len(queue) { t := queue[iterations] + remaining := FormatQueueRemaining(ticketNumbersAfter(queue, iterations)) iterations++ - if err := r.iterate(ctx, t, branch, iterations); err != nil { + if err := r.iterate(ctx, t, branch, iterations, remaining); err != nil { return err } done = append(done, t) @@ -108,7 +114,7 @@ func (r Orchestrator) Run(ctx context.Context) error { // (which must commit), the Review Phase (which may be commitless), then Done. // A failed Phase or missing side effect Aborts: undoes that Ticket's commits // and stops the Run. Unfinished Tickets keep ship queue membership. -func (r Orchestrator) iterate(ctx context.Context, t ticket.Ticket, branch string, iteration int) error { +func (r Orchestrator) iterate(ctx context.Context, t ticket.Ticket, branch string, iteration int, remaining string) error { restore, err := r.Repo.RecordRestorePoint() if err != nil { return r.abort(t, gitops.RestorePoint(""), fmt.Errorf("record restore point for Ticket #%d: %w", t.Number, err)) @@ -116,7 +122,7 @@ func (r Orchestrator) iterate(ctx context.Context, t ticket.Ticket, branch strin ticketLabel := fmt.Sprintf("#%d %s", t.Number, t.Title) implInput := prompt.ImplementInput{Ticket: ticketInput(t), Branch: branch} - if err := r.runPhase(ctx, throbber.Status{Phase: "Implement", Iteration: iteration, Ticket: ticketLabel}, prompt.Implement(implInput)); err != nil { + if err := r.runPhase(ctx, throbber.Status{Phase: "Implement", Iteration: iteration, Ticket: ticketLabel, Remaining: remaining}, prompt.Implement(implInput)); err != nil { return r.abort(t, restore, fmt.Errorf("Implement Phase for Ticket #%d: %w", t.Number, err)) } @@ -129,7 +135,7 @@ func (r Orchestrator) iterate(ctx context.Context, t ticket.Ticket, branch strin } reviewInput := prompt.ReviewInput{Ticket: ticketInput(t), Branch: branch} - if err := r.runPhase(ctx, throbber.Status{Phase: "Review", Iteration: iteration, Ticket: ticketLabel}, prompt.Review(reviewInput)); err != nil { + if err := r.runPhase(ctx, throbber.Status{Phase: "Review", Iteration: iteration, Ticket: ticketLabel, Remaining: remaining}, prompt.Review(reviewInput)); err != nil { return r.abort(t, restore, fmt.Errorf("Review Phase for Ticket #%d: %w", t.Number, err)) } @@ -140,6 +146,17 @@ func (r Orchestrator) iterate(ctx context.Context, t ticket.Ticket, branch strin return nil } +func ticketNumbersAfter(queue []ticket.Ticket, idx int) []int { + if idx+1 >= len(queue) { + return nil + } + out := make([]int, 0, len(queue)-idx-1) + for _, t := range queue[idx+1:] { + out = append(out, t.Number) + } + return out +} + // abort undoes that Ticket's commits on the Run branch and returns a clear // Abort summary wrapping cause. Tracker labels are left alone so unfinished // Tickets keep ship queue membership. diff --git a/internal/run/orchestrator_test.go b/internal/run/orchestrator_test.go index 0c439bb..67fa117 100644 --- a/internal/run/orchestrator_test.go +++ b/internal/run/orchestrator_test.go @@ -90,8 +90,8 @@ func TestRun_afterPhase_dumpsHighSignalToolAndTokenLines(t *testing.T) { } got := dump.String() - // Three Phases each dump one high-signal tools+tokens line. - want := "tools 1 tokens input=120 output=45 cache_read=10 cache_write=2" + // Three Phases each dump one dense tools + compact in/out strip. + want := "tools 1 · in 120 · out 45" if c := strings.Count(got, want); c != 3 { t.Errorf("phase dump lines = %d, want 3; got:\n%s", c, got) } @@ -922,7 +922,7 @@ func TestRun_phaseFailure_printsAbortBannerWithReportPaths(t *testing.T) { } got := stderr.String() - for _, want := range []string{"Abort", "Run", "Phase", runDir, logs[0]} { + for _, want := range []string{"Abort", "report", "phase log", runDir, logs[0]} { if !strings.Contains(got, want) { t.Errorf("Abort banner missing %q; got:\n%s", want, got) } @@ -966,8 +966,8 @@ func TestRun_phaseFailure_abortBannerIncludesLastToolLines(t *testing.T) { got := stderr.String() for _, want := range []string{ - "tool Read 42ms ok", - "tool Write 7ms error", + "Read 42ms ok", + "Write 7ms error", } { if !strings.Contains(got, want) { t.Errorf("Abort banner missing %q; got:\n%s", want, got) @@ -1016,10 +1016,10 @@ func TestRun_phaseFailure_abortBannerWithoutSuccessDump(t *testing.T) { if !strings.Contains(got, "Abort") { t.Errorf("banner missing Abort; got:\n%s", got) } - if !strings.Contains(got, "tool Shell 3ms ok") { + if !strings.Contains(got, "Shell 3ms ok") { t.Errorf("banner missing last tool line; got:\n%s", got) } - if strings.Contains(got, "tokens") { + if strings.Contains(got, "tokens") || strings.Contains(got, " · in ") { t.Errorf("banner ran success dump (tokens line); got:\n%s", got) } runsRoot := filepath.Join(dir, ".ship", "runs") @@ -1299,12 +1299,14 @@ func TestRun_nextRunAfterPartialProgressAlwaysOpensPicker(t *testing.T) { // recordingThrobber records Phase labels and that work ran inside During. type recordingThrobber struct { - phases []string - workCalls int + phases []string + remainings []string + workCalls int } func (r *recordingThrobber) During(ctx context.Context, status throbber.Status, work func(context.Context) error) error { r.phases = append(r.phases, status.Phase) + r.remainings = append(r.remainings, status.Remaining) r.workCalls++ return work(ctx) } diff --git a/internal/throbber/line.go b/internal/throbber/line.go index 2999706..192e3fd 100644 --- a/internal/throbber/line.go +++ b/internal/throbber/line.go @@ -35,6 +35,9 @@ func (l Line) During(ctx context.Context, status Status, work func(context.Conte if !isTTY(out) { fmt.Fprintf(out, "ship: %s\n", formatStatus(status, '…')) + if status.Remaining != "" { + fmt.Fprintf(out, "%s\n", status.Remaining) + } err := work(ctx) fmt.Fprintln(out, finishLine(status, l.Color, err == nil, time.Since(start))) return err @@ -50,6 +53,10 @@ func (l Line) During(ctx context.Context, status Status, work func(context.Conte err := work(ctx) stopUI() <-done + if status.Remaining != "" { + clearLine(out) // hint line + fmt.Fprint(out, "\033[1A") + } clearLine(out) fmt.Fprintln(out, finishLine(status, l.Color, err == nil, time.Since(start))) return err @@ -113,13 +120,33 @@ func runLine(ctx context.Context, w io.Writer, status Status, color bool) { defer tick.Stop() frame := 0 + painted := false paint := func() { spin := brailleFrames[frame%len(brailleFrames)] line := formatStatus(status, spin) if color { line = "\033[38;2;230;175;90m" + line + "\033[0m" } - fmt.Fprintf(w, "\r\033[K%s", line) + hint := "" + if status.Remaining != "" { + hint = status.Remaining + if color { + hint = "\033[38;2;136;136;136m" + status.Remaining + "\033[0m" + } + } + // Two-line chrome leaves the cursor on the hint. Move up before + // redrawing so frames overwrite in place instead of scrolling. + if painted && hint != "" { + clearLine(w) + fmt.Fprint(w, "\033[1A") + } + clearLine(w) + if hint != "" { + fmt.Fprintf(w, "%s\n%s", line, hint) + } else { + fmt.Fprint(w, line) + } + painted = true } paint() for { diff --git a/internal/throbber/line_test.go b/internal/throbber/line_test.go new file mode 100644 index 0000000..29002c0 --- /dev/null +++ b/internal/throbber/line_test.go @@ -0,0 +1,138 @@ +package throbber + +import ( + "bytes" + "context" + "strings" + "testing" + "time" +) + +// ansiTerm is a tiny screen that understands the sequences runLine emits: +// \r, \n, \033[K (clear to EOL), \033[1A (cursor up), \033[?25l/h (ignored). +type ansiTerm struct { + lines [][]byte + row int + col int +} + +func (t *ansiTerm) Write(p []byte) (int, error) { + for i := 0; i < len(p); i++ { + switch p[i] { + case '\r': + t.col = 0 + case '\n': + t.row++ + t.col = 0 + t.ensureRow() + case '\033': + if i+1 < len(p) && p[i+1] == '[' { + i += 2 + n := 0 + for i < len(p) && !((p[i] >= 'A' && p[i] <= 'Z') || (p[i] >= 'a' && p[i] <= 'z')) { + if p[i] >= '0' && p[i] <= '9' { + n = n*10 + int(p[i]-'0') + } + i++ + } + if i >= len(p) { + break + } + switch p[i] { + case 'K': // clear to end of line + t.ensureRow() + if t.col < len(t.lines[t.row]) { + t.lines[t.row] = t.lines[t.row][:t.col] + } + case 'A': // cursor up n (default 1) + if n == 0 { + n = 1 + } + t.row -= n + if t.row < 0 { + t.row = 0 + } + t.col = 0 + case 'l', 'h': // cursor hide/show — ignore + } + } + default: + t.ensureRow() + for len(t.lines[t.row]) <= t.col { + t.lines[t.row] = append(t.lines[t.row], ' ') + } + t.lines[t.row][t.col] = p[i] + t.col++ + } + } + return len(p), nil +} + +func (t *ansiTerm) ensureRow() { + for len(t.lines) <= t.row { + t.lines = append(t.lines, nil) + } +} + +func (t *ansiTerm) String() string { + var b strings.Builder + for i, line := range t.lines { + if i > 0 { + b.WriteByte('\n') + } + b.Write(bytes.TrimRight(line, " ")) + } + return b.String() +} + +func captureTTYScreen(t *testing.T, status Status, ticks int) string { + t.Helper() + term := &ansiTerm{} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + runLine(ctx, term, status, false) + }() + time.Sleep(time.Duration(ticks)*80*time.Millisecond + 40*time.Millisecond) + cancel() + <-done + return term.String() +} + +func TestRunLine_withRemaining_rewritesInPlace(t *testing.T) { + screen := captureTTYScreen(t, Status{ + Phase: "Implement", + Iteration: 1, + Ticket: "#8 Add throbber", + Remaining: "queue remaining · #7 · #9", + }, 5) + + statusCopies := 0 + for _, line := range strings.Split(screen, "\n") { + if strings.Contains(line, "Iteration 1") { + statusCopies++ + } + } + if statusCopies != 1 { + t.Fatalf("visible Iteration lines = %d, want 1 (in-place redraw). screen=%q", statusCopies, screen) + } + if !strings.Contains(screen, "queue remaining · #7 · #9") { + t.Fatalf("missing remaining hint: %q", screen) + } +} + +func TestRunLine_withoutRemaining_rewritesInPlace(t *testing.T) { + screen := captureTTYScreen(t, Status{ + Phase: "Review", + Iteration: 2, + Ticket: "#3 fix", + }, 5) + + if strings.Count(screen, "\n") != 0 { + t.Fatalf("single-line throbber grew newlines: %q", screen) + } + if !strings.Contains(screen, "Iteration 2") { + t.Fatalf("missing status: %q", screen) + } +} diff --git a/internal/throbber/port.go b/internal/throbber/port.go index d7781a1..8353139 100644 --- a/internal/throbber/port.go +++ b/internal/throbber/port.go @@ -11,6 +11,7 @@ type Status struct { Phase string // Implement, Review, or Final Iteration int // 1-based; 0 omits Iteration (Final) Ticket string // e.g. "#7 one"; empty omits Ticket (Final) + Remaining string // optional queue-remainder hint under the throbber } // Label is the human status text without spinner chrome, e.g. diff --git a/internal/throbber/port_test.go b/internal/throbber/port_test.go index e929eca..7487b50 100644 --- a/internal/throbber/port_test.go +++ b/internal/throbber/port_test.go @@ -102,6 +102,30 @@ func TestLine_During_nonTTY_finalOmitsIterationAndTicket(t *testing.T) { } } +func TestLine_During_nonTTY_showsQueueRemainingHint(t *testing.T) { + var buf bytes.Buffer + err := throbber.Line{Out: &buf, Color: false}.During( + context.Background(), + throbber.Status{ + Phase: "Implement", + Iteration: 1, + Ticket: "#8 Add throbber", + Remaining: "queue remaining · #7 · #9", + }, + func(context.Context) error { return nil }, + ) + if err != nil { + t.Fatalf("During: %v", err) + } + out := buf.String() + if !strings.Contains(out, "queue remaining") { + t.Errorf("output missing queue remaining hint: %q", out) + } + if !strings.Contains(out, "#7") || !strings.Contains(out, "#9") { + t.Errorf("output missing remaining tickets: %q", out) + } +} + func TestLine_During_nonTTY_failureShowsNoCheckmark(t *testing.T) { var buf bytes.Buffer want := errors.New("boom")