Skip to content

Commit b6bcce1

Browse files
authored
Add an interactive submit TUI for customizing each PR's title, description, and draft state (#147)
* Add submitview data model and PR draft override plumbing Introduce the internal/tui/submitview package that will back the new interactive `gh stack submit` TUI, and wire its per-PR override contract into the submit command without changing current behavior. - submitview: BranchState model (NEW/OPEN/DRAFT/QUEUED/MERGED/CLOSED) with selectability/editability rules, SubmitNode UI state with edit detection, PRDraft override type, state derivation, title/description prefill, and state-badge/panel/tab styles. - submit: refactor ensurePR/createPR to accept an optional per-branch override map (title/body/draft/include); deselected NEW branches are pushed but get no PR. The override map is nil on the --auto / non-interactive path, so the agent-compat contract is unchanged. Fully unit tested. * Add single-screen submit TUI Introduce an interactive, single-screen editor for `gh stack submit`, built on Bubble Tea and Lip Gloss. The left panel renders the stack as a connected tree down to the trunk. Every branch without a PR is included by default; deselect one with its checkbox or `^x`. Because each PR builds on the branch below it, deselecting a branch also deselects the ones stacked above it, and re-including a branch re-includes the ones below it that it depends on. The cursor uses its own cyan accent so it reads distinctly from the green new/included color; existing PRs are shown dimmed with a no-entry glyph. The right panel edits the focused branch's PR in web-create-PR order: a header with the branch name and an include chip ("Creating PR" / "Skipped"), the title, a scrollable description (Glamour markdown preview and $EDITOR escape, with a scrollbar and mouse click-to-position), and a ready to draft segmented toggle (defaulting to ready). A footer strip shows the PR progress, the next branch, and the editor hints. Skipping a branch dims its body; branches that already have a PR show a read-only card linking to the PR. It shares the gh-stack header (art, title, stack info, and keyboard shortcuts) with `gh stack view` and `gh stack modify` for a unified look. Submit every included PR at once with Ctrl+S. Full keyboard and mouse support throughout. * Wire the single-screen submit TUI into `gh stack submit` Launch the submit editor from `gh stack submit` in interactive terminals, collecting per-branch PR drafts and applying them in a single batch. In non-interactive terminals or with --auto, fall back to auto-generated titles and skip the editor. Update the README and CLI reference to describe the single-screen flow. * Use the API PR title/body for existing PRs and fix new-PR defaults For existing PRs, the submit TUI showed a commit/template-derived draft instead of the pull request's real title and body. Fetch the actual title and body and render them in the read-only card: - open/draft/queued (tracked) and adopted-open PRs now carry title/body through the existing batch sync (added the fields to the GraphQL queries and PRDetails — no extra round trips), and - merged branches (which skip the live refresh) are filled in by a targeted enrichment step run only when the submit TUI opens. Also align the new-PR defaults with the non-TUI submit's defaultPRTitleBody: - Title: the commit subject only when the branch has exactly one commit, otherwise the humanized branch name (was: the oldest commit's subject even for multi-commit branches). - Description: the PR template, else the single commit's body, else empty (removed the bulleted commit-subject list for multi-commit branches). * Stop mouse wheel from leaking escape characters into form fields Scrolling the mouse wheel while a title or description field was focused could insert stray characters such as "[<65;54;51M" into the field. The submit TUI ran the Bubble Tea program with WithMouseAllMotion (mode 1003), which reports an event on every pointer move. During a wheel scroll that floods the input stream, and under that volume Bubble Tea splits an SGR mouse escape sequence ("\x1b[<Cb;Cx;Cy(M|m)") across input reads; the leftover bytes of a partially-parsed sequence are then emitted as key runes and inserted into the focused text input. Two changes fix this: - Switch to WithMouseCellMotion (mode 1002), which reports clicks, drag, and wheel but not idle pointer motion. That removes the per-move input flood, so under a real terminal's reads (up to 256 bytes) the only fragment that still surfaces is a single, clean burst at each wheel notch boundary. The TUI never used idle-hover for rendering, so cell-motion loses nothing. - Drop any leaked fragments before they reach a field. A split SGR mouse sequence surfaces as an Alt+"[" (the consumed "\x1b[") followed by body fragments ("<65;54;5", "1M"), or occasionally the whole body in one run ("[<65;54;51M"). consumeLeakedMouseKey recognises the start, swallows the body up to its "M"/"m" terminator, and bails out the moment a rune does not fit an SGR body, so ordinary typing (including "<", ";", digits, "M") and bracketed pastes are never eaten. Tests cover every split point of an SGR sequence, single-run tails, preserved real typing, a stray Alt+"[", bracketed paste, and that wheel events never modify the focused field. Verified end-to-end by feeding 1,500 wheel sequences through the real parser under terminal-sized reads and confirming the field stays empty. * Re-enable mouse tracking after the external editor closes Opening the description in $EDITOR with ^e and then quitting left the mouse unresponsive: clicks and wheel scrolling stopped working while keyboard navigation still did. The editor is launched with tea.ExecProcess, which releases the terminal before running the command and calls Bubble Tea's RestoreTerminal when it returns. RestoreTerminal re-enables the alt-screen, bracketed paste, and focus reporting, but it does not re-enable mouse tracking. The editor (e.g. vim) disables mouse reporting on exit, so once control returns to the TUI the terminal no longer emits mouse events. Re-arm mouse mode when the editor-finished message arrives by batching tea.EnableMouseCellMotion with the handler's command. That re-enables cell-motion and SGR mouse reporting, matching the WithMouseCellMotion option the program starts with, on every editor-return path (success or error). * dead code cleanup * support mouse input to move cursor in title field * use textArea for title to support word wrap for long inputs
1 parent 0efc764 commit b6bcce1

