Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,20 @@ and the project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v
> Tags prior to **v0.4.0** were cut in the private repository and produced no
> public artifacts; the first publicly released version is v0.4.0.

## [Unreleased]

### Changed
- **Interactive Yes/No confirmation prompts.** On a TTY, every confirmation
prompt — the pre-send `.commitbrief/**` guard, the secret-scan warning, the
cost preflight, the token/context-window preflight, `cache clear`, and the
`compress` replace prompt — now renders an arrow-key-selectable Yes/No toggle
(←/→ to choose, Enter to confirm), pre-selected on **No**, instead of a typed
`y/N` line. Built on `huh`, which is already used by `setup`. Non-TTY/CI
behaviour is unchanged: these prompts still auto-abort (or honour `--yes` /
`--allow-secrets` where applicable), and piped input continues to drive the
line-based `y/N` fallback. New catalog keys `common.affirmative` /
`common.negative` provide the localized button labels (TR: `Evet` / `Hayır`).

## [1.4.1] - 2026-05-30

### Changed
Expand Down
3 changes: 2 additions & 1 deletion internal/cli/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,12 @@ func newCacheClearCmd() *cobra.Command {
"cache.clear.summary", files, formatBytes(totalBytes), cacheDir))

if !global.yes {
ok, err := ui.AskYesNo(
ok, err := ui.Confirm(
os.Stdin,
cmd.OutOrStderr(),
app.Catalog.T("cache.clear.confirm"),
ui.AskOptions{
Interactive: ui.IsStdinTTY(os.Stdin),
NonInteractive: !ui.IsStdinTTY(os.Stdin),
Catalog: app.Catalog,
},
Expand Down
3 changes: 2 additions & 1 deletion internal/cli/compress.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,11 +129,12 @@ func newCompressCmd() *cobra.Command {

// Confirmation prompt unless --yes.
if !global.yes {
ok, err := ui.AskYesNo(
ok, err := ui.Confirm(
os.Stdin,
cmd.OutOrStderr(),
app.Catalog.T("compress.replace_prompt"),
ui.AskOptions{
Interactive: ui.IsStdinTTY(os.Stdin),
NonInteractive: !ui.IsStdinTTY(os.Stdin),
Catalog: app.Catalog,
},
Expand Down
60 changes: 25 additions & 35 deletions internal/cli/review.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ func runReview(cmd *cobra.Command, scope reviewScopeFlags, diffArgs []string) er
if res, _ := guard.CheckDiffForLocalConfig(parsed, guard.Options{
AssumeYes: global.yes,
NonInteractive: !ui.IsStdinTTY(os.Stdin),
Interactive: ui.IsStdinTTY(os.Stdin),
Catalog: app.Catalog,
Reader: stdinReader,
}); res == guard.Abort {
Expand Down Expand Up @@ -544,12 +545,14 @@ func handleTokenPreflight(cmd *cobra.Command, app *appContext, prov provider.Pro
return true
}

_, _ = fmt.Fprint(w, app.Catalog.T("guard.tokens.confirm_prompt"))
answer, err := readPromptLine(stdin)
if err != nil || answer == "" {
return true
}
return !ui.AcceptsYes(answer, app.Catalog)
// Interactive is hardcoded true (not IsStdinTTY) because the abort
// above already guarantees a TTY here. Do NOT soften it to
// IsStdinTTY: that routes the non-TTY case through ui.Confirm's
// line-based fallback, but non-TTY must abort (handled above),
// never line-read a piped answer.
ok, err := ui.Confirm(stdin, w, app.Catalog.T("guard.tokens.confirm_prompt"),
ui.AskOptions{Interactive: true, Catalog: app.Catalog})
return err != nil || !ok
}

// suggestCommitMessage runs a second, free-form provider call (ADR-0015)
Expand Down Expand Up @@ -814,29 +817,14 @@ func handleCostPreflight(cmd *cobra.Command, app *appContext, estCost float64, s
return true
}

_, _ = fmt.Fprint(w, app.Catalog.T("cost.confirm_prompt"))
answer, err := readPromptLine(stdin)
if err != nil || answer == "" {
return true
}
return !ui.AcceptsYes(answer, app.Catalog)
}

// readPromptLine pulls one line off the shared runReview-scoped
// bufio.Reader. UC-21: every interactive prompt during a review
// (guard, secret scan, cost preflight) shares the same buffered
// reader so a piped-in `e\ne\ne\n` reaches all three sites instead
// of being swallowed by whichever scanner asked first. Returns the
// trimmed lowercase answer and any read error.
func readPromptLine(r *bufio.Reader) (string, error) {
line, err := r.ReadString('\n')
if err != nil && line == "" {
if err == io.EOF {
return "", nil
}
return "", err
}
return strings.TrimSpace(strings.ToLower(line)), nil
// Interactive is hardcoded true (not IsStdinTTY): the abort above
// guarantees a TTY here. Do NOT soften it to IsStdinTTY — that would
// route non-TTY through the line fallback and let piped input
// auto-approve spend, reopening the hole UC-06 closed (non-TTY must
// abort, --yes must not bypass; PRD §310).
ok, err := ui.Confirm(stdin, w, app.Catalog.T("cost.confirm_prompt"),
ui.AskOptions{Interactive: true, Catalog: app.Catalog})
return err != nil || !ok
}

// estimateOutputTokens is a conservative-on-the-high-side guess for
Expand Down Expand Up @@ -877,10 +865,12 @@ func handleSecretMatches(cmd *cobra.Command, app *appContext, matches []guard.Se
return true
}

_, _ = fmt.Fprint(w, app.Catalog.T("guard.secrets.prompt"))
answer, err := readPromptLine(stdin)
if err != nil || answer == "" {
return true
}
return !ui.AcceptsYes(answer, app.Catalog)
// Interactive is hardcoded true (not IsStdinTTY): the abort above
// guarantees a TTY here. Do NOT soften it to IsStdinTTY — that would
// route non-TTY through the line fallback and let piped input
// auto-confirm a detected secret, reopening the hole UC-01 closed
// (non-TTY must abort, --yes must not bypass; PRD §309).
ok, err := ui.Confirm(stdin, w, app.Catalog.T("guard.secrets.prompt"),
ui.AskOptions{Interactive: true, Catalog: app.Catalog})
return err != nil || !ok
}
63 changes: 33 additions & 30 deletions internal/cli/shared_stdin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,59 +4,62 @@ package cli

import (
"bufio"
"io"
"strings"
"testing"

"github.com/CommitBrief/commitbrief/internal/ui"
)

func TestReadPromptLineConsumesOneLinePerCall(t *testing.T) {
// UC-21 regression guard. The shared *bufio.Reader created at the
// top of runReview must return one line per ReadString('\n') call
// — three sequential prompts (guard → secret → cost) reading from
// the same buffer must each get their own answer instead of one
// scanner gobbling everything via lookahead.
// UC-21 regression guard. runReview creates one *bufio.Reader at the top
// and threads it through every interactive prompt (guard → secret scan →
// token/cost preflight) via ui.Confirm. Each prompt must consume exactly
// one line from the shared buffer; a per-call bufio.Scanner inside
// AskYesNo would over-read via lookahead and swallow the answers meant
// for later prompts. These tests pin the behaviour at the ui.Confirm
// boundary the review pipeline actually uses.

func TestSharedReaderConsumesOneLinePerConfirm(t *testing.T) {
r := bufio.NewReader(strings.NewReader("yes\nno\ny\n"))

answers := make([]string, 0, 3)
got := make([]bool, 0, 3)
for i := 0; i < 3; i++ {
ans, err := readPromptLine(r)
ok, err := ui.Confirm(r, io.Discard, "?", ui.AskOptions{})
if err != nil {
t.Fatalf("call %d: %v", i, err)
}
answers = append(answers, ans)
got = append(got, ok)
}
if got, want := strings.Join(answers, ","), "yes,no,y"; got != want {
t.Errorf("answers = %q, want %q (one line per prompt)", got, want)

want := []bool{true, false, true}
for i := range want {
if got[i] != want[i] {
t.Fatalf("answers = %v, want %v (one line consumed per prompt)", got, want)
}
}
}

func TestReadPromptLineHandlesNoTrailingNewline(t *testing.T) {
// A user pressing Ctrl-D after typing "y" (no newline) should
// still surface "y" as the answer, not "".
func TestSharedReaderHandlesNoTrailingNewline(t *testing.T) {
// A user pressing Ctrl-D after typing "y" (no newline) should still
// surface "y" as affirmative, not an empty/negative answer.
r := bufio.NewReader(strings.NewReader("y"))
got, err := readPromptLine(r)
ok, err := ui.Confirm(r, io.Discard, "?", ui.AskOptions{})
if err != nil {
t.Fatal(err)
}
if got != "y" {
t.Errorf("answer = %q, want %q", got, "y")
}
}

func TestReadPromptLineNormalisesCaseAndWhitespace(t *testing.T) {
r := bufio.NewReader(strings.NewReader(" YES \n"))
got, _ := readPromptLine(r)
if got != "yes" {
t.Errorf("answer = %q, want lowercased+trimmed yes", got)
if !ok {
t.Errorf("no-trailing-newline 'y' should be affirmative")
}
}

func TestReadPromptLineEOFReturnsEmpty(t *testing.T) {
func TestSharedReaderEOFIsNegative(t *testing.T) {
// Drained buffer (immediate EOF) must read as the default-no, not error.
r := bufio.NewReader(strings.NewReader(""))
got, err := readPromptLine(r)
ok, err := ui.Confirm(r, io.Discard, "?", ui.AskOptions{})
if err != nil {
t.Errorf("EOF should return (\"\", nil), got err=%v", err)
t.Errorf("EOF should return (false, nil); got err=%v", err)
}
if got != "" {
t.Errorf("EOF should return empty string; got %q", got)
if ok {
t.Errorf("EOF should be negative (default no)")
}
}
29 changes: 29 additions & 0 deletions internal/guard/presend.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ type Options struct {
Writer io.Writer
Reader io.Reader

// Interactive routes the confirm through ui.Confirm's arrow-key
// Yes/No toggle (huh) instead of reading a line off Reader. CLI
// callers set it from ui.IsStdinTTY(os.Stdin); tests leave it
// false so the deterministic line path (Reader) still drives them.
Interactive bool

// Catalog plumbs i18n into the .commitbrief/* write-guard so the
// user-visible warning, file lines, prompt, and abort messages
// honour the active locale. Nil → English defaults (legacy
Expand Down Expand Up @@ -74,6 +80,29 @@ func CheckDiffForLocalConfig(d diff.Diff, opts Options) (Result, error) {
}

prompt := tr(opts.Catalog, "guard.prompt", " Continue?")

if opts.Interactive {
// Interactive routes through huh and ignores the reader, but pass
// a real one (shared reader, else os.Stdin) so a future change to
// Confirm's routing degrades to the line path with a usable reader
// instead of a nil-deref panic.
r := opts.Reader
if r == nil {
r = os.Stdin
}
ok, err := ui.Confirm(r, w, prompt, ui.AskOptions{
Interactive: true,
Catalog: opts.Catalog,
})
if err != nil {
return Abort, fmt.Errorf("guard: confirm: %w", err)
}
if ok {
return Continue, nil
}
return Abort, nil
}

suffix := ui.PromptSuffix(opts.Catalog)
_, _ = fmt.Fprintf(w, "%s %s: ", prompt, suffix)

Expand Down
11 changes: 8 additions & 3 deletions internal/i18n/messages.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,16 @@ guard.prompt: " Continue?"
guard.non_interactive: "Aborting (non-interactive mode); pass --yes to override."
guard.secrets.detected: "⚠ Possible secrets detected in diff (%d line(s)):"
guard.secrets.line: " line %d: %s"
guard.secrets.prompt: " Send to LLM anyway? [y/N]: "
guard.secrets.prompt: "Send to LLM anyway?"
guard.secrets.aborted_user: "aborted: pre-send secret scanner"
guard.secrets.aborted_non_interactive: "Aborted (non-interactive); pass --allow-secrets to override."

cost.estimate: "⚠ Estimated cost: $%.4f (threshold: $%.4f)"
cost.confirm_prompt: " Proceed with the review? [y/N]: "
cost.confirm_prompt: "Proceed with the review?"
cost.aborted_user: "aborted: cost preflight"
cost.aborted_non_interactive: "Aborted (non-interactive); pass --no-cost-check or raise cost.warn_threshold_usd to override."
guard.tokens.exceeds: "⚠ Estimated prompt is %d tokens; %s's context window for this model is %d."
guard.tokens.confirm_prompt: " Send it anyway? [y/N]: "
guard.tokens.confirm_prompt: "Send it anyway?"
guard.tokens.aborted_user: "aborted: token preflight"
guard.tokens.aborted_non_interactive: "Aborted (non-interactive); set guard.token_preflight=false to override."

Expand Down Expand Up @@ -80,6 +80,11 @@ common.error_prefix: "Error:"
common.yes_short: "y"
common.yes_long: "yes"
common.prompt_yn: "[y/N]"
# Button labels for the interactive (huh) Yes/No toggle that
# ui.Confirm renders on a TTY. Title-cased, locale-specific. The
# line-based fallback keeps using prompt_yn / yes_short above.
common.affirmative: "Yes"
common.negative: "No"

init.wrote: "Wrote %s"
init.skipped: "Skipped %s (already exists; pass --force to overwrite)"
Expand Down
11 changes: 8 additions & 3 deletions internal/i18n/messages.tr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,16 @@ guard.prompt: " Devam edilsin mi?"
guard.non_interactive: "İptal ediliyor (etkileşimsiz mod); zorlamak için --yes kullanın."
guard.secrets.detected: "⚠ Diff'te olası gizli anahtar tespit edildi (%d satır):"
guard.secrets.line: " satır %d: %s"
guard.secrets.prompt: " Yine de LLM'e gönderilsin mi? [e/H]: "
guard.secrets.prompt: "Yine de LLM'e gönderilsin mi?"
guard.secrets.aborted_user: "iptal edildi: pre-send secret scanner"
guard.secrets.aborted_non_interactive: "İptal edildi (etkileşimsiz); zorlamak için --allow-secrets kullanın."

cost.estimate: "⚠ Tahmini maliyet: $%.4f (eşik: $%.4f)"
cost.confirm_prompt: " Review devam etsin mi? [e/H]: "
cost.confirm_prompt: "Review devam etsin mi?"
cost.aborted_user: "iptal edildi: cost preflight"
cost.aborted_non_interactive: "İptal edildi (etkileşimsiz); zorlamak için --no-cost-check veya cost.warn_threshold_usd değerini yükseltin."
guard.tokens.exceeds: "⚠ Tahmini prompt %d token; %s sağlayıcısının bu model için bağlam penceresi %d."
guard.tokens.confirm_prompt: " Yine de gönderilsin mi? [e/H]: "
guard.tokens.confirm_prompt: "Yine de gönderilsin mi?"
guard.tokens.aborted_user: "iptal edildi: token preflight"
guard.tokens.aborted_non_interactive: "İptal edildi (etkileşimsiz); geçersiz kılmak için guard.token_preflight=false yapın."

Expand Down Expand Up @@ -78,6 +78,11 @@ common.error_prefix: "Hata:"
common.yes_short: "e"
common.yes_long: "evet"
common.prompt_yn: "[e/H]"
# Etkileşimli (huh) Evet/Hayır toggle'ının buton etiketleri;
# ui.Confirm TTY'de bunu çizer. Satır tabanlı fallback yukarıdaki
# prompt_yn / yes_short'u kullanmaya devam eder.
common.affirmative: "Evet"
common.negative: "Hayır"

init.wrote: "%s yazıldı"
init.skipped: "%s atlandı (zaten mevcut; üzerine yazmak için --force kullanın)"
Expand Down
Loading
Loading