diff --git a/README.md b/README.md
index 627a3c9..d008748 100644
--- a/README.md
+++ b/README.md
@@ -372,11 +372,13 @@ Creates a Stacked PR for every branch in the stack, pushing branches to the remo
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.
-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.
+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 (Ctrl+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. 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 Ctrl+S. Pass `--auto` (or run in CI) to skip the editor and use auto-generated titles.
+
+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`.
| Flag | Description |
|------|-------------|
-| `--auto` | Use auto-generated PR titles without prompting |
+| `--auto` | Skip the editor and use auto-generated PR titles |
| `--open` | Mark new and existing PRs as ready for review |
| `--remote ` | Remote to push to (defaults to auto-detected remote) |
diff --git a/cmd/submit.go b/cmd/submit.go
index 1824bcf..59ed811 100644
--- a/cmd/submit.go
+++ b/cmd/submit.go
@@ -6,6 +6,7 @@ import (
"strconv"
"strings"
+ tea "github.com/charmbracelet/bubbletea"
"github.com/cli/go-gh/v2/pkg/api"
"github.com/cli/go-gh/v2/pkg/prompter"
"github.com/github/gh-stack/internal/config"
@@ -14,6 +15,8 @@ import (
"github.com/github/gh-stack/internal/modify"
"github.com/github/gh-stack/internal/pr"
"github.com/github/gh-stack/internal/stack"
+ "github.com/github/gh-stack/internal/tui/stackview"
+ "github.com/github/gh-stack/internal/tui/submitview"
"github.com/spf13/cobra"
)
@@ -31,21 +34,28 @@ func SubmitCmd(cfg *config.Config) *cobra.Command {
Short: "Create a stack of PRs on GitHub",
Long: `Push all branches and create or update a stack of PRs on GitHub.
+In an interactive terminal, a single-screen editor opens. Every branch without a
+PR is included by default; deselect any you don't want with the checkbox or ^x,
+and draft each PR's title, description, and draft state, then submit them all at
+once with Ctrl+S. Pass --auto (or run in a non-interactive terminal) to skip the
+editor and use auto-generated titles.
+
This command performs several steps:
1. Pushes all branches to the remote
- 2. Creates new PRs for branches that don't have one
+ 2. Creates new PRs for the included branches
3. Updates base branches for existing PRs
4. Creates or updates the stack on GitHub
-New PRs are created as drafts by default. Use --open to mark them as ready
-for review.`,
- Example: ` # Push and create/update PRs (prompts for PR titles)
+In the editor, new PRs default to ready for review; switch any to draft with the
+"CREATE AS" toggle. With --auto, new PRs are created as drafts unless you pass
+--open.`,
+ Example: ` # Push and create/update PRs (opens the interactive editor)
$ gh stack submit
- # Use auto-generated PR titles without prompting
+ # Skip the editor and use auto-generated PR titles
$ gh stack submit --auto
- # Mark all PRs as ready for review
+ # Mark new and existing PRs as ready for review
$ gh stack submit --open`,
RunE: func(cmd *cobra.Command, args []string) error {
return runSubmit(cfg, opts)
@@ -131,7 +141,7 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error {
}
// Sync PR state to detect merged/queued PRs before pushing.
- _ = syncStackPRs(cfg, s)
+ prDetails := syncStackPRs(cfg, s)
// Resolve remote for pushing
remote, err := pickRemote(cfg, currentBranch, opts.remote)
@@ -178,6 +188,25 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error {
templateContent = pr.FindTemplate(repoRoot)
}
+ // In an interactive terminal, open the TUI so the user can pick which new
+ // branches become PRs and draft each PR's title, description, and draft
+ // state. The drafts feed the create path below. On the --auto /
+ // non-interactive path drafts stays nil and ensurePR/createPR fall back to
+ // auto-generated titles and bodies (today's behavior).
+ var drafts map[string]*submitview.PRDraft
+ if cfg.IsInteractive() && !opts.auto {
+ collected, cancelled, tuiErr := collectPRDrafts(cfg, client, s, currentBranch, prDetails, templateContent)
+ if tuiErr != nil {
+ cfg.Errorf("failed to run the submit editor: %s", tuiErr)
+ return ErrSilent
+ }
+ if cancelled {
+ cfg.Printf("Submit cancelled — no branches were pushed")
+ return nil
+ }
+ drafts = collected
+ }
+
// Push each branch and create/update its PR in stack order (bottom to top).
// Sequential pushing ensures each branch's base is up-to-date on the
// remote before the next branch is pushed, preventing race conditions.
@@ -195,7 +224,7 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error {
// Find or create PR, and fix base if needed
baseBranch := s.ActiveBaseBranch(b.Branch)
- if err := ensurePR(cfg, client, s, i, baseBranch, opts, templateContent); err != nil {
+ if err := ensurePR(cfg, client, s, i, baseBranch, opts, templateContent, drafts); err != nil {
if errors.Is(err, errInterrupt) {
printInterrupt(cfg)
return ErrSilent
@@ -222,10 +251,73 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error {
return nil
}
+// collectPRDrafts loads branch display data and runs the interactive submit TUI
+// so the user can choose which new branches become PRs and draft each one. It
+// returns the per-branch overrides, whether the user cancelled, and any error.
+// When the stack contains no branches without a PR, it skips the TUI and
+// returns nil drafts so the normal push/relink path runs.
+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) {
+ // Fill in the real title/description for existing PRs that were synced
+ // without them (e.g. merged branches) so the read-only cards show API data.
+ enrichPRContent(client, prDetails)
+
+ fmt.Fprintf(cfg.Err, "Loading stack...")
+ viewNodes := stackview.LoadBranchNodes(cfg, s, currentBranch, prDetails)
+ fmt.Fprintf(cfg.Err, "\r\033[2K")
+
+ // Reverse so index 0 = top of stack (matches the visual order).
+ reversed := make([]stackview.BranchNode, len(viewNodes))
+ for i, n := range viewNodes {
+ reversed[len(viewNodes)-1-i] = n
+ }
+ nodes := submitview.NewSubmitNodes(reversed, templateContent)
+
+ // Nothing to create — skip the TUI and run the normal push/relink path.
+ if submitview.CountNew(nodes) == 0 {
+ return nil, false, nil
+ }
+
+ repoLabel := ""
+ if repo, err := cfg.Repo(); err == nil {
+ repoLabel = repo.Owner + "/" + repo.Name
+ }
+
+ model := submitview.New(submitview.Options{
+ Nodes: nodes,
+ Trunk: s.Trunk,
+ RepoLabel: repoLabel,
+ Version: Version,
+ })
+
+ // Use cell-motion mouse mode (clicks, drag, and wheel) rather than all-motion.
+ // All-motion (mode 1003) reports an event on every pointer move, flooding the
+ // input; under that volume bubbletea can split an SGR mouse sequence across
+ // reads, leaking its bytes as text into a focused title/description field
+ // while scrolling. We don't use idle-hover, so cell-motion loses nothing.
+ p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion())
+ final, err := p.Run()
+ if err != nil {
+ return nil, false, fmt.Errorf("running submit TUI: %w", err)
+ }
+
+ m, ok := final.(submitview.Model)
+ if !ok {
+ return nil, false, fmt.Errorf("unexpected model type %T", final)
+ }
+ if m.Cancelled() || !m.SubmitRequested() {
+ return nil, true, nil
+ }
+ return submitview.BuildDrafts(m.Nodes()), false, nil
+}
+
// ensurePR finds or creates a PR for the branch at index i, and updates
// its base branch if needed. This is the single place where PR state is
// reconciled during submit.
-func ensurePR(cfg *config.Config, client github.ClientOps, s *stack.Stack, i int, baseBranch string, opts *submitOptions, templateContent string) error {
+//
+// drafts holds optional per-branch overrides from the interactive editor. When
+// a NEW branch has been deselected in the editor, it is pushed for stack
+// consistency but no PR is created for it.
+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 {
b := s.Branches[i]
pr, err := client.FindPRForBranch(b.Branch)
@@ -235,7 +327,12 @@ func ensurePR(cfg *config.Config, client github.ClientOps, s *stack.Stack, i int
}
if pr == nil {
- return createPR(cfg, client, s, i, baseBranch, opts, templateContent)
+ // A NEW branch the user deselected in the editor: pushed for stack
+ // consistency, but intentionally left without a PR.
+ if d := drafts[b.Branch]; d != nil && !d.Include {
+ return nil
+ }
+ return createPR(cfg, client, s, i, baseBranch, opts, templateContent, drafts)
}
// 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
}
// createPR creates a new PR for the branch at index i.
-func createPR(cfg *config.Config, client github.ClientOps, s *stack.Stack, i int, baseBranch string, opts *submitOptions, templateContent string) error {
+//
+// When the interactive editor has supplied a draft override for this branch
+// (drafts[branch] != nil), its title, body, and draft state are used verbatim
+// — the attribution footer is appended via generatePRBody. Otherwise the
+// auto-generated title/body path (with an optional line prompt in interactive
+// mode) is used, preserving today's --auto / non-interactive behavior.
+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 {
b := s.Branches[i]
- title, commitBody := defaultPRTitleBody(baseBranch, b.Branch)
- originalTitle := title
- if !opts.auto && cfg.IsInteractive() {
- input, err := inputWithPrefill(cfg, fmt.Sprintf("Title for PR (branch %s):", b.Branch), title)
- if err != nil {
- if isInterruptError(err) {
- return errInterrupt
- }
- // Non-interrupt error: keep the auto-generated title.
- } else if input != "" {
- title = input
- }
- }
-
- prBody := commitBody
- if title != originalTitle && commitBody != "" {
- prBody = originalTitle + "\n\n" + commitBody
+ var title, body string
+ isDraft := !opts.open
+
+ if d := drafts[b.Branch]; d != nil {
+ // Interactive editor override. The user already edited the description
+ // in the TUI (prefilled from the repo template when one exists), so
+ // d.Body is the final body. Pass no template so generatePRBody keeps the
+ // user's text and only appends the attribution footer, rather than
+ // discarding their edits in favor of the raw template.
+ title = d.Title
+ body = generatePRBody(d.Body, "")
+ isDraft = d.Draft
+ } else {
+ // Auto / non-interactive default path: an auto-generated title and a
+ // body built from the branch's commits (the interactive title is
+ // drafted in the submit TUI instead).
+ var commitBody string
+ title, commitBody = defaultPRTitleBody(baseBranch, b.Branch)
+ body = generatePRBody(commitBody, templateContent)
}
- body := generatePRBody(prBody, templateContent)
- newPR, createErr := client.CreatePR(baseBranch, b.Branch, title, body, !opts.open)
+ newPR, createErr := client.CreatePR(baseBranch, b.Branch, title, body, isDraft)
if createErr != nil {
cfg.Warningf("failed to create PR for %s: %v", b.Branch, createErr)
return nil
diff --git a/cmd/submit_test.go b/cmd/submit_test.go
index ae010d9..06f0091 100644
--- a/cmd/submit_test.go
+++ b/cmd/submit_test.go
@@ -15,6 +15,7 @@ import (
"github.com/github/gh-stack/internal/github"
"github.com/github/gh-stack/internal/modify"
"github.com/github/gh-stack/internal/stack"
+ "github.com/github/gh-stack/internal/tui/submitview"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -187,7 +188,7 @@ func TestSubmit_DefaultDraft(t *testing.T) {
cfg, _, _ := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
- ListStacksFn: func() ([]github.RemoteStack, error) { return nil, nil },
+ ListStacksFn: func() ([]github.RemoteStack, error) { return nil, nil },
FindPRForBranchFn: func(string) (*github.PullRequest, error) { return nil, nil },
CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) {
createdDraft = draft
@@ -229,7 +230,7 @@ func TestSubmit_OpenFlag(t *testing.T) {
cfg, _, _ := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
- ListStacksFn: func() ([]github.RemoteStack, error) { return nil, nil },
+ ListStacksFn: func() ([]github.RemoteStack, error) { return nil, nil },
FindPRForBranchFn: func(string) (*github.PullRequest, error) { return nil, nil },
CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) {
createdDraft = draft
@@ -1649,7 +1650,7 @@ func TestSubmit_UsesPRTemplate(t *testing.T) {
cfg, _, _ := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
- ListStacksFn: func() ([]github.RemoteStack, error) { return nil, nil },
+ ListStacksFn: func() ([]github.RemoteStack, error) { return nil, nil },
FindPRForBranchFn: func(string) (*github.PullRequest, error) { return nil, nil },
CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) {
capturedBody = body
@@ -1696,7 +1697,7 @@ func TestSubmit_NoTemplate_UsesFooter(t *testing.T) {
cfg, _, _ := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
- ListStacksFn: func() ([]github.RemoteStack, error) { return nil, nil },
+ ListStacksFn: func() ([]github.RemoteStack, error) { return nil, nil },
FindPRForBranchFn: func(string) (*github.PullRequest, error) { return nil, nil },
CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) {
capturedBody = body
@@ -1835,8 +1836,8 @@ func TestSubmit_DisablesAutoMergeOnExistingPR(t *testing.T) {
case "b2":
return &github.PullRequest{
Number: 20, ID: "PR_20",
- URL: "https://github.com/owner/repo/pull/20",
- BaseRefName: "b1", HeadRefName: "b2",
+ URL: "https://github.com/owner/repo/pull/20",
+ BaseRefName: "b1", HeadRefName: "b2",
AutoMergeRequest: &github.AutoMergeRequest{EnabledAt: "2024-01-01T00:00:00Z"},
}, nil
}
@@ -1890,8 +1891,8 @@ func TestSubmit_DisableAutoMergeFailure_ContinuesWithWarning(t *testing.T) {
FindPRForBranchFn: func(branch string) (*github.PullRequest, error) {
return &github.PullRequest{
Number: 10, ID: "PR_10",
- URL: "https://github.com/owner/repo/pull/10",
- BaseRefName: "main", HeadRefName: "b1",
+ URL: "https://github.com/owner/repo/pull/10",
+ BaseRefName: "main", HeadRefName: "b1",
AutoMergeRequest: &github.AutoMergeRequest{EnabledAt: "2024-01-01T00:00:00Z"},
}, nil
},
@@ -1963,3 +1964,89 @@ func TestSubmit_NoAutoMerge_SkipsDisable(t *testing.T) {
assert.NoError(t, err)
}
+
+// --- Per-PR draft override plumbing (interactive editor contract) ---
+
+func TestCreatePR_UsesDraftOverride(t *testing.T) {
+ s := &stack.Stack{
+ Trunk: stack.BranchRef{Branch: "main"},
+ Branches: []stack.BranchRef{{Branch: "b1"}},
+ }
+
+ var gotTitle, gotBody string
+ var gotDraft bool
+ cfg, _, _ := config.NewTestConfig()
+ client := &github.MockClient{
+ CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) {
+ gotTitle, gotBody, gotDraft = title, body, draft
+ return &github.PullRequest{Number: 7, ID: "PR_7", URL: "https://github.com/o/r/pull/7"}, nil
+ },
+ }
+
+ drafts := map[string]*submitview.PRDraft{
+ "b1": {Branch: "b1", Include: true, Title: "Custom title", Body: "Custom body", Draft: true},
+ }
+
+ // --open would normally force ready; the override's Draft must win.
+ err := createPR(cfg, client, s, 0, "main", &submitOptions{open: true}, "", drafts)
+ require.NoError(t, err)
+
+ assert.Equal(t, "Custom title", gotTitle)
+ assert.Contains(t, gotBody, "Custom body")
+ assert.Contains(t, gotBody, "GitHub Stacks CLI", "footer is appended at submit time")
+ assert.True(t, gotDraft, "draft override should be honored over --open")
+ require.NotNil(t, s.Branches[0].PullRequest)
+ assert.Equal(t, 7, s.Branches[0].PullRequest.Number)
+}
+
+func TestCreatePR_DraftOverride_KeepsUserBodyOverTemplate(t *testing.T) {
+ s := &stack.Stack{
+ Trunk: stack.BranchRef{Branch: "main"},
+ Branches: []stack.BranchRef{{Branch: "b1"}},
+ }
+
+ var gotBody string
+ cfg, _, _ := config.NewTestConfig()
+ client := &github.MockClient{
+ CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) {
+ gotBody = body
+ return &github.PullRequest{Number: 1, ID: "PR_1"}, nil
+ },
+ }
+ // The user edited the description in the TUI; the repo also has a template.
+ // The user's edits must win — the template was only the prefill.
+ drafts := map[string]*submitview.PRDraft{
+ "b1": {Branch: "b1", Include: true, Title: "T", Body: "My edited description"},
+ }
+
+ err := createPR(cfg, client, s, 0, "main", &submitOptions{}, "## Raw repo template", drafts)
+ require.NoError(t, err)
+
+ assert.Contains(t, gotBody, "My edited description", "the user's edited body is used")
+ assert.NotContains(t, gotBody, "Raw repo template", "the raw template does not override the user's edits")
+ assert.Contains(t, gotBody, "GitHub Stacks CLI", "the attribution footer is appended")
+}
+
+func TestEnsurePR_DeselectedNewBranchSkipsCreate(t *testing.T) {
+ s := &stack.Stack{
+ Trunk: stack.BranchRef{Branch: "main"},
+ Branches: []stack.BranchRef{{Branch: "b1"}},
+ }
+
+ cfg, _, _ := config.NewTestConfig()
+ client := &github.MockClient{
+ FindPRForBranchFn: func(string) (*github.PullRequest, error) { return nil, nil },
+ CreatePRFn: func(string, string, string, string, bool) (*github.PullRequest, error) {
+ t.Fatal("CreatePR must not be called for a deselected NEW branch")
+ return nil, nil
+ },
+ }
+
+ drafts := map[string]*submitview.PRDraft{
+ "b1": {Branch: "b1", Include: false},
+ }
+
+ err := ensurePR(cfg, client, s, 0, "main", &submitOptions{}, "", drafts)
+ require.NoError(t, err)
+ assert.Nil(t, s.Branches[0].PullRequest, "no PR should be recorded for a deselected branch")
+}
diff --git a/cmd/submit_tui_test.go b/cmd/submit_tui_test.go
new file mode 100644
index 0000000..9cbac9f
--- /dev/null
+++ b/cmd/submit_tui_test.go
@@ -0,0 +1,43 @@
+package cmd
+
+import (
+ "testing"
+
+ "github.com/github/gh-stack/internal/config"
+ "github.com/github/gh-stack/internal/git"
+ "github.com/github/gh-stack/internal/github"
+ "github.com/github/gh-stack/internal/stack"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestCollectPRDrafts_SkipsWhenNoNewBranches verifies the TUI is skipped (no
+// program launched) when every branch already has a PR, returning nil drafts so
+// the normal push/relink path runs.
+func TestCollectPRDrafts_SkipsWhenNoNewBranches(t *testing.T) {
+ s := &stack.Stack{
+ Trunk: stack.BranchRef{Branch: "main"},
+ Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}},
+ }
+
+ mock := &git.MockOps{
+ RootDirFn: func() (string, error) { return t.TempDir(), nil },
+ IsAncestorFn: func(a, b string) (bool, error) { return true, nil },
+ MergeBaseFn: func(a, b string) (string, error) { return a, nil },
+ LogRangeFn: func(base, head string) ([]git.CommitInfo, error) { return []git.CommitInfo{{Subject: "c"}}, nil },
+ DiffStatFilesFn: func(base, head string) ([]git.FileDiffStat, error) { return nil, nil },
+ }
+ restore := git.SetOps(mock)
+ defer restore()
+
+ cfg, _, _ := config.NewTestConfig()
+ prDetails := map[string]*github.PRDetails{
+ "b1": {Number: 1, State: "OPEN"},
+ "b2": {Number: 2, State: "OPEN"},
+ }
+
+ drafts, cancelled, err := collectPRDrafts(cfg, nil, s, "b1", prDetails, "")
+ require.NoError(t, err)
+ assert.False(t, cancelled)
+ assert.Nil(t, drafts, "no NEW branches means the TUI is skipped and drafts are nil")
+}
diff --git a/cmd/utils.go b/cmd/utils.go
index 2cdd816..488cffe 100644
--- a/cmd/utils.go
+++ b/cmd/utils.go
@@ -412,6 +412,8 @@ func syncStackPRs(cfg *config.Config, s *stack.Stack) map[string]*github.PRDetai
Number: pr.Number,
State: "OPEN",
URL: pr.URL,
+ Title: pr.Title,
+ Body: pr.Body,
IsDraft: pr.IsDraft,
Merged: false,
IsQueued: pr.IsQueued(),
@@ -458,6 +460,8 @@ func prDetailsFromPR(pr *github.PullRequest) *github.PRDetails {
Number: pr.Number,
State: pr.State,
URL: pr.URL,
+ Title: pr.Title,
+ Body: pr.Body,
IsDraft: pr.IsDraft,
Merged: pr.Merged,
IsQueued: pr.IsQueued(),
@@ -481,6 +485,35 @@ func prDetailsFromTracked(ref *stack.PullRequestRef) *github.PRDetails {
}
}
+// enrichPRContent fills in the Title and Body of any existing PR whose details
+// were built without them (e.g. merged branches, which skip the live refresh in
+// syncStackPRs). It is used before the submit TUI renders an existing PR's
+// read-only card so it shows the real PR title and description. PRs that already
+// have a title (the common open/draft/queued case) are left untouched.
+func enrichPRContent(client github.ClientOps, details map[string]*github.PRDetails) {
+ if client == nil {
+ return
+ }
+ var wg sync.WaitGroup
+ sem := make(chan struct{}, maxAPIConcurrency)
+ for _, d := range details {
+ if d == nil || d.Number == 0 || strings.TrimSpace(d.Title) != "" {
+ continue
+ }
+ wg.Add(1)
+ go func(d *github.PRDetails) {
+ defer wg.Done()
+ sem <- struct{}{}
+ defer func() { <-sem }()
+ if pr, err := client.FindPRByNumber(d.Number); err == nil && pr != nil {
+ d.Title = pr.Title
+ d.Body = pr.Body
+ }
+ }(d)
+ }
+ wg.Wait()
+}
+
// syncStackPRsFromRemote uses the stack API to sync PR state. The remote
// stack's PR list is the source of truth — PRs stay associated even if
// closed. Returns the PRDetails map and true if sync succeeded, or nil and
diff --git a/cmd/utils_test.go b/cmd/utils_test.go
index 49ce5d9..75f38c4 100644
--- a/cmd/utils_test.go
+++ b/cmd/utils_test.go
@@ -810,3 +810,25 @@ func TestEnsureLocalTrunk_CreateFails(t *testing.T) {
assert.Error(t, err)
assert.Contains(t, err.Error(), "could not create local trunk branch main")
}
+
+func TestEnrichPRContent(t *testing.T) {
+ calls := 0
+ client := &github.MockClient{
+ FindPRByNumberFn: func(number int) (*github.PullRequest, error) {
+ calls++
+ return &github.PullRequest{Number: number, Title: "Fetched title", Body: "Fetched body"}, nil
+ },
+ }
+ details := map[string]*github.PRDetails{
+ "merged": {Number: 10, State: "MERGED"}, // missing title -> fetched
+ "open": {Number: 11, State: "OPEN", Title: "Has it"}, // already has a title -> skipped
+ "nonum": {Number: 0, State: "OPEN"}, // no number -> skipped
+ }
+
+ enrichPRContent(client, details)
+
+ assert.Equal(t, 1, calls, "only the title-less PR with a number is fetched")
+ assert.Equal(t, "Fetched title", details["merged"].Title)
+ assert.Equal(t, "Fetched body", details["merged"].Body)
+ assert.Equal(t, "Has it", details["open"].Title, "PRs that already have a title are untouched")
+}
diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md
index 3855fcb..ce2d268 100644
--- a/docs/src/content/docs/reference/cli.md
+++ b/docs/src/content/docs/reference/cli.md
@@ -269,11 +269,18 @@ gh stack submit [flags]
Creates a Stacked PR for every branch in the stack, pushing branches to the remote. 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 are added to the existing stack.
-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. New PRs are created as **drafts by default**; use `--open` to create new PRs as ready for review and to mark existing PRs as ready for review.
+In an interactive terminal, `submit` opens a full-screen editor on a single screen:
+
+- **Left panel** — every branch without a PR is **included by default**; deselect any you don't want to submit with Ctrl+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. Branches that already have a PR (open, draft, queued, or merged) are shown for context but are locked; edit those on the web.
+- **Right panel** — for the focused branch, draft the title, description (pre-filled from your repo's PR template or commits, with a Glamour markdown preview and `$EDITOR` escape), and whether it opens ready for review or as a draft (a ready ↔ draft toggle). Focusing a locked branch shows a read-only card with a link to its PR (o to open in the browser).
+
+Press Ctrl+S to submit all included PRs at once. The editor supports both keyboard and mouse input. Pass `--auto` (or run in a non-interactive terminal, such as CI) to skip the editor and use auto-generated titles.
+
+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`.
| Flag | Description |
|------|-------------|
-| `--auto` | Use auto-generated PR titles without prompting |
+| `--auto` | Skip the editor and use auto-generated PR titles |
| `--open` | Create new PRs as ready for review instead of drafts, and mark existing PRs as ready for review |
| `--remote ` | Remote to push to (defaults to auto-detected remote) |
diff --git a/go.mod b/go.mod
index 6307665..561c585 100644
--- a/go.mod
+++ b/go.mod
@@ -7,11 +7,13 @@ require (
github.com/BourgeoisBear/rasterm v1.1.2
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
github.com/charmbracelet/bubbletea v1.3.10
+ github.com/charmbracelet/glamour v0.10.0
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/cli/cli/v2 v2.93.0
github.com/cli/go-gh/v2 v2.13.0
github.com/cli/shurcooL-graphql v0.0.4
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d
+ github.com/muesli/termenv v0.16.0
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
golang.org/x/sys v0.45.0
@@ -20,19 +22,24 @@ require (
)
require (
+ github.com/alecthomas/chroma/v2 v2.19.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+ github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
+ github.com/charmbracelet/x/exp/slice v0.0.0-20250630141444-821143405392 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/cli/browser v1.3.0 // indirect
github.com/cli/safeexec v1.0.1 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+ github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
+ github.com/gorilla/css v1.0.1 // indirect
github.com/henvic/httpretty v0.1.4 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
@@ -42,14 +49,17 @@ require (
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
+ github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
- github.com/muesli/termenv v0.16.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/thlib/go-timezone-local v0.0.6 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+ github.com/yuin/goldmark v1.8.2 // indirect
+ github.com/yuin/goldmark-emoji v1.0.6 // indirect
+ golang.org/x/net v0.55.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index 1acb610..e0970f3 100644
--- a/go.sum
+++ b/go.sum
@@ -6,18 +6,28 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
+github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
+github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
+github.com/alecthomas/chroma/v2 v2.19.0 h1:Im+SLRgT8maArxv81mULDWN8oKxkzboH07CHesxElq4=
+github.com/alecthomas/chroma/v2 v2.19.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk=
+github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
+github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
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/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
+github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
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.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
+github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
+github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
@@ -26,6 +36,8 @@ github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMx
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
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/slice v0.0.0-20250630141444-821143405392 h1:VHLoEcL+kH60a4F8qMsPfOIfWjFE3ciaW4gge2YR3sA=
+github.com/charmbracelet/x/exp/slice v0.0.0-20250630141444-821143405392/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
@@ -51,14 +63,20 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
+github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
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/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
+github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
+github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
github.com/henvic/httpretty v0.1.4 h1:Jo7uwIRWVFxkqOnErcoYfH90o3ddQyVrSANeS4cxYmU=
github.com/henvic/httpretty v0.1.4/go.mod h1:Dn60sQTZfbt2dYsdUSNsCljyF4AfdqnuJFDLJA1I4AM=
+github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
+github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -85,6 +103,8 @@ github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhg
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
+github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
+github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
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=
@@ -118,6 +138,10 @@ github.com/thlib/go-timezone-local v0.0.6/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4
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=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
+github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
+github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
@@ -127,6 +151,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
+golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
diff --git a/internal/github/github.go b/internal/github/github.go
index 6a5a79b..78d3370 100644
--- a/internal/github/github.go
+++ b/internal/github/github.go
@@ -29,6 +29,8 @@ type PullRequest struct {
Number int `graphql:"number"`
State string `graphql:"state"`
URL string `graphql:"url"`
+ Title string `graphql:"title"`
+ Body string `graphql:"body"`
HeadRefName string `graphql:"headRefName"`
BaseRefName string `graphql:"baseRefName"`
IsDraft bool `graphql:"isDraft"`
@@ -101,6 +103,8 @@ func (c *Client) FindPRForBranch(branch string) (*PullRequest, error) {
ID string `graphql:"id"`
Number int `graphql:"number"`
URL string `graphql:"url"`
+ Title string `graphql:"title"`
+ Body string `graphql:"body"`
BaseRefName string `graphql:"baseRefName"`
IsDraft bool `graphql:"isDraft"`
MergeQueueEntry *MergeQueueEntry `graphql:"mergeQueueEntry"`
@@ -130,6 +134,8 @@ func (c *Client) FindPRForBranch(branch string) (*PullRequest, error) {
ID: n.ID,
Number: n.Number,
URL: n.URL,
+ Title: n.Title,
+ Body: n.Body,
BaseRefName: n.BaseRefName,
IsDraft: n.IsDraft,
MergeQueueEntry: n.MergeQueueEntry,
@@ -279,6 +285,8 @@ type PRDetails struct {
Number int
State string // OPEN, CLOSED, MERGED
URL string
+ Title string
+ Body string
IsDraft bool
Merged bool
IsQueued bool
@@ -342,6 +350,8 @@ func (c *Client) FindPRByNumber(number int) (*PullRequest, error) {
Number int `graphql:"number"`
State string `graphql:"state"`
URL string `graphql:"url"`
+ Title string `graphql:"title"`
+ Body string `graphql:"body"`
HeadRefName string `graphql:"headRefName"`
BaseRefName string `graphql:"baseRefName"`
IsDraft bool `graphql:"isDraft"`
@@ -371,6 +381,8 @@ func (c *Client) FindPRByNumber(number int) (*PullRequest, error) {
Number: n.Number,
State: n.State,
URL: n.URL,
+ Title: n.Title,
+ Body: n.Body,
HeadRefName: n.HeadRefName,
BaseRefName: n.BaseRefName,
IsDraft: n.IsDraft,
diff --git a/internal/tui/submitview/data.go b/internal/tui/submitview/data.go
new file mode 100644
index 0000000..8db39f1
--- /dev/null
+++ b/internal/tui/submitview/data.go
@@ -0,0 +1,157 @@
+package submitview
+
+import (
+ "strings"
+
+ "github.com/github/gh-stack/internal/tui/stackview"
+)
+
+// DeriveState classifies a branch node into a BranchState using both the stack's
+// tracked PR reference and any freshly fetched PR details. Merged and queued
+// states take priority, followed by closed, draft, and open. A branch with no
+// PR at all is NEW. A branch that tracks a PR but for which no fresh details are
+// available is treated as open (locked) rather than NEW, so it is never
+// presented as editable.
+func DeriveState(node stackview.BranchNode) BranchState {
+ pr := node.PR
+ ref := node.Ref
+
+ if ref.IsMerged() || (pr != nil && pr.Merged) {
+ return StateMerged
+ }
+ if ref.IsQueued() || (pr != nil && pr.IsQueued) {
+ return StateQueued
+ }
+ if pr != nil {
+ switch {
+ case pr.State == "CLOSED":
+ return StateClosed
+ case pr.IsDraft:
+ return StateDraft
+ default:
+ return StateOpen
+ }
+ }
+ // A tracked PR reference without fresh details: treat as open (locked),
+ // never NEW, so we don't offer to create a duplicate PR.
+ if ref.PullRequest != nil && ref.PullRequest.Number != 0 {
+ return StateOpen
+ }
+ return StateNew
+}
+
+// PrefillTitle returns the default PR title for a new branch: the subject of its
+// single commit when the branch has exactly one commit, otherwise the humanized
+// branch name. This mirrors the non-TUI submit's defaultPRTitleBody.
+func PrefillTitle(node stackview.BranchNode) string {
+ if commits := node.Commits; len(commits) == 1 {
+ if subject := strings.TrimSpace(commits[0].Subject); subject != "" {
+ return subject
+ }
+ }
+ return humanize(node.Ref.Branch)
+}
+
+// PrefillDescription returns the default PR description for a new branch: the
+// repo PR template if one exists, otherwise the body of the branch's single
+// commit, otherwise empty. Multi-commit branches with no template get an empty
+// description (no bulleted commit list). This mirrors the non-TUI submit. The
+// attribution footer is appended later, at submit time.
+func PrefillDescription(node stackview.BranchNode, template string) string {
+ if t := strings.TrimSpace(template); t != "" {
+ return t
+ }
+ if commits := node.Commits; len(commits) == 1 {
+ return strings.TrimSpace(commits[0].Body)
+ }
+ return ""
+}
+
+// NewSubmitNodes builds the per-branch UI state for the submit TUI from loaded
+// branch display data. NEW branches default to included; new PRs have their
+// title and description prefilled from commits and the PR template, while
+// existing PRs show their real title and description fetched from the API. New
+// PRs default to ready for review; the per-PR draft toggle starts off. The
+// prefill snapshots are retained for edit detection.
+func NewSubmitNodes(nodes []stackview.BranchNode, template string) []SubmitNode {
+ out := make([]SubmitNode, len(nodes))
+ for i, n := range nodes {
+ state := DeriveState(n)
+ title := PrefillTitle(n)
+ desc := PrefillDescription(n, template)
+ // Existing PRs render their real title/description from the API instead
+ // of the commit/template-derived draft used for new PRs.
+ if state != StateNew && n.PR != nil {
+ desc = n.PR.Body
+ if t := strings.TrimSpace(n.PR.Title); t != "" {
+ title = t
+ }
+ }
+ out[i] = SubmitNode{
+ BranchNode: n,
+ State: state,
+ Included: state == StateNew,
+ Title: title,
+ Description: desc,
+ titlePrefill: title,
+ descPrefill: desc,
+ }
+ }
+ return out
+}
+
+// CountNew returns the number of NEW (creatable) branches in the list.
+func CountNew(nodes []SubmitNode) int {
+ n := 0
+ for _, node := range nodes {
+ if node.State == StateNew {
+ n++
+ }
+ }
+ return n
+}
+
+// CountSelected returns the number of NEW branches currently marked for
+// inclusion.
+func CountSelected(nodes []SubmitNode) int {
+ n := 0
+ for _, node := range nodes {
+ if node.State == StateNew && node.Included {
+ n++
+ }
+ }
+ return n
+}
+
+// HasClosed reports whether any branch in the list has a closed PR, which blocks
+// the stack and triggers the closed-branch callout.
+func HasClosed(nodes []SubmitNode) bool {
+ for _, node := range nodes {
+ if node.State == StateClosed {
+ return true
+ }
+ }
+ return false
+}
+
+// ClosedBranches returns the names of branches with a closed PR, in list order.
+func ClosedBranches(nodes []SubmitNode) []string {
+ var names []string
+ for _, node := range nodes {
+ if node.State == StateClosed {
+ names = append(names, node.Ref.Branch)
+ }
+ }
+ return names
+}
+
+// humanize replaces hyphens and underscores with spaces. It mirrors the helper
+// used by the submit command so auto-generated titles match across paths.
+func humanize(s string) string {
+ return strings.Map(func(r rune) rune {
+ if r == '-' || r == '_' {
+ return ' '
+ }
+ return r
+ }, s)
+}
diff --git a/internal/tui/submitview/data_test.go b/internal/tui/submitview/data_test.go
new file mode 100644
index 0000000..ef364fc
--- /dev/null
+++ b/internal/tui/submitview/data_test.go
@@ -0,0 +1,201 @@
+package submitview
+
+import (
+ "testing"
+
+ "github.com/github/gh-stack/internal/git"
+ ghapi "github.com/github/gh-stack/internal/github"
+ "github.com/github/gh-stack/internal/stack"
+ "github.com/github/gh-stack/internal/tui/stackview"
+ "github.com/stretchr/testify/assert"
+)
+
+// node builds a stackview.BranchNode for a branch with no PR and the given
+// commits.
+func node(branch string, commits ...git.CommitInfo) stackview.BranchNode {
+ return stackview.BranchNode{
+ Ref: stack.BranchRef{Branch: branch},
+ Commits: commits,
+ }
+}
+
+// withPR attaches fresh PR details to a node.
+func withPR(n stackview.BranchNode, pr *ghapi.PRDetails) stackview.BranchNode {
+ n.PR = pr
+ return n
+}
+
+// withTrackedPR attaches a tracked PR reference (as persisted in the stack file)
+// to a node.
+func withTrackedPR(n stackview.BranchNode, ref *stack.PullRequestRef) stackview.BranchNode {
+ n.Ref.PullRequest = ref
+ return n
+}
+
+func commit(subject, body string) git.CommitInfo {
+ return git.CommitInfo{Subject: subject, Body: body}
+}
+
+func TestDeriveState(t *testing.T) {
+ tests := []struct {
+ name string
+ node stackview.BranchNode
+ want BranchState
+ }{
+ {
+ name: "no PR is new",
+ node: node("feat/a"),
+ want: StateNew,
+ },
+ {
+ name: "open PR",
+ node: withPR(node("feat/a"), &ghapi.PRDetails{Number: 1, State: "OPEN"}),
+ want: StateOpen,
+ },
+ {
+ name: "draft PR",
+ node: withPR(node("feat/a"), &ghapi.PRDetails{Number: 1, State: "OPEN", IsDraft: true}),
+ want: StateDraft,
+ },
+ {
+ name: "closed PR",
+ node: withPR(node("feat/a"), &ghapi.PRDetails{Number: 1, State: "CLOSED"}),
+ want: StateClosed,
+ },
+ {
+ name: "merged via PR details",
+ node: withPR(node("feat/a"), &ghapi.PRDetails{Number: 1, State: "MERGED", Merged: true}),
+ want: StateMerged,
+ },
+ {
+ name: "queued via PR details",
+ node: withPR(node("feat/a"), &ghapi.PRDetails{Number: 1, State: "OPEN", IsQueued: true}),
+ want: StateQueued,
+ },
+ {
+ name: "merged via tracked ref",
+ node: withTrackedPR(node("feat/a"), &stack.PullRequestRef{Number: 1, Merged: true}),
+ want: StateMerged,
+ },
+ {
+ name: "tracked ref without details treated as open",
+ node: withTrackedPR(node("feat/a"), &stack.PullRequestRef{Number: 7}),
+ want: StateOpen,
+ },
+ {
+ name: "merged takes priority over draft",
+ node: withPR(node("feat/a"), &ghapi.PRDetails{Number: 1, State: "OPEN", IsDraft: true, Merged: true}),
+ want: StateMerged,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.want, DeriveState(tt.node))
+ })
+ }
+}
+
+func TestPrefillTitle(t *testing.T) {
+ t.Run("single commit uses subject", func(t *testing.T) {
+ n := node("feat/auth/middleware", commit("Add auth middleware", "body"))
+ assert.Equal(t, "Add auth middleware", PrefillTitle(n))
+ })
+
+ t.Run("multiple commits humanize the branch name", func(t *testing.T) {
+ // Only a single-commit branch uses the commit subject; multi-commit
+ // branches default to the humanized branch name (matches non-TUI submit).
+ n := node("feat/auth-middleware", commit("Polish middleware", ""), commit("Add auth middleware", ""))
+ assert.Equal(t, "feat/auth middleware", PrefillTitle(n))
+ })
+
+ t.Run("zero commits humanize branch name", func(t *testing.T) {
+ n := node("feat/new_feature")
+ assert.Equal(t, "feat/new feature", PrefillTitle(n))
+ })
+
+ t.Run("blank subject falls back to branch name", func(t *testing.T) {
+ n := node("feat/x", commit(" ", ""))
+ assert.Equal(t, "feat/x", PrefillTitle(n))
+ })
+}
+
+func TestPrefillDescription(t *testing.T) {
+ t.Run("template takes priority", func(t *testing.T) {
+ n := node("feat/a", commit("subject", "commit body"))
+ assert.Equal(t, "## Description", PrefillDescription(n, "## Description"))
+ })
+
+ t.Run("single commit uses body", func(t *testing.T) {
+ n := node("feat/a", commit("subject", "Detailed body\nsecond line"))
+ assert.Equal(t, "Detailed body\nsecond line", PrefillDescription(n, ""))
+ })
+
+ t.Run("multi commit with no template is empty", func(t *testing.T) {
+ // No bulleted commit list — multi-commit branches default to empty.
+ n := node("feat/a", commit("newest", ""), commit("middle", ""), commit("oldest", ""))
+ assert.Equal(t, "", PrefillDescription(n, ""))
+ })
+
+ t.Run("no commits no template is empty", func(t *testing.T) {
+ n := node("feat/a")
+ assert.Equal(t, "", PrefillDescription(n, ""))
+ })
+}
+
+func TestNewSubmitNodes(t *testing.T) {
+ nodes := []stackview.BranchNode{
+ node("feat/a", commit("Add a", "body a")),
+ withPR(node("feat/b"), &ghapi.PRDetails{Number: 2, State: "OPEN"}),
+ }
+
+ got := NewSubmitNodes(nodes, "")
+
+ assert.Len(t, got, 2)
+
+ // NEW branch: included by default, prefilled, ready (not draft).
+ assert.Equal(t, StateNew, got[0].State)
+ assert.True(t, got[0].Included)
+ assert.Equal(t, "Add a", got[0].Title)
+ assert.Equal(t, "body a", got[0].Description)
+ assert.False(t, got[0].Draft, "new PRs default to ready for review")
+ assert.False(t, got[0].Edited(), "freshly prefilled node is not edited")
+
+ // OPEN branch: not included, locked.
+ assert.Equal(t, StateOpen, got[1].State)
+ assert.False(t, got[1].Included)
+}
+
+func TestNewSubmitNodes_ExistingPRUsesAPIContent(t *testing.T) {
+ // An existing PR shows its real title/body from the API, not the
+ // commit-subject / template prefill used for new PRs.
+ nodes := []stackview.BranchNode{
+ withPR(node("feat/b", commit("commit subject", "commit body")),
+ &ghapi.PRDetails{Number: 2, State: "OPEN", Title: "Real PR title", Body: "Real PR body"}),
+ }
+ got := NewSubmitNodes(nodes, "## Template")
+ assert.Len(t, got, 1)
+ assert.Equal(t, StateOpen, got[0].State)
+ assert.Equal(t, "Real PR title", got[0].Title, "existing PR uses the API title")
+ assert.Equal(t, "Real PR body", got[0].Description, "existing PR uses the API body, not the template")
+}
+
+func TestCounts(t *testing.T) {
+ nodes := []SubmitNode{
+ {State: StateNew, Included: true},
+ {State: StateNew, Included: false},
+ {State: StateOpen},
+ {State: StateClosed},
+ }
+ assert.Equal(t, 2, CountNew(nodes))
+ assert.Equal(t, 1, CountSelected(nodes))
+ assert.True(t, HasClosed(nodes))
+}
+
+func TestClosedBranches(t *testing.T) {
+ nodes := []SubmitNode{
+ {State: StateNew, BranchNode: node("feat/a")},
+ {State: StateClosed, BranchNode: node("feat/legacy")},
+ }
+ assert.Equal(t, []string{"feat/legacy"}, ClosedBranches(nodes))
+}
diff --git a/internal/tui/submitview/editor.go b/internal/tui/submitview/editor.go
new file mode 100644
index 0000000..c4d6d92
--- /dev/null
+++ b/internal/tui/submitview/editor.go
@@ -0,0 +1,585 @@
+package submitview
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/charmbracelet/bubbles/textarea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+// currentNode returns a pointer to the focused node, or nil.
+func (m Model) currentNode() *SubmitNode {
+ if m.cursor < 0 || m.cursor >= len(m.nodes) {
+ return nil
+ }
+ return &m.nodes[m.cursor]
+}
+
+// renderIncludedEditor renders Mode 1: the editor for an included NEW branch —
+// the header (with the "Creating PR" chip), a separator rule, the title and
+// description fields and ready/draft toggle, and the footer strip.
+func (m Model) renderIncludedEditor(n SubmitNode, innerW int) string {
+ var b strings.Builder
+ b.WriteString(m.renderRightHeader(n, innerW))
+ b.WriteString("\n")
+ b.WriteString(rule(innerW))
+ b.WriteString("\n")
+ b.WriteString(m.renderEditBody(innerW))
+ b.WriteString("\n")
+ b.WriteString(rule(innerW))
+ b.WriteString("\n")
+ b.WriteString(m.renderRightFooter(n, innerW))
+ return b.String()
+}
+
+// renderSkippedCard renders Mode 2: a NEW branch the user opted out of. The
+// editor body is shown dimmed and non-interactive so it is clear what would be
+// created, with the header chip reading "Skipped".
+func (m Model) renderSkippedCard(n SubmitNode, innerW int) string {
+ var b strings.Builder
+ b.WriteString(m.renderRightHeader(n, innerW))
+ b.WriteString("\n")
+ b.WriteString(rule(innerW))
+ b.WriteString("\n")
+ b.WriteString(dimBodyStyle.Render(stripANSI(m.renderEditBody(innerW))))
+ b.WriteString("\n")
+ b.WriteString(rule(innerW))
+ b.WriteString("\n")
+ b.WriteString(m.renderRightFooter(n, innerW))
+ return b.String()
+}
+
+// renderLockedCard renders Mode 3: a read-only card for a branch that already
+// has a PR. It shows the PR title and a scrollable markdown preview of the
+// description, with an "Open on GitHub" button in the top-right. A closed PR
+// blocks the stack, so it shows a short callout instead.
+func (m Model) renderLockedCard(n SubmitNode, innerW int) string {
+ var b strings.Builder
+ b.WriteString(composeLR(m.renderRightHeader(n, innerW), m.renderOpenButton(n), innerW))
+ b.WriteString("\n")
+
+ if n.State.Blocks() {
+ b.WriteString("\n")
+ b.WriteString(calloutErrorStyle.Render(" This branch has a closed pull request, which blocks the stack."))
+ b.WriteString("\n")
+ b.WriteString(hintStyle.Render(" Reopen it, or unstack and recreate to remove it."))
+ return b.String()
+ }
+
+ b.WriteString(rule(innerW))
+ b.WriteString("\n")
+ b.WriteString(sectionLabelStyle.Render("TITLE"))
+ b.WriteString("\n")
+ b.WriteString(fieldBox(lockedTitleStyle.Render(truncateVisible(n.Title, innerW-4)), innerW, false))
+ b.WriteString("\n")
+ b.WriteString(sectionLabelStyle.Render("DESCRIPTION"))
+ b.WriteString("\n")
+ b.WriteString(descBox(m.lockedDescContent(innerW), innerW, false))
+ return b.String()
+}
+
+// renderOpenButton renders the "↗ Open on GitHub (^o)" button for a locked
+// PR's card, or "" when there is no PR URL to open. "↗ Open on GitHub" is one
+// underlined white link; "(^o)" is a dim, non-link keyboard hint.
+func (m Model) renderOpenButton(n SubmitNode) string {
+ if lockedURL(n) == "" {
+ return ""
+ }
+ return openLinkStyle.Render("↗ Open on GitHub") + " " + hintStyle.Render("(^o)")
+}
+
+// lockedHeaderTargets returns the screen-x half-open ranges [start,end) of the
+// two click targets on a locked card's header row: the existing-PR number and
+// the "Open on GitHub" button. A zero-width range (start == end) means that
+// target is absent. handleClick and tests share this so the click regions stay
+// in sync with what renderLockedCard draws.
+func (m Model) lockedHeaderTargets(n SubmitNode) (numStart, numEnd, btnStart, btnEnd int) {
+ leftW, rightW := m.panelWidths()
+ contentLeft := leftW + 3 // left gap + panel border + panel padding
+ contentRight := contentLeft + (rightW - 4) // exclusive: panel content right edge
+
+ if num := prNumber(n); num != 0 {
+ lead := headerBranchStyle.Render(n.Ref.Branch) + " " + RenderBadge(n.State)
+ numStart = contentLeft + lipgloss.Width(lead)
+ numEnd = numStart + lipgloss.Width(fmt.Sprintf(" #%d", num))
+ }
+ if btn := m.renderOpenButton(n); btn != "" {
+ btnEnd = contentRight
+ btnStart = contentRight - lipgloss.Width(btn)
+ }
+ return numStart, numEnd, btnStart, btnEnd
+}
+
+// lockedDescContent renders the focused locked branch's description as a
+// scrollable markdown preview with a scrollbar, matching the editor's preview.
+func (m Model) lockedDescContent(innerW int) string {
+ textWidth := descTextWidth(innerW)
+ height := m.lockedDescHeight()
+ lines := m.descPreviewLines(innerW)
+ scroll := clampScroll(m.descScroll, len(lines), height)
+ rows := clipScrollRows(lines, scroll, height)
+ return addScrollbar(rows, scroll, len(lines), height, textWidth)
+}
+
+// renderRightHeader renders the right-panel heading: the focused branch name
+// (white), its state badge, and — for a NEW branch — a "CREATE PR" switch on the
+// right (on when the branch will become a PR, off when skipped).
+func (m Model) renderRightHeader(n SubmitNode, innerW int) string {
+ left := headerBranchStyle.Render(n.Ref.Branch) + " " + RenderBadge(n.State)
+ if n.State != StateNew {
+ if num := prNumber(n); num != 0 {
+ left += " " + prNumberStyle.Render(fmt.Sprintf("#%d", num))
+ }
+ return left
+ }
+ return composeLR(left, m.renderIncludeChip(n), innerW)
+}
+
+// renderIncludeChip renders the "CREATE PR" toggle: the label in the shared
+// section-heading style, a dim Ctrl+X hint, then the two-state switch.
+func (m Model) renderIncludeChip(n SubmitNode) string {
+ return sectionLabelStyle.Render("CREATE PR") + " " + hintStyle.Render("(^x)") + " " + renderSwitch(n.Included)
+}
+
+// renderSwitch draws a two-state pill toggle with a square knob that slides
+// between the ends, kept one cell off each border so it never touches the edge:
+// " ■ " (green track, black knob right) when on, " ■ " (light track, dark
+// knob left) when off.
+func renderSwitch(on bool) string {
+ if on {
+ return switchOnStyle.Render(" ") + switchOnStyle.Foreground(switchOnKnob).Render("■") + switchOnStyle.Render(" ")
+ }
+ return switchOffStyle.Render(" ") + switchOffStyle.Foreground(switchOffKnob).Render("■") + switchOffStyle.Render(" ")
+}
+
+// renderEditBody renders the editable fields in web-create-PR order: the title
+// input, the description input with an edit/preview sub-toggle, and the
+// ready/draft segmented toggle. The skip and editor shortcuts are shown inline
+// (the CREATE PR switch and the CREATE AS row), not in the footer.
+func (m Model) renderEditBody(innerW int) string {
+ var b strings.Builder
+
+ // TITLE
+ b.WriteString(sectionLabelFor("TITLE", m.focusedField == fieldTitle))
+ b.WriteString("\n")
+ b.WriteString(fieldBox(m.titleInput.View(), innerW, m.focusedField == fieldTitle))
+ b.WriteString("\n")
+
+ // DESCRIPTION with edit/preview sub-toggle on the right.
+ b.WriteString(composeLR(sectionLabelFor("DESCRIPTION", m.focusedField == fieldDescription), m.renderDescToggle(), innerW))
+ b.WriteString("\n")
+ b.WriteString(descBox(m.descContent(innerW), innerW, m.focusedField == fieldDescription))
+ b.WriteString("\n")
+
+ // CREATE AS segmented toggle (with the Open in Editor hint on the right).
+ b.WriteString(m.renderDraftToggle(innerW))
+
+ return b.String()
+}
+
+// rule renders a full-width horizontal divider.
+func rule(width int) string {
+ if width < 1 {
+ width = 1
+ }
+ return ruleStyle.Render(strings.Repeat("─", width))
+}
+
+// renderRightFooter renders the thin footer strip at the bottom of the right
+// panel: the PR progress on the left and a bottom-right action — "NEXT BRANCH"
+// or, on the last PR, "SUBMIT N PRs" — right-aligned.
+func (m Model) renderRightFooter(n SubmitNode, innerW int) string {
+ return composeLR(m.renderProgress(n), m.footerRightButton(), innerW)
+}
+
+// footerRightButton renders the bottom-right footer action: "NEXT BRANCH" (white)
+// when another PR remains up the stack, or a prominent "SUBMIT N PRs" button on
+// the last PR. A skipped branch shows an inert gray "SKIPPED" instead. The
+// keyboard hint sits to the LEFT so the label/button stays anchored to the right
+// edge. The "(tab)" hint shows only while the CREATE AS row is focused — the
+// point at which Tab actually advances to the next branch. The NEXT BRANCH /
+// SUBMIT actions are clickable (handleClick); empty when there is nothing to do.
+func (m Model) footerRightButton() string {
+ if n := m.currentNode(); n != nil && n.State == StateNew && !n.Included {
+ return sectionLabelStyle.Render("SKIPPED")
+ }
+ if m.nextEditableIndex() != -1 {
+ label := nextBranchStyle.Render("NEXT BRANCH")
+ if m.focusedField == fieldDraft {
+ return hintStyle.Render("(tab) ") + label
+ }
+ return label
+ }
+ if _, total := m.prProgress(); total > 0 {
+ noun := "PRs"
+ if total == 1 {
+ noun = "PR"
+ }
+ return hintStyle.Render("(^s) ") + submitButtonStyle.Render(fmt.Sprintf("SUBMIT %d %s", total, noun))
+ }
+ return ""
+}
+
+// renderProgress renders the "●○ PR k of m" progress affordance for the new-PR
+// set. When the focused branch is not an included NEW branch, only the count is
+// shown.
+func (m Model) renderProgress(n SubmitNode) string {
+ pos, total := m.prProgress()
+ if total == 0 {
+ return ""
+ }
+ var dots strings.Builder
+ for i := 1; i <= total; i++ {
+ if i == pos {
+ dots.WriteString(footerKeyStyle.Render("●"))
+ } else {
+ dots.WriteString(hintStyle.Render("○"))
+ }
+ }
+ if pos == 0 {
+ word := "PRs"
+ if total == 1 {
+ word = "PR"
+ }
+ return dots.String() + hintStyle.Render(fmt.Sprintf(" %d %s", total, word))
+ }
+ return dots.String() + hintStyle.Render(fmt.Sprintf(" PR %d of %d", pos, total))
+}
+
+// scrollbarReserve is the number of columns reserved on the right of the
+// description box for a 1-column margin plus the 1-column scrollbar.
+const scrollbarReserve = 2
+
+// descTextWidth returns the wrap width for the description text. The description
+// box uses left-only padding (descBox) so its content area is innerW-3; the
+// scrollbar and its margin take scrollbarReserve more.
+func descTextWidth(innerW int) int {
+ w := innerW - 3 - scrollbarReserve
+ if w < 10 {
+ w = 10
+ }
+ return w
+}
+
+// descBox wraps the description content in a rounded box with left-only padding,
+// so the scrollbar (the content's last column) sits flush against the right
+// border with no extra margin.
+func descBox(content string, width int, focused bool) string {
+ bc := lipgloss.Color("8")
+ if focused {
+ bc = lipgloss.Color("14")
+ }
+ w := width - 2
+ if w < 1 {
+ w = 1
+ }
+ return lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(bc).
+ Padding(0, 0, 0, 1).
+ Width(w).
+ Render(content)
+}
+
+// descContent returns the description body to display: the rendered markdown
+// preview or the editable text, both shown through a scrollable viewport with a
+// scrollbar on the right.
+func (m Model) descContent(innerW int) string {
+ textWidth := descTextWidth(innerW)
+ height := m.descAreaHeight()
+
+ if m.descPreview {
+ lines := m.descPreviewLines(innerW)
+ scroll := clampScroll(m.descScroll, len(lines), height)
+ rows := clipScrollRows(lines, scroll, height)
+ return addScrollbar(rows, scroll, len(lines), height, textWidth)
+ }
+
+ lines := m.descFullLines(innerW)
+ cursorRow := m.descCursorRow(innerW)
+
+ // When not free-scrolling, the viewport follows the cursor; otherwise the
+ // user's absolute scroll offset is used.
+ scroll := m.descScroll
+ if !m.descScrollPinned {
+ scroll = cursorViewTop(cursorRow, height)
+ }
+ scroll = clampScroll(scroll, len(lines), height)
+
+ // Overlay the block cursor when the description is focused and the cursor is
+ // within the visible window.
+ if m.descArea.Focused() && cursorRow >= scroll && cursorRow < scroll+height && cursorRow < len(lines) {
+ lines = overlayCursor(lines, cursorRow, m.descArea.LineInfo().ColumnOffset)
+ }
+ rows := clipScrollRows(lines, scroll, height)
+ return addScrollbar(rows, scroll, len(lines), height, textWidth)
+}
+
+// clampScroll bounds a scroll offset to [0, total-height].
+func clampScroll(scroll, total, height int) int {
+ if max := total - height; scroll > max {
+ scroll = max
+ }
+ if scroll < 0 {
+ scroll = 0
+ }
+ return scroll
+}
+
+// descPreviewLines renders the description markdown to visual rows for the
+// scrollable preview.
+func (m Model) descPreviewLines(innerW int) []string {
+ desc := ""
+ if n := m.currentNode(); n != nil {
+ desc = n.Description
+ }
+ return strings.Split(renderMarkdown(desc, descTextWidth(innerW)), "\n")
+}
+
+// cursorViewTop returns the scroll offset that keeps the cursor's row visible at
+// the bottom of the viewport once the text grows past it (a normal editor feel).
+func cursorViewTop(cursorRow, height int) int {
+ if cursorRow >= height {
+ return cursorRow - height + 1
+ }
+ return 0
+}
+
+// descFullLines renders the whole description, wrapped exactly like the editing
+// textarea (same width, same component) but with no cursor, as visual rows. It
+// reads the live textarea value so it always matches descCursorRow.
+func (m Model) descFullLines(innerW int) []string {
+ return wrapDescLines(m.descArea.Value(), descTextWidth(innerW))
+}
+
+// descCursorRow returns the cursor's absolute visual row in the wrapped
+// description, accounting for soft-wrapped lines above it.
+func (m Model) descCursorRow(innerW int) int {
+ li := m.descArea.LineInfo()
+ bufRow := m.descArea.Line()
+ if bufRow <= 0 {
+ return li.RowOffset
+ }
+ bufLines := strings.Split(m.descArea.Value(), "\n")
+ if bufRow > len(bufLines) {
+ bufRow = len(bufLines)
+ }
+ // A zero-width sentinel keeps a trailing empty line from being trimmed away,
+ // so the rows above the cursor are counted correctly.
+ before := strings.Join(bufLines[:bufRow], "\n") + "\u200b"
+ return len(wrapDescLines(before, descTextWidth(innerW))) + li.RowOffset
+}
+
+// clipScrollRows returns height rows of lines starting at offset, padding with
+// blank rows when the offset runs past the end.
+func clipScrollRows(lines []string, offset, height int) []string {
+ out := make([]string, 0, height)
+ for i := offset; i < offset+height; i++ {
+ if i >= 0 && i < len(lines) {
+ out = append(out, lines[i])
+ } else {
+ out = append(out, "")
+ }
+ }
+ return out
+}
+
+// addScrollbar appends a 1-column margin and a vertical scrollbar to each row.
+// The thumb size and position reflect the visible window over the total rows;
+// when everything fits, only blank margin is added so the layout is stable.
+func addScrollbar(rows []string, scroll, total, height, textWidth int) string {
+ start, size := scrollbarThumb(scroll, total, height)
+ var b strings.Builder
+ for i, row := range rows {
+ if i > 0 {
+ b.WriteByte('\n')
+ }
+ if gap := textWidth - lipgloss.Width(row); gap > 0 {
+ row += strings.Repeat(" ", gap)
+ }
+ b.WriteString(row)
+ b.WriteByte(' ') // margin between text and bar
+ switch {
+ case total <= height:
+ b.WriteByte(' ') // content fits: no bar
+ case i >= start && i < start+size:
+ b.WriteString(scrollThumbStyle.Render("┃"))
+ default:
+ b.WriteString(scrollTrackStyle.Render("│"))
+ }
+ }
+ return b.String()
+}
+
+// scrollbarThumb returns the thumb's start row and size for a viewport of the
+// given height over total rows scrolled to offset.
+func scrollbarThumb(scroll, total, height int) (start, size int) {
+ if total <= height || height <= 0 {
+ return 0, 0
+ }
+ size = height * height / total
+ if size < 1 {
+ size = 1
+ }
+ if size > height {
+ size = height
+ }
+ if denom := total - height; denom > 0 {
+ start = scroll * (height - size) / denom
+ }
+ if start < 0 {
+ start = 0
+ }
+ if start > height-size {
+ start = height - size
+ }
+ return start, size
+}
+
+// wrapDescLines wraps text to width using a throwaway textarea so the wrapping
+// matches the editor exactly, returning the visual rows as plain text (ANSI
+// stripped) with trailing blank rows trimmed. Stripping ANSI is essential: in a
+// real terminal the textarea pads blank rows with styled spaces, which would
+// otherwise defeat the trailing-blank trim (inflating the scroll range) and the
+// cursor-overlay column math.
+func wrapDescLines(text string, width int) []string {
+ if width < 10 {
+ width = 10
+ }
+ ta := textarea.New()
+ ta.Prompt = ""
+ ta.ShowLineNumbers = false
+ ta.MaxHeight = 0
+ ta.SetWidth(width)
+ ta.SetHeight(strings.Count(text, "\n") + 1 + len([]rune(text))/width + 5)
+ ta.SetValue(text)
+ lines := strings.Split(ta.View(), "\n")
+ for i := range lines {
+ lines[i] = stripANSI(lines[i])
+ }
+ for len(lines) > 1 && strings.TrimSpace(lines[len(lines)-1]) == "" {
+ lines = lines[:len(lines)-1]
+ }
+ return lines
+}
+
+// stripANSI removes ANSI escape sequences from s.
+func stripANSI(s string) string {
+ var b strings.Builder
+ inEsc := false
+ for _, r := range s {
+ if r == '\x1b' {
+ inEsc = true
+ continue
+ }
+ if inEsc {
+ if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
+ inEsc = false
+ }
+ continue
+ }
+ b.WriteRune(r)
+ }
+ return b.String()
+}
+
+// overlayCursor renders a block cursor at (row, col) within the given lines.
+func overlayCursor(lines []string, row, col int) []string {
+ if row < 0 || row >= len(lines) || col < 0 {
+ return lines
+ }
+ r := []rune(lines[row])
+ for len(r) <= col {
+ r = append(r, ' ')
+ }
+ lines[row] = string(r[:col]) + descCursorStyle.Render(string(r[col])) + string(r[col+1:])
+ return lines
+}
+
+// renderDescToggle renders the inline edit/preview sub-toggle with a dim Ctrl+P
+// hint to its right.
+func (m Model) renderDescToggle() string {
+ var toggle string
+ if m.descPreview {
+ toggle = tabInactiveStyle.Render("edit") + stackInfoStyle.Render(" · ") + tabActiveStyle.Render("preview")
+ } else {
+ toggle = tabActiveStyle.Render("edit") + stackInfoStyle.Render(" · ") + tabInactiveStyle.Render("preview")
+ }
+ return toggle + " " + hintStyle.Render("(^p)")
+}
+
+// renderDraftToggle renders the ready-for-review ↔ draft choice as a single
+// segmented control "[ Ready | Draft ]" under a "CREATE AS" label, with the
+// "Open in Editor (^e)" hint right-aligned on the same line. The selected
+// segment is filled green; the other is dim. New PRs default to ready for review.
+func (m Model) renderDraftToggle(innerW int) string {
+ n := m.currentNode()
+ draft := n != nil && n.Draft
+
+ ready := segOffStyle.Render("Ready")
+ draftOpt := segOffStyle.Render("Draft")
+ if draft {
+ draftOpt = segOnStyle.Render("Draft")
+ } else {
+ ready = segOnStyle.Render("Ready")
+ }
+ seg := segFrameStyle.Render("[") + ready + segFrameStyle.Render("|") + draftOpt + segFrameStyle.Render("]")
+
+ left := sectionLabelFor("CREATE AS", m.focusedField == fieldDraft) + " " + seg
+ return composeLR(left, hintStyle.Render("open in $EDITOR (^e)"), innerW)
+}
+
+// sectionLabelFor renders a field's section label, turning it cyan when the
+// field is focused (matching the focused branch name and CREATE AS treatment).
+func sectionLabelFor(text string, focused bool) string {
+ if focused {
+ return focusNameStyle.Render(text)
+ }
+ return sectionLabelStyle.Render(text)
+}
+
+// draftSegmentBounds returns the screen-x coordinates of the CREATE AS segmented
+// control on the draft line: segStart (the "[" column), dividerX (the "|"
+// column), and segEnd (exclusive, just past "]"). handleClick uses these so only
+// clicks inside the brackets change the value — clicks on the "CREATE AS" label or
+// elsewhere on the line are ignored — and the half a click lands in selects that
+// option (left of the divider = Ready, right = Draft).
+func (m Model) draftSegmentBounds() (segStart, dividerX, segEnd int) {
+ leftW, _ := m.panelWidths()
+ contentLeft := leftW + 3 // left gap + panel border + panel padding
+ segStart = contentLeft + lipgloss.Width("CREATE AS") + 2 // label + two-space gap
+ dividerX = segStart + lipgloss.Width("[") + lipgloss.Width(segOffStyle.Render("Ready"))
+ segEnd = dividerX + lipgloss.Width("|") + lipgloss.Width(segOffStyle.Render("Draft")) + lipgloss.Width("]")
+ return segStart, dividerX, segEnd
+}
+
+// fieldBox wraps a field's content in a rounded box whose border highlights when
+// focused. width is the desired outer width.
+func fieldBox(content string, width int, focused bool) string {
+ bc := lipgloss.Color("8")
+ if focused {
+ bc = lipgloss.Color("14")
+ }
+ w := width - 2
+ if w < 1 {
+ w = 1
+ }
+ return lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(bc).
+ Padding(0, 1).
+ Width(w).
+ Render(content)
+}
+
+// lockedURL returns the web URL for a locked branch's PR, or "".
+func lockedURL(n SubmitNode) string {
+ if n.PR != nil && n.PR.URL != "" {
+ return n.PR.URL
+ }
+ if n.Ref.PullRequest != nil {
+ return n.Ref.PullRequest.URL
+ }
+ return ""
+}
diff --git a/internal/tui/submitview/help.go b/internal/tui/submitview/help.go
new file mode 100644
index 0000000..21561aa
--- /dev/null
+++ b/internal/tui/submitview/help.go
@@ -0,0 +1,151 @@
+package submitview
+
+import (
+ "strings"
+
+ "github.com/charmbracelet/lipgloss"
+)
+
+var (
+ helpOverlayStyle = lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(lipgloss.Color("8")).
+ Padding(1, 2)
+ helpTitleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true).Underline(true)
+ helpSectionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true)
+ helpKeyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true)
+ helpDescStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
+)
+
+// helpEntry is a single key/description pair in the help overlay.
+type helpEntry struct {
+ key string
+ desc string
+}
+
+// helpSection groups related key bindings under a heading.
+type helpSection struct {
+ heading string
+ entries []helpEntry
+}
+
+var helpSections = []helpSection{
+ {
+ heading: "Choose which branches become PRs",
+ entries: []helpEntry{
+ {"↑ / ↓ (or k / j)", "move between branches"},
+ {"^x / space", "skip / include the focused branch (works while typing)"},
+ {"", "skipping also skips the branches above; including adds those below"},
+ },
+ },
+ {
+ heading: "Edit PR details",
+ entries: []helpEntry{
+ {"tab / shift+tab", "cycle fields (title, description, ready/draft) and PRs"},
+ {"space / ← / →", "flip the ready ↔ draft toggle"},
+ {"^p", "toggle description preview"},
+ {"^e", "open $EDITOR for the description"},
+ },
+ },
+ {
+ heading: "Existing PRs",
+ entries: []helpEntry{
+ {"^o", "open the focused branch's PR on the web"},
+ },
+ },
+ {
+ heading: "Anywhere",
+ entries: []helpEntry{
+ {"^s", "submit all included PRs"},
+ {"? / ^h", "toggle this help (^h also works while editing a field)"},
+ {"q / esc", "quit (confirm if edits exist)"},
+ {"mouse", "click rows, checkboxes, the include switch, toggles, and fields"},
+ },
+ },
+}
+
+// renderHelpOverlay renders a centered, full-screen help modal listing every
+// key binding.
+func renderHelpOverlay(width, height int) string {
+ var b strings.Builder
+ b.WriteString(helpTitleStyle.Render("gh stack submit — keyboard & mouse"))
+ b.WriteString("\n")
+
+ // Compute the key-column width for alignment.
+ keyW := 0
+ for _, s := range helpSections {
+ for _, e := range s.entries {
+ if w := lipgloss.Width(e.key); w > keyW {
+ keyW = w
+ }
+ }
+ }
+
+ for _, s := range helpSections {
+ b.WriteString("\n")
+ b.WriteString(helpSectionStyle.Render(s.heading))
+ b.WriteString("\n")
+ for _, e := range s.entries {
+ pad := keyW - lipgloss.Width(e.key)
+ if pad < 0 {
+ pad = 0
+ }
+ b.WriteString(" ")
+ b.WriteString(helpKeyStyle.Render(e.key))
+ b.WriteString(strings.Repeat(" ", pad))
+ b.WriteString(" ")
+ b.WriteString(helpDescStyle.Render(e.desc))
+ b.WriteString("\n")
+ }
+ }
+
+ b.WriteString("\n")
+ b.WriteString(helpDescStyle.Render("Press ?, ^h, or Esc to close"))
+
+ styled := helpOverlayStyle.Render(b.String())
+ return centerOverlay(styled, width, height)
+}
+
+// renderQuitConfirm renders the centered discard-edits confirmation modal.
+func renderQuitConfirm(width, height int) string {
+ body := helpTitleStyle.Render("Discard edits?") + "\n\n" +
+ helpDescStyle.Render("You have unsaved changes. Quit without submitting?") + "\n\n" +
+ helpKeyStyle.Render("y") + helpDescStyle.Render(" quit ") +
+ helpKeyStyle.Render("n") + helpDescStyle.Render(" keep editing")
+ styled := helpOverlayStyle.Render(body)
+ return centerOverlay(styled, width, height)
+}
+
+// centerOverlay positions a styled block in the center of the viewport.
+func centerOverlay(block string, width, height int) string {
+ blockLines := strings.Split(block, "\n")
+ blockHeight := len(blockLines)
+ blockWidth := 0
+ for _, l := range blockLines {
+ if w := lipgloss.Width(l); w > blockWidth {
+ blockWidth = w
+ }
+ }
+
+ topPad := (height - blockHeight) / 2
+ if topPad < 0 {
+ topPad = 0
+ }
+ leftPad := (width - blockWidth) / 2
+ if leftPad < 0 {
+ leftPad = 0
+ }
+
+ var b strings.Builder
+ for i := 0; i < topPad; i++ {
+ b.WriteString("\n")
+ }
+ for i, l := range blockLines {
+ if i > 0 {
+ b.WriteString("\n")
+ }
+ b.WriteString(strings.Repeat(" ", leftPad))
+ b.WriteString(l)
+ }
+ return b.String()
+}
diff --git a/internal/tui/submitview/integration_test.go b/internal/tui/submitview/integration_test.go
new file mode 100644
index 0000000..0cb626b
--- /dev/null
+++ b/internal/tui/submitview/integration_test.go
@@ -0,0 +1,62 @@
+package submitview
+
+import (
+ "testing"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSubmit_EmptyTitleGuard(t *testing.T) {
+ m := testModel(t, newNodes())
+ m.titleInput.SetValue("") // blank the focused branch's title
+
+ updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlS})
+ m = updated.(Model)
+ assert.False(t, m.submitRequested)
+ assert.True(t, m.statusIsError)
+ assert.Nil(t, cmd)
+ assert.Equal(t, fieldTitle, m.focusedField)
+ assert.Equal(t, 1, m.cursor)
+}
+
+func TestSubmit_SucceedsWhenTitlesPresent(t *testing.T) {
+ m := testModel(t, newNodes())
+ updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlS})
+ m = updated.(Model)
+ assert.True(t, m.submitRequested)
+ assert.NotNil(t, cmd)
+}
+
+func TestSubmit_SkippedBranchWithoutTitleDoesNotBlock(t *testing.T) {
+ m := testModel(t, newNodes())
+ // Blank the first branch's title, then skip it; submit should proceed.
+ m.titleInput.SetValue("")
+ m.saveEditor()
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlX}) // skip branch 0
+
+ updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlS})
+ m = updated.(Model)
+ assert.True(t, m.submitRequested, "a skipped branch's empty title does not block submit")
+}
+
+func TestBuildDrafts_FromModel(t *testing.T) {
+ m := testModel(t, newNodes())
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyUp}) // focus the top branch (0)
+ require.Equal(t, 0, m.cursor)
+ m.nodes[1].Title = "Custom title"
+ // Skip the top branch (branch 0); the branch below it stays included.
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlX})
+ require.False(t, m.nodes[0].Included)
+ require.True(t, m.nodes[1].Included)
+
+ drafts := BuildDrafts(m.Nodes())
+ require.NotNil(t, drafts["feat/auth/tests"])
+ assert.False(t, drafts["feat/auth/tests"].Include, "skipped branch")
+ require.NotNil(t, drafts["feat/auth/middleware"])
+ assert.True(t, drafts["feat/auth/middleware"].Include, "still included")
+ assert.Equal(t, "Custom title", drafts["feat/auth/middleware"].Title)
+ // Locked branches never produce drafts.
+ assert.Nil(t, drafts["feat/auth/handlers"])
+}
diff --git a/internal/tui/submitview/model.go b/internal/tui/submitview/model.go
new file mode 100644
index 0000000..028fe56
--- /dev/null
+++ b/internal/tui/submitview/model.go
@@ -0,0 +1,272 @@
+package submitview
+
+import (
+ "github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/textarea"
+ "github.com/charmbracelet/bubbles/textinput"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/github/gh-stack/internal/stack"
+)
+
+// editField identifies the focused field in the right editor panel.
+type editField int
+
+const (
+ fieldTitle editField = iota
+ fieldDescription
+ fieldDraft
+)
+
+// keyMap holds the bindings the model matches centrally (the help overlay lists
+// the full set separately).
+type keyMap struct {
+ Help key.Binding
+ Quit key.Binding
+}
+
+var keys = keyMap{
+ Help: key.NewBinding(
+ key.WithKeys("?", "ctrl+h"),
+ key.WithHelp("?", "help"),
+ ),
+ Quit: key.NewBinding(
+ key.WithKeys("q", "ctrl+c"),
+ key.WithHelp("q", "quit"),
+ ),
+}
+
+// Options configures a new submit TUI model.
+type Options struct {
+ // Nodes is the branch list in display order (index 0 = top of stack).
+ Nodes []SubmitNode
+ // Trunk is the stack's trunk branch, shown for context.
+ Trunk stack.BranchRef
+ // RepoLabel is the "owner/repo" string shown in the header.
+ RepoLabel string
+ // Version is the CLI version string.
+ Version string
+}
+
+// Model is the Bubble Tea model backing the interactive `gh stack submit` TUI.
+type Model struct {
+ nodes []SubmitNode
+ trunk stack.BranchRef
+ repoLabel string
+ version string
+
+ cursor int // index into nodes (the focused branch)
+
+ width, height int
+
+ // Editor state for the focused branch.
+ titleInput textinput.Model
+ descArea textarea.Model
+ focusedField editField
+ descPreview bool // description preview (vs edit)
+
+ // descScroll is the wheel-scroll offset (absolute top visual row) for the
+ // description box. descScrollPinned is true while the user is free-scrolling
+ // with the wheel; when false the view follows the cursor. A keystroke unpins
+ // it so editing always shows the cursor.
+ descScroll int
+ descScrollPinned bool
+
+ showHelp bool
+
+ // Transient status line shown below the content (cleared on next key).
+ statusMessage string
+ statusIsError bool
+
+ // leftScroll is the first visible row offset of the left stack timeline when
+ // its content is taller than the panel.
+ leftScroll int
+
+ // confirmingQuit is true while the discard-edits confirmation is shown.
+ confirmingQuit bool
+
+ // Outcome flags consumed by the command layer once the program exits.
+ submitRequested bool
+ cancelled bool
+
+ // openURL, when non-nil, is called instead of launching the system browser
+ // to open an existing PR. Tests inject a no-op so the suite never spawns a
+ // real browser.
+ openURL func(string)
+
+ // mouseLeak tracks an in-progress terminal mouse escape sequence that the
+ // Bubble Tea input parser split across reads and surfaced as key runes. See
+ // consumeLeakedMouseKey for details.
+ mouseLeakActive bool
+ mouseLeakBuf string
+}
+
+// New constructs a submit TUI model from the given options. The single screen
+// opens immediately with the first branch focused (preferring the first NEW
+// branch) and the title field ready for editing.
+func New(opts Options) Model {
+ // Start on the bottom-most NEW branch (closest to trunk) — the first PR
+ // created, in stack order. Nodes are ordered top (index 0) to bottom, so the
+ // bottom-most NEW branch is the highest-indexed one.
+ cursor := 0
+ for i, n := range opts.Nodes {
+ if n.State == StateNew {
+ cursor = i
+ }
+ }
+
+ ti := textinput.New()
+ ti.Prompt = ""
+ ti.CharLimit = 256
+
+ ta := textarea.New()
+ ta.Prompt = ""
+ ta.ShowLineNumbers = false
+ ta.CharLimit = 0 // unlimited
+ // Soften the textarea chrome; the panel provides the border.
+ ta.FocusedStyle.CursorLine = lipgloss.NewStyle()
+
+ m := Model{
+ nodes: opts.Nodes,
+ trunk: opts.Trunk,
+ repoLabel: opts.RepoLabel,
+ version: opts.Version,
+ cursor: cursor,
+
+ titleInput: ti,
+ descArea: ta,
+ focusedField: fieldTitle,
+ }
+
+ m.loadEditor()
+ // Focus the first field of the initial branch (title for an included NEW
+ // branch, the Create-PR toggle otherwise).
+ _ = m.focusFirstField()
+
+ return m
+}
+
+// --- Getters for the command layer ---
+
+// SubmitRequested reports whether the user confirmed the batch submit.
+func (m Model) SubmitRequested() bool { return m.submitRequested }
+
+// Cancelled reports whether the user quit without submitting.
+func (m Model) Cancelled() bool { return m.cancelled }
+
+// Nodes returns the current per-branch state, from which the command builds
+// the per-PR overrides.
+func (m Model) Nodes() []SubmitNode { return m.nodes }
+
+// --- Bubble Tea interface ---
+
+func (m Model) Init() tea.Cmd {
+ return textinput.Blink
+}
+
+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ m.width = msg.Width
+ m.height = msg.Height
+ m.resizeEditor()
+ return m, nil
+
+ case tea.KeyMsg:
+ // Drop fragments of a terminal mouse escape sequence that the input
+ // parser split across reads and leaked as key runes; otherwise they get
+ // typed into the focused title/description field while scrolling.
+ if m.consumeLeakedMouseKey(msg) {
+ return m, nil
+ }
+
+ // Any key dismisses a transient status hint.
+ m.statusMessage = ""
+ m.statusIsError = false
+
+ if m.confirmingQuit {
+ return m.updateQuitConfirm(msg)
+ }
+ if m.showHelp {
+ return m.updateHelp(msg)
+ }
+ return m.updateScreen(msg)
+
+ case tea.MouseMsg:
+ return m.handleMouse(msg)
+
+ case editorFinishedMsg:
+ updated, cmd := m.handleEditorFinished(msg)
+ // After tea.ExecProcess runs the external editor, Bubble Tea's
+ // RestoreTerminal re-enables the alt-screen, bracketed paste, and focus
+ // reporting but NOT mouse tracking, so the terminal stops emitting mouse
+ // events once the editor closes. Re-enable cell-motion mouse mode (which
+ // also re-arms SGR mode) to match the program's startup options.
+ return updated, tea.Batch(cmd, tea.EnableMouseCellMotion)
+ }
+
+ return m, nil
+}
+
+// updateHelp handles keys while the help overlay is visible.
+func (m Model) updateHelp(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ if key.Matches(msg, keys.Help) || msg.Type == tea.KeyEscape {
+ m.showHelp = false
+ }
+ return m, nil
+}
+
+// updateQuitConfirm handles keys while the discard-edits confirmation is shown.
+func (m Model) updateQuitConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ switch msg.String() {
+ case "y", "Y":
+ m.cancelled = true
+ return m, tea.Quit
+ case "n", "N", "esc":
+ m.confirmingQuit = false
+ return m, nil
+ }
+ if msg.Type == tea.KeyCtrlC {
+ m.cancelled = true
+ return m, tea.Quit
+ }
+ return m, nil
+}
+
+func (m Model) View() string {
+ if m.width == 0 {
+ return ""
+ }
+ if m.confirmingQuit {
+ return renderQuitConfirm(m.width, m.height)
+ }
+ if m.showHelp {
+ return renderHelpOverlay(m.width, m.height)
+ }
+ return m.viewScreen()
+}
+
+// anyEdited reports whether the user has made any change worth a quit
+// confirmation: a deselected NEW branch or an edited title/description/draft.
+func (m Model) anyEdited() bool {
+ for _, n := range m.nodes {
+ if n.Edited() {
+ return true
+ }
+ }
+ return false
+}
+
+// quit marks the session cancelled and exits. If the user has unsaved edits, it
+// first raises a discard-edits confirmation instead of quitting immediately.
+func (m Model) quit() (tea.Model, tea.Cmd) {
+ if m.anyEdited() && !m.confirmingQuit {
+ m.confirmingQuit = true
+ return m, nil
+ }
+ m.cancelled = true
+ return m, tea.Quit
+}
+
+// Ensure Model satisfies the tea.Model interface.
+var _ tea.Model = Model{}
diff --git a/internal/tui/submitview/model_test.go b/internal/tui/submitview/model_test.go
new file mode 100644
index 0000000..90d932e
--- /dev/null
+++ b/internal/tui/submitview/model_test.go
@@ -0,0 +1,371 @@
+package submitview
+
+import (
+ "testing"
+
+ tea "github.com/charmbracelet/bubbletea"
+ ghapi "github.com/github/gh-stack/internal/github"
+ "github.com/github/gh-stack/internal/stack"
+ "github.com/github/gh-stack/internal/tui/stackview"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// --- test helpers ---
+
+// newNode builds a SubmitNode with prefill snapshots set (so it reads as
+// unedited) for the given branch and state.
+func newNode(branch string, st BranchState) SubmitNode {
+ title := "Title " + branch
+ desc := "Desc"
+ n := SubmitNode{
+ BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: branch}},
+ State: st,
+ Included: st == StateNew,
+ Title: title,
+ Description: desc,
+ titlePrefill: title,
+ descPrefill: desc,
+ }
+ if st != StateNew {
+ n.Ref.PullRequest = &stack.PullRequestRef{Number: 1240, URL: "https://github.com/o/r/pull/1240"}
+ n.PR = &ghapi.PRDetails{Number: 1240, State: "OPEN", URL: "https://github.com/o/r/pull/1240"}
+ }
+ return n
+}
+
+// newNodes returns a representative stack: two NEW branches followed by locked
+// branches in several states.
+func newNodes() []SubmitNode {
+ return []SubmitNode{
+ newNode("feat/auth/tests", StateNew),
+ newNode("feat/auth/middleware", StateNew),
+ newNode("feat/auth/handlers", StateDraft),
+ newNode("feat/auth/models", StateOpen),
+ newNode("feat/auth/router", StateQueued),
+ newNode("feat/auth/ui", StateMerged),
+ }
+}
+
+func testModel(t *testing.T, nodes []SubmitNode) Model {
+ t.Helper()
+ m := New(Options{
+ Nodes: nodes,
+ Trunk: stack.BranchRef{Branch: "main"},
+ RepoLabel: "myorg/myrepo",
+ Version: "1.0.0",
+ })
+ // Never spawn a real browser from the test suite when opening an existing PR.
+ m.openURL = func(string) {}
+ // Height accommodates the shared 12-line header plus the full editor.
+ updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
+ return updated.(Model)
+}
+
+func sendKey(t *testing.T, m Model, msg tea.KeyMsg) Model {
+ t.Helper()
+ updated, _ := m.Update(msg)
+ return updated.(Model)
+}
+
+func runeKey(r rune) tea.KeyMsg {
+ return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}
+}
+
+// --- constructor ---
+
+func TestNew_OpensEditorOnBottomMostNew(t *testing.T) {
+ m := testModel(t, newNodes())
+ assert.Equal(t, 1, m.cursor, "opens on the bottom-most NEW branch (closest to trunk)")
+ assert.Equal(t, fieldTitle, m.focusedField)
+ assert.True(t, m.titleInput.Focused(), "title is focused for immediate editing")
+}
+
+func TestNew_AllNewIncludedByDefault(t *testing.T) {
+ m := testModel(t, newNodes())
+ assert.True(t, m.nodes[0].Included)
+ assert.True(t, m.nodes[1].Included)
+}
+
+// --- navigation ---
+
+func TestNavigation_MovesAcrossAllBranches(t *testing.T) {
+ m := testModel(t, newNodes())
+ require.Equal(t, 1, m.cursor) // bottom-most NEW
+
+ // Lands on locked branches too (Mode 3).
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyDown})
+ assert.Equal(t, 2, m.cursor)
+ assert.True(t, m.nodes[m.cursor].State.Locked())
+
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyUp})
+ assert.Equal(t, 1, m.cursor)
+
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyUp})
+ assert.Equal(t, 0, m.cursor)
+}
+
+func TestNavigation_ClampsAtEnds(t *testing.T) {
+ m := testModel(t, newNodes())
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyUp})
+ assert.Equal(t, 0, m.cursor)
+
+ for i := 0; i < 20; i++ {
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyDown})
+ }
+ assert.Equal(t, len(m.nodes)-1, m.cursor)
+}
+
+func TestNavigation_FocusesLockedWithNoTextInput(t *testing.T) {
+ m := testModel(t, newNodes())
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyDown})
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyDown}) // locked
+ require.True(t, m.nodes[m.cursor].State.Locked())
+ assert.False(t, m.titleInput.Focused())
+ assert.False(t, m.descArea.Focused())
+}
+
+// --- include / skip ---
+
+func TestCtrlX_TogglesInclude(t *testing.T) {
+ m := testModel(t, newNodes())
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyUp}) // focus the top NEW branch
+ require.Equal(t, 0, m.cursor)
+ require.True(t, m.nodes[0].Included)
+
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlX})
+ assert.False(t, m.nodes[0].Included, "^x skips the focused NEW branch")
+ assert.False(t, m.titleInput.Focused(), "a skipped branch's title is non-interactive")
+
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlX})
+ assert.True(t, m.nodes[0].Included, "^x includes it again")
+ assert.True(t, m.titleInput.Focused(), "re-including focuses the title for editing")
+}
+
+func TestCtrlX_WorksWhileTypingTitle(t *testing.T) {
+ m := testModel(t, newNodes())
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyUp}) // focus the top NEW branch
+ require.Equal(t, fieldTitle, m.focusedField)
+ m = sendKey(t, m, runeKey('Z')) // typing into title
+ require.Contains(t, m.nodes[0].Title, "Z")
+
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlX})
+ assert.False(t, m.nodes[0].Included, "^x works mid-text-entry")
+ assert.Contains(t, m.nodes[0].Title, "Z", "in-progress title is preserved")
+}
+
+func TestCtrlX_NoOpOnLockedBranch(t *testing.T) {
+ m := testModel(t, newNodes())
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyDown})
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyDown}) // locked
+ require.True(t, m.nodes[m.cursor].State.Locked())
+ before := m.nodes[m.cursor].Included
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlX})
+ assert.Equal(t, before, m.nodes[m.cursor].Included)
+}
+
+func TestSpace_ReincludesSkippedBranch(t *testing.T) {
+ m := testModel(t, newNodes())
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlX}) // skip the focused branch
+ require.False(t, m.nodes[m.cursor].Included)
+
+ // space on a skipped branch re-includes it (no Create-PR field anymore).
+ m = sendKey(t, m, runeKey(' '))
+ assert.True(t, m.nodes[m.cursor].Included)
+}
+
+// --- quit ---
+
+func TestQuit_WhenUnedited(t *testing.T) {
+ m := testModel(t, newNodes())
+ require.False(t, m.anyEdited())
+ // 'q' quits from a non-text field (it is literal while editing a title).
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // description
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // ready/draft toggle
+ require.Equal(t, fieldDraft, m.focusedField)
+ updated, cmd := m.Update(runeKey('q'))
+ m = updated.(Model)
+ assert.True(t, m.cancelled)
+ assert.False(t, m.confirmingQuit)
+ assert.NotNil(t, cmd)
+}
+
+func TestEsc_Quits(t *testing.T) {
+ m := testModel(t, newNodes())
+ updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEscape})
+ m = updated.(Model)
+ assert.True(t, m.cancelled)
+}
+
+func TestQuitConfirm_WhenEdited(t *testing.T) {
+ m := testModel(t, newNodes())
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlX}) // skip a branch = an edit
+ require.True(t, m.anyEdited())
+
+ updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEscape})
+ m = updated.(Model)
+ assert.True(t, m.confirmingQuit)
+ assert.False(t, m.cancelled)
+ assert.Contains(t, m.View(), "Discard edits")
+
+ m = sendKey(t, m, runeKey('n'))
+ assert.False(t, m.confirmingQuit)
+
+ updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEscape})
+ m = updated.(Model)
+ require.True(t, m.confirmingQuit)
+ updated, cmd := m.Update(runeKey('y'))
+ m = updated.(Model)
+ assert.True(t, m.cancelled)
+ assert.NotNil(t, cmd)
+}
+
+// --- help ---
+
+func TestHelpToggle(t *testing.T) {
+ m := testModel(t, newNodes())
+ // '?' opens help only when not in a text field; move to the ready/draft toggle.
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // description
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // ready/draft toggle
+ require.Equal(t, fieldDraft, m.focusedField)
+ m = sendKey(t, m, runeKey('?'))
+ assert.True(t, m.showHelp)
+ assert.Contains(t, m.View(), "keyboard & mouse")
+
+ m = sendKey(t, m, runeKey('?'))
+ assert.False(t, m.showHelp)
+}
+
+func TestHelp_NotOpenedWhileTypingTitle(t *testing.T) {
+ m := testModel(t, newNodes())
+ require.Equal(t, fieldTitle, m.focusedField)
+ m = sendKey(t, m, runeKey('?'))
+ assert.False(t, m.showHelp, "'?' is literal while editing the title")
+ assert.Contains(t, m.nodes[1].Title, "?")
+}
+
+// --- view ---
+
+func TestView_RendersKeyElements(t *testing.T) {
+ m := testModel(t, newNodes())
+ out := m.View()
+ assert.Contains(t, out, "Submit Stack") // shared header title
+ assert.Contains(t, out, "Repo: myorg/myrepo") // repo info line (header)
+ assert.Contains(t, out, "Creating 2 PRs") // pending-PR info line (header)
+ assert.Contains(t, out, "submit PRs") // keyboard shortcut in the shared header
+ assert.Contains(t, out, "CREATE PR") // include switch
+ assert.Contains(t, out, "tests")
+ assert.Contains(t, out, "main")
+}
+
+func TestHelp_CtrlHTogglesWhileEditing(t *testing.T) {
+ m := testModel(t, newNodes())
+ require.Equal(t, fieldTitle, m.focusedField) // editing the title
+
+ // '?' is typed into the title field, not treated as help, while editing.
+ m = sendKey(t, m, runeKey('?'))
+ assert.False(t, m.showHelp, "? is typed into the field while editing")
+ assert.Contains(t, m.nodes[m.cursor].Title, "?")
+
+ // ^h opens help even while editing a field.
+ updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlH})
+ m = updated.(Model)
+ assert.True(t, m.showHelp, "^h opens help while editing")
+
+ // ^h closes it again.
+ updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlH})
+ m = updated.(Model)
+ assert.False(t, m.showHelp, "^h closes help")
+}
+
+func TestHelp_OverlayListsFullShortcuts(t *testing.T) {
+ m := testModel(t, newNodes())
+ updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlH})
+ m = updated.(Model)
+ out := m.View()
+ for _, s := range []string{"^x", "^p", "^e", "^o", "^s", "^h", "tab", "mouse"} {
+ assert.Contains(t, out, s, "the help overlay lists %q", s)
+ }
+}
+
+func TestHeaderConfig_InfoLines(t *testing.T) {
+ // Repo and base are the first two info lines, in order.
+ cfg := testModel(t, newNodes()).buildHeaderConfig()
+ require.GreaterOrEqual(t, len(cfg.InfoLines), 3)
+ assert.Equal(t, "○", cfg.InfoLines[0].Icon)
+ assert.Equal(t, "Repo: myorg/myrepo", cfg.InfoLines[0].Label)
+ assert.Equal(t, "◆", cfg.InfoLines[1].Icon)
+ assert.Equal(t, "Base: main", cfg.InfoLines[1].Label)
+
+ // Two included NEW branches -> solid (styled) square, pluralized.
+ last := cfg.InfoLines[len(cfg.InfoLines)-1]
+ assert.Equal(t, "■", last.Icon)
+ assert.Equal(t, "Creating 2 PRs", last.Label)
+ assert.NotNil(t, last.IconStyle, "the creating line is styled yellow")
+
+ // A single included NEW branch -> singular.
+ oneCfg := testModel(t, []SubmitNode{newNode("feat/x", StateNew)}).buildHeaderConfig()
+ oneLast := oneCfg.InfoLines[len(oneCfg.InfoLines)-1]
+ assert.Equal(t, "■", oneLast.Icon)
+ assert.Equal(t, "Creating 1 PR", oneLast.Label)
+
+ // No NEW branches (all already have PRs) -> empty square, default style.
+ noneCfg := testModel(t, []SubmitNode{
+ newNode("feat/a", StateOpen),
+ newNode("feat/b", StateMerged),
+ }).buildHeaderConfig()
+ noneLast := noneCfg.InfoLines[len(noneCfg.InfoLines)-1]
+ assert.Equal(t, "□", noneLast.Icon)
+ assert.Equal(t, "No pending PRs", noneLast.Label)
+ assert.Nil(t, noneLast.IconStyle, "the empty line uses the default icon style")
+}
+
+func TestView_ClosedBanner(t *testing.T) {
+ nodes := newNodes()
+ nodes = append(nodes, newNode("feat/auth/legacy", StateClosed))
+ m := testModel(t, nodes)
+ out := m.View()
+ assert.Contains(t, out, "closed PR")
+ assert.Contains(t, out, "feat/auth/legacy")
+}
+
+func TestView_EmptyWhenNoSize(t *testing.T) {
+ m := New(Options{Nodes: newNodes(), Trunk: stack.BranchRef{Branch: "main"}})
+ assert.Equal(t, "", m.View())
+}
+
+// --- mouse ---
+
+func TestMouse_ClickBranchFocuses(t *testing.T) {
+ m := testModel(t, newNodes())
+ y := m.panelTopRow() + 2 + 1 // branch index 1
+ updated, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: 10, Y: y})
+ m = updated.(Model)
+ assert.Equal(t, 1, m.cursor)
+}
+
+func TestMouse_ClickCheckboxToggles(t *testing.T) {
+ m := testModel(t, newNodes())
+ require.True(t, m.nodes[0].Included)
+ y, cbX := leftBranchNode(m, 0)
+ updated, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: cbX, Y: y})
+ m = updated.(Model)
+ assert.False(t, m.nodes[0].Included)
+}
+
+// leftBranchNode returns the screen Y of branch idx's node line and the X of its
+// right-edge checkbox, using the live left-panel timeline layout (scroll-aware).
+func leftBranchNode(m Model, idx int) (y, checkboxX int) {
+ leftW, _ := m.panelWidths()
+ rows := m.buildLeftRows(leftW - 2)
+ visH := m.leftVisibleHeight()
+ scroll := clampScroll(m.leftScroll, len(rows), visH)
+ for i, r := range rows {
+ if r.branch == idx && r.nodeLine {
+ y = m.panelTopRow() + (i - scroll)
+ break
+ }
+ }
+ checkboxX = leftW - 4 // middle column of the right-aligned "[x]"
+ return y, checkboxX
+}
diff --git a/internal/tui/submitview/mouse.go b/internal/tui/submitview/mouse.go
new file mode 100644
index 0000000..5346399
--- /dev/null
+++ b/internal/tui/submitview/mouse.go
@@ -0,0 +1,298 @@
+package submitview
+
+import (
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+func (m Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
+ if m.showHelp || m.confirmingQuit {
+ if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft {
+ m.showHelp = false
+ }
+ return m, nil
+ }
+
+ switch msg.Action {
+ case tea.MouseActionPress:
+ leftW, _ := m.panelWidths()
+ overLeft := msg.X >= 0 && msg.X < leftW
+ switch msg.Button {
+ case tea.MouseButtonWheelUp:
+ if overLeft {
+ m.scrollLeft(-leftScrollStep)
+ } else if m.isDescScrollable() {
+ m.scrollDesc(-descScrollStep)
+ }
+ return m, nil
+ case tea.MouseButtonWheelDown:
+ if overLeft {
+ m.scrollLeft(leftScrollStep)
+ } else if m.isDescScrollable() {
+ m.scrollDesc(descScrollStep)
+ }
+ return m, nil
+ case tea.MouseButtonLeft:
+ return m.handleClick(msg.X, msg.Y)
+ }
+ }
+ return m, nil
+}
+
+// leftScrollStep is the number of rows the left timeline scrolls per wheel notch.
+const leftScrollStep = 2
+
+// descScrollStep is the number of rows the description scrolls per wheel notch.
+const descScrollStep = 3
+
+// scrollDesc moves the description's scroll offset by delta rows, clamped to the
+// scrollable range. In edit mode the first scroll pins the offset to the current
+// cursor view for continuity; in preview mode the offset is absolute.
+func (m *Model) scrollDesc(delta int) {
+ _, rightW := m.panelWidths()
+ innerW := rightW - 4
+ // In the editor's edit mode the first scroll pins the offset to the cursor
+ // view; the locked preview (and edit-preview) use an absolute offset.
+ n := m.currentNode()
+ editing := n != nil && n.State == StateNew && !m.descPreview
+ if editing && !m.descScrollPinned {
+ m.descScroll = cursorViewTop(m.descCursorRow(innerW), m.descAreaHeight())
+ m.descScrollPinned = true
+ }
+ next := m.descScroll + delta
+ if next < 0 {
+ next = 0
+ }
+ if max := m.maxDescScroll(innerW); next > max {
+ next = max
+ }
+ m.descScroll = next
+}
+
+// isDescScrollable reports whether the wheel should scroll the description: the
+// focused description of an included NEW branch, or a locked PR's read-only
+// description preview.
+func (m Model) isDescScrollable() bool {
+ n := m.currentNode()
+ if n == nil {
+ return false
+ }
+ if n.State == StateNew {
+ return n.Included && m.focusedField == fieldDescription
+ }
+ return n.State.Locked()
+}
+
+// panelTopRow returns the screen row of the first inner line of the panels
+// (past the header, the optional closed-PR banner, and the panel top border).
+func (m Model) panelTopRow() int {
+ row := m.headerHeight()
+ if m.renderClosedBanner() != "" {
+ row++
+ }
+ return row + 1
+}
+
+// branchRowAt maps screen coordinates to a left-panel branch index, or -1. A
+// click anywhere on a branch's node, wrapped-name, or meta lines resolves to it.
+func (m Model) branchRowAt(x, y int) int {
+ leftW, _ := m.panelWidths()
+ if x < 0 || x >= leftW {
+ return -1
+ }
+ rows := m.buildLeftRows(leftW - 2)
+ visH := m.leftVisibleHeight()
+ scroll := clampScroll(m.leftScroll, len(rows), visH)
+ vis := y - m.panelTopRow()
+ if vis < 0 || vis >= visH {
+ return -1
+ }
+ off := vis + scroll
+ if off < 0 || off >= len(rows) {
+ return -1
+ }
+ return rows[off].branch
+}
+
+// leftCheckboxHit reports whether (x,y) lands on a NEW branch's right-edge
+// include checkbox in the left panel.
+func (m Model) leftCheckboxHit(x, y int) bool {
+ leftW, _ := m.panelWidths()
+ rows := m.buildLeftRows(leftW - 2)
+ visH := m.leftVisibleHeight()
+ scroll := clampScroll(m.leftScroll, len(rows), visH)
+ vis := y - m.panelTopRow()
+ if vis < 0 || vis >= visH {
+ return false
+ }
+ off := vis + scroll
+ if off < 0 || off >= len(rows) {
+ return false
+ }
+ r := rows[off]
+ if r.branch < 0 || !r.nodeLine || m.nodes[r.branch].State != StateNew {
+ return false
+ }
+ // The checkbox sits one cell in from the right border: cols [leftW-5, leftW-2).
+ return x >= leftW-5 && x < leftW-2
+}
+
+// handleClick routes a left click to a branch row (left map) or an editor
+// element (right panel).
+func (m Model) handleClick(x, y int) (tea.Model, tea.Cmd) {
+ m.statusMessage = ""
+ m.statusIsError = false
+
+ leftW, rightW := m.panelWidths()
+
+ // Left panel: focus a branch, toggling include when its checkbox is clicked.
+ if idx := m.branchRowAt(x, y); idx != -1 {
+ onCheckbox := m.leftCheckboxHit(x, y) && m.nodes[idx].State == StateNew
+ m.saveEditor()
+ m.cursor = idx
+ m.scrollLeftToCursor()
+ m.loadEditor()
+ if onCheckbox {
+ excluding := m.nodes[idx].Included
+ extra := m.applyIncludeCascade(idx)
+ m.setCascadeStatus(excluding, extra)
+ cmd := m.focusFirstField()
+ return m, cmd
+ }
+ cmd := m.focusFirstField()
+ return m, cmd
+ }
+
+ // Right panel.
+ if x < leftW {
+ return m, nil
+ }
+ n := m.currentNode()
+ if n == nil {
+ return m, nil
+ }
+
+ // Mode 3 (locked): the existing PR opens only from the PR number or the
+ // "↗ Open on GitHub" button on the header row, not from clicking the card body.
+ if n.State.Locked() || n.State.Blocks() {
+ if lockedURL(*n) != "" && y-m.panelTopRow() == 0 {
+ numStart, numEnd, btnStart, btnEnd := m.lockedHeaderTargets(*n)
+ onNumber := numEnd > numStart && x >= numStart && x < numEnd
+ onButton := btnEnd > btnStart && x >= btnStart && x < btnEnd
+ if onNumber || onButton {
+ m.openFocusedPR()
+ }
+ }
+ return m, nil
+ }
+
+ rel := y - m.panelTopRow()
+ titleLine, descLabel, descTop, descBot, draftLine := m.rightZones()
+ rightEdge := leftW + 1 + rightW
+
+ // Include chip (header row 0, right side) toggles inclusion.
+ if rel == 0 {
+ if x >= rightEdge-2-lipgloss.Width(m.renderIncludeChip(*n)) {
+ cmd := m.toggleInclude()
+ return m, cmd
+ }
+ return m, nil
+ }
+
+ // Mode 2 (skipped): the body is non-interactive (the footer shows an inert
+ // "SKIPPED"); only the chip toggles.
+ if n.State == StateNew && !n.Included {
+ return m, nil
+ }
+
+ // Footer bottom-right action (NEXT BRANCH / SUBMIT) for an included branch.
+ // The footer sits two rows below the CREATE AS line.
+ if rel == draftLine+2 {
+ if btn := m.footerRightButton(); btn != "" && x >= rightEdge-2-lipgloss.Width(btn) {
+ if next := m.nextEditableIndex(); next != -1 {
+ cmd := m.moveCursor(next - m.cursor)
+ return m, cmd
+ }
+ return m.requestSubmit()
+ }
+ return m, nil
+ }
+
+ // Mode 1 (included editor).
+ switch {
+ case rel == descLabel:
+ // The edit/preview sub-toggle is right-aligned on the DESCRIPTION line.
+ if x >= rightEdge-2-lipgloss.Width(m.renderDescToggle()) {
+ cmd := m.togglePreview()
+ return m, cmd
+ }
+ return m, nil
+ case rel >= titleLine-1 && rel <= titleLine+1:
+ cmd := m.focusField(fieldTitle)
+ return m, cmd
+ case rel >= descTop && rel <= descBot:
+ // Resolve the current top visible row, then map the clicked screen row to
+ // an absolute row in the wrapped text.
+ innerW := rightW - 4
+ scroll := m.descScroll
+ if !m.descScrollPinned {
+ scroll = cursorViewTop(m.descCursorRow(innerW), m.descAreaHeight())
+ }
+ cmd := m.focusField(fieldDescription)
+ if !m.descPreview {
+ m.positionDescCursor(scroll+rel-(descTop+1), x-(leftW+5))
+ // Keep the clicked view fixed so the cursor lands where it was clicked.
+ m.descScroll = scroll
+ m.descScrollPinned = true
+ }
+ return m, cmd
+ case rel == draftLine:
+ // Only clicks inside the [ Ready | Draft ] brackets change the value; the
+ // "CREATE AS" label and the rest of the line are inert. The half the click
+ // lands in selects that option (left = Ready, right = Draft).
+ segStart, dividerX, segEnd := m.draftSegmentBounds()
+ if x >= segStart && x < segEnd {
+ _ = m.focusField(fieldDraft)
+ n.Draft = x >= dividerX
+ }
+ return m, nil
+ }
+ return m, nil
+}
+
+// rightZones returns the right-panel line offsets (from panelTopRow) of the
+// interactive editor elements for the included-NEW layout.
+func (m Model) rightZones() (titleLine, descLabel, descTop, descBot, draftLine int) {
+ descH := m.descAreaHeight()
+ titleLine = 4 // title box content (header 0, rule 1, label 2, box 3..5)
+ descLabel = 6
+ descTop = 7 // description box top border
+ descBot = 8 + descH
+ draftLine = 9 + descH
+ return
+}
+
+// positionDescCursor moves the description textarea's cursor to the clicked
+// visual row and column. It resets to the top of the buffer and walks down, so
+// it is exact when the text is not scrolled (the common case now that the box
+// fills the panel) and approximate otherwise. CursorUp/CursorDown move by
+// visual line, so soft-wrapped lines are handled correctly.
+func (m *Model) positionDescCursor(visRow, col int) {
+ if visRow < 0 {
+ visRow = 0
+ }
+ if col < 0 {
+ col = 0
+ }
+ for guard := 0; guard < 1000; guard++ {
+ row, off := m.descArea.Line(), m.descArea.LineInfo().RowOffset
+ m.descArea.CursorUp()
+ if m.descArea.Line() == row && m.descArea.LineInfo().RowOffset == off {
+ break // reached the top-left of the buffer
+ }
+ }
+ for i := 0; i < visRow; i++ {
+ m.descArea.CursorDown()
+ }
+ m.descArea.SetCursor(m.descArea.LineInfo().StartColumn + col)
+}
diff --git a/internal/tui/submitview/mousefilter.go b/internal/tui/submitview/mousefilter.go
new file mode 100644
index 0000000..aedf9ae
--- /dev/null
+++ b/internal/tui/submitview/mousefilter.go
@@ -0,0 +1,86 @@
+package submitview
+
+import (
+ "regexp"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+var (
+ // sgrMouseTailRe matches a complete SGR mouse-sequence body: the bytes that
+ // follow the "\x1b[" prefix, optionally including a leftover "[" that the
+ // parser surfaced separately. Examples: "[<65;54;51M", "<0;12;7m".
+ sgrMouseTailRe = regexp.MustCompile(`^\[?<\d+;\d+;\d+[Mm]$`)
+ // sgrMouseBodyRe matches a partial SGR mouse body that is still accumulating
+ // its parameters, with no terminator yet. Examples: "<65;54;5", "<65;", "<".
+ sgrMouseBodyRe = regexp.MustCompile(`^[\d;]*$`)
+)
+
+// mouseLeakBufCap bounds how many runes we will swallow for a single suspected
+// mouse sequence before giving up, so a stray Alt+"[" can never eat an unbounded
+// run of real keystrokes. Real SGR mouse bodies are far shorter than this.
+const mouseLeakBufCap = 32
+
+// consumeLeakedMouseKey reports whether key is a fragment of a terminal mouse
+// escape sequence that Bubble Tea's input parser split across reads and emitted
+// as key runes instead of a tea.MouseMsg. Such fragments must be dropped so the
+// user does not see them typed into the focused title/description field while
+// scrolling the mouse wheel.
+//
+// A split SGR mouse sequence ("\x1b[ 2 {
+ burst = append(burst, runeMsg(seq[2:cut], false))
+ }
+ burst = append(burst, runeMsg(seq[cut:], false))
+ return burst
+}
+
+func TestConsumeLeakedMouseKey_SwallowsSplitSequences(t *testing.T) {
+ for _, seq := range []string{"\x1b[<65;54;51M", "\x1b[<64;10;20m", "\x1b[<0;1;1M"} {
+ for cut := 2; cut < len(seq); cut++ {
+ t.Run(fmt.Sprintf("%q@%d", seq, cut), func(t *testing.T) {
+ m := testModel(t, newNodes())
+ require.Equal(t, fieldTitle, m.focusedField)
+ before := m.titleInput.Value()
+
+ for _, msg := range splitSGRBurst(seq, cut) {
+ updated, _ := m.Update(msg)
+ m = updated.(Model)
+ }
+
+ assert.Equal(t, before, m.titleInput.Value(), "no fragment should reach the title field")
+ assert.False(t, m.mouseLeakActive, "the sequence resets once its terminator is consumed")
+
+ // A normal keystroke afterwards must still register.
+ m = sendKey(t, m, runeKey('x'))
+ assert.Equal(t, before+"x", m.titleInput.Value(), "typing works after a swallowed sequence")
+ })
+ }
+ }
+}
+
+func TestConsumeLeakedMouseKey_SwallowsSingleRunTail(t *testing.T) {
+ // When the parser consumes "\x1b" as a lone Escape, the rest of the body can
+ // arrive as one run.
+ m := testModel(t, newNodes())
+ before := m.titleInput.Value()
+ m = sendKey(t, m, runeMsg("[<65;54;51M", false))
+ assert.Equal(t, before, m.titleInput.Value(), "a whole leaked body in one run is dropped")
+}
+
+func TestConsumeLeakedMouseKey_PreservesRealTyping(t *testing.T) {
+ m := testModel(t, newNodes())
+ before := m.titleInput.Value()
+
+ // Characters from the mouse alphabet typed normally must pass through. Each
+ // real keystroke is a single-rune message, never a complete SGR body.
+ for _, r := range []rune{'<', '3', ';', '5', 'M', 'm'} {
+ m = sendKey(t, m, runeKey(r))
+ }
+ assert.Equal(t, before+"<3;5Mm", m.titleInput.Value(), "ordinary characters are never filtered")
+}
+
+func TestConsumeLeakedMouseKey_StrayAltBracketDoesNotEatNextKey(t *testing.T) {
+ m := testModel(t, newNodes())
+ before := m.titleInput.Value()
+
+ // A lone Alt+"[" opens a sequence, but the following key is not a mouse body,
+ // so it must still be handled (here: typed into the field).
+ m = sendKey(t, m, runeMsg("[", true))
+ require.True(t, m.mouseLeakActive)
+ m = sendKey(t, m, runeKey('z'))
+ assert.False(t, m.mouseLeakActive, "a non-body rune ends the sequence")
+ assert.Equal(t, before+"z", m.titleInput.Value(), "the following real key is not eaten")
+}
+
+func TestConsumeLeakedMouseKey_BracketedPasteIsNotFiltered(t *testing.T) {
+ m := testModel(t, newNodes())
+ before := m.titleInput.Value()
+
+ // A genuine paste that happens to look like a mouse body is preserved.
+ paste := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("<65;54;51M"), Paste: true}
+ updated, _ := m.Update(paste)
+ m = updated.(Model)
+ assert.Equal(t, before+"<65;54;51M", m.titleInput.Value(), "pasted content is never filtered")
+}
+
+func TestConsumeLeakedMouseKey_DescriptionField(t *testing.T) {
+ m := testModel(t, newNodes())
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // focus description
+ require.Equal(t, fieldDescription, m.focusedField)
+ before := m.descArea.Value()
+
+ for _, msg := range splitSGRBurst("\x1b[<65;54;51M", 10) {
+ updated, _ := m.Update(msg)
+ m = updated.(Model)
+ }
+ assert.Equal(t, before, m.descArea.Value(), "no fragment should reach the description field")
+}
diff --git a/internal/tui/submitview/preview.go b/internal/tui/submitview/preview.go
new file mode 100644
index 0000000..8a2e17f
--- /dev/null
+++ b/internal/tui/submitview/preview.go
@@ -0,0 +1,150 @@
+package submitview
+
+import (
+ "os"
+ "os/exec"
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/glamour"
+ "github.com/charmbracelet/glamour/styles"
+)
+
+// editorFinishedMsg is delivered after the external $EDITOR process exits.
+type editorFinishedMsg struct {
+ path string
+ err error
+}
+
+// togglePreview flips the description between edit and preview, blurring or
+// focusing the textarea accordingly. It returns any focus command.
+func (m *Model) togglePreview() tea.Cmd {
+ // Edit and preview use different line coordinates, so reset the scroll.
+ m.descScroll = 0
+ m.descScrollPinned = false
+ m.descPreview = !m.descPreview
+ if m.descPreview {
+ m.descArea.Blur()
+ return nil
+ }
+ if m.focusedField == fieldDescription {
+ return m.descArea.Focus()
+ }
+ return nil
+}
+
+// openEditor launches $EDITOR on the focused branch's description, returning the
+// ExecProcess command. If no editor is configured it surfaces a brief error and
+// leaves the in-TUI textarea editable.
+func (m Model) openEditor() (tea.Model, tea.Cmd) {
+ n := m.currentNode()
+ if n == nil || n.State != StateNew {
+ return m, nil
+ }
+ m.saveEditor()
+
+ editor := resolveEditor()
+ if editor == "" {
+ m.statusMessage = "$EDITOR is not set — edit inline or set $EDITOR"
+ m.statusIsError = true
+ return m, nil
+ }
+
+ path, err := writeTempDescription(m.nodes[m.cursor].Description)
+ if err != nil {
+ m.statusMessage = "Could not open editor: " + err.Error()
+ m.statusIsError = true
+ return m, nil
+ }
+
+ fields := strings.Fields(editor)
+ args := append(fields[1:], path)
+ cmd := exec.Command(fields[0], args...)
+ return m, tea.ExecProcess(cmd, func(err error) tea.Msg {
+ return editorFinishedMsg{path: path, err: err}
+ })
+}
+
+// handleEditorFinished reloads the description from the temp file after the
+// editor exits, then removes the file.
+func (m Model) handleEditorFinished(msg editorFinishedMsg) (tea.Model, tea.Cmd) {
+ defer func() { _ = os.Remove(msg.path) }()
+
+ if msg.err != nil {
+ m.statusMessage = "Editor exited with an error — your inline edits are kept"
+ m.statusIsError = true
+ return m, nil
+ }
+
+ data, err := os.ReadFile(msg.path)
+ if err != nil {
+ m.statusMessage = "Could not read the editor's output"
+ m.statusIsError = true
+ return m, nil
+ }
+
+ content := strings.TrimRight(string(data), "\n")
+ if m.cursor >= 0 && m.cursor < len(m.nodes) {
+ m.nodes[m.cursor].Description = content
+ m.descArea.SetValue(content)
+ }
+ return m, nil
+}
+
+// resolveEditor returns the configured editor command, checking GH_EDITOR,
+// VISUAL, then EDITOR. It returns "" when none are set.
+func resolveEditor() string {
+ for _, key := range []string{"GH_EDITOR", "VISUAL", "EDITOR"} {
+ if v := strings.TrimSpace(os.Getenv(key)); v != "" {
+ return v
+ }
+ }
+ return ""
+}
+
+// writeTempDescription writes content to a temporary markdown file and returns
+// its path.
+func writeTempDescription(content string) (string, error) {
+ f, err := os.CreateTemp("", "gh-stack-pr-*.md")
+ if err != nil {
+ return "", err
+ }
+ defer f.Close()
+ if _, err := f.WriteString(content); err != nil {
+ return "", err
+ }
+ return f.Name(), nil
+}
+
+// renderMarkdown renders markdown to styled terminal output using Glamour. It
+// uses a fixed dark style rather than glamour.WithAutoStyle(): auto-style probes
+// the terminal background with an OSC query whose response is consumed by Bubble
+// Tea's own input reader, so the query blocks forever and freezes the UI. On any
+// error it falls back to the raw markdown so the user still sees their content.
+func renderMarkdown(md string, width int) string {
+ if strings.TrimSpace(md) == "" {
+ return hintStyle.Render("(no description)")
+ }
+ if width < 10 {
+ width = 10
+ }
+ // Use glamour's "dark" style but drop the document block's default 2-column
+ // margin so the preview text aligns flush-left with the edit-mode textarea
+ // instead of being indented. Copying the struct and replacing the Margin
+ // pointer leaves the shared package-level style untouched.
+ style := styles.DarkStyleConfig
+ var noMargin uint
+ style.Document.Margin = &noMargin
+ r, err := glamour.NewTermRenderer(
+ glamour.WithStyles(style),
+ glamour.WithWordWrap(width),
+ )
+ if err != nil {
+ return md
+ }
+ out, err := r.Render(md)
+ if err != nil {
+ return md
+ }
+ return strings.Trim(out, "\n")
+}
diff --git a/internal/tui/submitview/preview_test.go b/internal/tui/submitview/preview_test.go
new file mode 100644
index 0000000..77c1bb5
--- /dev/null
+++ b/internal/tui/submitview/preview_test.go
@@ -0,0 +1,140 @@
+package submitview
+
+import (
+ "os"
+ "reflect"
+ "testing"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestTogglePreview(t *testing.T) {
+ m := testModel(t, newNodes())
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // focus description
+ require.Equal(t, fieldDescription, m.focusedField)
+ require.True(t, m.descArea.Focused())
+
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlP})
+ assert.True(t, m.descPreview)
+ assert.False(t, m.descArea.Focused(), "textarea blurs in preview")
+
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlP})
+ assert.False(t, m.descPreview)
+ assert.True(t, m.descArea.Focused(), "textarea refocuses on returning to edit")
+}
+
+func TestResolveEditor(t *testing.T) {
+ t.Setenv("GH_EDITOR", "")
+ t.Setenv("VISUAL", "")
+ t.Setenv("EDITOR", "")
+ assert.Equal(t, "", resolveEditor())
+
+ t.Setenv("EDITOR", "nano")
+ assert.Equal(t, "nano", resolveEditor())
+ t.Setenv("VISUAL", "vim")
+ assert.Equal(t, "vim", resolveEditor())
+ t.Setenv("GH_EDITOR", "code --wait")
+ assert.Equal(t, "code --wait", resolveEditor())
+}
+
+func TestOpenEditor_NoEditorSet(t *testing.T) {
+ t.Setenv("GH_EDITOR", "")
+ t.Setenv("VISUAL", "")
+ t.Setenv("EDITOR", "")
+
+ m := testModel(t, newNodes())
+ updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlE})
+ m = updated.(Model)
+ assert.Nil(t, cmd)
+ assert.True(t, m.statusIsError)
+ assert.Contains(t, m.statusMessage, "EDITOR")
+}
+
+func TestHandleEditorFinished_UpdatesDescription(t *testing.T) {
+ m := testModel(t, newNodes())
+ f, err := os.CreateTemp(t.TempDir(), "ed-*.md")
+ require.NoError(t, err)
+ _, _ = f.WriteString("Edited externally\n")
+ require.NoError(t, f.Close())
+
+ updated, _ := m.Update(editorFinishedMsg{path: f.Name()})
+ m = updated.(Model)
+ assert.Equal(t, "Edited externally", m.nodes[1].Description)
+
+ _, statErr := os.Stat(f.Name())
+ assert.True(t, os.IsNotExist(statErr))
+}
+
+// cmdMessages flattens the message(s) a command (possibly a tea.Batch) emits.
+func cmdMessages(cmd tea.Cmd) []tea.Msg {
+ if cmd == nil {
+ return nil
+ }
+ switch msg := cmd().(type) {
+ case tea.BatchMsg:
+ var out []tea.Msg
+ for _, c := range msg {
+ out = append(out, cmdMessages(c)...)
+ }
+ return out
+ default:
+ return []tea.Msg{msg}
+ }
+}
+
+func containsType(msgs []tea.Msg, want tea.Msg) bool {
+ wt := reflect.TypeOf(want)
+ for _, m := range msgs {
+ if reflect.TypeOf(m) == wt {
+ return true
+ }
+ }
+ return false
+}
+
+func TestEditorFinished_ReenablesMouse(t *testing.T) {
+ // Bubble Tea's RestoreTerminal (run after the external editor exits) does not
+ // re-enable mouse tracking, so the editor-finished handler must re-arm it or
+ // the mouse stops working after the editor closes.
+ want := tea.EnableMouseCellMotion()
+
+ t.Run("success", func(t *testing.T) {
+ m := testModel(t, newNodes())
+ f, err := os.CreateTemp(t.TempDir(), "ed-*.md")
+ require.NoError(t, err)
+ require.NoError(t, f.Close())
+ _, cmd := m.Update(editorFinishedMsg{path: f.Name()})
+ assert.True(t, containsType(cmdMessages(cmd), want), "mouse tracking is re-enabled after a clean editor exit")
+ })
+
+ t.Run("editor error", func(t *testing.T) {
+ m := testModel(t, newNodes())
+ _, cmd := m.Update(editorFinishedMsg{path: "/no/such/file", err: assert.AnError})
+ assert.True(t, containsType(cmdMessages(cmd), want), "mouse tracking is re-enabled even when the editor errored")
+ })
+}
+
+func TestRenderMarkdown(t *testing.T) {
+ assert.Contains(t, renderMarkdown("", 40), "no description")
+ // Glamour styles each word as a separate ANSI span, so assert on the words
+ // individually rather than the literal "Hello World".
+ out := renderMarkdown("# Hello World", 40)
+ assert.Contains(t, out, "Hello")
+ assert.Contains(t, out, "World")
+}
+
+func TestPreview_RendersWithoutHanging(t *testing.T) {
+ m := testModel(t, newNodes())
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // focus description (saves prefill)
+ // Set the description after focusing so the Tab's saveEditor doesn't clobber it.
+ m.nodes[1].Description = "# Heading\n\nBody text here."
+ m.descArea.SetValue("# Heading\n\nBody text here.")
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlP}) // switch to preview
+ require.True(t, m.descPreview)
+ // View() exercises the Glamour render path; with a fixed style it must not
+ // query the terminal (which would block) and must show the content.
+ out := m.View()
+ assert.Contains(t, out, "Heading")
+}
diff --git a/internal/tui/submitview/render.go b/internal/tui/submitview/render.go
new file mode 100644
index 0000000..5d9233a
--- /dev/null
+++ b/internal/tui/submitview/render.go
@@ -0,0 +1,174 @@
+package submitview
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/charmbracelet/lipgloss"
+ "github.com/github/gh-stack/internal/tui/shared"
+)
+
+// Chrome styles shared across the submit views.
+var (
+ stackInfoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
+)
+
+// headerHeight returns the number of screen rows the shared header occupies, or
+// 0 when the terminal is too small to show it.
+func (m Model) headerHeight() int {
+ if shared.ShouldShowHeader(m.width, m.height) {
+ return shared.HeaderHeightFor(m.buildHeaderConfig())
+ }
+ return 0
+}
+
+// renderHeader renders the shared gh-stack header (GitHub art, title, stack
+// info, and keyboard shortcuts), matching `gh stack view` and `gh stack
+// modify`. It returns "" when the terminal is too small for the header.
+func (m Model) renderHeader() string {
+ if !shared.ShouldShowHeader(m.width, m.height) {
+ return ""
+ }
+ var b strings.Builder
+ shared.RenderHeader(&b, m.buildHeaderConfig(), m.width, m.height)
+ return strings.TrimSuffix(b.String(), "\n")
+}
+
+// buildHeaderConfig assembles the shared header configuration: title, stack
+// info lines (including the consequence summary), and keyboard shortcuts.
+func (m Model) buildHeaderConfig() shared.HeaderConfig {
+ repo := m.repoLabel
+ if repo == "" {
+ repo = "unknown"
+ }
+
+ infoLines := []shared.HeaderInfoLine{
+ {Icon: "○", Label: "Repo: " + repo},
+ {Icon: "◆", Label: "Base: " + m.trunk.Branch},
+ }
+
+ // Third line mirrors the modify header's pending line: a solid yellow square
+ // with the count when PRs will be created, or an empty square otherwise.
+ newCount := 0
+ for _, n := range m.nodes {
+ if n.State == StateNew && n.Included {
+ newCount++
+ }
+ }
+ if newCount > 0 {
+ yellowStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("3"))
+ prWord := "PRs"
+ if newCount == 1 {
+ prWord = "PR"
+ }
+ infoLines = append(infoLines, shared.HeaderInfoLine{
+ Icon: "■",
+ Label: fmt.Sprintf("Creating %d %s", newCount, prWord),
+ IconStyle: &yellowStyle,
+ })
+ } else {
+ infoLines = append(infoLines, shared.HeaderInfoLine{Icon: "□", Label: "No pending PRs"})
+ }
+
+ return shared.HeaderConfig{
+ ShowArt: true,
+ Title: "Submit Stack",
+ Subtitle: "v" + m.version,
+ InfoLines: infoLines,
+ ShortcutColumns: 1,
+ Shortcuts: m.headerShortcuts(),
+ }
+}
+
+// headerShortcuts returns the six primary single-screen keyboard shortcuts shown
+// in the header (the help overlay lists the full set).
+func (m Model) headerShortcuts() []shared.ShortcutEntry {
+ return []shared.ShortcutEntry{
+ {Key: "↑↓", Desc: "select branch"},
+ {Key: "tab", Desc: "cycle field"},
+ {Key: "^x", Desc: "skip/include"},
+ {Key: "^s", Desc: "submit PRs"},
+ {Key: "^h", Desc: "help"},
+ {Key: "esc", Desc: "quit"},
+ }
+}
+
+// renderClosedBanner renders a slim full-width red banner directly under the
+// header when the stack contains a closed PR, or "" otherwise.
+func (m Model) renderClosedBanner() string {
+ closed := ClosedBranches(m.nodes)
+ if len(closed) == 0 {
+ return ""
+ }
+ verb := "branch has"
+ if len(closed) > 1 {
+ verb = "branches have"
+ }
+ msg := fmt.Sprintf(" %s %d %s a closed PR (%s) — reopen it, or unstack and recreate.",
+ RenderDot(StateClosed), len(closed), verb, strings.Join(closed, ", "))
+ if lipgloss.Width(msg) > m.width {
+ msg = truncateVisible(msg, m.width)
+ }
+ return calloutErrorStyle.Render(msg)
+}
+
+// composeLR places left and right content on one line of the given width with
+// the right content flush to the right edge.
+func composeLR(left, right string, width int) string {
+ lw := lipgloss.Width(left)
+ rw := lipgloss.Width(right)
+ gap := width - lw - rw
+ if gap < 1 {
+ gap = 1
+ // Truncate the left side so the right stays visible.
+ if lw > width-rw-1 {
+ left = truncateVisible(left, width-rw-1)
+ }
+ }
+ return left + strings.Repeat(" ", gap) + right
+}
+
+// truncateVisible truncates a possibly-styled string to at most maxWidth
+// visible columns, appending an ellipsis when it had to cut. It resets styling
+// at the cut so trailing ANSI does not leak.
+func truncateVisible(s string, maxWidth int) string {
+ if maxWidth <= 0 {
+ return ""
+ }
+ if lipgloss.Width(s) <= maxWidth {
+ return s
+ }
+ var b strings.Builder
+ width := 0
+ inEscape := false
+ for _, r := range s {
+ if r == '\x1b' {
+ inEscape = true
+ }
+ if inEscape {
+ b.WriteRune(r)
+ if r == 'm' {
+ inEscape = false
+ }
+ continue
+ }
+ if width >= maxWidth-1 {
+ b.WriteString("…")
+ b.WriteString("\x1b[0m")
+ break
+ }
+ b.WriteRune(r)
+ width++
+ }
+ return b.String()
+}
+
+// contentHeight returns the number of lines available for the two panels,
+// between the header and the reserved status line.
+func (m Model) contentHeight() int {
+ h := m.height - m.headerHeight() - 1 // status line
+ if h < 1 {
+ h = 1
+ }
+ return h
+}
diff --git a/internal/tui/submitview/screen.go b/internal/tui/submitview/screen.go
new file mode 100644
index 0000000..011ae3f
--- /dev/null
+++ b/internal/tui/submitview/screen.go
@@ -0,0 +1,958 @@
+package submitview
+
+import (
+ "fmt"
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/github/gh-stack/internal/tui/shared"
+)
+
+// --- Update ---
+
+// updateScreen handles all key input on the single submit screen.
+func (m Model) updateScreen(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ // Global keys, handled regardless of focus.
+ switch msg.Type {
+ case tea.KeyCtrlC:
+ return m.quit()
+ case tea.KeyEsc:
+ m.saveEditor()
+ return m.quit()
+ case tea.KeyCtrlS:
+ m.saveEditor()
+ return m.requestSubmit()
+ case tea.KeyCtrlX:
+ cmd := m.toggleInclude()
+ return m, cmd
+ case tea.KeyCtrlO:
+ m.openFocusedPR()
+ return m, nil
+ case tea.KeyCtrlH:
+ // Global help so it works even while editing a field (where ? is typed).
+ m.showHelp = true
+ return m, nil
+ }
+
+ node := m.currentNode()
+ editable := node != nil && node.State == StateNew && node.Included
+
+ // Field cycling and editor controls.
+ if editable {
+ switch msg.Type {
+ case tea.KeyCtrlP:
+ cmd := m.togglePreview()
+ return m, cmd
+ case tea.KeyCtrlE:
+ return m.openEditor()
+ case tea.KeyTab:
+ m.saveEditor()
+ cmd := m.advanceField()
+ return m, cmd
+ case tea.KeyShiftTab:
+ m.saveEditor()
+ cmd := m.retreatField()
+ return m, cmd
+ }
+ } else {
+ // Skipped NEW or locked: tab just moves between branches.
+ switch msg.Type {
+ case tea.KeyTab:
+ cmd := m.moveCursor(1)
+ return m, cmd
+ case tea.KeyShiftTab:
+ cmd := m.moveCursor(-1)
+ return m, cmd
+ }
+ }
+
+ // Up/down move between branches, except while actively editing the
+ // multi-line description text.
+ editingDesc := editable && m.focusedField == fieldDescription && !m.descPreview
+ if !editingDesc {
+ switch msg.Type {
+ case tea.KeyUp:
+ cmd := m.moveCursor(-1)
+ return m, cmd
+ case tea.KeyDown:
+ cmd := m.moveCursor(1)
+ return m, cmd
+ }
+ }
+
+ // Locked branch (Mode 3): read-only card.
+ if node != nil && (node.State.Locked() || node.State.Blocks()) {
+ switch msg.String() {
+ case "?":
+ m.showHelp = true
+ return m, nil
+ case "q":
+ return m.quit()
+ }
+ return m, nil
+ }
+
+ // Skipped NEW branch (Mode 2): only the Create-PR toggle is actionable.
+ if node != nil && node.State == StateNew && !node.Included {
+ switch {
+ case isSpaceKey(msg) || msg.Type == tea.KeyEnter:
+ cmd := m.toggleInclude()
+ return m, cmd
+ case msg.String() == "?":
+ m.showHelp = true
+ return m, nil
+ case msg.String() == "q":
+ return m.quit()
+ }
+ return m, nil
+ }
+
+ // Included NEW branch (Mode 1): field-specific handling.
+ switch m.focusedField {
+ case fieldDraft:
+ switch msg.Type {
+ case tea.KeyLeft:
+ if node != nil {
+ node.Draft = false
+ }
+ return m, nil
+ case tea.KeyRight:
+ if node != nil {
+ node.Draft = true
+ }
+ return m, nil
+ }
+ switch {
+ case isSpaceKey(msg) || msg.Type == tea.KeyEnter:
+ if node != nil {
+ node.Draft = !node.Draft
+ }
+ return m, nil
+ case msg.String() == "?":
+ m.showHelp = true
+ return m, nil
+ case msg.String() == "q":
+ return m.quit()
+ }
+ return m, nil
+
+ case fieldDescription:
+ if m.descPreview {
+ return m, nil
+ }
+ m.descScrollPinned = false // a keystroke snaps the view back to the cursor
+ var cmd tea.Cmd
+ m.descArea, cmd = m.descArea.Update(msg)
+ if node != nil {
+ node.Description = m.descArea.Value()
+ }
+ return m, cmd
+
+ case fieldTitle:
+ var cmd tea.Cmd
+ m.titleInput, cmd = m.titleInput.Update(msg)
+ if node != nil {
+ node.Title = m.titleInput.Value()
+ }
+ return m, cmd
+ }
+
+ return m, nil
+}
+
+// --- Navigation & focus ---
+
+// moveCursor moves the focused branch by delta (clamped), saving and reloading
+// the editor and focusing the new branch's first field.
+func (m *Model) moveCursor(delta int) tea.Cmd {
+ next := m.cursor + delta
+ if next < 0 || next >= len(m.nodes) {
+ return nil
+ }
+ m.saveEditor()
+ m.cursor = next
+ m.scrollLeftToCursor()
+ m.loadEditor()
+ return m.focusFirstField()
+}
+
+// moveCursorFocusLast is moveCursor but focuses the new branch's last field
+// (used by shift+tab onto the previous branch).
+func (m *Model) moveCursorFocusLast(delta int) tea.Cmd {
+ next := m.cursor + delta
+ if next < 0 || next >= len(m.nodes) {
+ return nil
+ }
+ m.saveEditor()
+ m.cursor = next
+ m.scrollLeftToCursor()
+ m.loadEditor()
+ return m.focusLastField()
+}
+
+// focusFirstField focuses the first focusable element of the current branch:
+// the title for an included NEW branch, nothing (a neutral state) otherwise.
+func (m *Model) focusFirstField() tea.Cmd {
+ n := m.currentNode()
+ if n != nil && n.State == StateNew && n.Included {
+ return m.focusField(fieldTitle)
+ }
+ m.titleInput.Blur()
+ m.descArea.Blur()
+ m.focusedField = fieldTitle
+ return nil
+}
+
+// focusLastField focuses the last focusable element of the current branch.
+func (m *Model) focusLastField() tea.Cmd {
+ n := m.currentNode()
+ if n != nil && n.State == StateNew && n.Included {
+ return m.focusField(fieldDraft)
+ }
+ m.titleInput.Blur()
+ m.descArea.Blur()
+ m.focusedField = fieldTitle
+ return nil
+}
+
+// toggleInclude flips the included state of the focused NEW branch, cascading
+// the stack dependency to dependent branches, and keeps the Create-PR toggle
+// focused.
+func (m *Model) toggleInclude() tea.Cmd {
+ n := m.currentNode()
+ if n == nil || n.State != StateNew {
+ return nil
+ }
+ m.saveEditor()
+ excluding := n.Included
+ extra := m.applyIncludeCascade(m.cursor)
+ m.setCascadeStatus(excluding, extra)
+ return m.focusFirstField()
+}
+
+// applyIncludeCascade flips the included state of the NEW branch at idx and
+// propagates the stack dependency: because each PR builds on the branch below
+// it, excluding a branch also excludes every branch stacked above it, while
+// including a branch also includes every branch below it that it depends on.
+// The cascade stops at the first non-NEW (locked) branch, which already
+// provides a base. It returns the number of OTHER branches whose inclusion
+// changed, for the status hint.
+func (m *Model) applyIncludeCascade(idx int) int {
+ if idx < 0 || idx >= len(m.nodes) || m.nodes[idx].State != StateNew {
+ return 0
+ }
+ if m.nodes[idx].Included {
+ return m.excludeFrom(idx)
+ }
+ return m.includeFrom(idx)
+}
+
+// excludeFrom skips the NEW branch at idx and every NEW branch stacked above it
+// (lower indices), stopping at the first locked branch. It returns the count of
+// branches other than idx that changed.
+func (m *Model) excludeFrom(idx int) int {
+ changed := 0
+ for i := idx; i >= 0; i-- {
+ n := &m.nodes[i]
+ if n.State != StateNew {
+ break
+ }
+ if n.Included {
+ n.Included = false
+ if i != idx {
+ changed++
+ }
+ }
+ }
+ return changed
+}
+
+// includeFrom includes the NEW branch at idx and every NEW branch below it
+// (higher indices) that it depends on, stopping at the first locked branch. It
+// returns the count of branches other than idx that changed.
+func (m *Model) includeFrom(idx int) int {
+ changed := 0
+ for i := idx; i < len(m.nodes); i++ {
+ n := &m.nodes[i]
+ if n.State != StateNew {
+ break
+ }
+ if !n.Included {
+ n.Included = true
+ if i != idx {
+ changed++
+ }
+ }
+ }
+ return changed
+}
+
+// setCascadeStatus surfaces a transient hint when toggling a branch's inclusion
+// also changed other branches because of the stack dependency.
+func (m *Model) setCascadeStatus(excluded bool, extra int) {
+ if extra <= 0 {
+ return
+ }
+ noun := "branch"
+ if extra > 1 {
+ noun = "branches"
+ }
+ m.statusIsError = false
+ if excluded {
+ m.statusMessage = fmt.Sprintf("Also skipped %d %s stacked above — a PR builds on the branch below it", extra, noun)
+ return
+ }
+ m.statusMessage = fmt.Sprintf("Also included %d %s below that this PR depends on", extra, noun)
+}
+
+// openFocusedPR opens the focused locked branch's PR in the browser.
+func (m *Model) openFocusedPR() {
+ n := m.currentNode()
+ if n == nil {
+ return
+ }
+ url := ""
+ if n.PR != nil {
+ url = n.PR.URL
+ } else if n.Ref.PullRequest != nil {
+ url = n.Ref.PullRequest.URL
+ }
+ if url == "" {
+ return
+ }
+ if m.openURL != nil {
+ m.openURL(url)
+ return
+ }
+ shared.OpenBrowserInBackground(url)
+}
+
+// advanceField moves focus to the next field on an included NEW branch, flowing
+// onto the next PR (the next included NEW branch up the stack) after the
+// ready/draft toggle.
+func (m *Model) advanceField() tea.Cmd {
+ switch m.focusedField {
+ case fieldTitle:
+ return m.focusField(fieldDescription)
+ case fieldDescription:
+ return m.focusField(fieldDraft)
+ case fieldDraft:
+ if idx := m.nextEditableIndex(); idx != -1 {
+ return m.moveCursor(idx - m.cursor)
+ }
+ }
+ return nil
+}
+
+// retreatField moves focus to the previous field, flowing onto the previous PR
+// (the next included NEW branch down the stack) before the title.
+func (m *Model) retreatField() tea.Cmd {
+ switch m.focusedField {
+ case fieldDraft:
+ return m.focusField(fieldDescription)
+ case fieldDescription:
+ return m.focusField(fieldTitle)
+ case fieldTitle:
+ if idx := m.prevEditableIndex(); idx != -1 {
+ return m.moveCursorFocusLast(idx - m.cursor)
+ }
+ }
+ return nil
+}
+
+// --- Editor field/content management ---
+
+// focusField blurs all text components and focuses the given field, returning
+// any cursor-blink command. The Create-PR and draft toggles are not text inputs.
+func (m *Model) focusField(f editField) tea.Cmd {
+ m.focusedField = f
+ m.titleInput.Blur()
+ m.descArea.Blur()
+ switch f {
+ case fieldTitle:
+ return m.titleInput.Focus()
+ case fieldDescription:
+ if !m.descPreview {
+ return m.descArea.Focus()
+ }
+ }
+ return nil
+}
+
+// maxDescScroll is the largest valid description scroll offset: the number of
+// wrapped rows beyond what the box can show at once.
+func (m Model) maxDescScroll(innerW int) int {
+ if n := m.currentNode(); n != nil && n.State.Locked() {
+ max := len(m.descPreviewLines(innerW)) - m.lockedDescHeight()
+ if max < 0 {
+ return 0
+ }
+ return max
+ }
+ total := len(m.descFullLines(innerW))
+ if m.descPreview {
+ total = len(m.descPreviewLines(innerW))
+ }
+ max := total - m.descAreaHeight()
+ if max < 0 {
+ return 0
+ }
+ return max
+}
+
+// loadEditor loads the focused branch's draft into the input components.
+func (m *Model) loadEditor() {
+ if m.cursor < 0 || m.cursor >= len(m.nodes) {
+ return
+ }
+ n := m.nodes[m.cursor]
+ m.titleInput.SetValue(n.Title)
+ m.descArea.SetValue(n.Description)
+ // Start at the top: SetValue leaves the cursor at the end, so move it back to
+ // the first line and reset the scroll. This keeps a prefilled (e.g. template)
+ // description scrolled to the top with the cursor on the first line.
+ m.descArea.CursorStart()
+ for i := 0; i < 100000 && m.descArea.Line() > 0; i++ {
+ m.descArea.CursorUp()
+ }
+ m.descArea.CursorStart()
+ m.descScroll = 0
+ m.descScrollPinned = false
+}
+
+// saveEditor writes the input components back to the focused branch.
+func (m *Model) saveEditor() {
+ if m.cursor < 0 || m.cursor >= len(m.nodes) {
+ return
+ }
+ n := &m.nodes[m.cursor]
+ if n.State != StateNew {
+ return
+ }
+ n.Title = m.titleInput.Value()
+ n.Description = m.descArea.Value()
+}
+
+// resizeEditor sizes the text components to the right panel's inner width.
+func (m *Model) resizeEditor() {
+ _, rightW := m.panelWidths()
+ innerW := rightW - 4 // border + padding
+ if innerW < 10 {
+ innerW = 10
+ }
+ // Field boxes add a border (2) and horizontal padding (2). The description
+ // also reserves columns for its scrollbar so its wrap matches descContent.
+ m.titleInput.Width = innerW - 4
+ m.descArea.SetWidth(descTextWidth(innerW))
+ m.descArea.SetHeight(m.descAreaHeight())
+}
+
+// nextEditableIndex returns the index of the next PR up the stack — the nearest
+// included NEW branch above the cursor (lower index) — or -1. PRs are created
+// bottom-up, so "up the stack" is the next PR in the flow.
+func (m Model) nextEditableIndex() int {
+ for i := m.cursor - 1; i >= 0; i-- {
+ if m.nodes[i].State == StateNew && m.nodes[i].Included {
+ return i
+ }
+ }
+ return -1
+}
+
+// prevEditableIndex returns the index of the previous PR — the nearest included
+// NEW branch below the cursor (higher index) — or -1.
+func (m Model) prevEditableIndex() int {
+ for i := m.cursor + 1; i < len(m.nodes); i++ {
+ if m.nodes[i].State == StateNew && m.nodes[i].Included {
+ return i
+ }
+ }
+ return -1
+}
+
+// prProgress returns the focused branch's position in the new-PR set and the
+// total number of PRs to create. PRs are numbered bottom-up (the branch closest
+// to trunk is PR 1). pos is 0 when the focused branch is not an included NEW
+// branch.
+func (m Model) prProgress() (pos, total int) {
+ for i, n := range m.nodes {
+ if n.State == StateNew && n.Included {
+ total++
+ if i >= m.cursor {
+ pos++
+ }
+ }
+ }
+ if cur := m.currentNode(); cur == nil || cur.State != StateNew || !cur.Included {
+ pos = 0
+ }
+ return pos, total
+}
+
+// requestSubmit validates the included PRs and, if they are complete, marks the
+// batch submit as requested and exits. If any included branch has an empty
+// title, it focuses that branch's title field and surfaces a hint instead.
+func (m Model) requestSubmit() (tea.Model, tea.Cmd) {
+ m.saveEditor()
+ if idx := m.firstEmptyTitleIndex(); idx != -1 {
+ m.cursor = idx
+ m.loadEditor()
+ _ = m.focusField(fieldTitle)
+ m.statusMessage = "A title is required — fill it in for " + m.nodes[idx].Ref.Branch
+ m.statusIsError = true
+ return m, nil
+ }
+ m.submitRequested = true
+ return m, tea.Quit
+}
+
+// firstEmptyTitleIndex returns the index of the first included NEW branch with a
+// blank title, or -1.
+func (m Model) firstEmptyTitleIndex() int {
+ for i, n := range m.nodes {
+ if n.State == StateNew && n.Included && strings.TrimSpace(n.Title) == "" {
+ return i
+ }
+ }
+ return -1
+}
+
+// --- View ---
+
+// panelWidths returns the left and right panel outer widths.
+func (m Model) panelWidths() (left, right int) {
+ left = m.width * 30 / 100
+ if left < 22 {
+ left = 22
+ }
+ if left > 34 {
+ left = 34
+ }
+ right = m.width - left - 1 // 1-column gap
+ if right < 20 {
+ right = 20
+ }
+ return left, right
+}
+
+// descAreaHeight returns the number of text rows for the description textarea,
+// sized so the description box fills the right panel's remaining vertical space.
+func (m Model) descAreaHeight() int {
+ // Right-panel rows excluding the description text: header (1), separator
+ // rule (1), TITLE label + box (4), DESCRIPTION label (1), description box
+ // borders (2), OPEN-AS toggle (1), footer rule (1), footer strip (1) = 12.
+ overhead := 12
+ // contentHeight() already excludes the slim header and bottom bar; subtract
+ // the panel border (2) and the chrome so the textarea fills the rest.
+ h := m.contentHeight() - 2 - overhead
+ if h < 3 {
+ h = 3
+ }
+ return h
+}
+
+// lockedDescHeight returns the rows for the read-only description preview in a
+// locked PR's card, sized to fill the panel. Its chrome is lighter than the
+// editor's: header(1), rule(1), TITLE label(1) + title box(3), DESCRIPTION
+// label(1), and the description box borders(2) = 9, plus the panel border (2).
+func (m Model) lockedDescHeight() int {
+ h := m.contentHeight() - 2 - 9
+ if h < 3 {
+ h = 3
+ }
+ return h
+}
+
+func (m Model) viewScreen() string {
+ status := m.renderStatusLine()
+ banner := m.renderClosedBanner()
+
+ panelH := m.contentHeight()
+ if banner != "" {
+ panelH-- // the banner occupies one content row
+ }
+ leftW, rightW := m.panelWidths()
+
+ left := m.renderLeftPanel(leftW, panelH)
+ right := m.renderRightPanel(rightW, panelH)
+ body := lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right)
+
+ var out strings.Builder
+ if header := m.renderHeader(); header != "" {
+ out.WriteString(header)
+ out.WriteString("\n")
+ } else {
+ // The header (and its inline-image logo) is hidden; clear any logo that
+ // was previously drawn so it does not linger in the graphics layer.
+ out.WriteString(shared.ClearLogo())
+ }
+ if banner != "" {
+ out.WriteString(banner)
+ out.WriteString("\n")
+ }
+ out.WriteString(body)
+ out.WriteString("\n")
+ out.WriteString(status)
+ return out.String()
+}
+
+// renderStatusLine renders the transient status/hint line at the bottom.
+func (m Model) renderStatusLine() string {
+ if m.statusMessage == "" {
+ return ""
+ }
+ if m.statusIsError {
+ return " " + calloutErrorStyle.Render("✗ "+m.statusMessage)
+ }
+ return " " + hintStyle.Render(m.statusMessage)
+}
+
+// leftRow is one rendered line of the left stack panel plus the metadata the
+// mouse layer needs to map a click back to a branch.
+type leftRow struct {
+ text string // rendered content (without the panel frame)
+ branch int // owning branch index, or -1 for chrome (header/connector/trunk)
+ nodeLine bool // the branch's first line, where the right-edge checkbox sits
+}
+
+// renderLeftPanel renders the stack as a vertical timeline: a circle node per
+// branch — filled cyan when it will become a PR, a dotted gray ring when skipped,
+// a state-colored ring for an existing PR — joined by a spine down to the trunk.
+// Existing PRs show "state · #num" on a second line. The focused branch's full
+// row (and a gap above/below) is shaded edge to edge. Long names wrap, and the
+// content scrolls vertically when it is taller than the panel.
+func (m Model) renderLeftPanel(width, height int) string {
+ fullW := width - 2 // border only; rows manage their own gutters
+ if fullW < 6 {
+ fullW = 6
+ }
+ rows := m.buildLeftRows(fullW)
+ visH := height - 2
+ if visH < 1 {
+ visH = 1
+ }
+ scroll := clampScroll(m.leftScroll, len(rows), visH)
+ end := scroll + visH
+ if end > len(rows) {
+ end = len(rows)
+ }
+
+ var b strings.Builder
+ for i, r := range rows[scroll:end] {
+ if i > 0 {
+ b.WriteString("\n")
+ }
+ b.WriteString(r.text)
+ }
+ return leftPanelBox(b.String(), width, height)
+}
+
+// leftPanelBox frames the left panel with the shared rounded border but no inner
+// horizontal padding, so a focused row's shade can span the full inner width.
+func leftPanelBox(content string, width, height int) string {
+ innerW := width - 2
+ innerH := height - 2
+ if innerW < 1 {
+ innerW = 1
+ }
+ if innerH < 1 {
+ innerH = 1
+ }
+ return lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(lipgloss.Color("8")).
+ Width(innerW).
+ Height(innerH).
+ MaxHeight(height).
+ Render(content)
+}
+
+// buildLeftRows lays out the whole left panel: the STACK header, each branch's
+// node/name/meta lines, a spine gap between branches (shaded as padding around
+// the focused branch), and the trunk. It is deterministic given the
+// nodes/cursor/width, so the mouse layer can recompute it to resolve clicks.
+func (m Model) buildLeftRows(fullW int) []leftRow {
+ cur := m.cursor
+ rows := []leftRow{
+ {text: pad(1, false) + sectionLabelStyle.Render("STACK"), branch: -1},
+ {text: m.gapRow(fullW, false, cur == 0), branch: -1}, // blank under STACK; top pad for branch 0
+ }
+ for i := range m.nodes {
+ rows = append(rows, m.branchRows(i, fullW)...)
+ // Spine gap below branch i (to the next branch or the trunk). Shaded when
+ // it borders the focused branch, giving the highlight vertical padding.
+ rows = append(rows, leftRow{text: m.gapRow(fullW, true, i == cur || i+1 == cur), branch: -1})
+ }
+ rows = append(rows, leftRow{
+ text: pad(1, false) + spineStyle.Render("└─ ") + shared.TrunkStyle.Render(m.trunk.Branch),
+ branch: -1,
+ })
+ return rows
+}
+
+// gapRow renders a full-width spacer row: a 1-col gutter, an optional spine, then
+// shaded fill. Shaded gaps form the focused branch's vertical padding.
+func (m Model) gapRow(fullW int, withSpine, shaded bool) string {
+ lead := pad(1, shaded)
+ if withSpine {
+ lead += bgIf(spineStyle, shaded).Render("│")
+ }
+ return lead + pad(fullW-lipgloss.Width(lead), shaded)
+}
+
+// branchRows renders one branch: the node line (circle + name, plus a right-edge
+// include checkbox for a NEW branch), any wrapped name lines (spine + name), and
+// — for an existing PR — a "state · #num" line below the name. The focused
+// branch is shaded edge to edge.
+func (m Model) branchRows(idx, fullW int) []leftRow {
+ n := m.nodes[idx]
+ focused := idx == m.cursor
+ nameStyle := m.branchNameStyle(n, focused)
+
+ // Lead is gutter(1) + glyph(1) + gap(2) = 4; the trailing gutter is 1. A NEW
+ // branch also reserves its checkbox plus a separating space on the node line.
+ contWidth := fullW - 4 - 1
+ firstWidth := contWidth
+ var checkbox string
+ if n.State == StateNew {
+ checkbox = m.branchCheckbox(n, focused)
+ firstWidth = fullW - 4 - lipgloss.Width(checkbox) - 1 - 1
+ }
+ if firstWidth < 4 {
+ firstWidth = 4
+ }
+ if contWidth < 4 {
+ contWidth = 4
+ }
+
+ parts := wrapName(n.Ref.Branch, firstWidth, contWidth)
+ rows := make([]leftRow, 0, len(parts)+1)
+ for li, part := range parts {
+ glyph := bgIf(spineStyle, focused).Render("│")
+ trailing := ""
+ if li == 0 {
+ glyph = m.branchCircle(n, focused)
+ trailing = checkbox // "" for existing PRs
+ }
+ rows = append(rows, leftRow{
+ text: leftLine(glyph, nameStyle.Render(part), trailing, focused, fullW),
+ branch: idx,
+ nodeLine: li == 0,
+ })
+ }
+ if n.State != StateNew {
+ rows = append(rows, leftRow{
+ text: leftLine(bgIf(spineStyle, focused).Render("│"), m.branchMetaLine(n, focused), "", focused, fullW),
+ branch: idx,
+ })
+ }
+ return rows
+}
+
+// leftLine assembles a full-width (fullW) timeline row: a 1-col gutter, the
+// timeline glyph, a 2-col gap, the body, an optional right-aligned trailing
+// element before a 1-col gutter, with the remainder filled. Pre-styled pieces
+// already carry the focus shade; the gutters/fill add it via pad.
+func leftLine(glyph, body, trailing string, focused bool, fullW int) string {
+ lead := pad(1, focused) + glyph + pad(2, focused)
+ if trailing != "" {
+ gap := fullW - lipgloss.Width(lead) - lipgloss.Width(body) - lipgloss.Width(trailing) - 1
+ if gap < 1 {
+ gap = 1
+ }
+ return lead + body + pad(gap, focused) + trailing + pad(1, focused)
+ }
+ fill := fullW - lipgloss.Width(lead) - lipgloss.Width(body)
+ if fill < 0 {
+ fill = 0
+ }
+ return lead + body + pad(fill, focused)
+}
+
+// branchCircle renders the timeline node for a branch: a filled cyan circle when
+// it will become a PR, a dotted gray ring when skipped, or a state-colored open
+// ring for an existing PR.
+func (m Model) branchCircle(n SubmitNode, focused bool) string {
+ glyph, color := "○", n.State.Color()
+ switch {
+ case n.State == StateNew && n.Included:
+ glyph, color = "●", lipgloss.Color("14") // filled cyan
+ case n.State == StateNew:
+ glyph, color = "◌", lipgloss.Color("245") // dotted ring: skipped
+ }
+ return bgIf(lipgloss.NewStyle().Foreground(color), focused).Render(glyph)
+}
+
+// branchNameStyle returns the full-name style: white and bold for a branch that
+// will become a PR, muted gray for skipped or existing-PR branches.
+func (m Model) branchNameStyle(n SubmitNode, focused bool) lipgloss.Style {
+ st := lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
+ if n.State == StateNew && n.Included {
+ st = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true)
+ }
+ return bgIf(st, focused)
+}
+
+// branchCheckbox renders a NEW branch's include checkbox: cyan [x] when included,
+// gray [ ] when skipped.
+func (m Model) branchCheckbox(n SubmitNode, focused bool) string {
+ if n.Included {
+ return bgIf(lipgloss.NewStyle().Foreground(lipgloss.Color("14")), focused).Render("[x]")
+ }
+ return bgIf(lipgloss.NewStyle().Foreground(lipgloss.Color("8")), focused).Render("[ ]")
+}
+
+// branchMetaLine renders an existing PR's "state · #num" line, the state word in
+// its color and the separator/number in dim gray.
+func (m Model) branchMetaLine(n SubmitNode, focused bool) string {
+ state := bgIf(lipgloss.NewStyle().Foreground(n.State.Color()), focused).Render(strings.ToLower(n.State.Label()))
+ if num := prNumber(n); num != 0 {
+ return state + bgIf(stackInfoStyle, focused).Render(fmt.Sprintf(" · #%d", num))
+ }
+ return state
+}
+
+// bgIf returns s with the focused row shade applied when focused, else s.
+func bgIf(s lipgloss.Style, focused bool) lipgloss.Style {
+ if focused {
+ return s.Background(rowShadeColor)
+ }
+ return s
+}
+
+// pad renders n spaces, shaded when focused, used to fill a focused row's
+// background across the panel's content width.
+func pad(n int, focused bool) string {
+ if n <= 0 {
+ return ""
+ }
+ return bgIf(lipgloss.NewStyle(), focused).Render(strings.Repeat(" ", n))
+}
+
+// leftVisibleHeight is the number of timeline rows the left panel can show.
+func (m Model) leftVisibleHeight() int {
+ h := m.contentHeight() - 2 // panel border
+ if h < 1 {
+ h = 1
+ }
+ return h
+}
+
+// scrollLeftToCursor adjusts leftScroll so the focused branch's rows are visible.
+func (m *Model) scrollLeftToCursor() {
+ leftW, _ := m.panelWidths()
+ rows := m.buildLeftRows(leftW - 2)
+ visH := m.leftVisibleHeight()
+ first, lastRow := -1, -1
+ for i, r := range rows {
+ if r.branch == m.cursor {
+ if first < 0 {
+ first = i
+ }
+ lastRow = i
+ }
+ }
+ if first < 0 {
+ return
+ }
+ if m.leftScroll > first {
+ m.leftScroll = first
+ }
+ if m.leftScroll < lastRow-visH+1 {
+ m.leftScroll = lastRow - visH + 1
+ }
+ m.leftScroll = clampScroll(m.leftScroll, len(rows), visH)
+}
+
+// scrollLeft moves the left timeline's scroll offset by delta rows, clamped.
+func (m *Model) scrollLeft(delta int) {
+ leftW, _ := m.panelWidths()
+ rows := m.buildLeftRows(leftW - 2)
+ m.leftScroll = clampScroll(m.leftScroll+delta, len(rows), m.leftVisibleHeight())
+}
+
+// wrapName splits a branch name into lines, the first at most firstWidth runes
+// (the node line, which also carries the right-edge checkbox) and the rest at
+// most contWidth runes. The full name is always shown — long names wrap rather
+// than truncate.
+func wrapName(s string, firstWidth, contWidth int) []string {
+ if firstWidth < 1 {
+ firstWidth = 1
+ }
+ if contWidth < 1 {
+ contWidth = 1
+ }
+ r := []rune(s)
+ if len(r) == 0 {
+ return []string{""}
+ }
+ var lines []string
+ w := firstWidth
+ for len(r) > w {
+ lines = append(lines, string(r[:w]))
+ r = r[w:]
+ w = contWidth
+ }
+ return append(lines, string(r))
+}
+
+// renderRightPanel renders the editor panel for the focused branch in one of
+// three modes: included editor, skipped placeholder, or locked read-only card.
+func (m Model) renderRightPanel(width, height int) string {
+ innerW := width - 4
+ if innerW < 8 {
+ innerW = 8
+ }
+
+ n := m.currentNode()
+ var b strings.Builder
+
+ switch {
+ case n == nil:
+ // Nothing focused.
+ case n.State.Locked() || n.State.Blocks():
+ b.WriteString(m.renderLockedCard(*n, innerW))
+ case n.State == StateNew && !n.Included:
+ b.WriteString(m.renderSkippedCard(*n, innerW))
+ default:
+ b.WriteString(m.renderIncludedEditor(*n, innerW))
+ }
+
+ return panelBox(b.String(), width, height)
+}
+
+// panelBox wraps content in a rounded panel of the given outer dimensions. Both
+// panels use the same muted border; focus is conveyed by the highlighted input
+// field inside, not the panel frame.
+func panelBox(content string, width, height int) string {
+ innerW := width - 2
+ innerH := height - 2
+ if innerW < 1 {
+ innerW = 1
+ }
+ if innerH < 1 {
+ innerH = 1
+ }
+ return panelBorderStyle.Width(innerW).Height(innerH).MaxHeight(height).Render(content)
+}
+
+// isSpaceKey reports whether a key message represents the space bar.
+func isSpaceKey(msg tea.KeyMsg) bool {
+ if msg.Type == tea.KeySpace {
+ return true
+ }
+ return msg.Type == tea.KeyRunes && len(msg.Runes) == 1 && msg.Runes[0] == ' '
+}
+
+// prNumber returns the PR number associated with a node, preferring fresh
+// details, or 0 if none.
+func prNumber(n SubmitNode) int {
+ if n.PR != nil && n.PR.Number != 0 {
+ return n.PR.Number
+ }
+ if n.Ref.PullRequest != nil {
+ return n.Ref.PullRequest.Number
+ }
+ return 0
+}
diff --git a/internal/tui/submitview/screen_test.go b/internal/tui/submitview/screen_test.go
new file mode 100644
index 0000000..638fcce
--- /dev/null
+++ b/internal/tui/submitview/screen_test.go
@@ -0,0 +1,763 @@
+package submitview
+
+import (
+ "strings"
+ "testing"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/muesli/termenv"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestFieldCycling_IncludedBranch(t *testing.T) {
+ m := testModel(t, newNodes())
+ require.Equal(t, fieldTitle, m.focusedField)
+
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab})
+ assert.Equal(t, fieldDescription, m.focusedField)
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab})
+ assert.Equal(t, fieldDraft, m.focusedField)
+
+ // shift+tab back up through the fields to the title.
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyShiftTab})
+ assert.Equal(t, fieldDescription, m.focusedField)
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyShiftTab})
+ assert.Equal(t, fieldTitle, m.focusedField)
+}
+
+func TestFieldCycling_TabFromDraftMovesToNextPR(t *testing.T) {
+ m := testModel(t, newNodes())
+ require.Equal(t, 1, m.cursor) // bottom-most NEW (PR 1)
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // desc
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // draft
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // next PR (up the stack)
+ assert.Equal(t, 0, m.cursor)
+ assert.Equal(t, fieldTitle, m.focusedField)
+}
+
+func TestTypingEditsTitle(t *testing.T) {
+ m := testModel(t, newNodes())
+ m = sendKey(t, m, runeKey('!'))
+ assert.Contains(t, m.nodes[1].Title, "!")
+}
+
+func TestDraftToggle(t *testing.T) {
+ m := testModel(t, newNodes())
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // desc
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // draft
+ require.Equal(t, fieldDraft, m.focusedField)
+ before := m.nodes[1].Draft
+ m = sendKey(t, m, runeKey(' '))
+ assert.Equal(t, !before, m.nodes[1].Draft)
+}
+
+func TestDraftToggle_DefaultsToReady(t *testing.T) {
+ m := testModel(t, newNodes())
+ assert.False(t, m.nodes[1].Draft, "new PRs default to ready for review, not draft")
+}
+
+func TestDraftToggle_ArrowKeysSelectOption(t *testing.T) {
+ m := testModel(t, newNodes())
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // desc
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // draft
+ require.Equal(t, fieldDraft, m.focusedField)
+
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyRight})
+ assert.True(t, m.nodes[1].Draft, "→ selects draft")
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyRight})
+ assert.True(t, m.nodes[1].Draft, "→ is idempotent")
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyLeft})
+ assert.False(t, m.nodes[1].Draft, "← selects ready for review")
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyLeft})
+ assert.False(t, m.nodes[1].Draft, "← is idempotent")
+}
+
+func TestDraftToggle_RendersBothOptions(t *testing.T) {
+ m := testModel(t, newNodes())
+ out := m.View()
+ assert.Contains(t, out, "Ready")
+ assert.Contains(t, out, "Draft")
+ assert.Contains(t, out, "CREATE AS")
+ assert.NotContains(t, out, "OPEN AS", "the label was renamed to CREATE AS")
+ assert.NotContains(t, out, "Open as draft", "the old checkbox label is gone")
+ assert.NotContains(t, out, "submitted as ready by default")
+}
+
+func TestRightPanel_InlineShortcutHints(t *testing.T) {
+ m := testModel(t, newNodes())
+ out := m.View()
+ assert.Contains(t, out, "CREATE PR (^x)", "the include switch shows its Ctrl+X hint")
+ assert.Contains(t, out, "preview (^p)", "the description toggle shows its Ctrl+P hint")
+ assert.Contains(t, out, "open in $EDITOR (^e)", "the CREATE AS row shows the editor shortcut")
+}
+
+func TestRightFooter_NextBranchAndSubmit(t *testing.T) {
+ m := testModel(t, newNodes())
+ _, rightW := m.panelWidths()
+ innerW := rightW - 4
+
+ // Bottom-most NEW (cursor starts here, title focused) has a PR up the stack.
+ require.NotEqual(t, -1, m.nextEditableIndex())
+ require.NotEqual(t, fieldDraft, m.focusedField)
+ footer := m.renderRightFooter(m.nodes[m.cursor], innerW)
+ assert.Contains(t, footer, "NEXT BRANCH")
+ assert.NotContains(t, footer, "(tab)", "the tab hint is hidden unless the CREATE AS row is focused")
+
+ // Focus the CREATE AS row: the tab hint appears (to the left of NEXT BRANCH).
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // description
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // draft (CREATE AS)
+ require.Equal(t, fieldDraft, m.focusedField)
+ footer = m.renderRightFooter(m.nodes[m.cursor], innerW)
+ assert.Contains(t, footer, "(tab) ", "the tab hint shows on the CREATE AS row")
+ assert.True(t, strings.Index(footer, "(tab)") < strings.Index(footer, "NEXT BRANCH"), "the tab hint sits to the left")
+
+ // Move up to the top-most NEW: it is the last PR, so the footer offers submit.
+ for m.nextEditableIndex() != -1 {
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyUp})
+ }
+ footer = m.renderRightFooter(m.nodes[m.cursor], innerW)
+ assert.Contains(t, footer, "SUBMIT 2 PRs")
+ assert.Contains(t, footer, "(^s)")
+ assert.True(t, strings.Index(footer, "(^s)") < strings.Index(footer, "SUBMIT"), "the submit hint sits to the left")
+}
+
+func TestRightFooter_SkippedShowsInertLabel(t *testing.T) {
+ m := testModel(t, newNodes())
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlX}) // skip the focused branch
+ require.False(t, m.nodes[m.cursor].Included)
+ _, rightW := m.panelWidths()
+ footer := m.renderRightFooter(m.nodes[m.cursor], rightW-4)
+ assert.Contains(t, footer, "SKIPPED", "a skipped branch shows SKIPPED")
+ assert.NotContains(t, footer, "SUBMIT", "no submit button on a skipped branch")
+ assert.NotContains(t, footer, "NEXT BRANCH", "no next-branch label on a skipped branch")
+
+ // Clicking the SKIPPED label does nothing.
+ before := m.cursor
+ _, _, _, _, draftLine := m.rightZones()
+ leftW, rW := m.panelWidths()
+ updated, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: leftW + 1 + rW - 4, Y: m.panelTopRow() + draftLine + 2})
+ m = updated.(Model)
+ assert.Equal(t, before, m.cursor, "clicking SKIPPED is inert")
+ assert.False(t, m.submitRequested)
+}
+
+func TestMouse_ClickNextBranchAdvances(t *testing.T) {
+ m := testModel(t, newNodes())
+ start := m.cursor
+ next := m.nextEditableIndex()
+ require.NotEqual(t, -1, next)
+ _, _, _, _, draftLine := m.rightZones()
+ leftW, rightW := m.panelWidths()
+ y := m.panelTopRow() + draftLine + 2 // footer row
+ x := leftW + 1 + rightW - 4 // within the right-aligned button
+ updated, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: x, Y: y})
+ m = updated.(Model)
+ assert.Equal(t, next, m.cursor, "clicking NEXT BRANCH advances to the next PR")
+ assert.NotEqual(t, start, m.cursor)
+}
+
+func TestMouse_ClickSubmitRequestsSubmit(t *testing.T) {
+ m := testModel(t, newNodes())
+ for m.nextEditableIndex() != -1 { // move to the last PR
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyUp})
+ }
+ _, _, _, _, draftLine := m.rightZones()
+ leftW, rightW := m.panelWidths()
+ y := m.panelTopRow() + draftLine + 2
+ x := leftW + 1 + rightW - 4
+ updated, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: x, Y: y})
+ m = updated.(Model)
+ assert.True(t, m.submitRequested, "clicking SUBMIT requests the batch submit")
+}
+
+// --- dependency cascade ---
+
+func TestCascade_SkippingCascadesUp(t *testing.T) {
+ m := testModel(t, newNodes())
+ require.Equal(t, 1, m.cursor) // bottom-most NEW
+
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlX}) // skip it
+ assert.False(t, m.nodes[1].Included, "the toggled branch is skipped")
+ assert.False(t, m.nodes[0].Included, "the branch stacked above it is skipped too")
+ assert.Contains(t, m.statusMessage, "Also skipped")
+}
+
+func TestCascade_IncludingCascadesDown(t *testing.T) {
+ m := testModel(t, newNodes())
+ require.Equal(t, 1, m.cursor)
+ // Skip branch 1, which cascades up to also skip branch 0.
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlX})
+ require.False(t, m.nodes[0].Included)
+ require.False(t, m.nodes[1].Included)
+
+ // Re-include branch 0 (top); the branch below it that it depends on is
+ // re-included too.
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyUp})
+ require.Equal(t, 0, m.cursor)
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlX})
+ assert.True(t, m.nodes[0].Included)
+ assert.True(t, m.nodes[1].Included, "the dependency below is re-included")
+ assert.Contains(t, m.statusMessage, "Also included")
+}
+
+func TestCascade_TopBranchToggleDoesNotCascade(t *testing.T) {
+ m := testModel(t, newNodes())
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyUp}) // top branch
+ require.Equal(t, 0, m.cursor)
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlX})
+ assert.False(t, m.nodes[0].Included)
+ assert.True(t, m.nodes[1].Included, "branches below the top are unaffected")
+ assert.Empty(t, m.statusMessage, "no cascade hint when nothing else changed")
+}
+
+func TestPerBranchPersistence(t *testing.T) {
+ m := testModel(t, newNodes())
+ require.Equal(t, 1, m.cursor)
+ m = sendKey(t, m, runeKey('X'))
+ edited := m.nodes[1].Title
+ require.Contains(t, edited, "X")
+
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyUp}) // branch 0
+ require.Equal(t, 0, m.cursor)
+ assert.NotEqual(t, edited, m.titleInput.Value())
+
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyDown}) // back to 1
+ assert.Equal(t, edited, m.titleInput.Value())
+}
+
+func TestSkippedBranch_ShowsDimmedBody(t *testing.T) {
+ m := testModel(t, newNodes())
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlX}) // skip the focused branch
+ out := m.View()
+ assert.Contains(t, out, "CREATE PR", "the header switch reads CREATE PR")
+ assert.Contains(t, out, "include", "the footer offers to re-include")
+ assert.NotContains(t, out, "Create a pull request", "the old checkbox is gone")
+}
+
+func TestSkippedBranch_SpaceReincludes(t *testing.T) {
+ m := testModel(t, newNodes())
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlX}) // skip
+ require.False(t, m.nodes[1].Included)
+
+ m = sendKey(t, m, runeKey(' ')) // re-include the skipped branch
+ assert.True(t, m.nodes[1].Included)
+}
+
+func TestIncludeSwitch_ReflectsState(t *testing.T) {
+ on := []rune(stripANSI(renderSwitch(true)))
+ off := []rune(stripANSI(renderSwitch(false)))
+ knobIdx := func(rs []rune) int {
+ for i, r := range rs {
+ if r == '■' {
+ return i
+ }
+ }
+ return -1
+ }
+ onIdx, offIdx := knobIdx(on), knobIdx(off)
+ require.GreaterOrEqual(t, onIdx, 0, "on switch must show a square knob")
+ require.GreaterOrEqual(t, offIdx, 0, "off switch must show a square knob")
+ assert.Greater(t, onIdx, offIdx, "the knob sits further right when on than off")
+ // The knob is inset one cell from each border in both states.
+ assert.Greater(t, onIdx, 0, "knob has left padding when on")
+ assert.Less(t, onIdx, len(on)-1, "knob has right padding when on")
+ assert.Greater(t, offIdx, 0, "knob has left padding when off")
+ assert.Less(t, offIdx, len(off)-1, "knob has right padding when off")
+}
+
+func TestLockedBranch_ShowsReadOnlyCard(t *testing.T) {
+ m := testModel(t, newNodes())
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyDown})
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyDown}) // locked (draft PR)
+ require.True(t, m.nodes[m.cursor].State.Locked())
+ out := m.View()
+ assert.NotContains(t, out, "already has a pull request")
+ assert.NotContains(t, out, "pushed as part of this submit")
+ assert.Contains(t, out, "TITLE")
+ assert.Contains(t, out, "DESCRIPTION")
+ assert.Contains(t, out, "↗ Open on GitHub")
+ assert.Contains(t, out, "^o")
+}
+
+func TestLockedBranch_CtrlOOpensPR(t *testing.T) {
+ m := testModel(t, newNodes())
+ // Capture the open instead of launching a real browser.
+ var opened string
+ m.openURL = func(url string) { opened = url }
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyDown})
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyDown})
+ require.True(t, m.nodes[m.cursor].State.Locked())
+ // ^o opens the focused branch's existing PR and must not error or quit.
+ updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
+ m = updated.(Model)
+ assert.False(t, m.cancelled)
+ assert.Nil(t, cmd)
+ assert.Equal(t, m.nodes[m.cursor].PR.URL, opened, "^o opens the focused PR's URL")
+}
+
+func TestLockedBranch_DescriptionScrolls(t *testing.T) {
+ open := newNode("feat/open", StateOpen)
+ open.Description = "# Heading\n\n" + strings.Repeat("- a list item\n", 200)
+ m := testModel(t, []SubmitNode{newNode("feat/new", StateNew), open})
+ for m.nodes[m.cursor].State == StateNew {
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyDown})
+ }
+ require.True(t, m.nodes[m.cursor].State.Locked())
+ assert.True(t, m.isDescScrollable(), "a locked PR's description should be scrollable")
+
+ m.scrollDesc(descScrollStep)
+ assert.Equal(t, descScrollStep, m.descScroll, "wheel-down advances the locked description")
+ m.scrollDesc(-descScrollStep * 2)
+ assert.Equal(t, 0, m.descScroll, "scrolling is clamped at the top")
+}
+
+// navigateToLocked moves the cursor down to the first locked branch.
+func navigateToLocked(t *testing.T, m Model) Model {
+ t.Helper()
+ for !m.nodes[m.cursor].State.Locked() {
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyDown})
+ }
+ return m
+}
+
+func TestLockedBranch_ClickOpenButtonOpensPR(t *testing.T) {
+ m := navigateToLocked(t, testModel(t, newNodes()))
+ n := m.currentNode()
+ var opened string
+ m.openURL = func(url string) { opened = url }
+ _, _, btnStart, btnEnd := m.lockedHeaderTargets(*n)
+ require.Greater(t, btnEnd, btnStart, "the Open on GitHub button needs a click range")
+
+ updated, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: btnStart, Y: m.panelTopRow()})
+ m = updated.(Model)
+ assert.Equal(t, n.PR.URL, opened, "clicking the Open on GitHub button opens the PR")
+}
+
+func TestLockedBranch_ClickPRNumberOpensPR(t *testing.T) {
+ m := navigateToLocked(t, testModel(t, newNodes()))
+ n := m.currentNode()
+ var opened string
+ m.openURL = func(url string) { opened = url }
+ numStart, numEnd, _, _ := m.lockedHeaderTargets(*n)
+ require.Greater(t, numEnd, numStart, "the PR number needs a click range")
+
+ updated, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: (numStart + numEnd) / 2, Y: m.panelTopRow()})
+ m = updated.(Model)
+ assert.Equal(t, n.PR.URL, opened, "clicking the PR number opens the PR")
+}
+
+func TestLockedBranch_ClickCardBodyDoesNotOpen(t *testing.T) {
+ m := navigateToLocked(t, testModel(t, newNodes()))
+ n := m.currentNode()
+ opened := ""
+ m.openURL = func(url string) { opened = url }
+ numStart, numEnd, btnStart, btnEnd := m.lockedHeaderTargets(*n)
+ require.Greater(t, numEnd, numStart)
+ require.Greater(t, btnEnd, btnStart)
+ require.Less(t, numEnd, btnStart, "the number and button should not be adjacent")
+ leftW, _ := m.panelWidths()
+
+ // A click on the header row between the two targets does nothing.
+ updated, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: (numEnd + btnStart) / 2, Y: m.panelTopRow()})
+ m = updated.(Model)
+ assert.Empty(t, opened, "clicking between the targets must not open the PR")
+
+ // A click deeper in the card body (description area) does nothing either.
+ updated, _ = m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: leftW + 5, Y: m.panelTopRow() + 6})
+ m = updated.(Model)
+ assert.Empty(t, opened, "clicking the card body must not open the PR")
+}
+
+func TestEsc_QuitsFromAnyField(t *testing.T) {
+ m := testModel(t, newNodes())
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // description focused
+ updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC})
+ m = updated.(Model)
+ assert.True(t, m.cancelled)
+ assert.NotNil(t, cmd)
+}
+
+// --- mouse on the editor ---
+
+func TestMouse_ClickIncludeChip(t *testing.T) {
+ m := testModel(t, newNodes())
+ leftW, rightW := m.panelWidths()
+ y := m.panelTopRow() // header row 0
+ x := leftW + 1 + rightW - 5 // the chip is right-aligned in the header
+ require.True(t, m.nodes[m.cursor].Included)
+ updated, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: x, Y: y})
+ m = updated.(Model)
+ assert.False(t, m.nodes[m.cursor].Included, "clicking the chip skips the branch")
+}
+
+func TestMouse_CheckboxClickCascadesUp(t *testing.T) {
+ m := testModel(t, newNodes())
+ // Click the include checkbox on branch index 1 (its right-edge box).
+ y, cbX := leftBranchNode(m, 1)
+ updated, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: cbX, Y: y})
+ m = updated.(Model)
+ require.Equal(t, 1, m.cursor)
+ assert.False(t, m.nodes[1].Included)
+ assert.False(t, m.nodes[0].Included, "clicking a checkbox cascades like ^x")
+ assert.Contains(t, m.statusMessage, "Also skipped")
+}
+
+func TestLeftPanel_TimelineNoLegend(t *testing.T) {
+ m := testModel(t, newNodes())
+ out := m.View()
+ assert.NotContains(t, out, "LEGEND", "the legend was removed")
+ assert.Contains(t, out, "feat/auth/tests", "the full branch name (incl prefix) is shown")
+ assert.Contains(t, out, "· #1240", "existing PRs show their number on the right")
+ assert.Contains(t, out, "└─ main", "the trunk anchors the timeline")
+}
+
+func TestLeftPanel_SkippedUsesDottedRing(t *testing.T) {
+ m := testModel(t, newNodes())
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlX}) // skip the focused branch
+ out := m.View()
+ assert.Contains(t, out, "◌", "skipped branches use a dotted ring")
+ assert.Contains(t, out, "[ ]", "skipped branches show an empty checkbox")
+}
+
+func TestMouse_ClickWrappedNameFocuses(t *testing.T) {
+ long := newNode("feat/really/long/branch/name/that/wraps", StateNew)
+ m := testModel(t, []SubmitNode{long, newNode("feat/x", StateNew)})
+ require.Equal(t, 1, m.cursor) // starts on the bottom-most NEW
+ leftW, _ := m.panelWidths()
+ rows := m.buildLeftRows(leftW - 4)
+ contY := -1
+ for i, r := range rows {
+ if r.branch == 0 && !r.nodeLine {
+ contY = m.panelTopRow() + i
+ break
+ }
+ }
+ require.GreaterOrEqual(t, contY, 0, "the long branch name should wrap")
+ updated, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: 6, Y: contY})
+ m = updated.(Model)
+ assert.Equal(t, 0, m.cursor, "clicking a wrapped name line focuses that branch")
+}
+
+func TestLeftPanel_ExistingPRMetaOnSecondLine(t *testing.T) {
+ m := testModel(t, newNodes())
+ leftW, _ := m.panelWidths()
+ rows := m.buildLeftRows(leftW - 2)
+ bi := -1
+ for i, n := range m.nodes {
+ if n.State != StateNew {
+ bi = i
+ break
+ }
+ }
+ require.GreaterOrEqual(t, bi, 0, "fixture has an existing-PR branch")
+
+ var nodeText, metaText string
+ for _, r := range rows {
+ if r.branch != bi {
+ continue
+ }
+ if r.nodeLine {
+ nodeText = stripANSI(r.text)
+ } else {
+ metaText += stripANSI(r.text)
+ }
+ }
+ assert.NotContains(t, nodeText, "·", "the node line shows only the branch name")
+ assert.Contains(t, metaText, "· #", "the PR state and number sit on a line below the name")
+}
+
+func TestLeftPanel_ScrollsToFocusedBranch(t *testing.T) {
+ m := testModel(t, newNodes())
+ u, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 20}) // short window -> overflow
+ m = u.(Model)
+ leftW, _ := m.panelWidths()
+ require.Greater(t, len(m.buildLeftRows(leftW-2)), m.leftVisibleHeight(), "timeline overflows the panel")
+ require.Equal(t, 0, m.leftScroll)
+
+ for m.cursor < len(m.nodes)-1 {
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyDown})
+ }
+ assert.Greater(t, m.leftScroll, 0, "navigating down scrolls the focused branch into view")
+
+ rows := m.buildLeftRows(leftW - 2)
+ visH := m.leftVisibleHeight()
+ scroll := clampScroll(m.leftScroll, len(rows), visH)
+ nodeIdx := -1
+ for i, r := range rows {
+ if r.branch == m.cursor && r.nodeLine {
+ nodeIdx = i
+ break
+ }
+ }
+ require.GreaterOrEqual(t, nodeIdx, 0)
+ assert.GreaterOrEqual(t, nodeIdx, scroll, "the focused node is not above the window")
+ assert.Less(t, nodeIdx, scroll+visH, "the focused node is within the window")
+
+ before := m.leftScroll
+ mm, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonWheelUp, X: 3})
+ m = mm.(Model)
+ assert.Less(t, m.leftScroll, before, "wheel-up over the left panel scrolls it up")
+}
+
+func TestMouse_ClickTitleFocuses(t *testing.T) {
+ m := testModel(t, newNodes())
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // move off title
+ require.Equal(t, fieldDescription, m.focusedField)
+
+ titleLine, _, _, _, _ := m.rightZones()
+ leftW, _ := m.panelWidths()
+ y := m.panelTopRow() + titleLine
+ updated, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: leftW + 5, Y: y})
+ m = updated.(Model)
+ assert.Equal(t, fieldTitle, m.focusedField)
+}
+
+func TestMouse_ClickDraftSegmentSelects(t *testing.T) {
+ m := testModel(t, newNodes())
+ _, _, _, _, draftLine := m.rightZones()
+ y := m.panelTopRow() + draftLine
+ segStart, dividerX, segEnd := m.draftSegmentBounds()
+ require.False(t, m.nodes[1].Draft, "new PRs default to ready for review")
+
+ // Clicking the right (Draft) half selects draft.
+ updated, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: (dividerX + segEnd) / 2, Y: y})
+ m = updated.(Model)
+ assert.True(t, m.nodes[1].Draft, "clicking the Draft segment selects draft")
+ assert.Equal(t, fieldDraft, m.focusedField)
+
+ // Clicking the left (Ready) half selects ready.
+ updated, _ = m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: (segStart + dividerX) / 2, Y: y})
+ m = updated.(Model)
+ assert.False(t, m.nodes[1].Draft, "clicking the Ready segment selects ready")
+}
+
+func TestMouse_ClickDraftLabelDoesNotToggle(t *testing.T) {
+ m := testModel(t, newNodes())
+ _, _, _, _, draftLine := m.rightZones()
+ leftW, _ := m.panelWidths()
+ y := m.panelTopRow() + draftLine
+ before := m.nodes[1].Draft
+
+ // A click on the "OPEN AS" label (left of the brackets) is inert.
+ updated, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: leftW + 5, Y: y})
+ m = updated.(Model)
+ assert.Equal(t, before, m.nodes[1].Draft, "clicking the OPEN AS label must not toggle the value")
+
+ // A click past the closing bracket is also inert.
+ _, _, segEnd := m.draftSegmentBounds()
+ updated, _ = m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: segEnd + 3, Y: y})
+ m = updated.(Model)
+ assert.Equal(t, before, m.nodes[1].Draft, "clicking past the brackets must not toggle the value")
+}
+
+// --- description editor ---
+
+func TestDescription_ArrowsMoveCursorWithinText(t *testing.T) {
+ m := testModel(t, newNodes())
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // focus description
+ require.Equal(t, fieldDescription, m.focusedField)
+ m.descArea.SetValue("one\ntwo\nthree")
+ m.descArea.CursorEnd() // last line
+ start := m.descArea.Line()
+ require.Greater(t, start, 0)
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyUp})
+ assert.Equal(t, start-1, m.descArea.Line(), "up arrow moves the cursor up a line")
+}
+
+func TestMouse_ClickDescriptionPositionsCursor(t *testing.T) {
+ m := testModel(t, newNodes())
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // focus description
+ m.descArea.SetValue("alpha\nbravo\ncharlie\ndelta")
+ leftW, _ := m.panelWidths()
+ _, _, descTop, _, _ := m.rightZones()
+ y := m.panelTopRow() + descTop + 1 + 2 // textarea content row index 2
+ updated, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: leftW + 5, Y: y})
+ m = updated.(Model)
+ assert.Equal(t, 2, m.descArea.Line(), "clicking content row 2 positions the cursor on the third line")
+ assert.True(t, m.descArea.Focused())
+}
+
+func TestMouse_WheelScrollsDescriptionViewport(t *testing.T) {
+ m := testModel(t, newNodes())
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // focus description
+ var sb strings.Builder
+ for i := 0; i < 40; i++ {
+ sb.WriteString("line\n")
+ }
+ m.nodes[0].Description = sb.String()
+ m.descArea.SetValue(sb.String())
+ m.descArea.CursorEnd()
+ for i := 0; i < 60; i++ {
+ m.descArea.CursorUp() // cursor to the top
+ }
+ require.Equal(t, 0, m.descArea.Line())
+
+ updated, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonWheelDown, X: 60})
+ m = updated.(Model)
+ assert.Greater(t, m.descScroll, 0, "wheel scrolls the viewport")
+ assert.True(t, m.descScrollPinned, "wheel pins the scroll offset")
+ assert.Equal(t, 0, m.descArea.Line(), "the cursor does not move while scrolling")
+
+ // A keystroke returns to the cursor-following view.
+ m = sendKey(t, m, runeKey('z'))
+ assert.False(t, m.descScrollPinned, "typing returns to the cursor-following view")
+}
+
+// TestMouse_WheelDoesNotEnterFieldText guards against the regression where
+// scrolling the mouse wheel leaked escape-sequence bytes as text into the
+// focused title/description field. handleMouse must consume every wheel event
+// (returning without forwarding it to the text inputs), so the field contents
+// never change regardless of which panel the pointer is over.
+func TestMouse_WheelDoesNotEnterFieldText(t *testing.T) {
+ m := testModel(t, newNodes())
+ require.Equal(t, fieldTitle, m.focusedField)
+
+ leftW, _ := m.panelWidths()
+ leftX := leftW / 2 // over the left timeline
+ rightX := leftW + 5 // over the right editor
+ titleLine, _, _, _, _ := m.rightZones()
+ y := m.panelTopRow() + titleLine
+
+ wheel := func(m Model) Model {
+ t.Helper()
+ for _, x := range []int{leftX, rightX} {
+ for _, b := range []tea.MouseButton{tea.MouseButtonWheelUp, tea.MouseButtonWheelDown} {
+ u, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: b, X: x, Y: y})
+ m = u.(Model)
+ }
+ }
+ return m
+ }
+
+ // Title focused: wheeling over either panel must not alter the title.
+ titleBefore := m.nodes[m.cursor].Title
+ m = wheel(m)
+ assert.Equal(t, titleBefore, m.nodes[m.cursor].Title, "wheel must not modify the focused title")
+ assert.Equal(t, titleBefore, m.titleInput.Value(), "wheel must not modify the title input")
+
+ // Description focused: same guarantee.
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab})
+ require.Equal(t, fieldDescription, m.focusedField)
+ descBefore := m.nodes[m.cursor].Description
+ m = wheel(m)
+ assert.Equal(t, descBefore, m.nodes[m.cursor].Description, "wheel must not modify the focused description")
+ assert.Equal(t, descBefore, m.descArea.Value(), "wheel must not modify the description input")
+}
+
+func TestMouse_WheelScrollBoundedByContent(t *testing.T) {
+ // The over-scroll bug only appears with a real color profile (the textarea
+ // pads blank rows with styled spaces), so force one here.
+ old := lipgloss.DefaultRenderer().ColorProfile()
+ lipgloss.SetColorProfile(termenv.ANSI256)
+ defer lipgloss.SetColorProfile(old)
+
+ m := testModel(t, newNodes())
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // focus description
+ var sb strings.Builder
+ for i := 0; i < 30; i++ {
+ sb.WriteString("row\n")
+ }
+ m.nodes[0].Description = strings.TrimRight(sb.String(), "\n")
+ m.descArea.SetValue(strings.TrimRight(sb.String(), "\n"))
+
+ _, rightW := m.panelWidths()
+ innerW := rightW - 4
+ // The wrapped line count must match the real content (30 rows), not the
+ // textarea's padded height — otherwise the scroll range over-counts and the
+ // user can scroll past the last line.
+ assert.Equal(t, 30, len(m.descFullLines(innerW)), "wrapped line count matches content")
+
+ // Wheel down far past the end.
+ for i := 0; i < 50; i++ {
+ u, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonWheelDown, X: 60})
+ m = u.(Model)
+ }
+ assert.Equal(t, m.maxDescScroll(innerW), m.descScroll, "scroll is clamped to the content")
+ // The last rendered row must be real content, not a blank past the last line.
+ rows := strings.Split(m.descContent(innerW), "\n")
+ last := strings.TrimSpace(stripANSI(rows[len(rows)-1]))
+ assert.NotEmpty(t, last, "cannot scroll past the last line of text")
+}
+
+func TestDescAreaHeight_FillsAvailableSpace(t *testing.T) {
+ tall, _ := testModel(t, newNodes()).Update(tea.WindowSizeMsg{Width: 100, Height: 70})
+ short, _ := testModel(t, newNodes()).Update(tea.WindowSizeMsg{Width: 100, Height: 40})
+ tallH := tall.(Model).descAreaHeight()
+ shortH := short.(Model).descAreaHeight()
+ assert.Greater(t, tallH, shortH, "the description box grows with the terminal")
+ assert.Greater(t, tallH, 20, "the box is no longer clamped to 20 rows")
+}
+
+func TestLoadEditor_StartsAtTop(t *testing.T) {
+ m := testModel(t, newNodes())
+ var sb strings.Builder
+ for i := 0; i < 20; i++ {
+ sb.WriteString("line\n")
+ }
+ m.nodes[m.cursor].Description = strings.TrimRight(sb.String(), "\n")
+ m.loadEditor()
+ assert.Equal(t, 0, m.descArea.Line(), "cursor starts on the first line")
+ assert.Equal(t, 0, m.descScroll, "view starts scrolled to the top")
+ assert.False(t, m.descScrollPinned)
+}
+
+func TestDescScroll_PreservedWhenDefocused(t *testing.T) {
+ m := testModel(t, newNodes())
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // focus description
+ var sb strings.Builder
+ for i := 0; i < 30; i++ {
+ sb.WriteString("line\n")
+ }
+ m.nodes[0].Description = strings.TrimRight(sb.String(), "\n")
+ m.descArea.SetValue(strings.TrimRight(sb.String(), "\n"))
+ for i := 0; i < 3; i++ {
+ u, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonWheelDown, X: 60})
+ m = u.(Model)
+ }
+ require.Greater(t, m.descScroll, 0)
+ scrolled := m.descScroll
+
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // de-focus (move to draft)
+ require.NotEqual(t, fieldDescription, m.focusedField)
+ assert.Equal(t, scrolled, m.descScroll, "scroll is preserved when the box loses focus")
+ assert.True(t, m.descScrollPinned)
+}
+
+func TestScrollbar_RendersWhenOverflowing(t *testing.T) {
+ m := testModel(t, newNodes())
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab})
+ var sb strings.Builder
+ for i := 0; i < 40; i++ {
+ sb.WriteString("line\n")
+ }
+ m.nodes[0].Description = strings.TrimRight(sb.String(), "\n")
+ m.descArea.SetValue(strings.TrimRight(sb.String(), "\n"))
+ _, rightW := m.panelWidths()
+ out := m.descContent(rightW - 4)
+ assert.Contains(t, out, "┃", "scrollbar thumb is drawn when content overflows")
+ assert.Contains(t, out, "│", "scrollbar track is drawn when content overflows")
+}
+
+func TestPreview_ScrollableWithScrollbar(t *testing.T) {
+ m := testModel(t, newNodes())
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // focus description
+ var sb strings.Builder
+ sb.WriteString("# Title\n\n")
+ for i := 0; i < 40; i++ {
+ sb.WriteString("A paragraph of preview text.\n\n")
+ }
+ m.nodes[1].Description = strings.TrimRight(sb.String(), "\n")
+ m.descArea.SetValue(strings.TrimRight(sb.String(), "\n"))
+
+ m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlP}) // switch to preview
+ require.True(t, m.descPreview)
+ _, rightW := m.panelWidths()
+ assert.Contains(t, m.descContent(rightW-4), "┃", "preview has a scrollbar when it overflows")
+
+ before := m.descScroll
+ u, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonWheelDown, X: 60})
+ m = u.(Model)
+ assert.Greater(t, m.descScroll, before, "the wheel scrolls the preview")
+}
diff --git a/internal/tui/submitview/styles.go b/internal/tui/submitview/styles.go
new file mode 100644
index 0000000..a691c38
--- /dev/null
+++ b/internal/tui/submitview/styles.go
@@ -0,0 +1,169 @@
+package submitview
+
+import "github.com/charmbracelet/lipgloss"
+
+// State foreground colors, matching how GitHub.com colors these PR states.
+var stateColors = map[BranchState]lipgloss.Color{
+ StateNew: lipgloss.Color("4"), // blue
+ StateOpen: lipgloss.Color("2"), // green
+ StateDraft: lipgloss.Color("250"), // gray
+ StateQueued: lipgloss.Color("137"), // brown
+ StateMerged: lipgloss.Color("5"), // purple
+ StateClosed: lipgloss.Color("1"), // red
+}
+
+// State background tints for pill badges (dark 256-color shades that read as a
+// low-opacity wash of the foreground color across most terminal themes).
+var stateBgColors = map[BranchState]lipgloss.Color{
+ StateNew: lipgloss.Color("18"), // dark blue
+ StateOpen: lipgloss.Color("22"), // dark green
+ StateDraft: lipgloss.Color("238"), // dark gray
+ StateQueued: lipgloss.Color("58"), // dark brown
+ StateMerged: lipgloss.Color("53"), // dark purple
+ StateClosed: lipgloss.Color("52"), // dark red
+}
+
+// Label returns the uppercase badge text for a state (e.g. "NEW").
+func (s BranchState) Label() string {
+ switch s {
+ case StateNew:
+ return "NEW"
+ case StateOpen:
+ return "OPEN"
+ case StateDraft:
+ return "DRAFT"
+ case StateQueued:
+ return "QUEUED"
+ case StateMerged:
+ return "MERGED"
+ case StateClosed:
+ return "CLOSED"
+ default:
+ return ""
+ }
+}
+
+// Color returns the foreground color associated with a state.
+func (s BranchState) Color() lipgloss.Color { return stateColors[s] }
+
+// Dot returns the compact status glyph for a state.
+func (s BranchState) Dot() string {
+ switch s {
+ case StateNew:
+ return "●"
+ case StateOpen:
+ return "○"
+ case StateDraft:
+ return "◐"
+ case StateQueued:
+ return "◌"
+ case StateMerged:
+ return "◍"
+ case StateClosed:
+ return "✗"
+ default:
+ return "·"
+ }
+}
+
+// RenderBadge renders a state as a pill badge: the uppercase label in the state
+// color on a tinted background with single-column horizontal padding.
+func RenderBadge(s BranchState) string {
+ return lipgloss.NewStyle().
+ Foreground(s.Color()).
+ Background(stateBgColors[s]).
+ Bold(true).
+ Padding(0, 1).
+ Render(s.Label())
+}
+
+// RenderDot renders the state's legend glyph in the state color.
+func RenderDot(s BranchState) string {
+ return lipgloss.NewStyle().Foreground(s.Color()).Render(s.Dot())
+}
+
+// Shared submit-view styles. These are intentionally centralized so the left
+// stack tree, the editor, and the chrome render with a consistent visual
+// language.
+var (
+ focusNameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true) // cyan focused label
+ // headerBranchStyle renders the focused branch name in the right-panel card
+ // header in white (the left-panel cursor name stays cyan).
+ headerBranchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true)
+
+ // rowShadeColor tints the focused (currently-viewed) branch row in the left
+ // timeline. A neutral cool gray (truecolor, so it doesn't pick up a warm tint
+ // from a themed 256-color palette) reading as a translucent-white highlight.
+ rowShadeColor = lipgloss.Color("#3b3e46")
+
+ // Panel border shared by both panels (focus is shown on the active input
+ // field, not the panel frame).
+ panelBorderStyle = lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(lipgloss.Color("8")).
+ Padding(0, 1)
+
+ // Section labels (e.g. STACK, EDITING, TITLE, DESCRIPTION).
+ sectionLabelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Bold(true)
+
+ // Tab strip styles.
+ tabActiveStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true).Underline(true)
+ tabInactiveStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
+
+ // Footer / status styles.
+ footerKeyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14"))
+
+ // openLinkStyle renders the underlined white "↗ Open on GitHub" link (arrow
+ // included) in a locked PR's read-only card header; lockedTitleStyle renders
+ // that PR's title.
+ openLinkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true).Underline(true)
+ lockedTitleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true)
+
+ // Footer bottom-right actions: nextBranchStyle is the white "NEXT BRANCH"
+ // label; submitButtonStyle is the prominent solid-white "SUBMIT N PRs" button
+ // (dark text) shown on the last PR.
+ nextBranchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true)
+ submitButtonStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("0")).Background(lipgloss.Color("15")).Bold(true).Padding(0, 1)
+ // prNumberStyle renders a clickable existing-PR number as an underlined
+ // white link.
+ prNumberStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Underline(true)
+
+ // Tree spine + horizontal rules (dim chrome).
+ spineStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
+ ruleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
+
+ // CREATE PR switch in the right-panel header. On: a green pill (matching the
+ // CREATE AS selected color) with a black square knob inset on the right. Off:
+ // the colors invert to a light-gray pill with a darker square inset on the
+ // left. The "CREATE PR" label uses the shared section-heading style.
+ switchOnStyle = lipgloss.NewStyle().Background(lipgloss.Color("2"))
+ switchOffStyle = lipgloss.NewStyle().Background(lipgloss.Color("245"))
+ switchOnKnob = lipgloss.Color("0") // black knob (matches CREATE AS selected text)
+ switchOffKnob = lipgloss.Color("236") // dark square on a lighter track
+
+ // Segmented Ready/Draft control: the selected segment is filled green; the
+ // other is dim. Brackets/divider are dim chrome.
+ segOnStyle = lipgloss.NewStyle().
+ Foreground(lipgloss.Color("0")).
+ Background(lipgloss.Color("2")).
+ Bold(true).
+ Padding(0, 1)
+ segOffStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Padding(0, 1)
+ segFrameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
+
+ // dimBodyStyle renders the skipped branch's body as muted, non-interactive
+ // chrome.
+ dimBodyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
+
+ // descCursorStyle renders the block cursor overlaid on the scrollable
+ // description view.
+ descCursorStyle = lipgloss.NewStyle().Reverse(true)
+
+ // Description scrollbar (track + thumb), drawn inside the box.
+ scrollTrackStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
+ scrollThumbStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15"))
+
+ // Callouts.
+ calloutErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1"))
+ hintStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
+)
diff --git a/internal/tui/submitview/styles_test.go b/internal/tui/submitview/styles_test.go
new file mode 100644
index 0000000..a72be3c
--- /dev/null
+++ b/internal/tui/submitview/styles_test.go
@@ -0,0 +1,42 @@
+package submitview
+
+import (
+ "testing"
+
+ "github.com/charmbracelet/lipgloss"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestStateLabel(t *testing.T) {
+ cases := map[BranchState]string{
+ StateNew: "NEW",
+ StateOpen: "OPEN",
+ StateDraft: "DRAFT",
+ StateQueued: "QUEUED",
+ StateMerged: "MERGED",
+ StateClosed: "CLOSED",
+ }
+ for state, want := range cases {
+ assert.Equal(t, want, state.Label())
+ }
+}
+
+func TestStateColorAndDot(t *testing.T) {
+ for _, s := range []BranchState{StateNew, StateOpen, StateDraft, StateQueued, StateMerged, StateClosed} {
+ assert.NotEqual(t, lipgloss.Color(""), s.Color(), "state %s should have a color", s.Label())
+ assert.NotEmpty(t, s.Dot(), "state %s should have a dot", s.Label())
+ }
+}
+
+func TestRenderBadgeContainsLabel(t *testing.T) {
+ // The rendered badge may contain ANSI escapes; assert the label text is
+ // present regardless of styling.
+ for _, s := range []BranchState{StateNew, StateOpen, StateDraft, StateQueued, StateMerged, StateClosed} {
+ out := RenderBadge(s)
+ assert.Contains(t, out, s.Label())
+ }
+}
+
+func TestRenderDotContainsGlyph(t *testing.T) {
+ assert.Contains(t, RenderDot(StateNew), StateNew.Dot())
+}
diff --git a/internal/tui/submitview/types.go b/internal/tui/submitview/types.go
new file mode 100644
index 0000000..1576fcb
--- /dev/null
+++ b/internal/tui/submitview/types.go
@@ -0,0 +1,149 @@
+// Package submitview implements the interactive single-screen TUI used by
+// `gh stack submit` to create a stack of pull requests. A left-hand timeline
+// lists the stack and lets the user choose which branches without a PR should
+// become PRs; the right-hand editor drafts each PR's title, description, and
+// ready/draft state before a single batch submit.
+//
+// The package builds on the shared Charm components in internal/tui/shared and
+// reuses the branch display data loaded by internal/tui/stackview. The submit
+// command supplies the per-PR overrides this package produces; the underlying
+// push/create/relink engine is unchanged.
+package submitview
+
+import (
+ "github.com/github/gh-stack/internal/tui/stackview"
+)
+
+// BranchState classifies a branch by the status of its pull request. The state
+// determines whether a branch is selectable and editable in the TUI, and which
+// badge color it renders with.
+type BranchState int
+
+const (
+ // StateNew is a branch with no PR yet. It is the only interactive state:
+ // selectable (default on) and editable.
+ StateNew BranchState = iota
+ // StateOpen is a branch with an open (non-draft) PR. Locked, shown for context.
+ StateOpen
+ // StateDraft is a branch with a draft PR. Locked, shown for context.
+ StateDraft
+ // StateQueued is a branch whose PR is in a merge queue. Locked.
+ StateQueued
+ // StateMerged is a branch whose PR has merged. Locked, historical context.
+ StateMerged
+ // StateClosed is a branch with a closed PR. It blocks the stack and is
+ // neither selectable nor editable.
+ StateClosed
+)
+
+// Selectable reports whether a branch in this state can be toggled for
+// inclusion. Only NEW branches are selectable.
+func (s BranchState) Selectable() bool { return s == StateNew }
+
+// Editable reports whether a branch in this state opens the editor. Only NEW
+// branches are editable.
+func (s BranchState) Editable() bool { return s == StateNew }
+
+// Locked reports whether a branch is shown for context only (open, draft,
+// queued, or merged). Closed is handled separately because it blocks the stack.
+func (s BranchState) Locked() bool {
+ switch s {
+ case StateOpen, StateDraft, StateQueued, StateMerged:
+ return true
+ default:
+ return false
+ }
+}
+
+// Blocks reports whether a branch in this state blocks the stack. Only closed
+// PRs block.
+func (s BranchState) Blocks() bool { return s == StateClosed }
+
+// SubmitNode wraps a stackview.BranchNode with the per-branch UI state used by
+// the submit TUI: its derived state, inclusion, the in-progress title and
+// description draft, and the draft toggle. Prefill snapshots are retained so
+// the model can detect unsaved edits for the quit confirmation.
+type SubmitNode struct {
+ stackview.BranchNode
+
+ // State is the derived PR state for this branch.
+ State BranchState
+
+ // Included reports whether a PR should be created for this branch on
+ // submit. Only meaningful for StateNew branches; defaults to true.
+ Included bool
+
+ // Title and Description hold the in-progress PR draft, prefilled from the
+ // branch's commits and the repo PR template (see data.go).
+ Title string
+ Description string
+
+ // Draft is the per-PR "Open as draft" toggle.
+ Draft bool
+
+ // Submitted is set once this branch's PR has been created during the
+ // current session. It drives the inline "✓ready" tag in the stack map.
+ Submitted bool
+
+ // prefill snapshots used to detect user edits.
+ titlePrefill string
+ descPrefill string
+ draftPrefill bool
+}
+
+// Edited reports whether the user has changed any field of this NEW branch from
+// its prefilled defaults: title, description, draft toggle, or inclusion.
+// Non-NEW branches are never editable and so are never considered edited.
+func (n SubmitNode) Edited() bool {
+ if n.State != StateNew {
+ return false
+ }
+ return n.Title != n.titlePrefill ||
+ n.Description != n.descPrefill ||
+ n.Draft != n.draftPrefill ||
+ !n.Included
+}
+
+// PRDraft is the per-branch override the TUI hands back to the submit command.
+// The command's create path consumes these instead of auto-generating titles
+// and bodies. The attribution footer is appended by the command at submit time,
+// so Body holds only the user-authored description.
+type PRDraft struct {
+ // Branch is the head branch the PR will be created from.
+ Branch string
+ // Include reports whether to create a PR for this branch. Deselected NEW
+ // branches are still pushed for stack consistency but get no PR.
+ Include bool
+ // Title is the PR title.
+ Title string
+ // Body is the user-authored PR description, without the attribution footer.
+ Body string
+ // Draft reports whether the PR should be created as a draft.
+ Draft bool
+}
+
+// ToDraft converts a SubmitNode into the command-layer PRDraft override.
+func (n SubmitNode) ToDraft() PRDraft {
+ return PRDraft{
+ Branch: n.Ref.Branch,
+ Include: n.Included,
+ Title: n.Title,
+ Body: n.Description,
+ Draft: n.Draft,
+ }
+}
+
+// BuildDrafts returns the per-branch overrides for every NEW branch, keyed by
+// branch name. Non-NEW branches are omitted because their PRs are never
+// modified by the TUI.
+func BuildDrafts(nodes []SubmitNode) map[string]*PRDraft {
+ drafts := make(map[string]*PRDraft)
+ for _, n := range nodes {
+ if n.State != StateNew {
+ continue
+ }
+ d := n.ToDraft()
+ drafts[n.Ref.Branch] = &d
+ }
+ return drafts
+}
diff --git a/internal/tui/submitview/types_test.go b/internal/tui/submitview/types_test.go
new file mode 100644
index 0000000..988de9c
--- /dev/null
+++ b/internal/tui/submitview/types_test.go
@@ -0,0 +1,119 @@
+package submitview
+
+import (
+ "testing"
+
+ "github.com/github/gh-stack/internal/stack"
+ "github.com/github/gh-stack/internal/tui/stackview"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestBranchStatePredicates(t *testing.T) {
+ tests := []struct {
+ state BranchState
+ selectable bool
+ editable bool
+ locked bool
+ blocks bool
+ }{
+ {StateNew, true, true, false, false},
+ {StateOpen, false, false, true, false},
+ {StateDraft, false, false, true, false},
+ {StateQueued, false, false, true, false},
+ {StateMerged, false, false, true, false},
+ {StateClosed, false, false, false, true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.state.Label(), func(t *testing.T) {
+ assert.Equal(t, tt.selectable, tt.state.Selectable())
+ assert.Equal(t, tt.editable, tt.state.Editable())
+ assert.Equal(t, tt.locked, tt.state.Locked())
+ assert.Equal(t, tt.blocks, tt.state.Blocks())
+ })
+ }
+}
+
+func TestSubmitNodeEdited(t *testing.T) {
+ base := SubmitNode{
+ BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "feat/a"}},
+ State: StateNew,
+ Included: true,
+ Title: "Title",
+ Description: "Desc",
+ Draft: false,
+ titlePrefill: "Title",
+ descPrefill: "Desc",
+ draftPrefill: false,
+ }
+
+ t.Run("unchanged is not edited", func(t *testing.T) {
+ assert.False(t, base.Edited())
+ })
+
+ t.Run("title change is edited", func(t *testing.T) {
+ n := base
+ n.Title = "New title"
+ assert.True(t, n.Edited())
+ })
+
+ t.Run("description change is edited", func(t *testing.T) {
+ n := base
+ n.Description = "New desc"
+ assert.True(t, n.Edited())
+ })
+
+ t.Run("draft toggle is edited", func(t *testing.T) {
+ n := base
+ n.Draft = true
+ assert.True(t, n.Edited())
+ })
+
+ t.Run("deselection is edited", func(t *testing.T) {
+ n := base
+ n.Included = false
+ assert.True(t, n.Edited())
+ })
+
+ t.Run("non-new node is never edited", func(t *testing.T) {
+ n := base
+ n.State = StateOpen
+ n.Title = "changed"
+ assert.False(t, n.Edited())
+ })
+}
+
+func TestToDraft(t *testing.T) {
+ n := SubmitNode{
+ BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "feat/a"}},
+ State: StateNew,
+ Included: true,
+ Title: "Add feature",
+ Description: "Body",
+ Draft: true,
+ }
+ got := n.ToDraft()
+ assert.Equal(t, PRDraft{
+ Branch: "feat/a",
+ Include: true,
+ Title: "Add feature",
+ Body: "Body",
+ Draft: true,
+ }, got)
+}
+
+func TestBuildDrafts(t *testing.T) {
+ nodes := []SubmitNode{
+ {State: StateNew, Included: true, Title: "A", BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "feat/a"}}},
+ {State: StateNew, Included: false, Title: "B", BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "feat/b"}}},
+ {State: StateOpen, BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "feat/c"}}},
+ }
+
+ drafts := BuildDrafts(nodes)
+
+ assert.Len(t, drafts, 2, "only NEW branches produce drafts")
+ assert.NotNil(t, drafts["feat/a"])
+ assert.True(t, drafts["feat/a"].Include)
+ assert.NotNil(t, drafts["feat/b"])
+ assert.False(t, drafts["feat/b"].Include)
+ assert.Nil(t, drafts["feat/c"], "non-new branch has no draft")
+}