@@ -9,7 +9,9 @@ import type { Writable } from 'stream'
99import { getCI } from '#env/ci'
1010import { generateSocketSpinnerFrames } from './effects/pulse-frames'
1111import type {
12+ ShimmerColor ,
1213 ShimmerColorGradient ,
14+ ShimmerColorRgb ,
1315 ShimmerConfig ,
1416 ShimmerDirection ,
1517 ShimmerState ,
@@ -101,7 +103,7 @@ function isRgbTuple(value: ColorValue): value is ColorRgb {
101103 * @param color - Color name or RGB tuple
102104 * @returns RGB tuple with values 0-255
103105 */
104- function toRgb ( color : ColorValue ) : ColorRgb {
106+ export function toRgb ( color : ColorValue ) : ColorRgb {
105107 if ( isRgbTuple ( color ) ) {
106108 return color
107109 }
@@ -163,6 +165,9 @@ export type Spinner = {
163165 /** Whether spinner is currently animating */
164166 get isSpinning ( ) : boolean
165167
168+ /** Get current shimmer state (enabled/disabled and configuration) */
169+ get shimmerState ( ) : ShimmerInfo | undefined
170+
166171 /** Clear the current line without stopping the spinner */
167172 clear ( ) : Spinner
168173
@@ -227,6 +232,13 @@ export type Spinner = {
227232 /** Increment progress by specified amount (default: 1) */
228233 progressStep ( amount ?: number ) : Spinner
229234
235+ /** Push current options onto stack and apply new temporary options */
236+ pushOptions (
237+ options : Partial < Pick < SpinnerOptions , 'color' | 'shimmer' > > ,
238+ ) : Spinner
239+ /** Pop and restore previous options from stack */
240+ popOptions ( ) : Spinner
241+
230242 /** Toggle shimmer effect on/off */
231243 shimmer ( enabled : boolean ) : Spinner
232244 /** Update shimmer configuration or set direction */
@@ -474,6 +486,10 @@ export function Spinner(options?: SpinnerOptions | undefined): Spinner {
474486 #progress?: ProgressInfo | undefined
475487 #shimmer?: ShimmerInfo | undefined
476488 #shimmerSavedConfig?: ShimmerInfo | undefined
489+ #optionsStack: Array < {
490+ color : ColorRgb
491+ shimmer : ShimmerInfo | undefined
492+ } > = [ ]
477493
478494 constructor ( options ?: SpinnerOptions | undefined ) {
479495 const opts = { __proto__ : null , ...options } as SpinnerOptions
@@ -583,6 +599,20 @@ export function Spinner(options?: SpinnerOptions | undefined): Spinner {
583599 super . color = isRgbTuple ( value ) ? value : toRgb ( value )
584600 }
585601
602+ // Getter to expose current shimmer state.
603+ get shimmerState ( ) : ShimmerInfo | undefined {
604+ if ( ! this . #shimmer) {
605+ return undefined
606+ }
607+ return {
608+ color : this . #shimmer. color ,
609+ currentDir : this . #shimmer. currentDir ,
610+ mode : this . #shimmer. mode ,
611+ speed : this . #shimmer. speed ,
612+ step : this . #shimmer. step ,
613+ } as ShimmerInfo
614+ }
615+
586616 /**
587617 * Apply a yocto-spinner method and update logger state.
588618 * Handles text normalization, extra arguments, and logger tracking.
@@ -946,6 +976,119 @@ export function Spinner(options?: SpinnerOptions | undefined): Spinner {
946976 return this
947977 }
948978
979+ /**
980+ * Push current spinner options onto the stack and apply new temporary options.
981+ * Use `popOptions()` to restore the previous options.
982+ * Supports nested calls - each push must be paired with a pop.
983+ *
984+ * @param options - Temporary options to apply (color, shimmer)
985+ * @returns This spinner for chaining
986+ *
987+ * @example
988+ * ```ts
989+ * const spinner = Spinner({ color: 'cyan' })
990+ * spinner.start('Processing...')
991+ *
992+ * // Temporarily change to red for error handling
993+ * spinner.pushOptions({ color: 'red' })
994+ * spinner.text('Handling error')
995+ * // ... do error work ...
996+ * spinner.popOptions() // Restore cyan
997+ *
998+ * // Nested example
999+ * spinner.pushOptions({ color: 'yellow' })
1000+ * spinner.pushOptions({ color: 'red' })
1001+ * spinner.popOptions() // Back to yellow
1002+ * spinner.popOptions() // Back to cyan
1003+ * ```
1004+ */
1005+ pushOptions (
1006+ options : Partial < Pick < SpinnerOptions , 'color' | 'shimmer' > > ,
1007+ ) : Spinner {
1008+ const opts = { __proto__ : null , ...options } as Partial <
1009+ Pick < SpinnerOptions , 'color' | 'shimmer' >
1010+ >
1011+
1012+ // Save current state
1013+ const savedState = {
1014+ color : this . color ,
1015+ shimmer : this . shimmerState
1016+ ? {
1017+ color : this . #shimmer! . color ,
1018+ currentDir : this . #shimmer! . currentDir ,
1019+ mode : this . #shimmer! . mode ,
1020+ speed : this . #shimmer! . speed ,
1021+ step : this . #shimmer! . step ,
1022+ }
1023+ : undefined ,
1024+ }
1025+ this . #optionsStack. push ( savedState )
1026+
1027+ // Apply new options
1028+ if ( opts . color !== undefined ) {
1029+ this . color = toRgb ( opts . color )
1030+ }
1031+ if ( opts . shimmer !== undefined ) {
1032+ this . shimmer ( opts . shimmer )
1033+ }
1034+
1035+ return this as unknown as Spinner
1036+ }
1037+
1038+ /**
1039+ * Pop and restore the previous spinner options from the stack.
1040+ * Must be paired with a previous `pushOptions()` call.
1041+ * If stack is empty, this method does nothing.
1042+ *
1043+ * @returns This spinner for chaining
1044+ *
1045+ * @example
1046+ * ```ts
1047+ * spinner.pushOptions({ color: 'red' })
1048+ * // ... do work with red spinner ...
1049+ * spinner.popOptions() // Restore previous color
1050+ * ```
1051+ */
1052+ popOptions ( ) : Spinner {
1053+ const savedState = this . #optionsStack. pop ( )
1054+ if ( ! savedState ) {
1055+ // Stack is empty, nothing to restore
1056+ return this as unknown as Spinner
1057+ }
1058+
1059+ // Restore color
1060+ this . color = savedState . color
1061+
1062+ // Restore shimmer state
1063+ if ( savedState . shimmer ) {
1064+ const shimmerColor = savedState . shimmer . color
1065+ let restoredColor : ShimmerColor | ShimmerColorGradient | undefined
1066+ if ( shimmerColor === COLOR_INHERIT ) {
1067+ restoredColor = COLOR_INHERIT
1068+ } else if ( Array . isArray ( shimmerColor ) ) {
1069+ // Check if it's a gradient (array of arrays) or single RGB
1070+ if ( shimmerColor . length > 0 && Array . isArray ( shimmerColor [ 0 ] ) ) {
1071+ restoredColor = shimmerColor as ShimmerColorGradient
1072+ } else {
1073+ restoredColor = shimmerColor as unknown as ShimmerColorRgb
1074+ }
1075+ } else if ( typeof shimmerColor === 'string' ) {
1076+ // It's a named color, convert to RGB
1077+ restoredColor = toRgb ( shimmerColor as ColorName )
1078+ }
1079+ this . shimmer ( {
1080+ color : restoredColor ,
1081+ dir : savedState . shimmer . mode ,
1082+ speed : savedState . shimmer . speed ,
1083+ } )
1084+ } else {
1085+ // Shimmer was disabled before
1086+ this . shimmer ( false )
1087+ }
1088+
1089+ return this as unknown as Spinner
1090+ }
1091+
9491092 /**
9501093 * Start the spinner animation with optional text.
9511094 * Begins displaying the animated spinner on stderr.
@@ -975,7 +1118,9 @@ export function Spinner(options?: SpinnerOptions | undefined): Spinner {
9751118 }
9761119
9771120 this . #updateSpinnerText( )
978- return this . #apply( 'start' , args )
1121+ // Don't pass text to yocto-spinner.start() since we already set it via #updateSpinnerText().
1122+ // Passing args would cause duplicate message output.
1123+ return this . #apply( 'start' , [ ] )
9791124 }
9801125
9811126 /**
@@ -1365,6 +1510,12 @@ export type WithSpinnerOptions<T> = {
13651510 * If not provided, operation runs without spinner.
13661511 */
13671512 spinner ?: Spinner | undefined
1513+ /**
1514+ * Optional spinner options to apply during the operation.
1515+ * These options will be pushed when the operation starts and popped when it completes.
1516+ * Supports color and shimmer configuration.
1517+ */
1518+ withOptions ?: Partial < Pick < SpinnerOptions , 'color' | 'shimmer' > > | undefined
13681519}
13691520
13701521/**
@@ -1407,7 +1558,7 @@ export type WithSpinnerOptions<T> = {
14071558export async function withSpinner < T > (
14081559 options : WithSpinnerOptions < T > ,
14091560) : Promise < T > {
1410- const { message, operation, spinner } = {
1561+ const { message, operation, spinner, withOptions } = {
14111562 __proto__ : null ,
14121563 ...options ,
14131564 } as WithSpinnerOptions < T >
@@ -1416,11 +1567,20 @@ export async function withSpinner<T>(
14161567 return await operation ( )
14171568 }
14181569
1570+ // Push options if provided
1571+ if ( withOptions ) {
1572+ spinner . pushOptions ( withOptions )
1573+ }
1574+
14191575 spinner . start ( message )
14201576 try {
14211577 return await operation ( )
14221578 } finally {
14231579 spinner . stop ( )
1580+ // Pop options if they were pushed
1581+ if ( withOptions ) {
1582+ spinner . popOptions ( )
1583+ }
14241584 }
14251585}
14261586
@@ -1500,6 +1660,12 @@ export type WithSpinnerSyncOptions<T> = {
15001660 * If not provided, operation runs without spinner.
15011661 */
15021662 spinner ?: Spinner | undefined
1663+ /**
1664+ * Optional spinner options to apply during the operation.
1665+ * These options will be pushed when the operation starts and popped when it completes.
1666+ * Supports color and shimmer configuration.
1667+ */
1668+ withOptions ?: Partial < Pick < SpinnerOptions , 'color' | 'shimmer' > > | undefined
15031669}
15041670
15051671/**
@@ -1531,7 +1697,7 @@ export type WithSpinnerSyncOptions<T> = {
15311697 * ```
15321698 */
15331699export function withSpinnerSync < T > ( options : WithSpinnerSyncOptions < T > ) : T {
1534- const { message, operation, spinner } = {
1700+ const { message, operation, spinner, withOptions } = {
15351701 __proto__ : null ,
15361702 ...options ,
15371703 } as WithSpinnerSyncOptions < T >
@@ -1540,10 +1706,19 @@ export function withSpinnerSync<T>(options: WithSpinnerSyncOptions<T>): T {
15401706 return operation ( )
15411707 }
15421708
1709+ // Push options if provided
1710+ if ( withOptions ) {
1711+ spinner . pushOptions ( withOptions )
1712+ }
1713+
15431714 spinner . start ( message )
15441715 try {
15451716 return operation ( )
15461717 } finally {
15471718 spinner . stop ( )
1719+ // Pop options if they were pushed
1720+ if ( withOptions ) {
1721+ spinner . popOptions ( )
1722+ }
15481723 }
15491724}
0 commit comments