29 files changed

Lines changed: 5502 additions & 43 deletions

‎README.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -372,11 +372,13 @@ Creates a Stacked PR for every branch in the stack, pushing branches to the remo
372372

373373
After creating PRs, `submit` automatically creates a **Stack** on GitHub to link the PRs together. If the stack already exists on GitHub (e.g., from a previous submit), new PRs will be added to the top of the stack.
374374

375-
When creating new PRs, you will be prompted to enter a title for each one. Press Enter to accept the default (branch name), or use `--auto` to skip prompting entirely.
375+
In an interactive terminal, `submit` opens a full-screen, mouse- and keyboard-driven editor on a single screen. Every branch without a PR is included by default — deselect any you don't want on the left panel (<kbd>Ctrl</kbd>+<kbd>X</kbd>). Because each PR builds on the branch below it, deselecting a branch also deselects the ones stacked above it, and re-including a branch re-includes the ones below it. Draft each PR's title, description (with a markdown preview and `$EDITOR` escape), and choose ready-for-review or draft on the right, then submit them all at once with <kbd>Ctrl</kbd>+<kbd>S</kbd>. Pass `--auto` (or run in CI) to skip the editor and use auto-generated titles.
376+
377+
In the editor, new PRs default to ready for review; flip any PR to draft with the ready ↔ draft toggle. With `--auto`, new PRs are created as drafts unless you pass `--open`.
376378

377379
| Flag | Description |
378380
|------|-------------|
379-
| `--auto` | Use auto-generated PR titles without prompting |
381+
| `--auto` | Skip the editor and use auto-generated PR titles |
380382
| `--open` | Mark new and existing PRs as ready for review |
381383
| `--remote <name>` | Remote to push to (defaults to auto-detected remote) |
382384

‎cmd/submit.go‎

Lines changed: 134 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"strconv"
77
"strings"
88

