Skip to content

Commit 89643dc

Browse files
authored
input prompter improvements (#98)
* include prefix in branch name input * custom prompter with colored input text * minor fix: arrow direction for initialized stack * fix comment * handle SetTermMode err
1 parent b219e96 commit 89643dc

7 files changed

Lines changed: 184 additions & 22 deletions

File tree

cmd/add.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package cmd
33
import (
44
"fmt"
55

6-
"github.com/cli/go-gh/v2/pkg/prompter"
76
"github.com/github/gh-stack/internal/branch"
87
"github.com/github/gh-stack/internal/config"
98
"github.com/github/gh-stack/internal/git"
@@ -146,9 +145,14 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error {
146145
if s.Numbered && s.Prefix != "" {
147146
branchName = branch.NextNumberedName(s.Prefix, existingBranches)
148147
} else {
149-
p := prompter.New(cfg.In, cfg.Out, cfg.Err)
148+
// Pre-fill the prompt with the prefix so the user can see
149+
// (and optionally edit) the full branch name.
150+
prefill := ""
151+
if s.Prefix != "" {
152+
prefill = s.Prefix + "/"
153+
}
150154
for {
151-
input, err := p.Input("Enter a name for the new branch", "")
155+
input, err := inputWithPrefill(cfg, "Enter a name for the new branch:", prefill)
152156
if err != nil {
153157
if isInterruptError(err) {
154158
printInterrupt(cfg)
@@ -160,7 +164,7 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error {
160164
cfg.Warningf("branch name cannot be empty, please try again")
161165
continue
162166
}
163-
branchName = applyPrefix(cfg, s.Prefix, input)
167+
branchName = input
164168
break
165169
}
166170
}

cmd/add_test.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,119 @@ func TestAdd_NothingToCommit(t *testing.T) {
321321
assert.Contains(t, output, "no changes to commit")
322322
}
323323

324+
func TestAdd_PromptPrefillsPrefix(t *testing.T) {
325+
gitDir := t.TempDir()
326+
saveStack(t, gitDir, stack.Stack{
327+
Prefix: "feat",
328+
Trunk: stack.BranchRef{Branch: "main"},
329+
Branches: []stack.BranchRef{{Branch: "feat/01"}},
330+
})
331+
332+
var createdBranch string
333+
restore := git.SetOps(&git.MockOps{
334+
GitDirFn: func() (string, error) { return gitDir, nil },
335+
CurrentBranchFn: func() (string, error) { return "feat/01", nil },
336+
CreateBranchFn: func(name, base string) error {
337+
createdBranch = name
338+
return nil
339+
},
340+
CheckoutBranchFn: func(name string) error { return nil },
341+
RevParseFn: func(ref string) (string, error) { return "abc", nil },
342+
})
343+
defer restore()
344+
345+
cfg, outR, errR := config.NewTestConfig()
346+
347+
var gotPrompt, gotDefault string
348+
cfg.InputFn = func(prompt, defaultValue string) (string, error) {
349+
gotPrompt = prompt
350+
gotDefault = defaultValue
351+
return "feat/my-branch", nil
352+
}
353+
354+
err := runAdd(cfg, &addOptions{}, nil)
355+
output := collectOutput(cfg, outR, errR)
356+
357+
require.NoError(t, err)
358+
require.NotContains(t, output, "\u2717", "unexpected error")
359+
assert.Contains(t, gotPrompt, ":", "prompt should end with a colon")
360+
assert.Equal(t, "feat/", gotDefault, "prompt should pre-fill prefix/")
361+
assert.Equal(t, "feat/my-branch", createdBranch, "full input should be used as branch name")
362+
}
363+
364+
func TestAdd_PromptNoPrefixEmptyDefault(t *testing.T) {
365+
gitDir := t.TempDir()
366+
saveStack(t, gitDir, stack.Stack{
367+
Trunk: stack.BranchRef{Branch: "main"},
368+
Branches: []stack.BranchRef{{Branch: "b1"}},
369+
})
370+
371+
var createdBranch string
372+
restore := git.SetOps(&git.MockOps{
373+
GitDirFn: func() (string, error) { return gitDir, nil },
374+
CurrentBranchFn: func() (string, error) { return "b1", nil },
375+
CreateBranchFn: func(name, base string) error {
376+
createdBranch = name
377+
return nil
378+
},
379+
CheckoutBranchFn: func(name string) error { return nil },
380+
RevParseFn: func(ref string) (string, error) { return "abc", nil },
381+
})
382+
defer restore()
383+
384+
cfg, outR, errR := config.NewTestConfig()
385+
386+
var gotDefault string
387+
cfg.InputFn = func(prompt, defaultValue string) (string, error) {
388+
gotDefault = defaultValue
389+
return "my-branch", nil
390+
}
391+
392+
err := runAdd(cfg, &addOptions{}, nil)
393+
output := collectOutput(cfg, outR, errR)
394+
395+
require.NoError(t, err)
396+
require.NotContains(t, output, "\u2717", "unexpected error")
397+
assert.Equal(t, "", gotDefault, "prompt should have empty default when no prefix")
398+
assert.Equal(t, "my-branch", createdBranch, "input should be used as-is")
399+
}
400+
401+
func TestAdd_PromptUserModifiesPrefix(t *testing.T) {
402+
gitDir := t.TempDir()
403+
saveStack(t, gitDir, stack.Stack{
404+
Prefix: "feat",
405+
Trunk: stack.BranchRef{Branch: "main"},
406+
Branches: []stack.BranchRef{{Branch: "feat/01"}},
407+
})
408+
409+
var createdBranch string
410+
restore := git.SetOps(&git.MockOps{
411+
GitDirFn: func() (string, error) { return gitDir, nil },
412+
CurrentBranchFn: func() (string, error) { return "feat/01", nil },
413+
CreateBranchFn: func(name, base string) error {
414+
createdBranch = name
415+
return nil
416+
},
417+
CheckoutBranchFn: func(name string) error { return nil },
418+
RevParseFn: func(ref string) (string, error) { return "abc", nil },
419+
})
420+
defer restore()
421+
422+
cfg, outR, errR := config.NewTestConfig()
423+
424+
cfg.InputFn = func(prompt, defaultValue string) (string, error) {
425+
// Simulate user changing the prefix entirely
426+
return "custom/other-name", nil
427+
}
428+
429+
err := runAdd(cfg, &addOptions{}, nil)
430+
output := collectOutput(cfg, outR, errR)
431+
432+
require.NoError(t, err)
433+
require.NotContains(t, output, "\u2717", "unexpected error")
434+
assert.Equal(t, "custom/other-name", createdBranch, "user-modified input should be used verbatim")
435+
}
436+
324437
func TestAdd_FromTrunk(t *testing.T) {
325438
gitDir := t.TempDir()
326439
saveStack(t, gitDir, stack.Stack{

cmd/init.go

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,7 @@ func runInit(cfg *config.Config, opts *initOptions) error {
159159
} else if opts.numbered {
160160
// === NUMBERED PATH (unchanged) ===
161161
if opts.prefix == "" && cfg.IsInteractive() {
162-
p := prompter.New(cfg.In, cfg.Out, cfg.Err)
163-
prefixInput, err := p.Input("Enter a branch prefix (required for --numbered)", "")
162+
prefixInput, err := inputWithPrefill(cfg, "Enter a branch prefix (required for --numbered):", "")
164163
if err != nil {
165164
if isInterruptError(err) {
166165
printInterrupt(cfg)
@@ -377,15 +376,15 @@ func runInteractiveInit(cfg *config.Config, sf *stack.StackFile, trunk, currentB
377376
branchName = currentBranch
378377
} else {
379378
// Create a new branch — fall through to input prompt
380-
name, err := promptBranchName(cfg, p, opts.prefix)
379+
name, err := promptBranchName(cfg, opts.prefix)
381380
if err != nil {
382381
return nil, false, err
383382
}
384383
branchName = name
385384
}
386385
} else {
387386
// On trunk or detached HEAD — prompt for name directly
388-
name, err := promptBranchName(cfg, p, opts.prefix)
387+
name, err := promptBranchName(cfg, opts.prefix)
389388
if err != nil {
390389
return nil, false, err
391390
}
@@ -430,14 +429,16 @@ func runInteractiveInit(cfg *config.Config, sf *stack.StackFile, trunk, currentB
430429
return []string{branchName}, wasAdopted, nil
431430
}
432431

433-
// promptBranchName prompts the user for a branch name, applying the
434-
// explicit --prefix if set.
435-
func promptBranchName(cfg *config.Config, p *prompter.Prompter, prefix string) (string, error) {
436-
prompt := "What's the name of the first branch?"
432+
// promptBranchName prompts the user for a branch name, pre-filling the
433+
// prefix in the input when set so the user can see and edit the full name.
434+
func promptBranchName(cfg *config.Config, prefix string) (string, error) {
435+
prefill := ""
436+
prompt := "What's the name of the first branch:"
437437
if prefix != "" {
438-
prompt = fmt.Sprintf("Enter a name for the first branch (will be prefixed with %s/)", prefix)
438+
prompt = "Enter a name for the first branch:"
439+
prefill = prefix + "/"
439440
}
440-
branchName, err := p.Input(prompt, "")
441+
branchName, err := inputWithPrefill(cfg, prompt, prefill)
441442
if err != nil {
442443
if isInterruptError(err) {
443444
printInterrupt(cfg)
@@ -451,9 +452,6 @@ func promptBranchName(cfg *config.Config, p *prompter.Prompter, prefix string) (
451452
cfg.Errorf("branch name cannot be empty")
452453
return "", ErrInvalidArgs
453454
}
454-
if prefix != "" {
455-
branchName = prefix + "/" + branchName
456-
}
457455
return branchName, nil
458456
}
459457

@@ -484,12 +482,12 @@ func detectPrefix(branches []string) string {
484482
func printWhatsNext(cfg *config.Config, s *stack.Stack, branches []string, hasAdopted bool, prCount int) {
485483
lastBranch := branches[len(branches)-1]
486484

487-
// Build the chain: main branch1 branch2
485+
// Build the chain: main branch1 branch2
488486
parts := []string{s.Trunk.Branch}
489487
for _, b := range s.Branches {
490488
parts = append(parts, b.Branch)
491489
}
492-
chain := strings.Join(parts, " ")
490+
chain := strings.Join(parts, " ")
493491

494492
// Success line
495493
if hasAdopted {

cmd/init_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -581,7 +581,7 @@ func TestInit_WhatsNext_Fresh(t *testing.T) {
581581
output := collectOutput(cfg, outR, errR)
582582

583583
assert.Contains(t, output, "Created stack")
584-
assert.Contains(t, output, "main my-feature")
584+
assert.Contains(t, output, "main my-feature")
585585
assert.Contains(t, output, "top of stack")
586586
assert.Contains(t, output, "What's next:")
587587
assert.Contains(t, output, "gh stack add")

cmd/submit.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -281,8 +281,7 @@ func createPR(cfg *config.Config, client github.ClientOps, s *stack.Stack, i int
281281
title, commitBody := defaultPRTitleBody(baseBranch, b.Branch)
282282
originalTitle := title
283283
if !opts.auto && cfg.IsInteractive() {
284-
p := prompter.New(cfg.In, cfg.Out, cfg.Err)
285-
input, err := p.Input(fmt.Sprintf("Title for PR (branch %s):", b.Branch), title)
284+
input, err := inputWithPrefill(cfg, fmt.Sprintf("Title for PR (branch %s):", b.Branch), title)
286285
if err != nil {
287286
if isInterruptError(err) {
288287
return errInterrupt

cmd/utils.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/github/gh-stack/internal/git"
1515
"github.com/github/gh-stack/internal/github"
1616
"github.com/github/gh-stack/internal/stack"
17+
"github.com/mgutz/ansi"
1718
)
1819

1920
// ErrSilent indicates the error has already been printed to the user.
@@ -69,6 +70,49 @@ func printInterrupt(cfg *config.Config) {
6970
cfg.Infof("Received interrupt, aborting operation")
7071
}
7172

73+
// inputWithPrefill prompts the user for text input with the given prefill
74+
// already editable in the input field. Unlike survey.Input's Default (which
75+
// shows in parentheses), this places the prefill text directly in the
76+
// editable line so the user can append to or modify it. The user's input
77+
// is rendered in cyan for visual distinction from the prompt message.
78+
func inputWithPrefill(cfg *config.Config, prompt, prefill string) (string, error) {
79+
if cfg.InputFn != nil {
80+
return cfg.InputFn(prompt, prefill)
81+
}
82+
83+
stdio := terminal.Stdio{In: cfg.In, Out: cfg.Out, Err: cfg.Err}
84+
rr := terminal.NewRuneReader(stdio)
85+
if err := rr.SetTermMode(); err != nil {
86+
return "", fmt.Errorf("failed to set terminal mode: %w", err)
87+
}
88+
defer func() { _ = rr.RestoreTermMode() }()
89+
90+
// Render the prompt in survey style: green bold "?" + message
91+
icon := "?"
92+
useColor := cfg.Terminal.IsColorEnabled()
93+
if useColor {
94+
icon = ansi.Color("?", "green+hb")
95+
}
96+
fmt.Fprintf(cfg.Out, "%s %s ", icon, prompt)
97+
98+
// Set cyan color for the user's input text
99+
if useColor {
100+
fmt.Fprint(cfg.Out, ansi.ColorCode("cyan"))
101+
}
102+
103+
line, err := rr.ReadLineWithDefault(0, []rune(prefill))
104+
105+
// Reset color after input
106+
if useColor {
107+
fmt.Fprint(cfg.Out, ansi.ColorCode("reset"))
108+
}
109+
110+
if err != nil {
111+
return "", err
112+
}
113+
return string(line), nil
114+
}
115+
72116
// selectPromptPageSize matches the PageSize used by the go-gh prompter.
73117
const selectPromptPageSize = 20
74118

internal/config/config.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ type Config struct {
4242
// ConfirmFn, when non-nil, is called instead of prompting via the
4343
// terminal. Used in tests to simulate yes/no confirmation prompts.
4444
ConfirmFn func(prompt string, defaultValue bool) (bool, error)
45+
46+
// InputFn, when non-nil, is called instead of prompting via the
47+
// terminal. Used in tests to simulate text input prompts.
48+
InputFn func(prompt, defaultValue string) (string, error)
4549
}
4650

4751
// New creates a new Config with terminal-aware output and color support.

0 commit comments

Comments
 (0)