@@ -210,7 +210,7 @@ To tag an already-finished session as a review, use
210210 }, deps )
211211 }
212212 if edit {
213- if ! interactive . IsTerminalWriter (cmd . OutOrStdout ()) || ! interactive . CanPromptInteractively ( ) {
213+ if ! reviewCommandIsInteractive (cmd ) {
214214 err := errors .New ("--edit requires an interactive terminal" )
215215 cmd .SilenceUsage = true
216216 fmt .Fprintln (cmd .ErrOrStderr (), "--edit requires an interactive terminal." )
@@ -270,6 +270,41 @@ type reviewConfigureOptions struct {
270270 Slots []string // reviewer slots as "agent[=model]" entries (--set-slot)
271271}
272272
273+ // reviewCommandIsInteractive requires the exact stdin consumed by huh and
274+ // Bubble Tea, plus stdout, to be terminals. CanPromptInteractively adds the
275+ // independent policy gate for tests, CI, and agent subprocess sentinels; a
276+ // controlling /dev/tty alone is insufficient because stdin may still be piped.
277+ func reviewCommandIsInteractive (cmd * cobra.Command ) bool {
278+ hardDisabled := reviewInteractivityHardDisabled (
279+ os .Getenv (interactive .EnvTestTTY ),
280+ os .Getenv ("CI" ),
281+ interactive .UnderTest (),
282+ )
283+ return reviewTTYIsInteractive (
284+ interactive .IsTerminalReader (cmd .InOrStdin ()),
285+ interactive .IsTerminalWriter (cmd .OutOrStdout ()),
286+ interactive .CanPromptInteractively (),
287+ hardDisabled ,
288+ )
289+ }
290+
291+ func reviewInteractivityHardDisabled (testTTY , ci string , underTest bool ) bool {
292+ // Match CanPromptInteractively's precedence: ENTIRE_TEST_TTY=1 may opt an
293+ // in-process test into interaction, while tests without that explicit
294+ // override must never read from a developer's real terminal.
295+ if testTTY != "" {
296+ return testTTY != "1"
297+ }
298+ return underTest || (ci != "" && ci != "false" )
299+ }
300+
301+ func reviewTTYIsInteractive (stdinTTY , stdoutTTY , canPrompt , hardDisabled bool ) bool {
302+ // Real stdio terminals are necessary but not sufficient: agent shells can
303+ // allocate a PTY while advertising that no human is available through the
304+ // sentinels enforced by CanPromptInteractively.
305+ return ! hardDisabled && stdinTTY && stdoutTTY && canPrompt
306+ }
307+
273308func (o reviewConfigureOptions ) scripted () bool {
274309 // Local selects the destination only; by itself it must not force the
275310 // non-interactive/scripted path. `entire review --configure --local` should
@@ -329,7 +364,7 @@ func runReviewConfigure(ctx context.Context, cmd *cobra.Command, profileOverride
329364 // duplicate the catalog here. Pass the raw --profile value (empty when not
330365 // given) so the guided setup runs the "what kind of review?" type picker
331366 // instead of being silently defaulted to the general profile.
332- if interactive . IsTerminalWriter ( out ) && interactive . CanPromptInteractively ( ) {
367+ if reviewCommandIsInteractive ( cmd ) {
333368 name , profile , setupErr := RunReviewGuidedSetup (ctx , out , installed , deps .ReviewerFor , strings .TrimSpace (profileOverride ), false , s )
334369 if setupErr != nil {
335370 return handlePickerError (cmd , silentErr , setupErr )
@@ -755,7 +790,7 @@ func runReview(ctx context.Context, cmd *cobra.Command, agentOverride, modelOver
755790 applyLegacyReviewProfileFallback (s )
756791
757792 profileOverride = strings .TrimSpace (profileOverride )
758- interactiveTTY := interactive . IsTerminalWriter ( out ) && interactive . CanPromptInteractively ( )
793+ interactiveTTY := reviewCommandIsInteractive ( cmd )
759794
760795 // Bare `entire review` never auto-runs a profile. Without a TTY we cannot
761796 // prompt, so list the profiles (or point at setup) and require an explicit
@@ -784,7 +819,7 @@ func runReview(ctx context.Context, cmd *cobra.Command, agentOverride, modelOver
784819 // Non-interactive first run writes the shared project settings; interactive
785820 // setup asks the user where to save below.
786821 saveScope := reviewScopeProject
787- guidedSetup := interactive . IsTerminalWriter ( out ) && interactive . CanPromptInteractively ()
822+ guidedSetup := interactiveTTY
788823 if guidedSetup {
789824 var setupErr error
790825 profileForSetup , profile , setupErr = RunReviewGuidedSetup (ctx , out , installed , deps .ReviewerFor , profileForSetup , true , s )
@@ -942,12 +977,12 @@ func nonLaunchableEligibleNames(profile settings.ReviewProfileConfig, eligible [
942977// (true, nil). In a non-interactive context it cannot prompt, so it proceeds
943978// (the user explicitly invoked `entire review`) after printing a note rather
944979// than blocking on a confirm form that would error out.
945- func confirmReReviewOrProceed (ctx context.Context , out io.Writer , deps Deps ) (bool , error ) {
980+ func confirmReReviewOrProceed (ctx context.Context , out io.Writer , deps Deps , canPrompt bool ) (bool , error ) {
946981 reviewed , meta := deps .HeadHasReviewCheckpoint (ctx )
947982 if ! reviewed {
948983 return true , nil
949984 }
950- if ! interactive . CanPromptInteractively () {
985+ if ! canPrompt {
951986 fmt .Fprintf (out , "Note: HEAD was already reviewed (%s); re-running.\n " , meta )
952987 return true , nil
953988 }
@@ -1009,7 +1044,8 @@ func runSingleAgentPath(
10091044 }
10101045
10111046 // 4. Re-run guard: check if HEAD's checkpoint already has a review.
1012- if proceed , guardErr := confirmReReviewOrProceed (ctx , out , deps ); guardErr != nil {
1047+ canPrompt := reviewCommandIsInteractive (cmd )
1048+ if proceed , guardErr := confirmReReviewOrProceed (ctx , out , deps , canPrompt ); guardErr != nil {
10131049 fmt .Fprintln (out , "prompt cancelled" )
10141050 return silentErr (guardErr )
10151051 } else if ! proceed {
@@ -1063,10 +1099,9 @@ func runSingleAgentPath(
10631099 defer cancelRun ()
10641100
10651101 runCfg .EnrichSummary = reviewSummaryTokenEnricher (worktreeRoot , headSHA )
1066- canPrompt := interactive .CanPromptInteractively ()
10671102 sinks := composeSingleAgentSinks (singleAgentSinkInputs {
10681103 out : out ,
1069- isTTY : interactive . IsTerminalWriter ( out ) && canPrompt ,
1104+ isTTY : canPrompt ,
10701105 canPrompt : canPrompt ,
10711106 agentName : displayName ,
10721107 cancelRun : cancelRun ,
@@ -1153,7 +1188,8 @@ func runMultiAgentPath(
11531188 return fmt .Errorf ("resolve HEAD: %w" , shaErr )
11541189 }
11551190
1156- if proceed , guardErr := confirmReReviewOrProceed (ctx , out , deps ); guardErr != nil {
1191+ canPrompt := reviewCommandIsInteractive (cmd )
1192+ if proceed , guardErr := confirmReReviewOrProceed (ctx , out , deps , canPrompt ); guardErr != nil {
11571193 fmt .Fprintln (out , "prompt cancelled" )
11581194 return deps .NewSilentError (guardErr )
11591195 } else if ! proceed {
@@ -1235,7 +1271,7 @@ func runMultiAgentPath(
12351271 masterLabel := judgeLabel (judge )
12361272 sinks := composeMultiAgentSinks (multiAgentSinkInputs {
12371273 out : out ,
1238- isTTY : interactive . IsTerminalWriter ( out ) && interactive . CanPromptInteractively () ,
1274+ isTTY : canPrompt ,
12391275 agentNames : agentNames ,
12401276 cancelRun : cancelRun ,
12411277 runContext : runCtx ,
@@ -1309,11 +1345,10 @@ func handlePickerError(cmd *cobra.Command, silentErr func(error) error, pickErr
13091345// instead of monkey-patching interactive helpers at run time.
13101346//
13111347// isTTY here means "the TUI sink is safe to compose" — production callers
1312- // AND IsTerminalWriter(out) with CanPromptInteractively() before passing
1313- // it in, since the TUI both writes ANSI to stdout AND reads keypresses
1314- // from stdin. A terminal-stdout-but-non-interactive-stdin scenario (an
1315- // agent host like Claude Code invoking `entire review`) must NOT use the
1316- // TUI — its dismissal loop would block forever.
1348+ // use reviewCommandIsInteractive before passing it in, since the TUI both
1349+ // writes ANSI to stdout and reads keypresses from stdin. A terminal stdout
1350+ // with non-interactive stdin must not use the TUI; its dismissal loop would
1351+ // block forever.
13171352type multiAgentSinkInputs struct {
13181353 out io.Writer
13191354 isTTY bool
0 commit comments