9+
tea "github.com/charmbracelet/bubbletea"
910
"github.com/cli/go-gh/v2/pkg/api"
1011
"github.com/cli/go-gh/v2/pkg/prompter"
1112
"github.com/github/gh-stack/internal/config"
@@ -14,6 +15,8 @@ import (
1415
"github.com/github/gh-stack/internal/modify"
1516
"github.com/github/gh-stack/internal/pr"
1617
"github.com/github/gh-stack/internal/stack"
18+
"github.com/github/gh-stack/internal/tui/stackview"
19+
"github.com/github/gh-stack/internal/tui/submitview"
1720
"github.com/spf13/cobra"
1821
)
1922

@@ -31,21 +34,28 @@ func SubmitCmd(cfg *config.Config) *cobra.Command {
3134
Short: "Create a stack of PRs on GitHub",
3235
Long: `Push all branches and create or update a stack of PRs on GitHub.
3336
37+
In an interactive terminal, a single-screen editor opens. Every branch without a
38+
PR is included by default; deselect any you don't want with the checkbox or ^x,
39+
and draft each PR's title, description, and draft state, then submit them all at
40+
once with Ctrl+S. Pass --auto (or run in a non-interactive terminal) to skip the
41+
editor and use auto-generated titles.
42+
3443
This command performs several steps:
3544
1. Pushes all branches to the remote
36-
2. Creates new PRs for branches that don't have one
45+
2. Creates new PRs for the included branches
3746
3. Updates base branches for existing PRs
3847
4. Creates or updates the stack on GitHub
3948
40-
New PRs are created as drafts by default. Use --open to mark them as ready
41-
for review.`,
42-
Example: ` # Push and create/update PRs (prompts for PR titles)
49+
In the editor, new PRs default to ready for review; switch any to draft with the
50+
"CREATE AS" toggle. With --auto, new PRs are created as drafts unless you pass
51+
--open.`,
52+
Example: ` # Push and create/update PRs (opens the interactive editor)
4353
$ gh stack submit
4454
45-
# Use auto-generated PR titles without prompting
55+
# Skip the editor and use auto-generated PR titles
4656
$ gh stack submit --auto
4757
48-
# Mark all PRs as ready for review
58+
# Mark new and existing PRs as ready for review
4959
$ gh stack submit --open`,
5060
RunE: func(cmd *cobra.Command, args []string) error {
5161
return runSubmit(cfg, opts)
@@ -131,7 +141,7 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error {
131141
}
132142

133143
// Sync PR state to detect merged/queued PRs before pushing.
134-
_ = syncStackPRs(cfg, s)
144+
prDetails := syncStackPRs(cfg, s)
135145

136146
// Resolve remote for pushing
137147
remote, err := pickRemote(cfg, currentBranch, opts.remote)
@@ -178,6 +188,25 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error {
178188
templateContent = pr.FindTemplate(repoRoot)
179189
}
180190

191+
// In an interactive terminal, open the TUI so the user can pick which new
192+
// branches become PRs and draft each PR's title, description, and draft
193+
// state. The drafts feed the create path below. On the --auto /
194+
// non-interactive path drafts stays nil and ensurePR/createPR fall back to
195+
// auto-generated titles and bodies (today's behavior).
196+
var drafts map[string]*submitview.PRDraft
197+
if cfg.IsInteractive() && !opts.auto {
198+
collected, cancelled, tuiErr := collectPRDrafts(cfg, client, s, currentBranch, prDetails, templateContent)
199+
if tuiErr != nil {
200+
cfg.Errorf("failed to run the submit editor: %s", tuiErr)
201+
return ErrSilent
202+
}
203+
if cancelled {
204+
cfg.Printf("Submit cancelled — no branches were pushed")
205+
return nil
206+
}
207+
drafts = collected
208+
}
209+
181210
// Push each branch and create/update its PR in stack order (bottom to top).
182211
// Sequential pushing ensures each branch's base is up-to-date on the
183212
// remote before the next branch is pushed, preventing race conditions.
@@ -195,7 +224,7 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error {
195224

196225
// Find or create PR, and fix base if needed
197226
baseBranch := s.ActiveBaseBranch(b.Branch)
198-
if err := ensurePR(cfg, client, s, i, baseBranch, opts, templateContent); err != nil {
227+
if err := ensurePR(cfg, client, s, i, baseBranch, opts, templateContent, drafts); err != nil {
199228
if errors.Is(err, errInterrupt) {
200229
printInterrupt(cfg)
201230
return ErrSilent
@@ -222,10 +251,73 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error {
222251
return nil
223252
}
224253

254+
// collectPRDrafts loads branch display data and runs the interactive submit TUI
255+
// so the user can choose which new branches become PRs and draft each one. It
256+
// returns the per-branch overrides, whether the user cancelled, and any error.
257+
// When the stack contains no branches without a PR, it skips the TUI and
258+
// returns nil drafts so the normal push/relink path runs.
259+
func collectPRDrafts(cfg *config.Config, client github.ClientOps, s *stack.Stack, currentBranch string, prDetails map[string]*github.PRDetails, templateContent string) (map[string]*submitview.PRDraft, bool, error) {
260+
// Fill in the real title/description for existing PRs that were synced
261+
// without them (e.g. merged branches) so the read-only cards show API data.
262+
enrichPRContent(client, prDetails)
263+
264+
fmt.Fprintf(cfg.Err, "Loading stack...")
265+
viewNodes := stackview.LoadBranchNodes(cfg, s, currentBranch, prDetails)
266+
fmt.Fprintf(cfg.Err, "\r\033[2K")
267+
268+
// Reverse so index 0 = top of stack (matches the visual order).
269+
reversed := make([]stackview.BranchNode, len(viewNodes))
270+
for i, n := range viewNodes {
271+
reversed[len(viewNodes)-1-i] = n
272+
}
273+
nodes := submitview.NewSubmitNodes(reversed, templateContent)
274+
275+
// Nothing to create — skip the TUI and run the normal push/relink path.
276+
if submitview.CountNew(nodes) == 0 {
277+
return nil, false, nil
278+
}
279+
280+
repoLabel := ""
281+
if repo, err := cfg.Repo(); err == nil {
282+
repoLabel = repo.Owner + "/" + repo.Name
283+
}
284+
285+
model := submitview.New(submitview.Options{
286+
Nodes: nodes,
287+
Trunk: s.Trunk,
288+
RepoLabel: repoLabel,
289+
Version: Version,
290+
})
291+
292+
// Use cell-motion mouse mode (clicks, drag, and wheel) rather than all-motion.
293+
// All-motion (mode 1003) reports an event on every pointer move, flooding the
294+
// input; under that volume bubbletea can split an SGR mouse sequence across
295+
// reads, leaking its bytes as text into a focused title/description field
296+
// while scrolling. We don't use idle-hover, so cell-motion loses nothing.
297+
p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion())
298+
final, err := p.Run()
299+
if err != nil {
300+
return nil, false, fmt.Errorf("running submit TUI: %w", err)
301+
}
302+
303+
m, ok := final.(submitview.Model)
304+
if !ok {
305+
return nil, false, fmt.Errorf("unexpected model type %T", final)
306+
}
307+
if m.Cancelled() || !m.SubmitRequested() {
308+
return nil, true, nil
309+
}
310+
return submitview.BuildDrafts(m.Nodes()), false, nil
311+
}
312+
225313
// ensurePR finds or creates a PR for the branch at index i, and updates
226314
// its base branch if needed. This is the single place where PR state is
227315
// reconciled during submit.
228-
func ensurePR(cfg *config.Config, client github.ClientOps, s *stack.Stack, i int, baseBranch string, opts *submitOptions, templateContent string) error {
316+
//
317+
// drafts holds optional per-branch overrides from the interactive editor. When
318+
// a NEW branch has been deselected in the editor, it is pushed for stack
319+
// consistency but no PR is created for it.
320+
func ensurePR(cfg *config.Config, client github.ClientOps, s *stack.Stack, i int, baseBranch string, opts *submitOptions, templateContent string, drafts map[string]*submitview.PRDraft) error {
229321
b := s.Branches[i]
230322

231323
pr, err := client.FindPRForBranch(b.Branch)
@@ -235,7 +327,12 @@ func ensurePR(cfg *config.Config, client github.ClientOps, s *stack.Stack, i int
235327
}
236328

237329
if pr == nil {
238-
return createPR(cfg, client, s, i, baseBranch, opts, templateContent)
330+
// A NEW branch the user deselected in the editor: pushed for stack
331+
// consistency, but intentionally left without a PR.
332+
if d := drafts[b.Branch]; d != nil && !d.Include {
333+
return nil
334+
}
335+
return createPR(cfg, client, s, i, baseBranch, opts, templateContent, drafts)
239336
}
240337

241338
// PR exists — record it and fix base if needed.
@@ -292,30 +389,37 @@ func ensurePR(cfg *config.Config, client github.ClientOps, s *stack.Stack, i int
292389
}
293390

294391
// createPR creates a new PR for the branch at index i.
295-
func createPR(cfg *config.Config, client github.ClientOps, s *stack.Stack, i int, baseBranch string, opts *submitOptions, templateContent string) error {
392+
//
393+
// When the interactive editor has supplied a draft override for this branch
394+
// (drafts[branch] != nil), its title, body, and draft state are used verbatim
395+
// — the attribution footer is appended via generatePRBody. Otherwise the
396+
// auto-generated title/body path (with an optional line prompt in interactive
397+
// mode) is used, preserving today's --auto / non-interactive behavior.
398+
func createPR(cfg *config.Config, client github.ClientOps, s *stack.Stack, i int, baseBranch string, opts *submitOptions, templateContent string, drafts map[string]*submitview.PRDraft) error {
296399
b := s.Branches[i]
297400

298-
title, commitBody := defaultPRTitleBody(baseBranch, b.Branch)
299-
originalTitle := title
300-
if !opts.auto && cfg.IsInteractive() {
301-
input, err := inputWithPrefill(cfg, fmt.Sprintf("Title for PR (branch %s):", b.Branch), title)
302-
if err != nil {
303-
if isInterruptError(err) {
304-
return errInterrupt
305-
}
306-
// Non-interrupt error: keep the auto-generated title.
307-
} else if input != "" {
308-
title = input
309-
}
310-
}
311-
312-
prBody := commitBody
313-
if title != originalTitle && commitBody != "" {
314-
prBody = originalTitle + "\n\n" + commitBody
401+
var title, body string
402+
isDraft := !opts.open
403+
404+
if d := drafts[b.Branch]; d != nil {
405+
// Interactive editor override. The user already edited the description
406+
// in the TUI (prefilled from the repo template when one exists), so
407+
// d.Body is the final body. Pass no template so generatePRBody keeps the
408+
// user's text and only appends the attribution footer, rather than
409+
// discarding their edits in favor of the raw template.
410+
title = d.Title
411+
body = generatePRBody(d.Body, "")
412+
isDraft = d.Draft
413+
} else {
414+
// Auto / non-interactive default path: an auto-generated title and a
415+
// body built from the branch's commits (the interactive title is
416+
// drafted in the submit TUI instead).
417+
var commitBody string
418+
title, commitBody = defaultPRTitleBody(baseBranch, b.Branch)
419+
body = generatePRBody(commitBody, templateContent)
315420
}
316-
body := generatePRBody(prBody, templateContent)
317421

318-
newPR, createErr := client.CreatePR(baseBranch, b.Branch, title, body, !opts.open)
422+
newPR, createErr := client.CreatePR(baseBranch, b.Branch, title, body, isDraft)
319423
if createErr != nil {
320424
cfg.Warningf("failed to create PR for %s: %v", b.Branch, createErr)
321425
return nil

0 commit comments

Comments
 (0)